Commit Graph

2 Commits

Author SHA1 Message Date
Tomas Sereikis 083ef01aab fix(llm): keep state-changing tool calls in the order the model asked for
Review on #1100 caught that the previous commit parallelised every tool call in
a turn, mutations included. `ctx.db_lock` makes the mutating handlers take turns
but does not decide whose turn comes first, so two writes from one assistant
turn could apply in an order the model did not request — `tool_results` stayed
ordered while the database did not.

Reads may still overlap freely, because they change nothing. So the loop now
splits the turn: anything named in `mutating_tools` runs one at a time in
request order, everything else runs concurrently alongside it.

`mutating_tools=None` is the default and means "assume every tool mutates",
which is the fully sequential behaviour that existed before this branch. A
caller opts in by naming its mutating set, so no existing caller changes
behaviour by being left alone.

`MUTATING_TOOL_NAMES` lives in agent_tools next to the dispatch table and lists
exactly the five names routing to a handler that takes `ctx.db_lock`:
create_observations, create_observations_deductive,
create_observations_inductive, update_peer_card, delete_observations.
`extract_preferences` and `finish_consolidation` are deliberately absent — both
only return text telling the model what to call next.

Dialectic passes that set rather than a "these are all reads" boolean. Its
loadout is reads today, but DIALECTIC_TOOLS already carries a commented-out
create_observations_deductive, so a flag would have gone quietly wrong the day
someone uncommented it. The parameter is threaded through honcho_llm_call
instead of imported inside src/llm, which keeps the llm layer free of tool
semantics and avoids the existing agent_tools <- dreamer.specialists cycle.

Two tests added: mutating calls are not reordered even when a later call
finishes first, and the default schedule is fully sequential.
2026-08-29 13:38:03 +03:00
Tomas Sereikis 5535415e89 perf(llm): run one turn's tool calls concurrently instead of serially
A single assistant turn routinely requests several independent reads — a
dialectic turn typically emits `search_memory` and `search_messages` together —
but the loop awaited them one at a time, so the turn cost their sum when it only
needed to cost the slowest. Measured on a production `minimal` dialectic: 1.2s
in `search_memory`, then a further 1.9s in `search_messages`, both pure reads.

Measured across 42 production dialectic requests (103 iterations, 77 of them
multi-tool, 3.34 tool calls per iteration on average), running each turn's calls
concurrently would cut 11.3% of total wall-clock: median 9.3% per request, p90
29.7%, best case 51.0%. Only 4 of the 42 requests gain nothing.

This is safe because tool handlers own their sessions: each opens a short-lived
one via `tracked_db()`, and only the mutating handlers (`create_observations`,
`update_peer_card`, `delete_observations`) take `ctx.db_lock`, so concurrent
reads do not contend on shared state.

The per-call telemetry ContextVars move inside the task. `asyncio` copies the
context per task, so `set_current_tool_call_seq` and `set_last_tool_metadata`
now bind to their own call instead of being written and read across one shared
context — which the previous code could only keep straight by never overlapping.
`gather` preserves argument order, so `tool_results` and `all_tool_calls` stay in
the order the model asked for them.

Fan-out is capped at MAX_CONCURRENT_TOOL_CALLS (4). Production has produced 18
tool calls in one iteration, and firing all of them at once would mean that many
simultaneous embedding + pgvector queries on a single instance. The cap leaves
the measured common case fully parallel while bounding the tail.
2026-08-29 13:26:19 +03:00