Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ Hosts may translate a durable LangGraph pause into the structured suspension
result. Untranslated LangGraph interrupts and parent commands retain the fork
and propagate as control flow for compatibility and routing.

## Terminal Run Continuation

A **Terminal Run Continuation** keeps one naturally completed `Run` alive when
its terminal hooks admit queued host input. Ordinary Stop hooks execute in
parallel and fold policy/notification requests first; the serialized
StopFinalize phase then observes whether they already planned or prevented a
continuation, allowing a durable host to make one atomic claim-or-seal decision.
A `block` result with injected messages starts another graph segment before Run
cleanup, hook-session teardown, or final content extraction. The continuation
retains the same public run, trace scope, event handlers, graph sidecars, and
content accumulator; it does not replay RunStart or UserPromptSubmit hooks.

Continuation is bounded. Both terminal phases report the admitted count and
remaining budget. StopFinalize additionally reports the folded planned and
prevented state so unrelated continuation sources cannot close each other's
admission. HITL pauses, aborts, hook halts, output truncation, and empty
Stop injections remain terminal and never enter this path. With a checkpointer,
the next segment submits only its injected delta; without one, it seeds the new
graph invocation from the live in-process transcript.

## Tool Caller Capabilities

A **Caller Capability Projection** is the effective classification of tool
Expand Down
8 changes: 8 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export const DEFAULT_TOOL_TOKEN_MULTIPLIER = 1.4;
*/
export const DEFAULT_MAX_SEALS = 8;

/**
* Default ceiling on Stop-hook continuations within one Run. A blocking Stop
* hook can keep a naturally terminal run warm by injecting another user turn;
* the ceiling prevents a faulty hook or continuously arriving input from
* keeping one processStream call alive forever.
*/
export const DEFAULT_MAX_STOP_CONTINUATIONS = 8;

/**
* Per-hook timeout for `PreemptBoundary`, deliberately far above
* `DEFAULT_HOOK_TIMEOUT_MS`. A host drain has already popped its queue and
Expand Down
45 changes: 45 additions & 0 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,9 @@ export abstract class Graph<
contentData: t.RunStep[] = [];
protected nextContentIndex = 0;
protected runStepStateRevision = 0;
protected stopContinuationCount = 0;
protected stopContinuationExecutionId = '';
protected streamSegment = 0;
stepKeyIds: Map<string, string[]> = new Map<string, string[]>();
contentIndexMap: Map<string, number> = new Map();
toolCallStepIds: Map<string, string> = new Map();
Expand Down Expand Up @@ -900,6 +903,9 @@ export abstract class Graph<
this.contentData = [];
this.nextContentIndex = 0;
this.runStepStateRevision = 0;
this.stopContinuationCount = 0;
this.stopContinuationExecutionId = '';
this.streamSegment = 0;
this.contentIndexMap = new Map();
this.stepKeyIds = new Map();
this.toolCallStepIds.clear();
Expand Down Expand Up @@ -1537,6 +1543,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
this.cachedRunMessages = undefined;
this.cachedDiscoveredTools = undefined;
this.config = resetIfNotEmpty(this.config, undefined);
this.stopContinuationCount = 0;
this.stopContinuationExecutionId = '';
this.streamSegment = 0;
if (keepContent !== true) {
this.contentData = resetIfNotEmpty(this.contentData, []);
this.nextContentIndex = 0;
Expand Down Expand Up @@ -1835,6 +1844,13 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
version: 1,
revision: this.runStepStateRevision,
nextIndex: this.nextContentIndex,
stopContinuationCount: this.stopContinuationCount,
...(this.stopContinuationExecutionId === ''
? {}
: {
stopContinuationExecutionId: this.stopContinuationExecutionId,
}),
streamSegment: this.streamSegment,
toolCallSteps: [...this.toolCallStepIds].map(([toolCallId, stepId]) => ({
toolCallId,
stepId,
Expand All @@ -1854,6 +1870,10 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {

this.nextContentIndex = state.nextIndex;
this.runStepStateRevision = state.revision;
this.stopContinuationCount = state.stopContinuationCount ?? 0;
this.stopContinuationExecutionId =
state.stopContinuationExecutionId ?? '';
this.streamSegment = state.streamSegment ?? 0;
Comment on lines +1873 to +1876

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset persisted continuation counts for fresh turns

When a checkpointer is reused for the next ordinary user turn, its latest runStepState still contains the prior turn's terminal-continuation count. Although resetValues() clears the in-memory counter for the fresh processStream() call, the first graph node restores that persisted value here, so stopHookActive is immediately true and the remaining budget shrinks cumulatively across unrelated turns; after enough turns, valid continuations are rejected as exhausted. Restore these lifecycle fields only for HITL resume, or overwrite them in graph state when starting a genuinely fresh execution.

Useful? React with 👍 / 👎.

for (const { toolCallId, stepId } of state.toolCallSteps) {
this.toolCallStepIds.set(toolCallId, stepId);
}
Expand Down Expand Up @@ -1888,6 +1908,30 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
return undefined;
}

getStopContinuationCount(): number {
return this.stopContinuationCount;
}

setStopContinuationCount(count: number): void {
this.stopContinuationCount = count;
}

getStopContinuationExecutionId(): string {
return this.stopContinuationExecutionId;
}

startStopContinuationExecution(executionId: string): void {
this.stopContinuationExecutionId = executionId;
}

getStreamSegment(): number {
return this.streamSegment;
}

advanceStreamSegment(): void {
this.streamSegment += 1;
}

/**
* Derives the same lane key `dispatchRunStep` stamps as `runStep.agentId`.
* The multi-agent check gates the lookup because `getAgentContext` signals
Expand Down Expand Up @@ -2336,6 +2380,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
metadata.langgraph_node as string,
metadata.langgraph_step as number,
checkpointNs,
this.streamSegment,
];

return keyList;
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/HookRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
/**
* Events whose hooks can change a tool call's input or output. Presence of
* any of these disables eager tool execution and early completion emission;
* observation-only events (`PostToolBatch`, `Stop`, telemetry hooks) do not.
* hooks that cannot rewrite a tool result (`PostToolBatch`, `Stop`, telemetry
* hooks) do not. A Stop hook may continue the Run, but only after every tool
* result in the terminal graph segment is already authoritative.
*/
const RESULT_ALTERING_HOOK_EVENTS = [
'PreToolUse',
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/__tests__/HookRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ describe('HookRegistry', () => {
expect(registry.hasResultAlteringHooks('run-1')).toBe(false);
});

it('returns false when only observation hooks are registered', () => {
it('returns false when hooks cannot rewrite tool results', () => {
const registry = new HookRegistry();
registry.register('PostToolBatch', { hooks: [async () => ({})] });
registry.register('Stop', { hooks: [async () => ({})] });
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/__tests__/executeHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function stopInput(overrides: Partial<StopHookInput> = {}): StopHookInput {
runId: 'run-1',
messages: [],
stopHookActive: false,
continuationCount: 0,
continuationBudgetRemaining: 8,
...overrides,
};
}
Expand Down Expand Up @@ -1209,6 +1211,7 @@ describe('executeHooks', () => {
matchQuery: 'Bash',
});
expect(result.errors).toHaveLength(0);
expect(result.hasHookFailures).toBe(true);
});

it('routes non-internal errors through an optional logger instead of console', async () => {
Expand Down
Loading
Loading