Skip to content

Commit 2df1b48

Browse files
srousseyclaude
andauthored
fix(util): last complete object wins when skipping JSON preamble (#718)
`createPartialJsonStream({ skipPreamble: true })` latched onto the FIRST syntactically valid `{…}` in the preamble and permanently ignored the real payload: input : '<think>the schema is {"type":"object"} so I will emit</think>{"name":"Bob"}' finish: {"type":"object"} complete: true `consumeSeek` opened a candidate at the first `{`; when it closed, `afterValue()` set `Mode.Done` and `consume()` returned on every later chunk. The `fail()` recovery only rescued candidates that produced NO data (`rootIsEmpty()`), so a well-formed prose object was unrecoverable — and `complete` reported `true`, so nothing downstream signalled a problem. Both `skipPreamble` call sites (HFT, TF-MediaPipe) are exactly the local / thinking models that restate the schema or a few-shot example before answering. A model emitting `{"name":"Alice"}` as an illustration inside `<think>` and then the real `{"name":"Bob"}` yielded `finish.data.object = {"name":"Alice"}` — schema-valid, so `StructuredGenerationTask`'s re-validation accepted it and the WRONG record was persisted. A closed root is now provisional under `skipPreamble`: it is retained as `lastComplete` and the parser returns to `Mode.Seek`, so the next `{` at depth 0 starts a fresh candidate that supersedes it. Nothing is re-scanned — a restart begins at the `{` that triggered it — so the parser stays O(total input) and the live-root aliasing contract is untouched. `finish()` returns the live root when it carries data (a genuinely truncated real payload), else `lastComplete`, else `{}`. Also in this change: - `finish()` is declared `JsonValue` (new exported type) instead of `Record<string, unknown>`. It always returned arrays and scalars for array-/scalar-rooted documents; the declared type was a lie, so `Object.keys(stream.finish())` on an array root type-checked and produced `["0","1","2"]`. New `finishObject()` returns `{}` for a non-object root; all 11 provider run-fns migrated to it, so an array root now fails required-key validation loudly. - `complete` no longer reports `true` for a root that only closed because `finish()` repaired a truncated token (`push('"abc')`), which contradicted its own JSDoc. - `resolveRef.ts`: the per-container `hasRef` probe inside `walk` re-scanned each node's whole subtree, making `resolveOutput` O(n·depth); `containsCycle` added a second full traversal, and both recursed once per level so a deeply nested value raised a `RangeError` out of cache resolution. One iterative pass now answers all three questions up front (which containers hold a reachable ref, whether any ref exists, whether there is a back-edge). Measured: 4k-deep chain 590ms -> 30ms; a 50k-deep ref-free value no longer overflows. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0efd950 commit 2df1b48

17 files changed

Lines changed: 433 additions & 114 deletions

File tree

.claude/CLAUDE.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,30 @@ sequence of partial deltas cannot supply.
247247
Do **not** accumulate the JSON text to produce it. Feed the deltas to
248248
`createPartialJsonStream()` (`@workglow/util/worker`, or `/schema` off-worker):
249249
`push(chunk)` is O(chunk) and returns the partial object to emit as an
250-
`object-delta`, and `finish()` returns the value for `finish.data.object`.
250+
`object-delta`, and `finishObject()` returns the value for `finish.data.object`.
251251
Re-parsing a growing buffer on every delta is O(n²) and blocks the worker
252252
thread. Use the `skipPreamble` option for providers that emit prose, a
253253
`<think>` block, or a code fence ahead of the JSON.
254254

255+
Use `finishObject()`, not `finish()`. `finish()` is typed `JsonValue` because it
256+
honestly returns whatever the document had at its root — an array or a scalar
257+
for a malformed response — and `StructuredGenerationTask` requires an object.
258+
`finishObject()` yields `{}` for a non-object root, so validation fails loudly on
259+
the missing required keys instead of the task receiving a value whose "keys" are
260+
array indices.
261+
262+
`skipPreamble` is **last-complete-wins**: a closed root is provisional, so a
263+
later `{` starts a fresh candidate that supersedes it and the LAST complete
264+
object is what `finish()` returns. A thinking model that restates the schema or
265+
shows a few-shot example before answering would otherwise lock onto the prose
266+
object — and a schema-shaped one passes re-validation, so the wrong record gets
267+
persisted with no error anywhere. The cost is that trailing prose containing its
268+
own complete object supersedes the payload, so keep asking for the JSON last;
269+
trailing prose with no `{` in it never restarts anything. Nothing is re-scanned
270+
(a restart begins at the `{` that triggered it), so the parser stays O(total
271+
input) — but it does keep scanning trailing text for the life of the stream
272+
rather than exiting early at the first close.
273+
255274
`push()` returns the parser's **live** root, which later pushes mutate — that
256275
aliasing is what keeps it linear. It is safe for the `object-delta` path
257276
(`StreamEventAccumulator` / `StreamProcessor` use replace semantics for

packages/task-graph/src/cache/resolveRef.ts

Lines changed: 91 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -114,100 +114,110 @@ export async function resolveOutput<T>(
114114
resolver: CacheRefResolver,
115115
options?: ResolveOutputOptions
116116
): Promise<T> {
117-
if (!hasRef(output, new WeakSet())) return output;
117+
if (isCacheRef(output)) {
118+
const limit = createLimiter(options?.concurrency);
119+
return (await walk(output, resolver, limit, new WeakSet(), undefined, new WeakSet())) as T;
120+
}
121+
const scan = scanContainers(output);
122+
if (!scan.hasRef) return output;
118123
const limit = createLimiter(options?.concurrency);
119124
// Acyclic values (the norm) memoize each container's resolution promise so a
120125
// subtree shared between two slots resolves ONCE and both slots receive the
121126
// same resolved copy — a plain visited-set would hand the second slot the
122127
// original, unresolved object. Cyclic values keep the conservative
123128
// visited-set behavior: cycles are returned by reference, unrewritten.
124-
const memo = containsCycle(output) ? undefined : new WeakMap<object, Promise<unknown>>();
125-
return (await walk(output, resolver, limit, new WeakSet(), memo)) as T;
129+
const memo = scan.hasCycle ? undefined : new WeakMap<object, Promise<unknown>>();
130+
return (await walk(output, resolver, limit, new WeakSet(), memo, scan.refBearing)) as T;
126131
}
127132

128-
/**
129-
* Depth-first cycle probe over the same container vocabulary as the walker
130-
* (plain objects, Array, Map, Set; leaves are opaque). Gray/black coloring:
131-
* an object seen again while still on the current path is a back-edge.
132-
*/
133-
function containsCycle(
134-
value: unknown,
135-
gray: WeakSet<object> = new WeakSet(),
136-
black: WeakSet<object> = new WeakSet()
137-
): boolean {
138-
if (value === null || typeof value !== "object" || isLeaf(value)) return false;
139-
const obj = value as object;
140-
if (black.has(obj)) return false;
141-
if (gray.has(obj)) return true;
142-
gray.add(obj);
143-
const children: Iterable<unknown> = Array.isArray(value)
144-
? value
145-
: value instanceof Map
146-
? value.values()
147-
: value instanceof Set
148-
? value
149-
: Object.values(value);
150-
for (const child of children) {
151-
if (containsCycle(child, gray, black)) return true;
152-
}
153-
gray.delete(obj);
154-
black.add(obj);
155-
return false;
133+
/** What one traversal of the output tells the walker. */
134+
interface ContainerScan {
135+
/** Containers from which a {@link CacheRef} is reachable. */
136+
readonly refBearing: WeakSet<object>;
137+
/** Whether any ref is reachable at all — lets `resolveOutput` preserve identity. */
138+
readonly hasRef: boolean;
139+
/** Whether a back-edge exists, which disables resolution memoization. */
140+
readonly hasCycle: boolean;
141+
}
142+
143+
/** Children the walker would descend into, in walk order. */
144+
function childrenOf(value: object): readonly unknown[] {
145+
if (Array.isArray(value)) return value;
146+
if (value instanceof Map) return Array.from(value.values());
147+
if (value instanceof Set) return Array.from(value);
148+
return Object.values(value as Record<string, unknown>);
156149
}
157150

158151
/**
159-
* Cheap pre-scan: returns `true` if any {@link CacheRef} is reachable inside
160-
* `value`. Lets `resolveOutput` short-circuit and preserve identity when
161-
* nothing needs resolving.
152+
* Single iterative pass over the walker's container vocabulary (plain objects,
153+
* Array, Map, Set; leaves are opaque) answering everything the walk needs to
154+
* know up front: which containers hold a reachable {@link CacheRef}, whether
155+
* any ref exists at all, and whether the graph has a back-edge.
162156
*
163-
* `visited` short-circuits cyclic and shared-subtree structures: revisiting an
164-
* already-seen object answers `false` instead of recursing forever. The
165-
* pre-scan is a containment check, so reporting `false` on a revisit is safe —
166-
* if a ref were reachable through that subtree, the FIRST visit would have
167-
* found it.
157+
* One pass, not three: a per-container `hasRef` probe inside the walk re-scanned
158+
* each node's whole subtree, making resolution O(n·depth). It is iterative for
159+
* the same reason — a deeply nested model output (thousands of levels) blew the
160+
* stack in the recursive probe, surfacing as a `RangeError` from cache
161+
* resolution rather than a cache miss.
162+
*
163+
* Cycles keep the pre-existing approximation: a container reached again while
164+
* still on the current path contributes nothing to its parent's answer. A ref
165+
* reachable ONLY through a back-edge can therefore go unnoticed, which is
166+
* consistent with the walker returning cycles by reference, unrewritten.
168167
*/
169-
function hasRef(value: unknown, visited: WeakSet<object>): boolean {
170-
if (isCacheRef(value)) return true;
171-
if (value === null || value === undefined) return false;
172-
if (isLeaf(value)) return false;
173-
if (typeof value === "object") {
174-
if (visited.has(value as object)) return false;
175-
visited.add(value as object);
168+
function scanContainers(root: unknown): ContainerScan {
169+
const refBearing = new WeakSet<object>();
170+
if (root === null || typeof root !== "object" || isLeaf(root)) {
171+
return { refBearing, hasRef: false, hasCycle: false };
176172
}
177-
if (Array.isArray(value)) {
178-
for (const v of value) {
179-
if (hasRef(v, visited)) return true;
173+
const done = new WeakSet<object>();
174+
const onPath = new WeakSet<object>();
175+
let hasCycle = false;
176+
177+
type Frame = { readonly node: object; readonly children: readonly unknown[]; index: number };
178+
const stack: Frame[] = [{ node: root, children: childrenOf(root), index: 0 }];
179+
onPath.add(root);
180+
181+
while (stack.length > 0) {
182+
const frame = stack[stack.length - 1]!;
183+
if (frame.index >= frame.children.length) {
184+
stack.pop();
185+
onPath.delete(frame.node);
186+
done.add(frame.node);
187+
const parent = stack[stack.length - 1];
188+
if (parent !== undefined && refBearing.has(frame.node)) refBearing.add(parent.node);
189+
continue;
180190
}
181-
return false;
182-
}
183-
if (value instanceof Map) {
184-
for (const v of value.values()) {
185-
if (hasRef(v, visited)) return true;
191+
const child = frame.children[frame.index++];
192+
if (isCacheRef(child)) {
193+
refBearing.add(frame.node);
194+
continue;
186195
}
187-
return false;
188-
}
189-
if (value instanceof Set) {
190-
for (const v of value) {
191-
if (hasRef(v, visited)) return true;
196+
if (child === null || typeof child !== "object" || isLeaf(child)) continue;
197+
const obj = child as object;
198+
if (onPath.has(obj)) {
199+
hasCycle = true;
200+
continue;
192201
}
193-
return false;
194-
}
195-
if (typeof value === "object") {
196-
const source = value as Record<string, unknown>;
197-
for (const k of Object.keys(source)) {
198-
if (hasRef(source[k], visited)) return true;
202+
if (done.has(obj)) {
203+
// Shared subtree: its answer is already final, so reuse it.
204+
if (refBearing.has(obj)) refBearing.add(frame.node);
205+
continue;
199206
}
200-
return false;
207+
onPath.add(obj);
208+
stack.push({ node: obj, children: childrenOf(obj), index: 0 });
201209
}
202-
return false;
210+
211+
return { refBearing, hasRef: refBearing.has(root), hasCycle };
203212
}
204213

205214
async function walk(
206215
value: unknown,
207216
resolver: CacheRefResolver,
208217
limit: Limiter,
209218
visited: WeakSet<object>,
210-
memo: WeakMap<object, Promise<unknown>> | undefined
219+
memo: WeakMap<object, Promise<unknown>> | undefined,
220+
refBearing: WeakSet<object>
211221
): Promise<unknown> {
212222
if (isCacheRef(value)) {
213223
return limit.run(() => resolver(value));
@@ -226,39 +236,44 @@ async function walk(
226236
// topology, including any unresolved refs the cycle contains.
227237
return value;
228238
}
229-
if (!hasRef(value, new WeakSet())) return value;
239+
// Answered by the single up-front scan; probing the subtree here made
240+
// resolution O(n·depth) and recursed once per level.
241+
if (!refBearing.has(obj)) return value;
230242
if (memo) {
231-
const promise = walkContainer(value, resolver, limit, visited, memo);
243+
const promise = walkContainer(value, resolver, limit, visited, memo, refBearing);
232244
memo.set(obj, promise);
233245
return promise;
234246
}
235247
visited.add(obj);
236-
return walkContainer(value, resolver, limit, visited, memo);
248+
return walkContainer(value, resolver, limit, visited, memo, refBearing);
237249
}
238250

239251
async function walkContainer(
240252
value: unknown,
241253
resolver: CacheRefResolver,
242254
limit: Limiter,
243255
visited: WeakSet<object>,
244-
memo: WeakMap<object, Promise<unknown>> | undefined
256+
memo: WeakMap<object, Promise<unknown>> | undefined,
257+
refBearing: WeakSet<object>
245258
): Promise<unknown> {
246259
if (Array.isArray(value)) {
247-
return Promise.all(value.map((v) => walk(v, resolver, limit, visited, memo)));
260+
return Promise.all(value.map((v) => walk(v, resolver, limit, visited, memo, refBearing)));
248261
}
249262
if (value instanceof Map) {
250263
const out = new Map();
251264
const entries = Array.from(value.entries());
252265
const resolved = await Promise.all(
253-
entries.map(async ([k, v]) => [k, await walk(v, resolver, limit, visited, memo)] as const)
266+
entries.map(
267+
async ([k, v]) => [k, await walk(v, resolver, limit, visited, memo, refBearing)] as const
268+
)
254269
);
255270
for (const [k, v] of resolved) out.set(k, v);
256271
return out;
257272
}
258273
if (value instanceof Set) {
259274
const out = new Set();
260275
const resolved = await Promise.all(
261-
Array.from(value).map((v) => walk(v, resolver, limit, visited, memo))
276+
Array.from(value).map((v) => walk(v, resolver, limit, visited, memo, refBearing))
262277
);
263278
for (const v of resolved) out.add(v);
264279
return out;
@@ -273,7 +288,7 @@ async function walkContainer(
273288
// matches the input even though resolutions race.
274289
const keys = Object.keys(source);
275290
const resolvedValues = await Promise.all(
276-
keys.map((k) => walk(source[k], resolver, limit, visited, memo))
291+
keys.map((k) => walk(source[k], resolver, limit, visited, memo, refBearing))
277292
);
278293
for (let i = 0; i < keys.length; i++) out[keys[i]!] = resolvedValues[i];
279294
return out;

packages/task-graph/src/task-graph/__tests__/resolveOutput.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,53 @@ describe("resolveOutput", () => {
192192
expect(out.target instanceof URL).toBe(true);
193193
expect(resolver).not.toHaveBeenCalled();
194194
});
195+
196+
describe("deep values", () => {
197+
/** A left-spine chain `{child:{child:…{bottom}}}`, `width` scalars per level. */
198+
const chain = (
199+
depth: number,
200+
bottom: Record<string, unknown>,
201+
width = 0
202+
): Record<string, unknown> => {
203+
let node = bottom;
204+
for (let i = 0; i < depth; i++) {
205+
const next: Record<string, unknown> = { child: node };
206+
for (let k = 0; k < width; k++) next[`f${k}`] = `v${k}`;
207+
node = next;
208+
}
209+
return node;
210+
};
211+
212+
it("scans a 50k-deep ref-free value without overflowing the stack", async () => {
213+
// The pre-scan recursed once per level, so a deeply nested model output
214+
// raised a RangeError out of cache resolution instead of answering "no
215+
// refs here". Identity is still preserved: nothing needed resolving, and
216+
// the walker is never entered.
217+
const resolver = vi.fn<CacheRefResolver>();
218+
const input = chain(50_000, { leaf: "bottom" });
219+
const out = await resolveOutput(input, resolver);
220+
expect(out).toBe(input);
221+
expect(resolver).not.toHaveBeenCalled();
222+
});
223+
224+
it("resolves a ref under a deep chain without re-scanning each level", async () => {
225+
// A per-container pre-scan inside the walk re-read each node's whole
226+
// subtree, making resolution O(n·depth). The bound is loose enough that
227+
// only an algorithmic regression trips it: ~590ms before, ~30ms after.
228+
const depth = 400;
229+
const blob = new Blob([new Uint8Array([1])]);
230+
const input = chain(depth, { payload: ref("cache://deep") as unknown as Blob }, 100);
231+
const started = performance.now();
232+
const out = (await resolveOutput(input, fakeResolver({ "cache://deep": blob }))) as Record<
233+
string,
234+
unknown
235+
>;
236+
const elapsed = performance.now() - started;
237+
238+
let cursor: Record<string, unknown> = out;
239+
for (let i = 0; i < depth; i++) cursor = cursor.child as Record<string, unknown>;
240+
expect(cursor.payload).toBe(blob);
241+
expect(elapsed).toBeLessThan(250);
242+
});
243+
});
195244
});

packages/util/CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# @workglow/util
22

3+
## Unreleased
4+
5+
### Bug Fixes
6+
7+
#### util
8+
9+
- `createPartialJsonStream({ skipPreamble: true })`: **last complete object wins**.
10+
A closed root is now provisional — it is retained and scanning resumes, so a
11+
later `{` starts a fresh candidate that supersedes it. Previously the parser
12+
latched onto the FIRST syntactically valid object in the preamble and ignored
13+
the real payload, while still reporting `complete: true`. A thinking model that
14+
restates the schema (`{"type":"object"}`) or shows a few-shot example
15+
(`{"name":"Alice"}`) before answering therefore returned the wrong document —
16+
and a schema-shaped one passes re-validation, so it was persisted silently.
17+
Nothing is re-scanned, so the parser stays O(total input); the accepted
18+
trade-off is that trailing prose containing a complete object now supersedes
19+
the payload. `Mode.Done` is no longer reachable in this mode, so the parser
20+
keeps scanning trailing text for the life of the stream (bounded, no buffering).
21+
- `PartialJsonStream.complete` no longer reports `true` for a root that only
22+
closed because `finish()` repaired a truncated token (`push('"abc')`).
23+
24+
### BREAKING
25+
26+
#### util
27+
28+
- `PartialJsonStream.finish()` is now declared as `JsonValue` (a new exported
29+
type) instead of `Record<string, unknown>`. The runtime behavior is unchanged
30+
— an array- or scalar-rooted document was always returned as such — but the
31+
declared type was a lie, so `Object.keys(stream.finish())` on an array root
32+
type-checked and silently produced `["0","1","2"]`. Callers feeding a consumer
33+
that requires an object should switch to the new
34+
`PartialJsonStream.finishObject()`, which yields `{}` for an array or scalar
35+
root; all in-repo provider run-fns have been migrated. Third-party run-fns
36+
calling `finish()` will see a compile break — intentionally.
37+
338
## 0.3.38
439

540
### Features

0 commit comments

Comments
 (0)