Skip to content

Commit ca25aa4

Browse files
V48 Gate 3 (implementation-only): a schema-valid final stitch is a success, and debug stops actually stop
Two Generation-primitive corrections surfaced by the SynthesizeAssetPacks sanity sweep: - StitchUntilComplete validated only at the top of each loop iteration, so the attempt that reached maxStitches was generated, paid for, and then discarded by the unconditional exceeded throw even when schema-valid. The exceeded guard now re-validates the final attempt first. - The BITCODE_DEBUG_STOP_AFTER_FIRST_{REASON,STRUCTURED_OUTPUT} stops were double-dead: the intentional stop throw was swallowed by its own guard try/catch, and the structured-output predicate required a 'gen-2' path segment that no longer exists post-thricified (the structured substep runs under gen-0/seq-2). The throw now escapes and the predicate matches the real gen-0 pathing. Regression-pinned in stitch-final-attempt-and-debug-stop.test.ts (5 tests). agent-generics 9 suites / 21 tests green; tsc clean; asset-pack 45/227 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1bb0aa0 commit ca25aa4

3 files changed

Lines changed: 129 additions & 17 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// @ts-nocheck
2+
import { z } from 'zod';
3+
import { Execution } from '@bitcode/execution-generics';
4+
import { factoryStitchUntilComplete, factoryReason, factoryStructuredOutput } from '../substeps/factories';
5+
6+
const schema = z.object({ value: z.string() });
7+
8+
describe('StitchUntilComplete final attempt', () => {
9+
it('returns a schema-valid final stitch instead of discarding it as exceeded', async () => {
10+
let calls = 0;
11+
const gen = async () => {
12+
calls++;
13+
return calls >= 5 ? { value: 'complete' } : { partial: true };
14+
};
15+
const stitch = factoryStitchUntilComplete([gen], schema);
16+
const result = await stitch({ partial: true }, new Execution('root'));
17+
18+
expect(calls).toBe(5);
19+
expect(result.finalOutput).toEqual({ value: 'complete' });
20+
});
21+
22+
it('still fails closed when every stitch attempt stays invalid', async () => {
23+
let calls = 0;
24+
const gen = async () => {
25+
calls++;
26+
return { partial: true };
27+
};
28+
const stitch = factoryStitchUntilComplete([gen], schema);
29+
30+
await expect(stitch({ partial: true }, new Execution('root'))).rejects.toThrow(
31+
/exceeded maximum stitch attempts/
32+
);
33+
expect(calls).toBe(5);
34+
});
35+
36+
it('returns valid input without any stitch generation', async () => {
37+
let calls = 0;
38+
const gen = async () => {
39+
calls++;
40+
return { value: 'stitched' };
41+
};
42+
const stitch = factoryStitchUntilComplete([gen], schema);
43+
const result = await stitch({ value: 'already complete' }, new Execution('root'));
44+
45+
expect(calls).toBe(0);
46+
expect(result.finalOutput).toEqual({ value: 'already complete' });
47+
});
48+
});
49+
50+
describe('debug stop escapes the substep', () => {
51+
const llms = (content: any) => ({
52+
getDefaultLLM: () => async () => ({
53+
content: typeof content === 'string' ? content : JSON.stringify(content),
54+
usage: { totalTokens: 5 },
55+
metadata: { provider: 'test', model: 'test-model' },
56+
}),
57+
});
58+
59+
const nestedDebugPathExecution = () => {
60+
const root = new Execution('agent-root');
61+
const nested = root.child('plan').child('failsafe:prepare_concise_context').child('gen-0');
62+
(nested as any).llms = llms({
63+
analysis: 'a',
64+
steps: ['s'],
65+
conclusion: 'c',
66+
confidence: 0.9,
67+
});
68+
return nested;
69+
};
70+
71+
afterEach(() => {
72+
delete process.env.BITCODE_DEBUG_STOP_AFTER_FIRST_REASON;
73+
delete process.env.BITCODE_DEBUG_STOP_AFTER_FIRST_STRUCTURED_OUTPUT;
74+
});
75+
76+
it('BITCODE_DEBUG_STOP_AFTER_FIRST_REASON stops after the first reason generation', async () => {
77+
process.env.BITCODE_DEBUG_STOP_AFTER_FIRST_REASON = '1';
78+
const reason = factoryReason();
79+
80+
await expect(reason({ read: 'anything' }, nestedDebugPathExecution())).rejects.toThrow(
81+
'__BITCODE_DEBUG_STOP_AFTER_FIRST_REASON__'
82+
);
83+
});
84+
85+
it('BITCODE_DEBUG_STOP_AFTER_FIRST_STRUCTURED_OUTPUT stops on the first structured output (gen-0 pathing)', async () => {
86+
process.env.BITCODE_DEBUG_STOP_AFTER_FIRST_STRUCTURED_OUTPUT = '1';
87+
const root = new Execution('agent-root');
88+
const nested = root.child('plan').child('failsafe:prepare_concise_context').child('gen-0');
89+
(nested as any).llms = llms({ value: 'ok' });
90+
const structured = factoryStructuredOutput(schema);
91+
92+
await expect(structured({ context: 'anything' }, nested)).rejects.toThrow(
93+
'__BITCODE_DEBUG_STOP_AFTER_FIRST_STRUCTURED_OUTPUT__'
94+
);
95+
});
96+
});

packages/agent-generics/src/diagnostics/instrumentation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,9 @@ export function shouldDebugStopAfterFirstStructuredOutput(substepExec: Execution
303303
const pathArr = (substepExec as any).getPath?.() || [];
304304
const isPlanStep = pathArr.includes('plan');
305305
const inPrepareFailsafe = pathArr.some((p: string) => String(p).includes('prepare_concise_context'));
306-
const isFirstStructured = pathArr.includes('gen-2');
306+
// The structured_output substep runs inside the first thricified
307+
// generation node (gen-0); the sequence gate above already pins the kind.
308+
const isFirstStructured = pathArr.includes('gen-0');
307309
const ctx = getCtx(substepExec);
308310
const agentFilter = process?.env?.BITCODE_DEBUG_STOP_AGENT_FILTER;
309311
const agentMatches = agentFilter ? String(ctx.agentName || '').includes(String(agentFilter)) : true;

packages/agent-generics/src/substeps/factories.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -290,20 +290,21 @@ function factoryLLMSubStep<TInput, TOutput>(
290290
try { substep.store('llm', 'provider', (output as any)?.metadata?.provider); } catch { }
291291
try { substep.store('llm', 'model', (output as any)?.metadata?.model); } catch { }
292292

293-
// Optional debug stop (centralized)
294-
try {
295-
if (shouldDebugStopAfterFirstReason(substep, String(sequence))) {
296-
substep.store('debug', 'stop_after_first_reason', true);
297-
throw new Error('__BITCODE_DEBUG_STOP_AFTER_FIRST_REASON__');
298-
}
299-
} catch { }
293+
// Optional debug stop (centralized). Only the predicate is guarded —
294+
// the stop throw itself must escape this substep.
295+
let debugStopAfterFirstReason = false;
296+
try { debugStopAfterFirstReason = shouldDebugStopAfterFirstReason(substep, String(sequence)); } catch { }
297+
if (debugStopAfterFirstReason) {
298+
try { substep.store('debug', 'stop_after_first_reason', true); } catch { }
299+
throw new Error('__BITCODE_DEBUG_STOP_AFTER_FIRST_REASON__');
300+
}
300301

301-
try {
302-
if (shouldDebugStopAfterFirstStructuredOutput(substep, String(sequence))) {
303-
substep.store('debug', 'stop_after_first_structured_output', true);
304-
throw new Error('__BITCODE_DEBUG_STOP_AFTER_FIRST_STRUCTURED_OUTPUT__');
305-
}
306-
} catch { }
302+
let debugStopAfterFirstStructuredOutput = false;
303+
try { debugStopAfterFirstStructuredOutput = shouldDebugStopAfterFirstStructuredOutput(substep, String(sequence)); } catch { }
304+
if (debugStopAfterFirstStructuredOutput) {
305+
try { substep.store('debug', 'stop_after_first_structured_output', true); } catch { }
306+
throw new Error('__BITCODE_DEBUG_STOP_AFTER_FIRST_STRUCTURED_OUTPUT__');
307+
}
307308

308309
// 9. Parse output if parser provided
309310
if (config.parseOutput) {
@@ -628,11 +629,24 @@ export function factoryStitchUntilComplete<T>(
628629
}
629630
}
630631

632+
// The iteration that reaches maxStitches exits the loop before the
633+
// top-of-loop validation can inspect its result, so re-validate here —
634+
// a schema-valid final stitch is a success, not an exceeded failure.
635+
const finalStitchValid = (() => {
636+
if (!outputSchema || stitchCount < maxStitches) return false;
637+
const candidate = (currentResult && (currentResult as any).output !== undefined)
638+
? (currentResult as any).output
639+
: currentResult;
640+
try { outputSchema.parse(candidate); return true; } catch { }
641+
try { outputSchema.parse(currentResult); return true; } catch { }
642+
return false;
643+
})();
644+
631645
failsafeExec.store('stitching', 'count', stitchCount);
632-
try { logFailsafeEvent(execution, 'stitch-until-complete', { complete: true, stitchCount, exceeded: stitchCount >= maxStitches }); } catch { }
646+
try { logFailsafeEvent(execution, 'stitch-until-complete', { complete: true, stitchCount, exceeded: stitchCount >= maxStitches && !finalStitchValid }); } catch { }
633647

634-
// Check if we exceeded max stitches
635-
if (stitchCount >= maxStitches) {
648+
// Check if we exceeded max stitches without ending on a valid output
649+
if (stitchCount >= maxStitches && !finalStitchValid) {
636650
const error = new Error(
637651
`StitchUntilComplete exceeded maximum stitch attempts (${maxStitches}). ` +
638652
`Output may be incomplete or truncated. Consider increasing maxTokens or ` +

0 commit comments

Comments
 (0)