Summary
ChatSynthesizer.synthesize() forces a tool call to record_graph, then passes
call.args.nodes straight into clean(), which opens with:
function clean(nodes) {
if (!Array.isArray(nodes))
return [];
Under a forced tool_choice, models routinely double-encode an array argument, so
nodes arrives as a JSON string rather than an array:
{"nodes": "[{\"name\": \"...\", \"type\": \"system\", \"summary\": \"...\"}]"}
The guard discards the entire payload and the run prints:
✓ concepts: 0 nodes, 0 links from 15 files
— with a ✓, and nothing else. Two twenty-minute runs produced that identical line before a
raw-traffic capture showed the model had in fact returned a complete, correct 15-node graph
each time.
This is not covered by the existing guards, and I want to be precise about why, because
the adjacent fixes all look like they should catch it:
Still present in 0.18.0 (dist/ai/synthesize.js, clean() at line 60,
return clean(nodesFromResponse(res)?.nodes) at line 111).
Environment
Evidence
Captured through a logging proxy sitting between graft and the endpoint:
finish_reason : tool_calls
completion : 3,148 tokens (513 reasoning)
tool_calls[0] : record_graph
arguments : {"nodes": "<12,012-char string>"}
That string contains 15 well-formed nodes — 10 system, 5 concept, 28 links, every one
with a summary — and they are accurate; I spot-checked them against the source. It is also
slightly malformed (a stray } at char 1204), so a bare JSON.parse of it fails too, which
matters for the fix.
Worth noting this is model-dependent, not universal: on a different, much larger repository the
same graft build produced 704 concept nodes across 2,783 files with zero concept-pass errors,
because that model serialized the argument correctly. A user whose model double-encodes gets an
empty concept layer and a ✓.
Suggested fix
Two parts, and I think the second is the more valuable one.
1. Coerce before the guard. If nodes is a string, JSON.parse it; on failure, salvage
balanced {"name": ...} objects from it. That recovers all 15 nodes on this fixture, including
past the stray brace.
2. Never return an empty batch in silence. A 0-node batch should report the reply size, the
tool-call count, and the shape actually received. A reply carrying a full architecture graph and
a reply carrying nothing must not print the same line — and the current line prints a ✓ for
both. This generalises past this particular bug: whatever the next provider serialization quirk
turns out to be, one run should be enough to see it rather than two.
clean() returning [] for a non-array is reasonable as a last-ditch guard; the problem is
that nothing above it distinguishes "wrong type" from "genuinely empty", and nothing prints the
difference.
Verified locally on the same fixture and model, meanings replayed from cache:
⚠ synthesize: tool call returned "nodes" as a 12012-char string, not an array
— salvaged 15 node(s) from malformed JSON
✓ concepts: 15 nodes, 27 links from 15 files (0 read, 15 cached)
About 70 lines against dist/ai/synthesize.js. Happy to open a PR against source if that is
easier than a patch against dist/.
A note on what already works well
The resumable cache is what made this diagnosable at all — every re-run replayed the meaning
tier instantly, so iterating on the concept pass cost nothing. Worth saying, since the rest of
this report is a complaint.
Summary
ChatSynthesizer.synthesize()forces a tool call torecord_graph, then passescall.args.nodesstraight intoclean(), which opens with:Under a forced
tool_choice, models routinely double-encode an array argument, sonodesarrives as a JSON string rather than an array:{"nodes": "[{\"name\": \"...\", \"type\": \"system\", \"summary\": \"...\"}]"}The guard discards the entire payload and the run prints:
— with a ✓, and nothing else. Two twenty-minute runs produced that identical line before a
raw-traffic capture showed the model had in fact returned a complete, correct 15-node graph
each time.
This is not covered by the existing guards, and I want to be precise about why, because
the adjacent fixes all look like they should catch it:
warnToolChoiceIgnored(synth/crux read results only fromtoolCalls; gateways that ignore forcedtool_choicesilently produce empty/degraded graphs #129) fires only when there is no usable tool call. Here therewas one, with a valid args object, so it correctly stays silent.
recoverToolArgsFromContent— including the bare-array handling proposed in recover-tool: accept a bare item array and repair truncated JSON (small local models via Ollama drop the tool envelope / hit max_tokens); add an opt-in dump of unparsed content #253 — is onlyreached when
nodesFromResponse()fails to returncall.args. It returns fine here, sorecovery never runs.
0 nodes, 0 linksand attributes it tothe
maxTokens: 8192truncation. That is a real cause, but it is not this one:finish_reasonwastool_calls, notlength, and the payload was complete. I suspect someshare of the reports pinned on truncation are actually this, since the printed line is
identical.
Still present in 0.18.0 (
dist/ai/synthesize.js,clean()at line 60,return clean(nodesFromResponse(res)?.nodes)at line 111).Environment
@nanonets/graft0.16.0, reconfirmed against 0.18.0 by readingdist/--provider openaiagainst a local SGLang endpoint serving a Qwen3 27B (qwen38-turbo)--deep: one crux call per file cannot describe a file with more than ~125 symbols — the truncated tool response is silently dropped #260 truncationnor the graft build --deep: hardcoded maxTokens=8192 truncates tool-call JSON, and file-node summaries discarded due to id-decoration mismatch #298/Graft build --deep fails with Grok (with fix) #327 id-decoration bug fires, which is how this one became visible on its own
Evidence
Captured through a logging proxy sitting between graft and the endpoint:
That string contains 15 well-formed nodes — 10
system, 5concept, 28 links, every onewith a summary — and they are accurate; I spot-checked them against the source. It is also
slightly malformed (a stray
}at char 1204), so a bareJSON.parseof it fails too, whichmatters for the fix.
Worth noting this is model-dependent, not universal: on a different, much larger repository the
same graft build produced 704 concept nodes across 2,783 files with zero concept-pass errors,
because that model serialized the argument correctly. A user whose model double-encodes gets an
empty concept layer and a ✓.
Suggested fix
Two parts, and I think the second is the more valuable one.
1. Coerce before the guard. If
nodesis a string,JSON.parseit; on failure, salvagebalanced
{"name": ...}objects from it. That recovers all 15 nodes on this fixture, includingpast the stray brace.
2. Never return an empty batch in silence. A 0-node batch should report the reply size, the
tool-call count, and the shape actually received. A reply carrying a full architecture graph and
a reply carrying nothing must not print the same line — and the current line prints a ✓ for
both. This generalises past this particular bug: whatever the next provider serialization quirk
turns out to be, one run should be enough to see it rather than two.
clean()returning[]for a non-array is reasonable as a last-ditch guard; the problem isthat nothing above it distinguishes "wrong type" from "genuinely empty", and nothing prints the
difference.
Verified locally on the same fixture and model, meanings replayed from cache:
About 70 lines against
dist/ai/synthesize.js. Happy to open a PR against source if that iseasier than a patch against
dist/.A note on what already works well
The resumable cache is what made this diagnosable at all — every re-run replayed the meaning
tier instantly, so iterating on the concept pass cost nothing. Worth saying, since the rest of
this report is a complaint.