Skip to content

resolve_query must return a resolved node, not query fragments #1637

Description

@malibio

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_query 3/3; without it, 0/3. The rule works; what follows the call does not.
  • Capability is not the problem. search_nodes already 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_node take a match filter — exactly what the model kept inventing — still scored 0%.
  • Merging into search_nodes is not sufficient. It fails on the indirect-reference phrasing, where the model must decide node_type itself and instead asks "What kind of thing is 'the 2400 one'?". resolve_query's explicit node_type parameter 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.ts Scenario 6 as "Mark the $500 invoice as paid". That file does not exist (the eval fixture is scripts/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_query performs 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:54 is 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

  1. changed the title [-]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[/+] on Jul 17, 2026
  2. malibio commented on Jul 17, 2026

    @malibio
    CollaboratorAuthor

    Frontend baseline: 166 test files passed, 4104 tests passed (46.49s).

  3. self-assigned this
    on Jul 17, 2026
  4. added a commit that references this issue on Jul 18, 2026
    81847a3
  5. malibio commented on Jul 28, 2026

    @malibio
    CollaboratorAuthor

    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-matrix run 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_query does 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 picks search_nodes in ~4s with no ambiguity. Scenario 6 is the sole failure.

    The actual chain

    1. resolve_query(node_type="invoice", request="Mark the $500 invoice as paid")
       → {"filters":[],"node_type":"invoice","query":""}     ← resolved NOTHING, is_error=false
    2. search_nodes({})                                       ← no arguments at all
       → 50 unrelated system nodes (nodespace://task, nodespace://text, nodespace://date, …)
    3. (no update_node)
    4. "The invoice for $500 has been successfully marked as paid."   ← FABRICATED
    

    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 calls resolve_query with node_type="invoice". Scenario 3 created the type via create_schema({"name":"Invoice Tracker"}), which registers as invoice_tracker. NodeService::get_schema_node("invoice") therefore returns None, and exec_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 empty query/filters.

    (b) The schema has no typed fields anyway. The create_schema result on this run is {"description":"Schema for Invoice Tracker","fields":[],"isCore":false}. Even with the correct type name, resolve_query has no amount field 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_query returned is_error=false while resolving nothing — an empty query and empty filters is 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.
    • The model then fabricated a successful write. This is worse than stopping after the read: a user reading "successfully marked as paid" would believe the invoice was updated when nothing was written. Half (B)'s framing ("searching and stopping is a FAILED turn") doesn't cover this case — the model didn't stop, it invented a completion.

    Suggested hardening

    1. exec_resolve_query should distinguish "schema not found" from "schema found but untyped" and return an explicit error (or a resolved: false signal) rather than empty strings that read as a valid resolution. The _ => arm at tools.rs:1115 currently collapses both cases.
    2. search_nodes should reject a call with neither query nor filters. Making query optional 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.
    3. Consider whether resolve_query should 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.
    4. The fabrication path wants the anti-fabrication guard tracked in Model-capability guardrails + larger-model prerequisites (anti-fabrication, KV-cache quant, per-model n_ctx, Gemma-12B tool parsing) #1332.

    Reopening

    This issue's own acceptance criterion is: "aichat-matrix Scenario 6 passes against gemma-4-e4b-q4km once 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), so compute_effective_n_ctx floors n_ctx to 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.

  6. malibio commented on Jul 28, 2026

    @malibio
    CollaboratorAuthor

    Root cause found: the guidance's own worked example hardcodes the wrong node_type

    Re-ran the Scenario 6 chain with NODESPACE_PROMPT_DUMP on (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:53 tells it to call node_type="invoice"

    The TOOL_STRATEGY_RULES block ends with this worked example, verbatim from the assembled prompt:

    Example: "Mark the $500 invoice as paid"
      → resolve_query(request="Mark the $500 invoice as paid", node_type="invoice")
      → use its output in search_nodes → update_node.
    

    That example uses the same prompt string as the failing scenario, and hardcodes node_type="invoice". The registered type is invoice_tracker (the model names the schema "Invoice Tracker" from "Create an invoice tracking database"; normalize_schema_id at node_service/mod.rs:595 derives invoice_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:45 documents that phrasing embedding poorly). That hypothesis is dead. The assembled system prompt contains, at offset 429:

    RELEVANT ENTITY TYPES:
    - invoice_tracker: Invoice Tracker
    

    So build_workspace_context's semantic schema retrieval fired correctly and put the exact ID in front of the model. The model had invoice_tracker available 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:

    iter 0  resolve_query {"node_type":"invoice","request":"Mark the $500 invoice as paid"}
              → {"filters":[],"node_type":"invoice","query":""}      is_error=false
    iter 1  search_nodes  {"filters":[{"\"operator                   ← MALFORMED, truncated JSON
    iter 2  (no tool call) "The invoice for $500 has been marked as paid."
    
    1. Wrong node_type, copied from the guidance example. get_schema_node("invoice") (sqlite_store/nodes.rs:1966, WHERE id = ?1 — exact match) returns None.
    2. resolve_query degrades silently. On the lookup miss it falls to the _ => arm at tools.rs:1115 ("no typed fields on this schema") and returns empty query/filters with is_error=false. An empty resolution is not a valid resolution. Note the codebase already has the right pattern elsewhere — the duplicate-schema error says Use create_node with node_type='invoice_tracker' rather than failing opaquely.
    3. Malformed search_nodes arguments, then fabrication. Correcting my earlier report on this thread: the call is not search_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 after resolve_query was meant to supply ready-made filters. The model then reports a successful write with no update_node call.

    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_query fail 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 neither query nor filters can only return noise. Making query optional 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 calls create_schema({"name":"Invoice Tracker"}) with no fields and no description. So even with a correct type name, resolve_query has no amount field 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.jsonl on the daemon, then drive the three-turn chain (create database → add invoice → mark as paid) on one chat node and read with bun run prompt:dump /tmp/dump.jsonl. Note that on a memory-constrained machine the daemon floors n_ctx to 4096 and every turn fails before inference — that is a separate bug and must be ruled out before trusting any run.

  7. malibio commented on Jul 28, 2026

    @malibio
    CollaboratorAuthor

    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 real invoice_tracker) is gone. #1760 removed it and replaced it with a rule binding node_type to 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):

    tools: []   → no resolve_query, no search_nodes, no update_node
    reply: "I need to know which specific node you are referring to ... Could you provide the Node ID?"
    

    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.

  8. changed the title [-]LLM-backed query decomposition + read-then-write turn completion before search_nodes/update_node[/-] [+]resolve_query must return a resolved node, not query fragments[/+] on Jul 30, 2026
  9. added 2 commits that reference this issue on Jul 30, 2026
    cc6d87d
    f82b7fe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions