Skip to content

handles instead of ref-ids - #1533

Open
gonzaloaune wants to merge 1 commit into
mainfrom
feature/handles-instead-ref-ids
Open

handles instead of ref-ids#1533
gonzaloaune wants to merge 1 commit into
mainfrom
feature/handles-instead-ref-ids

Conversation

@gonzaloaune

Copy link
Copy Markdown
Contributor

Cut create_batch_triplet token cost: node handles + trimmed results

Why

create_batch_triplet is 56.7% of all agent wall-clock time in production, and its
steps are generation-bound — the cost is the model writing JSON, not executing it
(Neo4j sits near 0% CPU during these steps, after the separate query fixes in
jarvis-backend).

Four full production conversation traces were tokenized (cl100k_base):

output tokens 192,666 | create_batch_triplet args 150,179 (77.9%) | reasoning 34,752 (18.0%)
tool results  265,802 | create_batch_triplet results 145,944 (54.9%)

Arguments to one tool are 78% of everything the model generates. Two independent
problems inside that, each addressed here.

1. ref_ids are 36.6% of argument tokens

A UUID costs 23 tokens every time the model writes one. Across the four traces:

2,309 ref_id occurrences in tool arguments, over 559 distinct nodes  (reuse 3.0-5.4x)
55,022 tokens = 36.6% of all create_batch_triplet argument tokens

2. Results echo back what the caller just sent

Results average 4,349 tokens per call — larger than the arguments — and each entry
repeated three fields the caller already had:

{"status":"Success",
 "source_ref_id":"691c3551-…",   // caller supplied
 "target_ref_id":"a0f45762-…",   // caller supplied
 "edge_ref_id":"020cfe85-…",     // never used
 "edge_type":"CONTAINS"}         // caller supplied

Measured across all four traces: 1,426 edge_ref_id values returned, 0 ever
referenced in a later call.
Node ref_ids are the opposite — 221 of 222 get reused,
because that is how the agent learns the ids of nodes it created inline.

What changed

Node handles (@nN)

A per-run table maps ref_id ↔ handle. Every tool that surfaces a node now returns
@n7 in place of the raw ref_id; every tool that accepts a ref_id takes either form.
Jarvis only ever sees real ref_ids — resolution happens at the tool boundary.

  • Cost: "@n7" and "@n148" both tokenize to 4 tokens, against 23 for a UUID. The
    @ prefix is free and makes a handle unmistakable in a payload.
  • Correlation is free: search/neighbour results already carry name and node_type,
    so the model still knows what @n7 is without a legend.
minted on output resolved on input
graph_search, graph_get, graph_neighbors graph_get, graph_neighbors
create_triplet, create_batch_triplet create_triplet, create_batch_triplet

return_edge_ids (opt-in, default off)

Successful batch entries no longer carry edge_ref_id (opt-in via the new flag) or
edge_type (a pure echo, always dropped). Error entries keep both — the context is
worth the tokens and the volume is negligible.

Guards

  • findHandleInNodeData rejects a handle used as a property value on either side of
    either write tool. A handle persisted as node data would be untraceable garbage.
  • graph_sub_agent rewrites @nN to real ref_ids in the prompt before spawning: the
    child builds its own table, so the parent's handles mean nothing to it.
  • Unknown handle → explicit error naming it and listing the known ones. In
    create_batch_triplet this fails that item only, matching the existing
    partial-failure contract.

Expected impact

measured
dropping edge_ref_id + edge_type −34% of batch result tokens (50,276 tok across the 4 traces)
handles in arguments ref_ids are 36.6% of arg tokens; at 4 tok vs 23 that is ≈29% off arguments
handles in results the two ref_ids per entry go 23 → 4 tokens each

Results persist in context and are re-sent on every subsequent step (context grew
6,820 → 180,494 over 26 steps in one traced session, 2.40M cumulative input), so the
result-side savings compound.

Backward compatibility

  • Raw UUIDs still work as input everywhere. resolve() passes through anything that
    is not @n\d+, so ids from session history, a sub-agent prompt, or another process
    keep working.
  • No change to the Jarvis HTTP contract.
  • return_edge_ids defaults to false; set it to restore the previous field.

Known gap — read before merging

Persisted sessions will contain @nN in stored tool calls. These resolve correctly
in-process, but a session resumed in a different process — or replayed via transparent
mode — starts with an empty table, and the model could reuse a stale handle from
replayed history.

The fix is to rewrite handles back to real ref_ids at persist time in
extractMessagesFromSteps (utils.ts), plumbing a handleTableRef through get_tools
the way messagesRef and provenanceCollector already are. Not in this PR. If
multi-turn or replayed sessions are in scope for this deploy, that should land first.

Scope was verified for the in-process case: get_tools is called from prepareAgent,
which both entry points (get_context, stream_context) invoke fresh per request with
no memoization, so the closure is correctly per-run. There is a test asserting two
tables are independent — it looks trivial but pins the one assumption that would
silently resolve one document's handle into another document's node if get_tools ever
became memoized.

Tests

New in mcp/src/repo/__tests__/toolsJarvis.test.ts:

  • createHandleTable — mint idempotence, sequential allocation, resolve, UUID
    pass-through, near-miss rejection (@node7, n7), unknown-handle error, rewrite
    semantics, table independence
  • handle round-trip — mint→resolve, one handle per node across tools, raw ref_id still
    accepted, sub-agent prompt rewrite
  • findHandleInNodeData — rejects handle-as-value, allows "see @note7", trims first
  • return_edge_ids — off by default, on when set, changes nothing else

simulateBatch (the in-test mirror of the production result assembly) was updated to
match, including the new flag, so it does not drift.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant