Repository navigation
resolve_query must return a resolved node, not query fragments #1637
Description
Overview
resolve_query decomposition works and is invoked reliably. The write still never lands, because the tool returns query fragments the model must route rather than a resolved node it can act on. Measurement says changing the return shape fixes the scenario outright.
This issue previously proposed building an LLM-backed decomposition step. That step already exists (packages/agent/src/local_agent/tools.rs:352, implemented at :1279 with a deterministic schema lookup plus a scoped temperature-0 inference call). The issue has been rewritten around what measurement actually found.
Problem Statement
For a request that identifies a node indirectly ("Mark the $500 invoice as paid"), the intended chain is resolve_query → search_nodes → update_node. The model does not complete it. Having received a correct filter, it emits:
update_node{filters:[{property:"amount",value:500}], node_id:"invoice", updates:{"status":"paid"}}
It skips search_nodes, invents a filters parameter that is not in update_node's schema, passes the type name as a node id, and uses updates: instead of properties:. Deterministic across trials.
The generalizable principle, now recorded as ADR-064 rule 4: the model acts on results and narrates or mis-executes plans. A sub-step that returns a plan invites the model to improvise its execution.
Evidence
Measured through the real LocalAgentLoop with the ADR-056 locked model, scored on whether the graph ended in the required state. Three request phrasings, replicated at n=2 and n=3 (15 conversations per arm):
| mechanism | "$500 invoice" | "due next Friday" | "the 2400 one" |
|---|---|---|---|
baseline — returns {query, filters} |
0% | 0% | 0% |
returns {id, title} |
100% | 100% | 100% |
update_node rejects filters with a next-step error |
0% | 0% | 0% |
search_nodes given typed filters |
0% | 0% | 0% |
update_node accepts a match filter |
0% | 0% | 0% |
resolution merged into search_nodes(request=…) |
100% | 100% | 0% |
Notes that constrain the fix:
- Invocation is not the problem. With the prose rule present the model called
resolve_query3/3; without it, 0/3. The rule works; what follows the call does not. - Capability is not the problem.
search_nodesalready accepts typed filters with operators (tools.rs:259) and already returns{id, title, properties}. The "search takes filters" arm scored 0% because constructing the filter is the gap, not having somewhere to put it. - Matching the model's instinct is not sufficient. Letting
update_nodetake amatchfilter — exactly what the model kept inventing — still scored 0%. - Merging into
search_nodesis not sufficient. It fails on the indirect-reference phrasing, where the model must decidenode_typeitself and instead asks "What kind of thing is 'the 2400 one'?".resolve_query's explicitnode_typeparameter is doing load-bearing work.
Half B (read-then-write completion) is closed as already satisfied
This issue previously owned a second half: ensuring a read-then-write instruction completes the write in the same turn. That instruction already exists (packages/agent/src/agent_guidance.rs:49) and is measurably inert — the model continued to the write in 72 of 72 trials, flat across bulky and compact tool-result payloads and with and without explicit continuation nudges. Neither hypothesized cause (recency displacement, turn-budget economics) reproduced.
Deleting that inert rule is tracked separately as part of the resident-prompt reduction work.
Corrections to this issue's earlier content
- It cited
aichat-matrix.tsScenario 6 as "Mark the $500 invoice as paid". That file does not exist (the eval fixture isscripts/eval/fixtures/agent-matrix.ts) and that prompt is not in the matrix; Scenario 6 is now "The 2400 one came back — set it to returned". That phrasing is included in the measurements above and is the one that discriminates between candidate fixes. - It stated a baseline of 11/12 with Scenario 6 the sole failure. That figure was recorded under eval contamination and is not usable — see the eval baseline issue.
Acceptance Criteria
-
resolve_queryperforms the search internally and returns the resolved node:id,title, and identifying properties - Zero-match and multi-match cases return a discriminated result the model can act on, not a plan to search again. Multi-match in particular must not degrade into "here are filters, try harder" — the single-match assumption is untested and needs a defined behavior
- The tool description is rewritten for the new contract
- The compensating paragraph at
packages/agent/src/agent_guidance.rs:54is deleted — it exists only to tell the model how to route the old return shape - The three request phrasings above become regression tests
- Existing callers updated in the same change; per the pre-release stance, no dual-return-shape support and no deprecation period
- The real scoped inference call is exercised — the measurements above used a stubbed resolver, so its live accuracy is unverified
Architecture Impact
Implements ADR-064 rule 4 (tool results own facts, not plans). This is the strongest-evidenced finding from the prompting spike and the template for auditing other tool return shapes.
Reference harness for reproducing these measurements: ~/nodespace/spike-1802-harness/ (deliberately not committed; see its README for known defects, including a Metal memory leak that caps reliable runs at n=3 per cell).
Activity
Fix search_nodes-vs-execute_query guidance for unfiltered listing req…
ollama_integration test suite has ~17 failing tests due to empty tool…
[-]Consider an LLM-backed query-decomposition step before search_nodes/execute_query tool calls[/-][+]LLM-backed query decomposition + read-then-write turn completion before search_nodes/update_node[/+]Collapse execute_query into search_nodes as one agent query tool (closes
malibio commentedon Jul 17, 2026
Frontend baseline: 166 test files passed, 4104 tests passed (46.49s).
LLM-backed query decomposition + read-then-write turn completion befo…
malibio commentedon Jul 28, 2026
First live matrix run since #1688 merged: E4B scores 11/12 — Scenario 6 still fails, but not for the reason this issue predicted
#1688's commit message noted the live
aichat-matrixrun could not be completed in that environment (memory-fit granted only the 4096-token floor, short of the ~6600 the 14-tool prompt needs). That's now been run.resolve_querydoes fire on the Scenario 6 turn — the decomposition step is wired and reached. It returns nothing usable, and the turn then fails in a worse way than "the model stopped after the read."Full result
gemma-4-e4b-q4km, 12 scenarios, 11 passed. Scenario 5 (list/query) passes — the #1640 tool collapse worked, and the model now pickssearch_nodesin ~4s with no ambiguity. Scenario 6 is the sole failure.The actual chain
Harness assertion:
Expected sequence [search_nodes,update_node] as a subsequence, got: resolve_query,search_nodes.Two root causes, neither of which is the read-then-write rule
(a) Type-name mismatch —
resolve_query's schema lookup misses. The model callsresolve_querywithnode_type="invoice". Scenario 3 created the type viacreate_schema({"name":"Invoice Tracker"}), which registers asinvoice_tracker.NodeService::get_schema_node("invoice")therefore returnsNone, andexec_resolve_query(packages/agent/src/local_agent/tools.rs:1096) falls through to its_ =>branch —"(no typed fields on this schema — resolve to a title/content keyword only)"— and the nested call returns emptyquery/filters.(b) The schema has no typed fields anyway. The
create_schemaresult on this run is{"description":"Schema for Invoice Tracker","fields":[],"isCore":false}. Even with the correct type name,resolve_queryhas noamountfield to resolve "$500" against. Its whole purpose is mapping ambiguous values onto real schema fields; with zero fields it cannot succeed by construction.So the decomposition step is not being skipped, and this is not a prompt-adherence problem. It is reached, executes, and has nothing to work with.
The failure is silent at every step, which is the deeper problem
resolve_queryreturnedis_error=falsewhile resolving nothing — an emptyqueryand emptyfiltersis not a valid resolution, but it is reported as success.search_nodes({})— no query, no filters — succeeded and returned 50 arbitrary system nodes. A search with no constraints can only return noise.Suggested hardening
exec_resolve_queryshould distinguish "schema not found" from "schema found but untyped" and return an explicit error (or aresolved: falsesignal) rather than empty strings that read as a valid resolution. The_ =>arm attools.rs:1115currently collapses both cases.search_nodesshould reject a call with neitherquerynorfilters. Makingqueryoptional in LLM-backed query decomposition + read-then-write turn completion before search_nodes/update_node #1688 (correct on its own) made the fully-empty call reachable — the two changes interact.resolve_queryshould resolve the type name itself (fuzzy/schema-registry lookup for"invoice"→invoice_tracker) rather than requiring the model to pass the exact registered type, since the model has only the truncated schema summary in its prompt.Reopening
This issue's own acceptance criterion is: "aichat-matrix Scenario 6 passes against
gemma-4-e4b-q4kmonce both (A) and (B) land — pushing E4B to 12/12 with no regression to the other 11." The other 11 have no regression, but Scenario 6 does not pass, so that criterion is unmet.Artifacts / reproduction
Run produced
results-e4b.json(12 scenarios) — the artifact ADR-056 cites as the evidentiary standard but which has never existed in the repo. Worth committing alongside the fix.Note on reproducing: on a 16GB machine
sysinfo::available_memory()reports ~0 after E4B's weights load (it excludes macOS's reclaimable inactive pool), socompute_effective_n_ctxfloorsn_ctxto 4096 and every tool-using turn fails with "Context window exceeded" before the model does anything. That is the same constraint #1688 hit. This run required temporarily overriding the memory probe. That floor behavior looks like a genuine user-facing bug on any Mac with a warm page cache, not just a test-rig issue — filing separately.malibio commentedon Jul 28, 2026
Root cause found: the guidance's own worked example hardcodes the wrong
node_typeRe-ran the Scenario 6 chain with
NODESPACE_PROMPT_DUMPon (which I should have done from the start — it is what this issue's original investigation used). The verbatim prompt and tool calls change the diagnosis substantially. The model is not ignoring instructions. It is following them precisely.agent_guidance.rs:53tells it to callnode_type="invoice"The
TOOL_STRATEGY_RULESblock ends with this worked example, verbatim from the assembled prompt:That example uses the same prompt string as the failing scenario, and hardcodes
node_type="invoice". The registered type isinvoice_tracker(the model names the schema "Invoice Tracker" from "Create an invoice tracking database";normalize_schema_idatnode_service/mod.rs:595derivesinvoice_tracker). The example is therefore wrong for the very case it illustrates, and the model copies it exactly:{"name":"resolve_query","arguments":"{\"node_type\":\"invoice\",\"request\":\"Mark the $500 invoice as paid\"}"}Same for the "skip resolve_query" branch on the next line:
search_nodes(node_type="invoice", query="Acme").The retrieval layer was working — this is not a missing-context problem
I initially suspected the schema was not being injected (the semantic-retrieval query is the raw action-phrased user message, and
context_ops.rs:45documents that phrasing embedding poorly). That hypothesis is dead. The assembled system prompt contains, at offset 429:So
build_workspace_context's semantic schema retrieval fired correctly and put the exact ID in front of the model. The model hadinvoice_trackeravailable and used"invoice"because the worked example 7 KB later told it to.Worth noting: no guidance anywhere states "pass the exact id from RELEVANT ENTITY TYPES as
node_type." The block is injected but never bound to the parameter it should populate.Three distinct defects in the one turn
Full dump for the failing turn:
node_type, copied from the guidance example.get_schema_node("invoice")(sqlite_store/nodes.rs:1966,WHERE id = ?1— exact match) returnsNone.resolve_querydegrades silently. On the lookup miss it falls to the_ =>arm attools.rs:1115("no typed fields on this schema") and returns emptyquery/filterswithis_error=false. An empty resolution is not a valid resolution. Note the codebase already has the right pattern elsewhere — the duplicate-schema error saysUse create_node with node_type='invoice_tracker'rather than failing opaquely.search_nodesarguments, then fabrication. Correcting my earlier report on this thread: the call is notsearch_nodes({}). The raw arguments are{"filters":[{"\"operator— truncated mid-token, which parses down to nothing and returns unrelated system nodes. This is the original argument-construction failure this issue was filed about, still occurring afterresolve_querywas meant to supply ready-made filters. The model then reports a successful write with noupdate_nodecall.Suggested order
(a) Fix the example first — it is a one-line change and costs nothing to test. Either use a name whose normalization is identity (
node_type="invoice"only works if the schema is literally named "Invoice"), or make the example explicitly reference the injected list rather than a literal, e.g. "use the exact id shown in RELEVANT ENTITY TYPES". Then re-run and see how much of Scenario 6 survives. Given the model reproduced the example token-for-token, this may move the needle on its own.(b) Make
resolve_queryfail loudly on an unknown type. Distinguish "schema not found" from "schema found but untyped"; return an actionable error (Unknown node_type 'invoice'; did you mean 'invoice_tracker'?) instead of empty strings that read as success. The model can self-correct from a message; it cannot self-correct from"".(c) Reject unconstrained
search_nodes. A call with neitherquerynorfilterscan only return noise. Makingqueryoptional in #1688 was correct on its own but made the fully-empty call reachable.(3) — malformed argument JSON — is the genuinely hard one and is unchanged by any of the above.
Note on schema fields
The schema is created with
"fields":[]— the model callscreate_schema({"name":"Invoice Tracker"})with no fields and no description. So even with a correct type name,resolve_queryhas noamountfield to resolve "$500" against. Whether the model should be pushed to define fields when creating a schema for tracking amounts and dates is a separate prompt-tuning question, but it caps how much (a) alone can achieve.Reproduction
NODESPACE_PROMPT_DUMP=/tmp/dump.jsonlon the daemon, then drive the three-turn chain (create database → add invoice → mark as paid) on one chat node and read withbun run prompt:dump /tmp/dump.jsonl. Note that on a memory-constrained machine the daemon floorsn_ctxto 4096 and every turn fails before inference — that is a separate bug and must be ruled out before trusting any run.malibio commentedon Jul 28, 2026
The first uncontaminated matrix run (PR #1763, gemma-4-e4b-q4km, 9/12) still shows Scenario 6 failing, so this issue stays open — but the failure mode has changed, and one contributing cause is now removed.
What changed: the guidance's worked example that hardcoded
node_type="invoice"(against the realinvoice_tracker) is gone. #1760 removed it and replaced it with a rule bindingnode_typeto the injected RELEVANT ENTITY TYPES block. That was the specific defect the earlier prompt-dump analysis on this issue identified, so this issue's remaining scope is the decomposition problem proper, not the bad literal.Current Scenario 6 result — prompt reworded to
The 2400 one came back — set it to returned(eval prompts had to move off the contaminated wording):Caveat worth weighing: this run's Scenario 4 failed to create the node Scenario 6 needs (tracked in #1775), so Scenario 6 had nothing to find and the clarifying question is arguably correct behavior. Scenario 6 is not cleanly measurable until #1775 is fixed. Re-assess this issue against a run where Scenario 4 passes.
[-]LLM-backed query decomposition + read-then-write turn completion before search_nodes/update_node[/-][+]resolve_query must return a resolved node, not query fragments[/+]resolve_query returns a resolved node, not query fragments (closes #1637
resolve_query must return a resolved node, not query fragments (#1825)
Record the honest post-ADR-064 baseline: 9/12 (6/9/8 across 3 runs), …
Correct #1637 misattribution in the baseline; fix a stale doc-comment…
Make the agent eval diagnostic before recording a baseline; revisit A…