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.
- package/dist/lib/runtime/callbackForwarding.d.ts +4 -1
- package/dist/lib/runtime/callbackForwarding.js +9 -2
- package/dist/lib/runtime/debugger.d.ts +2 -1
- package/dist/lib/runtime/debugger.js +20 -1
- package/dist/lib/runtime/handoff.d.ts +28 -12
- package/dist/lib/runtime/handoff.js +35 -45
- package/dist/lib/runtime/hooks.d.ts +10 -1
- package/dist/lib/runtime/prompt.js +50 -25
- package/dist/lib/runtime/runner.d.ts +6 -0
- package/dist/lib/runtime/runner.js +16 -4
- package/dist/lib/runtime/state/messageThread.d.ts +32 -4
- package/dist/lib/runtime/state/messageThread.js +63 -5
- package/dist/lib/runtime/state/schemas.d.ts +6 -0
- package/dist/lib/runtime/state/schemas.js +1 -0
- package/dist/lib/stdlib/embedding.d.ts +9 -0
- package/dist/lib/stdlib/embedding.js +124 -0
- package/dist/lib/types/function.d.ts +1 -1
- package/dist/lib/types/function.js +1 -0
- package/package.json +2 -2
- package/stdlib/docs/guide/callbacks.md +5 -1
- package/stdlib/docs/stdlib/embedding.md +166 -0
- package/stdlib/embedding.agency +104 -0
- package/stdlib/embedding.js +781 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
20
|
+
export declare function dropHandoffToolCall(thread: MessageThread): void;
|
|
13
21
|
/**
|
|
14
|
-
* Remove the system messages the body pushed: every
|
|
15
|
-
* this dispatch's
|
|
16
|
-
*
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
53
|
+
thread.replaceAt(index, smoltalk.assistantMessage(text));
|
|
58
54
|
}
|
|
59
55
|
/**
|
|
60
|
-
* Remove the system messages the body pushed: every
|
|
61
|
-
* this dispatch's
|
|
62
|
-
*
|
|
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,
|
|
67
|
-
|
|
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(
|
|
80
|
-
stripHandoffSystemMessages(thread,
|
|
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(
|
|
85
|
-
stripHandoffSystemMessages(thread,
|
|
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 {
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 .
|
|
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 .
|
|
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,
|
|
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(
|
|
1082
|
+
finishStoppedHandoff({
|
|
1083
|
+
thread: messages,
|
|
1084
|
+
scopeKey,
|
|
1085
|
+
toolName: handler.name,
|
|
1086
|
+
reason: stoppedReason,
|
|
1087
|
+
});
|
|
1068
1088
|
return;
|
|
1069
1089
|
}
|
|
1070
|
-
finishHandoff(
|
|
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
|
|
1319
|
-
//
|
|
1320
|
-
// the checkpoint, so a resumed pass
|
|
1321
|
-
// The thread ends on that assistant
|
|
1322
|
-
// the handoff is the round's only call
|
|
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}.
|
|
1326
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|