Repository navigation
Goal planner inlines entire JSON records into the goal prompt, making each goal statement a multi-thousand-token wall #680
Description
Summary
The goal planner is instructed to put entire JSON records into each dynamic goal's replacements map, so a 161-character goal prompt expands into a multi-thousand-token JSON wall — which the hunter prompt then introduces as "the first sentence above is the authoritative focused goal". This is the root cause of the goal-node timeouts previously attributed to model speed in #675.
Evidence
Measured across all 88 goals of one real default-profile run:
| characters | approx tokens | |
|---|---|---|
goal_prompt template |
161 | ~40 |
| replacement payload, min | 3,751 | ~940 |
| replacement payload, median | 13,956 | ~3,500 |
| replacement payload, max | 42,483 | ~10,600 |
The template itself is exactly the crisp instruction you would want:
Your /goal is to find a vulnerability of type {{class:<class-id>}} using threat model {{<threat-id>}}.
Both placeholders resolve from the item's replacements map. For one representative class goal, that map held two entries of 10,051 and 4,744 characters — each a full raw JSON record: the vulnerability-class record with its guidance sub-object, and the threat-model entry with nested attack_surfaces, assets, actors, and every evidence array.
It is mandated by the planner prompt
.ultrafuzz/prompts/setup/goal-plan.md, under "## MDX replacement contract":
The replacement value for each threat key contains the full selected threat, assets, actors, surfaces, preconditions, invariants, assumptions, unknowns, and evidence. The class replacement contains the database record's focused hunter instructions and relevant examples. Never flatten a placeholder to a bare literal ID.
So the bloat is the specified behaviour, and the last sentence explicitly forbids the fix.
Nothing constrains the size
replacementsSchema in packages/artifacts/src/goal-plan.ts accepts any non-empty string:
const replacementsSchema = z
.record(replacementKey, z.union([nonEmptyString, z.number().finite(), z.boolean()]))
.refine((value) => Object.keys(value).length > 0, { message: "At least one item-scoped replacement is required" });A 42,483-character "replacement" is contract-valid.
Impact
At maximum reasoning effort, every goal node must ingest and reason over several thousand tokens of dense nested JSON before it begins analysing any repository code, and it is told that blob is its goal statement.
In the run measured: 9 nodes were killed at exactly the 7200000ms group timeout, and only 3 of 77 class-goal nodes ever produced output. The goal phase is the most valuable phase of the audit and it completed 4% of its work.
This also explains why #675 looked like a timeout problem. Raising the timeout is the wrong fix: with a 14k-character goal statement, more time buys a longer failure and doubles the worst-case cost of a stuck node.
The fix is already the pattern used three lines away
goal-hunter.mdx references its other large inputs by path, not by value:
Canonical threat model:
{{artifact_path:threat-model}}/threat-model.json
Goal plan and node mapping:
{{artifact_path:goal-plan}}/goal-plan.json
Goal-plan artifact directory containing the selected records under
`vulnerability-db/selected`:
{{artifact_path:goal-plan}}
The agent already has every full record available on disk and is already told where. Inlining the same JSON into the goal sentence is redundant with the file's own established convention.
Proposed:
- Replacement values become a short human-readable label — the record's
title, optionally with itsid. The hunter reads the full record from the paths it is already given. - Drop the "never flatten to a bare literal ID" instruction; keep the requirement that placeholders stay brace-wrapped in
goal_promptuntil the dynamic renderer resolves them, sincevalidatePromptReplacementsdepends on it. - Make
replacementsSchemaenforce a maximum length and reject values that are structurally JSON, so a planner that ignores the prompt cannot silently reintroduce the regression.
Acceptance criteria
- A goal node's rendered goal statement is on the order of hundreds of characters, not tens of thousands.
- The hunter can still locate the complete class record and threat-model entry for its assigned goal without guessing.
- An oversized or JSON-shaped replacement value fails contract validation with an actionable message.
- A regression test fails if a contract-valid goal plan can carry a replacement large enough to be a JSON record.
Activity
aviggiano commentedon Aug 20, 2026
Addressed by PR #690 (
fix/goal-prompt-and-coverage-integrity), which lists this as itsCloses. Full fix, with an enforcement backstop.The fix is the pattern
setup/goal-plan.mdalready used three lines away from the offending instruction: the threat model and goal plan are referenced by path, not by value. Replacements become short labels and the hunter reads the records from paths it is already given. The prompt's previous prohibition — "Never flatten a placeholder to a bare literal ID" — is what mandated the wall and is gone.Measured across all 88 goals of a real plan, rendered goal statements go from a 14,024-character median to 296 — 47x at the median, 72x at the max.
The part worth calling out is that the prompt change alone would not have held.
replacementsSchemainpackages/artifacts/src/goal-plan.tsnow enforcesREPLACEMENT_VALUE_MAX_LENGTH = 200(L65) and rejects structurally-JSON values, so a planner that ignores the prompt cannot silently reintroduce the wall — previously a 42,483-character "replacement" was contract-valid and nothing anywhere constrained the size. The JSON rejection is deliberately narrow (L67-73): a value fails only when it both begins with{/[after trimming and parses as a JSON object or array, so titles that open with a symbol or contain a code fragment still pass.Note the follow-on: with records no longer inlined, the hunter has to locate them itself, and the first version of those lookup instructions was wrong — tracked and fixed as #686 in the same PR.
aviggiano commentedon Aug 20, 2026
Shipped in #690, merged to
release/v0.1.0(f1b8c223).Measured result against the real 88-goal plan that exposed this: rendered goal statements went from min 3,861 / median 14,024 / max 42,555 characters to min 156 / median 296 / max 591 — 47× at the median, 72× at the max, ~342,000 tokens across the goal fleet. Not an estimate: each replacement was rebuilt from the referenced record's actual
titleand re-substituted into that goal's real template.Verified in the merged branch:
REPLACEMENT_VALUE_MAX_LENGTH = 200inpackages/artifacts/src/goal-plan.tswith JSON-record rejection that parses rather than prefix-matches (so prose opening with a brace is still accepted),maxLengthin the publishedgoal-plan.schema.json, and the planner prompt requiring a short human-readable label. All 254 replacement values from the original plan are rejected by the cap.The hunter reads full detail from the artifact paths the prompt already provided — which was the existing convention three lines away in the same file.
Closed manually: default branch is
main.fix(runtime): name the group pin a node timeout pin falls back to (#675…