agency-lang 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,10 @@ export declare const CALLBACK_PAYLOAD_LIMIT: number;
39
39
  * excluded by non-existence rather than listed here. See the plan's
40
40
  * "Scope & design tradeoffs" section. Exported so the parent-side handler
41
41
  * (ipc.ts) rejects the same names — the guard should match its intent even under
42
- * parent/child version skew. */
42
+ * parent/child version skew. onCheckpoint is JSON-safe but must stay local: a
43
+ * child run inherits the parent's run id, so a forwarded child checkpoint would
44
+ * look to the parent's host like the parent's own latest state, and resuming
45
+ * the parent from it would restore the child program's stack. */
43
46
  export declare const NON_FORWARDABLE_CALLBACKS: readonly CallbackName[];
44
47
  /** Forward one lifecycle event to the parent. No-op unless this process is a
45
48
  * forked Agency subprocess with a live IPC channel. `maxBytes` is overridable
@@ -34,8 +34,15 @@ export const CALLBACK_PAYLOAD_LIMIT = 1024 * 1024 * 1024; // 1gb
34
34
  * excluded by non-existence rather than listed here. See the plan's
35
35
  * "Scope & design tradeoffs" section. Exported so the parent-side handler
36
36
  * (ipc.ts) rejects the same names — the guard should match its intent even under
37
- * parent/child version skew. */
38
- export const NON_FORWARDABLE_CALLBACKS = ["onStream", "onOAuthRequired"];
37
+ * parent/child version skew. onCheckpoint is JSON-safe but must stay local: a
38
+ * child run inherits the parent's run id, so a forwarded child checkpoint would
39
+ * look to the parent's host like the parent's own latest state, and resuming
40
+ * the parent from it would restore the child program's stack. */
41
+ export const NON_FORWARDABLE_CALLBACKS = [
42
+ "onStream",
43
+ "onOAuthRequired",
44
+ "onCheckpoint",
45
+ ];
39
46
  /** Forward one lifecycle event to the parent. No-op unless this process is a
40
47
  * forked Agency subprocess with a live IPC channel. `maxBytes` is overridable
41
48
  * only so tests can exercise the oversize-skip without a gigabyte payload. */
@@ -1,8 +1,9 @@
1
1
  import type { Interrupt } from "./interrupts.js";
2
2
  import type { RuntimeContext } from "./state/context.js";
3
3
  import type { SourceLocation } from "./state/sourceLocation.js";
4
+ import type { StateStack } from "./state/stateStack.js";
4
5
  export declare function debugStep(ctx: RuntimeContext<any>, info: Omit<SourceLocation, "nodeId"> & {
5
6
  label: string | null;
6
7
  nodeContext: boolean;
7
8
  isUserAdded: boolean;
8
- }): Promise<Interrupt[] | undefined>;
9
+ }, stack?: StateStack): Promise<Interrupt[] | undefined>;
@@ -1,6 +1,8 @@
1
1
  import { createDebugInterrupt } from "./interrupts.js";
2
+ import { callHook, hasCallbackConsumer } from "./hooks.js";
3
+ import { nativeTypeReplacer } from "./revivers/index.js";
2
4
  import { Checkpoint } from "./state/checkpointStore.js";
3
- export async function debugStep(ctx, info) {
5
+ export async function debugStep(ctx, info, stack) {
4
6
  // Global initialization runs outside any graph node, so there's no node
5
7
  // context to create checkpoints against. Skip debugging entirely.
6
8
  if (!ctx.stateStack.currentNodeId()) {
@@ -13,6 +15,23 @@ export async function debugStep(ctx, info) {
13
15
  if (!skipCheckpoint && ctx.stateStack.currentNodeId()) {
14
16
  const cp = Checkpoint.fromContext(ctx, info);
15
17
  await ctx.writeCheckpointToTraceWriter(cp);
18
+ // Only a checkpoint on the top-level stack is reported: a branch (fork,
19
+ // parallel block, tool call) cannot be re-entered from outside, the same
20
+ // rule a pause follows. The payload goes through nativeTypeReplacer so
21
+ // a scoped Agency callback becomes a function ref the host can store
22
+ // and the resume path can revive.
23
+ if (stack !== undefined &&
24
+ stack === ctx.stateStack &&
25
+ hasCallbackConsumer(ctx, "onCheckpoint", stack)) {
26
+ await callHook({
27
+ ctx,
28
+ name: "onCheckpoint",
29
+ data: {
30
+ runId: ctx.getRunId(),
31
+ checkpoint: JSON.parse(JSON.stringify(cp.toJSON(), nativeTypeReplacer)),
32
+ },
33
+ });
34
+ }
16
35
  }
17
36
  const dbg = ctx.debuggerState;
18
37
  if (!dbg) {
@@ -1,29 +1,45 @@
1
1
  import type { MessageThread } from "./state/messageThread.js";
2
2
  /** The refusal for a handoff call that shares a round with another call. */
3
3
  export declare function handoffNotAloneMessage(toolName: string): string;
4
- export declare function handoffMarkerText(toolName: string, args: Record<string, unknown>): string;
4
+ /** The scope key one dispatch tags its body's system messages with. The
5
+ * nesting depth is part of it because a provider that sends no call ids
6
+ * (Gemini) leaves a handoff nested inside itself with the same name and
7
+ * the same empty id. Compute it before entering the scope and again after
8
+ * leaving it; the depth is the same at both points. */
9
+ export declare function handoffScopeKey(thread: MessageThread, toolName: string, toolCallId: string): string;
5
10
  export declare function handoffResumeText(toolName: string, body: string): string;
6
11
  /** The resume message for a handoff that failed or was aborted partway. */
7
12
  export declare function handoffStoppedText(toolName: string, reason: string): string;
8
13
  /**
9
- * Rewrite the assistant message that carried the handoff tool call: keep
10
- * its text, drop the tool call, append the marker.
14
+ * Drop the tool call from the assistant message that carried the handoff
15
+ * call. Its text stays; a message that was only the call is removed, so
16
+ * the thread reads as the user's request followed by the body's work.
17
+ * Nothing is added in its place: a model that sees dispatch narration in
18
+ * its history learns to write it instead of calling the tool.
11
19
  */
12
- export declare function applyHandoffMarker(thread: MessageThread, toolName: string, args: Record<string, unknown>): void;
20
+ export declare function dropHandoffToolCall(thread: MessageThread): void;
13
21
  /**
14
- * Remove the system messages the body pushed: every system message after
15
- * this dispatch's marker. Anchored on the marker rather than on a recorded
16
- * position because memory compaction rewrites the thread and shifts every
17
- * index. A marker that compaction summarized away took the body's earlier
18
- * system messages with it, so there is nothing left to remove.
22
+ * Remove the system messages the body pushed: every message tagged with
23
+ * this dispatch's scope key. Tags survive checkpoints and compaction, and
24
+ * a message compaction summarized away is simply not there to remove.
19
25
  */
20
- export declare function stripHandoffSystemMessages(thread: MessageThread, toolName: string, args: Record<string, unknown>): void;
26
+ export declare function stripHandoffSystemMessages(thread: MessageThread, scopeKey: string): void;
21
27
  /**
22
28
  * Close a handoff: remove the body's system messages (its persona is not
23
29
  * useful to the caller afterwards and would grow the context on every
24
30
  * dispatch), then hand control back with a user-role message that carries
25
31
  * the body's result.
26
32
  */
27
- export declare function finishHandoff(thread: MessageThread, toolName: string, args: Record<string, unknown>, body: string): void;
33
+ export declare function finishHandoff(args: {
34
+ thread: MessageThread;
35
+ scopeKey: string;
36
+ toolName: string;
37
+ body: string;
38
+ }): void;
28
39
  /** Close a handoff that failed or was aborted. */
29
- export declare function finishStoppedHandoff(thread: MessageThread, toolName: string, args: Record<string, unknown>, reason: string): void;
40
+ export declare function finishStoppedHandoff(args: {
41
+ thread: MessageThread;
42
+ scopeKey: string;
43
+ toolName: string;
44
+ reason: string;
45
+ }): void;
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Message plumbing for handoff functions (`handoff def`). When a model
3
3
  * calls one as a tool, the tool loop keeps the body on the caller's
4
- * thread and replaces the tool-call bookkeeping with two plain messages:
5
- * an assistant-role marker where the tool call was, and a user-role
6
- * resume message when the body returns. These helpers only touch a
7
- * MessageThread; prompt.ts decides when to call them. See
8
- * docs/dev/language/handoff-functions.md.
4
+ * thread: the tool call is dropped from the assistant message that
5
+ * carried it (providers demand a tool result right after a tool call,
6
+ * and the body's messages land there instead), the body's system
7
+ * messages are tagged with the dispatch's scope key while it runs, and a
8
+ * user-role resume message hands control back when the body returns.
9
+ * These helpers only touch a MessageThread; prompt.ts decides when to
10
+ * call them. See docs/dev/language/handoff-functions.md.
9
11
  */
10
12
  import * as smoltalk from "smoltalk";
11
13
  /** The refusal for a handoff call that shares a round with another call. */
@@ -13,8 +15,13 @@ export function handoffNotAloneMessage(toolName) {
13
15
  return (`Error: ${toolName} continues this conversation, so it must be the only tool call in its round. ` +
14
16
  `It was not run. Call it again by itself, with no other tool calls in the same response.`);
15
17
  }
16
- export function handoffMarkerText(toolName, args) {
17
- return `[dispatching ${toolName}: ${JSON.stringify(args)}]`;
18
+ /** The scope key one dispatch tags its body's system messages with. The
19
+ * nesting depth is part of it because a provider that sends no call ids
20
+ * (Gemini) leaves a handoff nested inside itself with the same name and
21
+ * the same empty id. Compute it before entering the scope and again after
22
+ * leaving it; the depth is the same at both points. */
23
+ export function handoffScopeKey(thread, toolName, toolCallId) {
24
+ return `${toolName}:${toolCallId}:${thread.handoffDepth()}`;
18
25
  }
19
26
  export function handoffResumeText(toolName, body) {
20
27
  return `[${toolName} finished. ${body}]\nContinue with the user's request.`;
@@ -25,10 +32,13 @@ export function handoffStoppedText(toolName, reason) {
25
32
  `Its work so far is in the messages above. Continue with the user's request using that work.`);
26
33
  }
27
34
  /**
28
- * Rewrite the assistant message that carried the handoff tool call: keep
29
- * its text, drop the tool call, append the marker.
35
+ * Drop the tool call from the assistant message that carried the handoff
36
+ * call. Its text stays; a message that was only the call is removed, so
37
+ * the thread reads as the user's request followed by the body's work.
38
+ * Nothing is added in its place: a model that sees dispatch narration in
39
+ * its history learns to write it instead of calling the tool.
30
40
  */
31
- export function applyHandoffMarker(thread, toolName, args) {
41
+ export function dropHandoffToolCall(thread) {
32
42
  const messages = thread.getMessages();
33
43
  const index = messages.length - 1;
34
44
  const last = messages[index];
@@ -36,39 +46,19 @@ export function applyHandoffMarker(thread, toolName, args) {
36
46
  throw new Error(`handoff: expected the thread to end with the assistant message carrying the tool call, found ${last?.role ?? "an empty thread"}`);
37
47
  }
38
48
  const text = typeof last.content === "string" ? last.content.trim() : "";
39
- const marker = handoffMarkerText(toolName, args);
40
- const content = text === "" ? marker : `${text}\n\n${marker}`;
41
- thread.replaceAt(index, smoltalk.assistantMessage(content));
42
- }
43
- /** The index of this dispatch's marker, searching from the end so the
44
- * newest dispatch of a tool wins. -1 when memory compaction has
45
- * summarized the marker away. */
46
- function markerIndex(thread, toolName, args) {
47
- const marker = handoffMarkerText(toolName, args);
48
- const messages = thread.getMessages();
49
- for (let index = messages.length - 1; index >= 0; index--) {
50
- const message = messages[index];
51
- if (message.role === "assistant" &&
52
- typeof message.content === "string" &&
53
- message.content.endsWith(marker)) {
54
- return index;
55
- }
49
+ if (text === "") {
50
+ thread.removeAt(index);
51
+ return;
56
52
  }
57
- return -1;
53
+ thread.replaceAt(index, smoltalk.assistantMessage(text));
58
54
  }
59
55
  /**
60
- * Remove the system messages the body pushed: every system message after
61
- * this dispatch's marker. Anchored on the marker rather than on a recorded
62
- * position because memory compaction rewrites the thread and shifts every
63
- * index. A marker that compaction summarized away took the body's earlier
64
- * system messages with it, so there is nothing left to remove.
56
+ * Remove the system messages the body pushed: every message tagged with
57
+ * this dispatch's scope key. Tags survive checkpoints and compaction, and
58
+ * a message compaction summarized away is simply not there to remove.
65
59
  */
66
- export function stripHandoffSystemMessages(thread, toolName, args) {
67
- const index = markerIndex(thread, toolName, args);
68
- if (index === -1) {
69
- return;
70
- }
71
- thread.removeMatching(index + 1, (message) => message.role === "system");
60
+ export function stripHandoffSystemMessages(thread, scopeKey) {
61
+ thread.removeHandoffScoped(scopeKey);
72
62
  }
73
63
  /**
74
64
  * Close a handoff: remove the body's system messages (its persona is not
@@ -76,12 +66,12 @@ export function stripHandoffSystemMessages(thread, toolName, args) {
76
66
  * dispatch), then hand control back with a user-role message that carries
77
67
  * the body's result.
78
68
  */
79
- export function finishHandoff(thread, toolName, args, body) {
80
- stripHandoffSystemMessages(thread, toolName, args);
81
- thread.push(smoltalk.userMessage(handoffResumeText(toolName, body)));
69
+ export function finishHandoff(args) {
70
+ stripHandoffSystemMessages(args.thread, args.scopeKey);
71
+ args.thread.push(smoltalk.userMessage(handoffResumeText(args.toolName, args.body)));
82
72
  }
83
73
  /** Close a handoff that failed or was aborted. */
84
- export function finishStoppedHandoff(thread, toolName, args, reason) {
85
- stripHandoffSystemMessages(thread, toolName, args);
86
- thread.push(smoltalk.userMessage(handoffStoppedText(toolName, reason)));
74
+ export function finishStoppedHandoff(args) {
75
+ stripHandoffSystemMessages(args.thread, args.scopeKey);
76
+ args.thread.push(smoltalk.userMessage(handoffStoppedText(args.toolName, args.reason)));
87
77
  }
@@ -2,7 +2,7 @@ import type { CostEstimate, MessageJSON, ModelName, PromptResult, TokenUsage, To
2
2
  import type { LLMRetryReason } from "./llmRetry.js";
3
3
  import type { RuntimeContext } from "./state/context.js";
4
4
  import type { StateStack } from "./state/stateStack.js";
5
- import type { TraceEvent } from "./trace/types.js";
5
+ import type { CheckpointJSON, TraceEvent } from "./trace/types.js";
6
6
  import type { RunNodeResult } from "./types.js";
7
7
  export type CallbackMap = {
8
8
  onAgentStart: {
@@ -83,6 +83,15 @@ export type CallbackMap = {
83
83
  error: any;
84
84
  };
85
85
  onTrace: TraceEvent;
86
+ /** One statement checkpoint on the top-level stack, fired from `debugStep`
87
+ * whenever a consumer is registered. `checkpoint` is plain JSON (function
88
+ * refs encoded, as in a pause result); `{ type: "paused", checkpoint,
89
+ * runId }` resumes through `resumeFromCheckpoint`. Never forwarded from
90
+ * a subprocess. */
91
+ onCheckpoint: {
92
+ runId: string;
93
+ checkpoint: CheckpointJSON;
94
+ };
86
95
  onOAuthRequired: {
87
96
  serverName: string;
88
97
  authUrl: string;
@@ -24,7 +24,7 @@ import { warnOnOversizedToolSchemas } from "./toolSchemaSize.js";
24
24
  import { DEFAULT_MAX_REPEATED_TOOL_CALLS, freshRepeatStreak, markupArgument, markupArgumentMessage, noteRepeat, repeatedCallMessage, repeatKey, repeatsBefore, resetRepeat, } from "./toolLoopGuards.js";
25
25
  import { GuardTripRetry, raiseGuardTripsUntilClear } from "./guardTripInterrupt.js";
26
26
  import { failure, isFailure, isSuccess, markDestructiveWork, runtimeFailure } from "./result.js";
27
- import { applyHandoffMarker, finishHandoff, finishStoppedHandoff, handoffNotAloneMessage, stripHandoffSystemMessages, } from "./handoff.js";
27
+ import { dropHandoffToolCall, finishHandoff, finishStoppedHandoff, handoffNotAloneMessage, handoffScopeKey, stripHandoffSystemMessages, } from "./handoff.js";
28
28
  import { isAborted } from "./abortedResult.js";
29
29
  import { MessageThread } from "./state/messageThread.js";
30
30
  import { claimFrameForScope } from "./state/stateStack.js";
@@ -164,13 +164,22 @@ async function invokeOnFreshThreadStore(ctx, invoke) {
164
164
  * active stack, so two prompts running at once (two `async llm()` calls,
165
165
  * say) cannot interleave pushes and pops on a shared one.
166
166
  */
167
- async function invokeOnThread(thread, invoke) {
168
- const parentFrame = agencyStore.getStore();
169
- if (!parentFrame) {
170
- return invoke();
167
+ async function invokeOnThread(thread, scopeKey, invoke) {
168
+ // The body's system messages are tagged with the dispatch's scope key
169
+ // while it runs, so the hand-back can remove them without a marker on
170
+ // the thread. Re-entered when a resume re-runs the dispatch.
171
+ thread.enterHandoffScope(scopeKey);
172
+ try {
173
+ const parentFrame = agencyStore.getStore();
174
+ if (!parentFrame) {
175
+ return await invoke();
176
+ }
177
+ const view = parentFrame.threads.viewWithActive(thread);
178
+ return await agencyStore.run({ ...parentFrame, threads: view }, invoke);
179
+ }
180
+ finally {
181
+ thread.exitHandoffScope();
171
182
  }
172
- const view = parentFrame.threads.viewWithActive(thread);
173
- return agencyStore.run({ ...parentFrame, threads: view }, invoke);
174
183
  }
175
184
  /** Classify a tool failure. Most-specific fact wins: a started destructive
176
185
  * operation, then a proved-nothing-ran, then the tool's own idempotent
@@ -350,10 +359,16 @@ async function runPostTurnMemory(ctx, targetStack, messages) {
350
359
  // Reassemble the thread from the ORIGINAL smoltalk Message
351
360
  // instances so tool_call metadata, ids, and other class-level
352
361
  // fields survive untouched.
362
+ // Labels and handoff scopes ride along with the kept messages; the
363
+ // summary has neither.
364
+ const kept = [...plan.systemPrefixIndices, ...plan.tailIndices];
353
365
  const head = plan.systemPrefixIndices.map((i) => original[i]);
354
366
  const tail = plan.tailIndices.map((i) => original[i]);
355
367
  const summary = smoltalk.systemMessage(plan.summaryMessageContent);
356
- messages.setMessages([...head, summary, ...tail]);
368
+ const labels = kept.map((i) => messages.labelAt(i));
369
+ const scopes = kept.map((i) => messages.scopeAt(i));
370
+ const splitAt = head.length;
371
+ messages.setMessages([...head, summary, ...tail], [...labels.slice(0, splitAt), null, ...labels.slice(splitAt)], [...scopes.slice(0, splitAt), null, ...scopes.slice(splitAt)]);
357
372
  }
358
373
  }
359
374
  catch (err) {
@@ -866,7 +881,7 @@ export async function runPrompt(args) {
866
881
  // Every other tool gets a fresh store.
867
882
  const continuesCallerThread = !!handler.markers?.handoff;
868
883
  if (continuesCallerThread) {
869
- toolResult = await invokeOnThread(messages, invokeAsTool);
884
+ toolResult = await invokeOnThread(messages, handoffScopeKey(messages, handler.name, toolCall.id), invokeAsTool);
870
885
  }
871
886
  else {
872
887
  toolResult = await invokeOnFreshThreadStore(ctx, invokeAsTool);
@@ -880,11 +895,10 @@ export async function runPrompt(args) {
880
895
  // "Tool call X crashed" for every tool on the stack and corrupt
881
896
  // the thread. Mirrors the function/node catch re-throws. A
882
897
  // cancelled handoff never reaches finishHandoff, so its body's
883
- // system messages are removed here; the marker stays as the
884
- // record of what was attempted.
898
+ // system messages are removed here.
885
899
  if (isAbortError(error)) {
886
900
  if (handler.markers?.handoff) {
887
- stripHandoffSystemMessages(messages, handler.name, namedArgs);
901
+ stripHandoffSystemMessages(messages, handoffScopeKey(messages, handler.name, toolCall.id));
888
902
  }
889
903
  stack.deleteBranch(branchKey);
890
904
  throw error;
@@ -1044,7 +1058,7 @@ export async function runPrompt(args) {
1044
1058
  // Push a plain notice ToolMessage for one call — the refusal gates
1045
1059
  // (unhandled, round cap, removed, markup, repeat, prior rejection,
1046
1060
  // handoff not alone) all answer the model this way. A refusal happens
1047
- // before the .handoffMarker step, so the assistant message is intact
1061
+ // before the .handoffDropCall step, so the assistant message is intact
1048
1062
  // and the notice pairs with its tool_use even for a handoff.
1049
1063
  const pushToolMessage = (content, toolCall) => {
1050
1064
  messages.push(smoltalk.toolMessage(content, { tool_call_id: toolCall.id, name: toolCall.name }));
@@ -1054,20 +1068,31 @@ export async function runPrompt(args) {
1054
1068
  };
1055
1069
  // Answer the model for one invoked call. An ordinary tool gets a
1056
1070
  // tool message paired with its tool_use. A handoff has no tool_use
1057
- // (the .handoffMarker step rewrote it), so it gets the user-role
1071
+ // (the .handoffDropCall step removed it), so it gets the user-role
1058
1072
  // resume message instead, after the body's system messages are
1059
1073
  // stripped. `content` may be structured; the resume message needs
1060
1074
  // text. `stoppedReason` is set when the call failed or was aborted;
1061
1075
  // a handoff's resume message then points at the work already on this
1062
1076
  // thread instead of carrying the error text an ordinary tool gets.
1063
1077
  const pushToolReply = (args) => {
1064
- const { content, toolCall, handler, namedArgs, stoppedReason } = args;
1078
+ const { content, toolCall, handler, stoppedReason } = args;
1065
1079
  if (handler.markers?.handoff) {
1080
+ const scopeKey = handoffScopeKey(messages, handler.name, toolCall.id);
1066
1081
  if (stoppedReason !== undefined) {
1067
- finishStoppedHandoff(messages, handler.name, namedArgs, stoppedReason);
1082
+ finishStoppedHandoff({
1083
+ thread: messages,
1084
+ scopeKey,
1085
+ toolName: handler.name,
1086
+ reason: stoppedReason,
1087
+ });
1068
1088
  return;
1069
1089
  }
1070
- finishHandoff(messages, handler.name, namedArgs, stringifyToolResult(content));
1090
+ finishHandoff({
1091
+ thread: messages,
1092
+ scopeKey,
1093
+ toolName: handler.name,
1094
+ body: stringifyToolResult(content),
1095
+ });
1071
1096
  return;
1072
1097
  }
1073
1098
  pushToolMessage(content, toolCall);
@@ -1315,15 +1340,15 @@ export async function runPrompt(args) {
1315
1340
  // Unreachable: a missing handler yields the "unhandled" verdict.
1316
1341
  return;
1317
1342
  }
1318
- // A handoff replaces the assistant message that carried
1319
- // its tool call with a marker. The rewritten thread is in
1320
- // the checkpoint, so a resumed pass does not rewrite again.
1321
- // The thread ends on that assistant message here because
1322
- // the handoff is the round's only call and the round
1323
- // boundary runs after the tools.
1343
+ // A handoff drops the tool call from the assistant message
1344
+ // that carried it, so the body's messages can follow. The
1345
+ // rewritten thread is in the checkpoint, so a resumed pass
1346
+ // does not rewrite again. The thread ends on that assistant
1347
+ // message here because the handoff is the round's only call
1348
+ // and the round boundary runs after the tools.
1324
1349
  if (handler.markers?.handoff) {
1325
- await b.step(`${invocationKey}.handoffMarker`, async () => {
1326
- applyHandoffMarker(messages, handler.name, namedArgs);
1350
+ await b.step(`${invocationKey}.handoffDropCall`, async () => {
1351
+ dropHandoffToolCall(messages);
1327
1352
  });
1328
1353
  }
1329
1354
  const branchKey = `tool_${callSlug}`;
@@ -184,6 +184,12 @@ export declare class Runner {
184
184
  private maybeDebugHook;
185
185
  /** Clean up the debug flag for a step after it completes without halting. */
186
186
  private clearDebugFlag;
187
+ /** Whether this step should build a checkpoint: the debugger wants one, a
188
+ * trace sink wants one, or a host or Agency callback registered for
189
+ * `onCheckpoint` wants one. `debugHook` and `clearDebugFlag` must agree,
190
+ * so both read this. The `onCheckpoint` reason applies only on the
191
+ * top-level stack, because a branch checkpoint is not reported. */
192
+ private recordsStepCheckpoints;
187
193
  private stepPath;
188
194
  private debugFlagKey;
189
195
  /** Seed branch.stack.localCost / localTokens from the parent stack
@@ -5,7 +5,7 @@ import { raiseGuardTripsAtStep } from "./guardTripInterrupt.js";
5
5
  import { debugStep } from "./debugger.js";
6
6
  import { RunControlSignal, readCause } from "./errors.js";
7
7
  import { HaltSignal } from "./haltSignal.js";
8
- import { invokeCallbacks, isInsideCallback } from "./hooks.js";
8
+ import { hasCallbackConsumer, invokeCallbacks, isInsideCallback } from "./hooks.js";
9
9
  import { hasInterrupts } from "./interrupts.js";
10
10
  import { pauseAtStep } from "./pause.js";
11
11
  import { __pipeBind } from "./result.js";
@@ -368,7 +368,7 @@ export class Runner {
368
368
  * that pauses) — the flag stays set so the next resume skips the hook.
369
369
  */
370
370
  async maybeDebugHook(id, label = null, isUserAdded = false) {
371
- if (!this.ctx.hasDebugger() && !this.ctx.hasTraceWriter())
371
+ if (!this.recordsStepCheckpoints())
372
372
  return false;
373
373
  if (this.ctx.isInsideToolCall())
374
374
  return false;
@@ -390,7 +390,7 @@ export class Runner {
390
390
  label,
391
391
  nodeContext: this.nodeContext,
392
392
  isUserAdded,
393
- });
393
+ }, this.stack);
394
394
  if (dbg) {
395
395
  if (this.nodeContext) {
396
396
  // Wrap in { messages, data } to match node return format.
@@ -411,11 +411,23 @@ export class Runner {
411
411
  }
412
412
  /** Clean up the debug flag for a step after it completes without halting. */
413
413
  clearDebugFlag(id) {
414
- if (!this.ctx.hasDebugger() && !this.ctx.hasTraceWriter()) {
414
+ if (!this.recordsStepCheckpoints()) {
415
415
  return;
416
416
  }
417
417
  delete this.frame.locals[this.debugFlagKey(id)];
418
418
  }
419
+ /** Whether this step should build a checkpoint: the debugger wants one, a
420
+ * trace sink wants one, or a host or Agency callback registered for
421
+ * `onCheckpoint` wants one. `debugHook` and `clearDebugFlag` must agree,
422
+ * so both read this. The `onCheckpoint` reason applies only on the
423
+ * top-level stack, because a branch checkpoint is not reported. */
424
+ recordsStepCheckpoints() {
425
+ if (this.ctx.hasDebugger() || this.ctx.hasTraceWriter())
426
+ return true;
427
+ return (this.stack !== undefined &&
428
+ this.stack === this.ctx.stateStack &&
429
+ hasCallbackConsumer(this.ctx, "onCheckpoint", this.stack));
430
+ }
419
431
  stepPath(id) {
420
432
  return this.path.length === 0 ? `${id}` : `${this.key()}.${id}`;
421
433
  }
@@ -2,6 +2,7 @@ import * as smoltalk from "smoltalk";
2
2
  export type MessageThreadJSON = {
3
3
  messages: smoltalk.MessageJSON[];
4
4
  messageLabels?: (string | null)[];
5
+ messageScopes?: (string | null)[];
5
6
  parentId?: string | null;
6
7
  hidden?: boolean;
7
8
  label?: string | null;
@@ -84,10 +85,22 @@ export declare class MessageThread {
84
85
  *
85
86
  * Nothing else touches `this.messages`. A desync does not degrade
86
87
  * gracefully — it shifts every later label onto the wrong message — so
87
- * keep it that way. A rewrite via `setMessages` with no labels
88
- * (summarization) drops them; that is intended. (Thread repair
89
- * appends via `push`, so it keeps them.) */
88
+ * keep it that way. A rewrite via `setMessages` with no labels drops
89
+ * them; summarization passes the labels of the messages it keeps.
90
+ * (Thread repair appends via `push`, so it keeps them.) */
90
91
  messageLabels: (string | null)[];
92
+ /** Which handoff dispatch a system message belongs to, aligned with
93
+ * `messages` by index like `messageLabels`, null for every other
94
+ * message. Stamped by `push` from `handoffScopes` while a dispatch's
95
+ * body is running on this thread, so the body's persona can be
96
+ * removed when the body returns without leaving a marker in the
97
+ * conversation. Serialized with the thread, kept through compaction,
98
+ * never sent to the provider. */
99
+ messageScopes: (string | null)[];
100
+ /** The dispatches whose bodies are running on this thread right now,
101
+ * innermost last. Transient: the prompt loop re-enters a scope when it
102
+ * re-runs a dispatch after a resume, so nothing here is serialized. */
103
+ private handoffScopes;
91
104
  /** Messages queued by `queueMessage`, waiting for the thread's next
92
105
  * request-turn. Drained by the turn-boundary machinery in the tool
93
106
  * loop; never sent to the provider directly from here. Serialized
@@ -108,7 +121,7 @@ export declare class MessageThread {
108
121
  * outright rather than padded or sliced: the lengths disagreeing means
109
122
  * the source is already wrong, and guessing an alignment would put
110
123
  * real labels on the wrong messages. Unlabeled beats mislabeled. */
111
- setMessages(messages: smoltalk.Message[], labels?: (string | null)[]): void;
124
+ setMessages(messages: smoltalk.Message[], labels?: (string | null)[], scopes?: (string | null)[]): void;
112
125
  /** Remove the message at `index`, taking its label with it. For the
113
126
  * callers that edit one message out of the thread and would otherwise
114
127
  * reach for `setMessages` and drop every label as collateral. */
@@ -137,6 +150,21 @@ export declare class MessageThread {
137
150
  push(message: smoltalk.Message, label?: string | null): void;
138
151
  /** The label of the message at `index`, or null when unlabeled. */
139
152
  labelAt(index: number): string | null;
153
+ /** The handoff dispatch the system message at `index` belongs to, or
154
+ * null for a message outside any dispatch. */
155
+ scopeAt(index: number): string | null;
156
+ /** Mark the start of a handoff body on this thread: every system message
157
+ * pushed until the matching `exitHandoffScope` belongs to `key`. Scopes
158
+ * nest, innermost winning, for a handoff dispatched inside a handoff. */
159
+ enterHandoffScope(key: string): void;
160
+ exitHandoffScope(): void;
161
+ /** How many handoff bodies are running on this thread right now. */
162
+ handoffDepth(): number;
163
+ private currentHandoffScope;
164
+ /** Remove every message that belongs to the dispatch `key`: the
165
+ * persona and any other system message its body pushed. Walks
166
+ * backwards so each removal leaves the indexes still to visit intact. */
167
+ removeHandoffScoped(key: string): void;
140
168
  /** Queue a message for delivery at this thread's next request-turn:
141
169
  * the start of the thread's next `llm()` call, or after a tool round
142
170
  * within a running call. The mid-turn-safe counterpart to an immediate