@gajae-code/agent-core 0.13.3 → 0.14.0
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/CHANGELOG.md +21 -0
- package/dist/types/agent-loop.d.ts +2 -1
- package/dist/types/agent.d.ts +36 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/tool-dispatch-identity.d.ts +27 -0
- package/dist/types/types.d.ts +11 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +805 -161
- package/src/agent.ts +116 -18
- package/src/index.ts +2 -0
- package/src/proxy.ts +3 -1
- package/src/tool-dispatch-identity.ts +87 -0
- package/src/types.ts +11 -0
package/src/agent-loop.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
EventStream,
|
|
15
15
|
isZodSchema,
|
|
16
16
|
streamSimple,
|
|
17
|
+
type ToolChoice,
|
|
17
18
|
type ToolResultMessage,
|
|
18
19
|
type TSchema,
|
|
19
20
|
transportFailureFacts,
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
neutralizeReservedControlTokens,
|
|
32
33
|
stripUnusableReasoningItems,
|
|
33
34
|
} from "@gajae-code/ai/utils";
|
|
34
|
-
import { sanitizeText } from "@gajae-code/utils";
|
|
35
|
+
import { logger, sanitizeText } from "@gajae-code/utils";
|
|
35
36
|
import type { AttemptScope } from "./attempt-scope";
|
|
36
37
|
import {
|
|
37
38
|
createHarmonyAuditEvent,
|
|
@@ -62,6 +63,11 @@ import {
|
|
|
62
63
|
startExecuteToolSpan,
|
|
63
64
|
startInvokeAgentSpan,
|
|
64
65
|
} from "./telemetry";
|
|
66
|
+
import {
|
|
67
|
+
activeToolForCallName,
|
|
68
|
+
bindDispatchedToolIdentity,
|
|
69
|
+
markNonDispatchedToolEvent,
|
|
70
|
+
} from "./tool-dispatch-identity";
|
|
65
71
|
import type {
|
|
66
72
|
AgentContext,
|
|
67
73
|
AgentEvent,
|
|
@@ -75,6 +81,11 @@ import type {
|
|
|
75
81
|
StreamFn,
|
|
76
82
|
} from "./types";
|
|
77
83
|
|
|
84
|
+
// Capture the intrinsic before any tool/hook can replace `Reflect.apply`. Calling this
|
|
85
|
+
// local binding performs no user-controlled property lookup between publishing a dispatch
|
|
86
|
+
// start and entering the selected execute function.
|
|
87
|
+
const intrinsicReflectApply = Reflect.apply;
|
|
88
|
+
|
|
78
89
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
79
90
|
/**
|
|
80
91
|
* Defensive caps for a provisional managed attempt. These are intentionally
|
|
@@ -84,15 +95,45 @@ import type {
|
|
|
84
95
|
export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
|
|
85
96
|
export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
|
|
86
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Closed set of local-failure sites. A bounded diagnostic may name only these
|
|
100
|
+
* literals: the log is shape-only, so no caller-supplied or provider-derived
|
|
101
|
+
* string may ever reach it.
|
|
102
|
+
*/
|
|
103
|
+
const MANAGED_LOCAL_FAILURE_STAGES = [
|
|
104
|
+
"shell.role",
|
|
105
|
+
"shell.content",
|
|
106
|
+
"event.snapshot",
|
|
107
|
+
"event.contentIndex",
|
|
108
|
+
"event.delta",
|
|
109
|
+
"event.content",
|
|
110
|
+
"event.toolcall",
|
|
111
|
+
"event.done.reason",
|
|
112
|
+
"event.error.reason",
|
|
113
|
+
"event.unknownType",
|
|
114
|
+
"staging.losslessSnapshot",
|
|
115
|
+
"staging.measure",
|
|
116
|
+
"staging.sanitize",
|
|
117
|
+
"staging.preMeasure",
|
|
118
|
+
"staging.overflow",
|
|
119
|
+
"overflow.preMeasure",
|
|
120
|
+
"overflow.staged",
|
|
121
|
+
] as const;
|
|
122
|
+
type ManagedLocalFailureStage = (typeof MANAGED_LOCAL_FAILURE_STAGES)[number];
|
|
123
|
+
const MANAGED_LOCAL_FAILURE_STAGE_SET: ReadonlySet<string> = new Set(MANAGED_LOCAL_FAILURE_STAGES);
|
|
124
|
+
|
|
87
125
|
/**
|
|
88
126
|
* Local staging failure: the provisional buffer limit was exceeded. Carries
|
|
89
127
|
* NO transport facts or status by design — only original typed provider
|
|
90
128
|
* transport facts may authorize provider fallback, so local buffer machinery
|
|
91
129
|
* must never masquerade as provider evidence or consume the fallback chain.
|
|
92
|
-
*
|
|
130
|
+
* The typed `local_buffer_overflow` kind lets session retry policy surface it
|
|
131
|
+
* immediately without any retry: re-streaming the same request reproduces the
|
|
132
|
+
* same oversized response, so an automatic re-issue only burns tokens.
|
|
93
133
|
*/
|
|
94
134
|
class ManagedAttemptBufferOverflowError extends Error {
|
|
95
|
-
|
|
135
|
+
readonly errorKind = "local_buffer_overflow" as const;
|
|
136
|
+
constructor(readonly stage: ManagedLocalFailureStage) {
|
|
96
137
|
super("Managed fallback attempt exceeded the provisional event buffer limit");
|
|
97
138
|
this.name = "ManagedAttemptBufferOverflowError";
|
|
98
139
|
}
|
|
@@ -101,10 +142,14 @@ class ManagedAttemptBufferOverflowError extends Error {
|
|
|
101
142
|
/**
|
|
102
143
|
* Local snapshot-machinery failure. Deliberately carries no transport facts
|
|
103
144
|
* or status, so managed fallback classification never treats it as a provider
|
|
104
|
-
* retry trigger — it
|
|
145
|
+
* retry trigger — it never burns the fallback chain, advances models, or
|
|
146
|
+
* mutates credentials. The typed `local_snapshot_failure` kind lets session
|
|
147
|
+
* policy surface the producer-boundary diagnostic immediately instead of
|
|
148
|
+
* amplifying one deterministic local defect across identical retries.
|
|
105
149
|
*/
|
|
106
150
|
class ManagedAttemptSnapshotError extends Error {
|
|
107
|
-
|
|
151
|
+
readonly errorKind = "local_snapshot_failure" as const;
|
|
152
|
+
constructor(readonly stage: ManagedLocalFailureStage) {
|
|
108
153
|
super(
|
|
109
154
|
"Managed fallback attempt could not produce a serializable event snapshot (local snapshot bug, not a provider failure)",
|
|
110
155
|
);
|
|
@@ -134,6 +179,29 @@ const standaloneOwnershipStates = new WeakMap<StandaloneRunOwnership, Standalone
|
|
|
134
179
|
*/
|
|
135
180
|
const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
|
|
136
181
|
|
|
182
|
+
/**
|
|
183
|
+
* How many times a single turn may be re-requested because its tool arguments
|
|
184
|
+
* arrived as `\uXXXX` escapes instead of literal UTF-8.
|
|
185
|
+
*
|
|
186
|
+
* The defect is a wire-format accident that resampling clears, so a small
|
|
187
|
+
* budget recovers the overwhelming majority of turns; past it the terminal
|
|
188
|
+
* per-call rejection takes over rather than spending the run on retries.
|
|
189
|
+
*/
|
|
190
|
+
const MAX_ESCAPED_NONASCII_RESAMPLES = 2;
|
|
191
|
+
|
|
192
|
+
/** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
|
|
193
|
+
function hasEscapedNonAsciiToolCall(message: AssistantMessage): boolean {
|
|
194
|
+
return message.content.some(block => block.type === "toolCall" && block.escapedNonAsciiArguments === true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Remove only the exact assistant response committed by its streaming attempt. */
|
|
198
|
+
function removeCommittedAssistantMessage(messages: AgentMessage[], message: AssistantMessage): boolean {
|
|
199
|
+
const index = messages.lastIndexOf(message);
|
|
200
|
+
if (index < 0) return false;
|
|
201
|
+
messages.splice(index, 1);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
137
205
|
function isComposerBashPolicyBlockedToolResult(result: ToolResultMessage): boolean {
|
|
138
206
|
return (
|
|
139
207
|
result.isError &&
|
|
@@ -263,18 +331,10 @@ function managedContextOverflowOutcome(message: AssistantMessage, scope?: Attemp
|
|
|
263
331
|
return { type: "context_overflow_discarded", message, scope };
|
|
264
332
|
}
|
|
265
333
|
|
|
266
|
-
/** Whether any tool call in the turn carried `\uXXXX`-escaped arguments. */
|
|
267
|
-
function hasEscapedNonAsciiToolCall(message: AssistantMessage): boolean {
|
|
268
|
-
return message.content.some(block => block.type === "toolCall" && block.escapedNonAsciiArguments === true);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function managedEscapedArgumentsOutcome(message: AssistantMessage, scope?: AttemptScope): ManagedAttemptOutcome {
|
|
272
|
-
return { type: "escaped_arguments_discarded", message, scope };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
334
|
function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
|
|
276
335
|
const errorMessage = managedProperty(error, "message");
|
|
277
336
|
const transportFailure = managedTransportFailure(error);
|
|
337
|
+
const errorKind = managedProperty(error, "errorKind");
|
|
278
338
|
let fallbackMessage = "Managed fallback attempt failed";
|
|
279
339
|
if (typeof errorMessage === "string") fallbackMessage = errorMessage;
|
|
280
340
|
else {
|
|
@@ -301,6 +361,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
|
|
|
301
361
|
stopReason: "error",
|
|
302
362
|
errorMessage: fallbackMessage,
|
|
303
363
|
...(transportFailure ? { transportFailure } : {}),
|
|
364
|
+
...(errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow" ? { errorKind } : {}),
|
|
304
365
|
timestamp: Date.now(),
|
|
305
366
|
};
|
|
306
367
|
}
|
|
@@ -592,6 +653,19 @@ function publishAgentEnd(
|
|
|
592
653
|
*/
|
|
593
654
|
export const MANAGED_SNAPSHOT_MAX_NODES = 100_000;
|
|
594
655
|
|
|
656
|
+
/**
|
|
657
|
+
* The sanitizer's closed set of placeholder strings. A degraded snapshot can
|
|
658
|
+
* never distinguish these from provider-sent strings by value alone, so
|
|
659
|
+
* downstream shape checks treat a sentinel-valued field as "original value was
|
|
660
|
+
* non-cloneable", never as benign provider variance.
|
|
661
|
+
*/
|
|
662
|
+
const SANITIZER_SENTINELS: ReadonlySet<string> = new Set([
|
|
663
|
+
"[unserializable]",
|
|
664
|
+
"[accessor]",
|
|
665
|
+
"[truncated]",
|
|
666
|
+
"[Circular]",
|
|
667
|
+
]);
|
|
668
|
+
|
|
595
669
|
/**
|
|
596
670
|
* Cycle-aware deep clone that always returns a detached, JSON-serializable
|
|
597
671
|
* value. Used whenever a detached snapshot cannot be safely obtained or
|
|
@@ -726,12 +800,33 @@ export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_S
|
|
|
726
800
|
* (e.g. a live `Headers` inside a provider error's `transportFailure` from a
|
|
727
801
|
* legacy payload), and a thrown `DataCloneError` here would mask the real
|
|
728
802
|
* provider outcome and burn the whole fallback chain.
|
|
803
|
+
*
|
|
804
|
+
* `structuredClone` success is not sufficient: it can erase a custom
|
|
805
|
+
* prototype `toJSON()` while retaining an own bigint field. The live value is
|
|
806
|
+
* JSON-safe, but the detached clone is not. Validate and measure the DETACHED
|
|
807
|
+
* value with the exact serialization operation used by staging; sanitize the
|
|
808
|
+
* detached clone when that validation fails so every accepted snapshot is
|
|
809
|
+
* both isolated and JSON-serializable.
|
|
729
810
|
*/
|
|
730
|
-
function
|
|
811
|
+
function managedSnapshotJsonBytes(value: unknown): number | undefined {
|
|
731
812
|
try {
|
|
732
|
-
|
|
813
|
+
const serialized = JSON.stringify(value);
|
|
814
|
+
return serialized === undefined ? undefined : managedAttemptTextEncoder.encode(serialized).byteLength;
|
|
733
815
|
} catch {
|
|
734
|
-
return
|
|
816
|
+
return undefined;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; jsonBytes?: number } {
|
|
821
|
+
try {
|
|
822
|
+
const snapshot = structuredClone(value);
|
|
823
|
+
const jsonBytes = managedSnapshotJsonBytes(snapshot);
|
|
824
|
+
if (jsonBytes !== undefined) return { snapshot, jsonBytes };
|
|
825
|
+
const sanitized = sanitizedDetachedClone(snapshot);
|
|
826
|
+
return { snapshot: sanitized, jsonBytes: managedSnapshotJsonBytes(sanitized) };
|
|
827
|
+
} catch {
|
|
828
|
+
const snapshot = sanitizedDetachedClone(value);
|
|
829
|
+
return { snapshot, jsonBytes: managedSnapshotJsonBytes(snapshot) };
|
|
735
830
|
}
|
|
736
831
|
}
|
|
737
832
|
|
|
@@ -739,6 +834,93 @@ function managedAttemptSnapshot<T>(value: T): T {
|
|
|
739
834
|
return managedAttemptSnapshotDetailed(value).snapshot;
|
|
740
835
|
}
|
|
741
836
|
|
|
837
|
+
const LOSSLESS_SNAPSHOT_KEYS = [
|
|
838
|
+
"role",
|
|
839
|
+
"content",
|
|
840
|
+
"api",
|
|
841
|
+
"provider",
|
|
842
|
+
"model",
|
|
843
|
+
"responseId",
|
|
844
|
+
"usage",
|
|
845
|
+
"stopReason",
|
|
846
|
+
"errorMessage",
|
|
847
|
+
"errorKind",
|
|
848
|
+
"errorStatus",
|
|
849
|
+
"transportFailure",
|
|
850
|
+
"disabledFeatures",
|
|
851
|
+
"providerPayload",
|
|
852
|
+
"timestamp",
|
|
853
|
+
"duration",
|
|
854
|
+
"ttft",
|
|
855
|
+
"type",
|
|
856
|
+
"contentIndex",
|
|
857
|
+
"delta",
|
|
858
|
+
"partial",
|
|
859
|
+
"toolCall",
|
|
860
|
+
"reason",
|
|
861
|
+
"message",
|
|
862
|
+
"error",
|
|
863
|
+
] as const;
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Detach unmanaged provider metadata without normalizing the cloneable parts.
|
|
867
|
+
* A failed subtree is removed at its own property boundary; siblings retain
|
|
868
|
+
* their exact structured-clone representation. The bounded recursive path is
|
|
869
|
+
* used only after cloning the complete value fails.
|
|
870
|
+
*/
|
|
871
|
+
function losslessDetachedClone<T>(value: T): T {
|
|
872
|
+
try {
|
|
873
|
+
const snapshot = structuredClone(value);
|
|
874
|
+
// `structuredClone()` preserves own bigint fields while removing a
|
|
875
|
+
// payload class's prototype `toJSON()`. The live event may therefore
|
|
876
|
+
// serialize successfully while its detached clone cannot be staged.
|
|
877
|
+
// Lossless staging still preserves every JSON-safe clone verbatim; only
|
|
878
|
+
// the non-serializable detached form is sanitized.
|
|
879
|
+
return managedSnapshotJsonBytes(snapshot) !== undefined ? snapshot : sanitizedDetachedClone(snapshot);
|
|
880
|
+
} catch {
|
|
881
|
+
// The managed sanitizer is explicitly bounded and total. Use it only to
|
|
882
|
+
// identify which top-level assistant metadata surfaces are cloneable; the
|
|
883
|
+
// cloneable surfaces themselves still come from structuredClone and remain
|
|
884
|
+
// lossless. This avoids recursive/unbounded traversal of hostile provider
|
|
885
|
+
// metadata while stripping only the failed top-level surface.
|
|
886
|
+
if (!isManagedPlainRecord(value)) return sanitizedDetachedClone(value);
|
|
887
|
+
const output: Record<string, unknown> = {};
|
|
888
|
+
let remaining = MANAGED_SNAPSHOT_MAX_NODES;
|
|
889
|
+
for (const key of LOSSLESS_SNAPSHOT_KEYS) {
|
|
890
|
+
if (remaining-- <= 0) break;
|
|
891
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
892
|
+
if (!descriptor || !("value" in descriptor)) continue;
|
|
893
|
+
try {
|
|
894
|
+
output[key] = structuredClone(descriptor.value);
|
|
895
|
+
} catch {
|
|
896
|
+
if (key === "transportFailure" && isManagedPlainRecord(descriptor.value)) {
|
|
897
|
+
const transport: Record<string, unknown> = {};
|
|
898
|
+
for (const transportKey of [
|
|
899
|
+
"kind",
|
|
900
|
+
"status",
|
|
901
|
+
"code",
|
|
902
|
+
"providerCode",
|
|
903
|
+
"openaiErrorCode",
|
|
904
|
+
"anthropicErrorType",
|
|
905
|
+
"retryAfterMs",
|
|
906
|
+
"headers",
|
|
907
|
+
] as const) {
|
|
908
|
+
const transportDescriptor = Object.getOwnPropertyDescriptor(descriptor.value, transportKey);
|
|
909
|
+
if (!transportDescriptor || !("value" in transportDescriptor)) continue;
|
|
910
|
+
try {
|
|
911
|
+
transport[transportKey] = structuredClone(transportDescriptor.value);
|
|
912
|
+
} catch {
|
|
913
|
+
// Strip only this non-cloneable transport fact.
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
output[key] = transport;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return managedSnapshotJsonBytes(output) !== undefined ? (output as T) : sanitizedDetachedClone(output as T);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
742
924
|
/**
|
|
743
925
|
* Recover the required assistant-message shell when a managed snapshot degrades
|
|
744
926
|
* at its root (notably for Proxy-wrapped provider messages). Only known fields
|
|
@@ -747,14 +929,39 @@ function managedAttemptSnapshot<T>(value: T): T {
|
|
|
747
929
|
*/
|
|
748
930
|
function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
|
|
749
931
|
const detailed = managedAttemptSnapshotDetailed(value);
|
|
750
|
-
const
|
|
751
|
-
|
|
932
|
+
const snapshotRecord = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : undefined;
|
|
933
|
+
// Two benign root degradations are repaired by reading through the
|
|
934
|
+
// original object — the provider's own view — with every read guarded so
|
|
935
|
+
// a hostile trap can only degrade to undefined, never escape
|
|
936
|
+
// (`managedProperty` is exactly that guarded read):
|
|
937
|
+
// - a root that could not be snapshotted into a plain record is a live
|
|
938
|
+
// Proxy (the sanitizer collapses proxies to a placeholder);
|
|
939
|
+
// - a plain-record snapshot that lost `role: "assistant"` came from a
|
|
940
|
+
// payload class whose fields live on its prototype: `structuredClone`
|
|
941
|
+
// copies only own enumerable properties, so the caller's live
|
|
942
|
+
// `message.role === "assistant"` check passes while the detached
|
|
943
|
+
// snapshot retains none of the message identity.
|
|
944
|
+
const source =
|
|
945
|
+
snapshotRecord !== undefined && managedProperty(snapshotRecord, "role") === "assistant" ? snapshotRecord : value;
|
|
946
|
+
if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError("shell.role");
|
|
752
947
|
const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
948
|
+
// Benign providers occasionally deliver a string or missing content value.
|
|
949
|
+
// Degrade those to an empty content array — an empty assistant turn —
|
|
950
|
+
// instead of failing the whole managed run: the staged shell must stay
|
|
951
|
+
// schema-valid, and empty content is the neutral, side-effect-free
|
|
952
|
+
// degradation. A string is benign ONLY when the provider actually sent a
|
|
953
|
+
// string: when the whole-message snapshot degraded, the sanitizer replaces
|
|
954
|
+
// a non-cloneable content value (proxy, function, accessor) with one of
|
|
955
|
+
// its own sentinel strings, and mistaking that sentinel for provider
|
|
956
|
+
// variance would silently drop real content (tool calls) behind a
|
|
957
|
+
// successful empty turn. Sentinel-string content therefore stays
|
|
958
|
+
// fail-closed, as does every other non-array shape, so the named-site
|
|
959
|
+
// diagnostic can report shell.content.
|
|
960
|
+
const rawArray = Array.isArray(rawContent) ? rawContent : undefined;
|
|
961
|
+
const benignContent =
|
|
962
|
+
rawContent === undefined || (typeof rawContent === "string" && !SANITIZER_SENTINELS.has(rawContent));
|
|
963
|
+
if (rawArray === undefined && !benignContent) throw new ManagedAttemptSnapshotError("shell.content");
|
|
964
|
+
const content = rawArray === undefined ? [] : rawArray.flatMap(managedContentBlock);
|
|
758
965
|
const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
|
|
759
966
|
const api = managedProperty(source, "api");
|
|
760
967
|
const provider = managedProperty(source, "provider");
|
|
@@ -794,6 +1001,11 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
|
|
|
794
1001
|
};
|
|
795
1002
|
}
|
|
796
1003
|
|
|
1004
|
+
function managedContentBlock(block: unknown): AssistantMessage["content"] {
|
|
1005
|
+
const normalized = managedAssistantContent(block);
|
|
1006
|
+
return normalized ? [normalized] : [];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
797
1009
|
function managedAssistantContent(value: unknown): AssistantMessage["content"][number] | undefined {
|
|
798
1010
|
if (!isManagedPlainRecord(value)) return undefined;
|
|
799
1011
|
const type = managedProperty(value, "type");
|
|
@@ -818,6 +1030,7 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
|
|
|
818
1030
|
const intent = managedProperty(value, "intent");
|
|
819
1031
|
const customWireName = managedProperty(value, "customWireName");
|
|
820
1032
|
const incompleteArguments = managedProperty(value, "incompleteArguments");
|
|
1033
|
+
const incompleteArgumentsReason = managedProperty(value, "incompleteArgumentsReason");
|
|
821
1034
|
const escapedNonAsciiArguments = managedProperty(value, "escapedNonAsciiArguments");
|
|
822
1035
|
return {
|
|
823
1036
|
type,
|
|
@@ -828,6 +1041,15 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
|
|
|
828
1041
|
...(typeof intent === "string" ? { intent } : {}),
|
|
829
1042
|
...(typeof customWireName === "string" ? { customWireName } : {}),
|
|
830
1043
|
...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
|
|
1044
|
+
...(typeof incompleteArgumentsReason === "string"
|
|
1045
|
+
? {
|
|
1046
|
+
incompleteArgumentsReason: incompleteArgumentsReason as
|
|
1047
|
+
| "truncated"
|
|
1048
|
+
| "malformed"
|
|
1049
|
+
| "conflicting"
|
|
1050
|
+
| "ambiguous",
|
|
1051
|
+
}
|
|
1052
|
+
: {}),
|
|
831
1053
|
...(typeof escapedNonAsciiArguments === "boolean" ? { escapedNonAsciiArguments } : {}),
|
|
832
1054
|
};
|
|
833
1055
|
}
|
|
@@ -858,13 +1080,30 @@ function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
|
|
|
858
1080
|
};
|
|
859
1081
|
}
|
|
860
1082
|
|
|
861
|
-
function managedAssistantEventSnapshot(
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const
|
|
1083
|
+
export function managedAssistantEventSnapshot(
|
|
1084
|
+
event: AssistantMessageEvent,
|
|
1085
|
+
message: AssistantMessage,
|
|
1086
|
+
): AssistantMessageEvent {
|
|
1087
|
+
const detached = managedAttemptSnapshot(event);
|
|
1088
|
+
const record = isManagedPlainRecord(detached) ? detached : undefined;
|
|
1089
|
+
// Root repair, mirroring the shell: two benign degradations are re-read
|
|
1090
|
+
// through the original event with guarded reads (`managedProperty`) —
|
|
1091
|
+
// - a proxy root (structuredClone rejects proxies; the sanitizer collapses
|
|
1092
|
+
// them to a placeholder) whose gets are readable, and
|
|
1093
|
+
// - a payload class whose event fields live on prototype getters
|
|
1094
|
+
// (`structuredClone` copies only own enumerable properties).
|
|
1095
|
+
// A hostile trap or throwing getter can only degrade a field to undefined,
|
|
1096
|
+
// which keeps the named fail-fast diagnostics below; a root that is
|
|
1097
|
+
// neither snapshottable nor readable as an event keeps the dedicated root
|
|
1098
|
+
// diagnostic.
|
|
1099
|
+
const source: unknown = record !== undefined && typeof managedProperty(record, "type") === "string" ? record : event;
|
|
1100
|
+
const type = managedProperty(source, "type");
|
|
1101
|
+
if (record === undefined && typeof type !== "string") throw new ManagedAttemptSnapshotError("event.snapshot");
|
|
1102
|
+
const contentIndex = managedProperty(source, "contentIndex");
|
|
866
1103
|
const indexed = () => {
|
|
867
|
-
if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0)
|
|
1104
|
+
if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) {
|
|
1105
|
+
throw new ManagedAttemptSnapshotError("event.contentIndex");
|
|
1106
|
+
}
|
|
868
1107
|
return contentIndex as number;
|
|
869
1108
|
};
|
|
870
1109
|
if (type === "start") return { type, partial: message };
|
|
@@ -881,31 +1120,36 @@ function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: As
|
|
|
881
1120
|
type === "reasoning_summary_delta" ||
|
|
882
1121
|
type === "toolcall_delta"
|
|
883
1122
|
) {
|
|
884
|
-
const delta = managedProperty(
|
|
885
|
-
if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
|
|
1123
|
+
const delta = managedProperty(source, "delta");
|
|
1124
|
+
if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta");
|
|
886
1125
|
return { type, contentIndex: indexed(), delta, partial: message };
|
|
887
1126
|
}
|
|
888
1127
|
if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") {
|
|
889
|
-
const content = managedProperty(
|
|
890
|
-
if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
|
|
1128
|
+
const content = managedProperty(source, "content");
|
|
1129
|
+
if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content");
|
|
891
1130
|
return { type, contentIndex: indexed(), content, partial: message };
|
|
892
1131
|
}
|
|
893
1132
|
if (type === "toolcall_end") {
|
|
894
|
-
const toolCall = managedAssistantContent(managedProperty(
|
|
895
|
-
if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError();
|
|
1133
|
+
const toolCall = managedAssistantContent(managedAttemptSnapshot(managedProperty(source, "toolCall")));
|
|
1134
|
+
if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError("event.toolcall");
|
|
896
1135
|
return { type, contentIndex: indexed(), toolCall, partial: message };
|
|
897
1136
|
}
|
|
898
1137
|
if (type === "done") {
|
|
899
|
-
const reason = managedProperty(
|
|
900
|
-
|
|
901
|
-
|
|
1138
|
+
const reason = managedProperty(source, "reason");
|
|
1139
|
+
// Degrade out-of-vocabulary done reasons to "stop", matching the closed
|
|
1140
|
+
// StopReason vocabulary already normalized by managedAssistantShell.
|
|
1141
|
+
const normalized = reason === "stop" || reason === "length" || reason === "toolUse" ? reason : "stop";
|
|
1142
|
+
return { type, reason: normalized, message };
|
|
902
1143
|
}
|
|
903
1144
|
if (type === "error") {
|
|
904
|
-
const reason = managedProperty(
|
|
905
|
-
|
|
906
|
-
return { type, reason, error: message };
|
|
1145
|
+
const reason = managedProperty(source, "reason");
|
|
1146
|
+
const normalized = reason === "aborted" || reason === "error" ? reason : "error";
|
|
1147
|
+
return { type, reason: normalized, error: message };
|
|
907
1148
|
}
|
|
908
|
-
|
|
1149
|
+
// An unknown string event type degrades to a terminal done/stop; a
|
|
1150
|
+
// non-string type is malformed provider output that must fail fast.
|
|
1151
|
+
if (typeof type === "string") return { type: "done", reason: "stop", message } as AssistantMessageEvent;
|
|
1152
|
+
throw new ManagedAttemptSnapshotError("event.unknownType");
|
|
909
1153
|
}
|
|
910
1154
|
|
|
911
1155
|
function isManagedPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -913,17 +1157,62 @@ function isManagedPlainRecord(value: unknown): value is Record<string, unknown>
|
|
|
913
1157
|
}
|
|
914
1158
|
|
|
915
1159
|
/**
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
1160
|
+
* Emit ONE bounded, shape-only diagnostic for a local managed-attempt failure
|
|
1161
|
+
* (snapshot machinery or staging buffer). Names only the envelope shape — the
|
|
1162
|
+
* failure stage, model identity, snapshot mode, staged counters, and (for
|
|
1163
|
+
* content stages only) the content block count — never raw text, thinking,
|
|
1164
|
+
* tool arguments, or any provider payload content. Emitted only for the
|
|
1165
|
+
* module-private local error identities, so a foreign error cannot self-label
|
|
1166
|
+
* into the log. Scoped to this stream invocation: not latched across
|
|
1167
|
+
* invocations, matching the #4443 precedent.
|
|
919
1168
|
*/
|
|
1169
|
+
function warnManagedSnapshotFailure(
|
|
1170
|
+
error: unknown,
|
|
1171
|
+
config: AgentLoopConfig,
|
|
1172
|
+
transaction: ManagedAttemptTransaction | undefined,
|
|
1173
|
+
): void {
|
|
1174
|
+
// Identity, never self-labeling: only the module-private local error classes
|
|
1175
|
+
// may produce this diagnostic. A foreign error that sets a matching
|
|
1176
|
+
// `errorKind` (and could smuggle provider or prompt text in `stage`) is
|
|
1177
|
+
// ignored here, so nothing outside this module can reach the log.
|
|
1178
|
+
if (!(error instanceof ManagedAttemptSnapshotError) && !(error instanceof ManagedAttemptBufferOverflowError)) {
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
// Defense in depth: even an in-module regression cannot widen the log past
|
|
1182
|
+
// the closed stage vocabulary.
|
|
1183
|
+
const stage = MANAGED_LOCAL_FAILURE_STAGE_SET.has(error.stage) ? error.stage : "unknown";
|
|
1184
|
+
const diagnostic: Record<string, unknown> = {
|
|
1185
|
+
stage,
|
|
1186
|
+
errorKind: error.errorKind,
|
|
1187
|
+
model: config.model.id,
|
|
1188
|
+
provider: config.model.provider,
|
|
1189
|
+
snapshotMode: transaction?.snapshotMode ?? "none",
|
|
1190
|
+
};
|
|
1191
|
+
const staged = transaction?.stagedShape() ?? { stagedEventCount: 0, stagedBytes: 0, contentBlockCount: 0 };
|
|
1192
|
+
diagnostic.stagedEventCount = staged.stagedEventCount;
|
|
1193
|
+
diagnostic.stagedBytes = staged.stagedBytes;
|
|
1194
|
+
if (stage === "shell.content") {
|
|
1195
|
+
diagnostic.contentBlockCount = staged.contentBlockCount;
|
|
1196
|
+
}
|
|
1197
|
+
logger.warn("agent: managed fallback attempt rejected a local snapshot", diagnostic);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Holds provisional assistant output above the public event stream. Managed
|
|
1202
|
+
* fallback keeps the whole attempt atomic; non-managed escaped-argument
|
|
1203
|
+
* detection uses lossless snapshots until visible output or the staging cap
|
|
1204
|
+
* commits the transaction.
|
|
1205
|
+
*/
|
|
1206
|
+
type ManagedAttemptBatchItem =
|
|
1207
|
+
| { type: "event"; event: AgentEvent }
|
|
1208
|
+
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };
|
|
1209
|
+
|
|
920
1210
|
class ManagedAttemptTransaction {
|
|
921
|
-
#batch:
|
|
922
|
-
| { type: "event"; event: AgentEvent }
|
|
923
|
-
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
|
|
924
|
-
> = [];
|
|
1211
|
+
#batch: ManagedAttemptBatchItem[] = [];
|
|
925
1212
|
#stagedEventCount = 0;
|
|
926
1213
|
#stagedBytes = 0;
|
|
1214
|
+
/** Shape snapshot retained across discard() for bounded failure diagnostics. */
|
|
1215
|
+
#lastStagedShape: { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } | undefined;
|
|
927
1216
|
#discarded = false;
|
|
928
1217
|
#committed = false;
|
|
929
1218
|
|
|
@@ -934,10 +1223,15 @@ class ManagedAttemptTransaction {
|
|
|
934
1223
|
| undefined,
|
|
935
1224
|
private readonly model: AgentLoopConfig["model"],
|
|
936
1225
|
readonly scope?: AttemptScope,
|
|
1226
|
+
readonly snapshotMode: "managed" | "lossless" = "managed",
|
|
937
1227
|
) {}
|
|
938
1228
|
|
|
939
1229
|
push(event: AgentEvent): void {
|
|
940
1230
|
if (this.#committed) {
|
|
1231
|
+
if (event.type === "message_end" || event.type === "turn_end") {
|
|
1232
|
+
this.#batch.push({ type: "event", event });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
941
1235
|
this.stream.push(event);
|
|
942
1236
|
return;
|
|
943
1237
|
}
|
|
@@ -949,16 +1243,20 @@ class ManagedAttemptTransaction {
|
|
|
949
1243
|
}
|
|
950
1244
|
|
|
951
1245
|
stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
952
|
-
const partial =
|
|
1246
|
+
const partial = this.#assistantSnapshot(message);
|
|
1247
|
+
if (this.#committed) {
|
|
1248
|
+
this.onAssistantMessageEvent?.(partial, this.#assistantEventSnapshot(event, partial));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
953
1251
|
this.#batch.push({
|
|
954
1252
|
type: "assistant_event",
|
|
955
1253
|
message: partial,
|
|
956
|
-
event:
|
|
1254
|
+
event: this.#assistantEventSnapshot(event, partial),
|
|
957
1255
|
});
|
|
958
1256
|
}
|
|
959
1257
|
|
|
960
1258
|
flush(): void {
|
|
961
|
-
if (this.#discarded
|
|
1259
|
+
if (this.#discarded) return;
|
|
962
1260
|
for (const item of this.#batch) {
|
|
963
1261
|
if (item.type === "assistant_event") {
|
|
964
1262
|
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
@@ -972,13 +1270,98 @@ class ManagedAttemptTransaction {
|
|
|
972
1270
|
this.#committed = true;
|
|
973
1271
|
}
|
|
974
1272
|
|
|
1273
|
+
flushNonTerminal(): void {
|
|
1274
|
+
if (this.#discarded || this.#committed) return;
|
|
1275
|
+
const retained: ManagedAttemptBatchItem[] = [];
|
|
1276
|
+
for (const item of this.#batch) {
|
|
1277
|
+
if (this.#isTerminalItem(item)) {
|
|
1278
|
+
retained.push(item);
|
|
1279
|
+
} else if (item.type === "assistant_event") {
|
|
1280
|
+
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
1281
|
+
} else {
|
|
1282
|
+
this.stream.push(item.event);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
this.#batch = retained;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
commitCallbacksAndUpdates(): void {
|
|
1289
|
+
if (this.#discarded || this.#committed) return;
|
|
1290
|
+
for (const item of this.#batch) {
|
|
1291
|
+
if (item.type === "assistant_event") {
|
|
1292
|
+
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
1293
|
+
} else if (item.event.type !== "message_end" && item.event.type !== "turn_end") {
|
|
1294
|
+
this.stream.push(item.event);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
this.#batch = this.#batch.filter(
|
|
1298
|
+
item => item.type === "event" && (item.event.type === "message_end" || item.event.type === "turn_end"),
|
|
1299
|
+
);
|
|
1300
|
+
this.#committed = true;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
replacePendingAssistantMessage(message: AssistantMessage): void {
|
|
1304
|
+
this.#batch = this.#batch.map(item => {
|
|
1305
|
+
if (item.type === "assistant_event") {
|
|
1306
|
+
return { ...item, message, event: this.#assistantEventSnapshot(item.event, message) };
|
|
1307
|
+
}
|
|
1308
|
+
if (item.event.type === "message_end") return { ...item, event: { ...item.event, message } };
|
|
1309
|
+
if (item.event.type === "turn_end") return { ...item, event: { ...item.event, message } };
|
|
1310
|
+
return item;
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
get committed(): boolean {
|
|
1315
|
+
return this.#committed;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
acceptedAssistantSnapshot(message: AssistantMessage): AssistantMessage {
|
|
1319
|
+
return this.#assistantSnapshot(message);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
975
1322
|
discard(): void {
|
|
1323
|
+
if (!this.#discarded) {
|
|
1324
|
+
this.#lastStagedShape = {
|
|
1325
|
+
stagedEventCount: this.#stagedEventCount,
|
|
1326
|
+
stagedBytes: this.#stagedBytes,
|
|
1327
|
+
contentBlockCount: this.#stagedContentBlockCount(),
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
976
1330
|
this.#batch = [];
|
|
977
1331
|
this.#stagedBytes = 0;
|
|
978
1332
|
this.#stagedEventCount = 0;
|
|
979
1333
|
this.#discarded = true;
|
|
980
1334
|
}
|
|
981
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Shape-only view of what was staged when the attempt failed; never carries
|
|
1338
|
+
* content. Reports the retained pre-discard shape when the failing path
|
|
1339
|
+
* discarded the batch, and the live counters when it threw before discard.
|
|
1340
|
+
*/
|
|
1341
|
+
stagedShape(): { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } {
|
|
1342
|
+
return (
|
|
1343
|
+
this.#lastStagedShape ?? {
|
|
1344
|
+
stagedEventCount: this.#stagedEventCount,
|
|
1345
|
+
stagedBytes: this.#stagedBytes,
|
|
1346
|
+
contentBlockCount: this.#stagedContentBlockCount(),
|
|
1347
|
+
}
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
#stagedContentBlockCount(): number {
|
|
1352
|
+
for (let i = this.#batch.length - 1; i >= 0; i--) {
|
|
1353
|
+
const item = this.#batch[i];
|
|
1354
|
+
const message =
|
|
1355
|
+
item.type === "assistant_event"
|
|
1356
|
+
? item.message
|
|
1357
|
+
: "message" in item.event
|
|
1358
|
+
? (item.event.message as unknown)
|
|
1359
|
+
: undefined;
|
|
1360
|
+
const content = managedProperty(message, "content");
|
|
1361
|
+
if (Array.isArray(content)) return content.length;
|
|
1362
|
+
}
|
|
1363
|
+
return 0;
|
|
1364
|
+
}
|
|
982
1365
|
#wouldOverflow(bytes: number): boolean {
|
|
983
1366
|
return (
|
|
984
1367
|
this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
|
|
@@ -987,6 +1370,43 @@ class ManagedAttemptTransaction {
|
|
|
987
1370
|
}
|
|
988
1371
|
|
|
989
1372
|
#stage(event: AgentEvent): void {
|
|
1373
|
+
if (this.snapshotMode === "lossless") {
|
|
1374
|
+
const snapshot = this.#repairAssistantEvent(event);
|
|
1375
|
+
let rawBytes: number | undefined;
|
|
1376
|
+
try {
|
|
1377
|
+
rawBytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
|
|
1378
|
+
} catch {
|
|
1379
|
+
rawBytes = undefined;
|
|
1380
|
+
}
|
|
1381
|
+
if (rawBytes !== undefined && this.#wouldOverflow(rawBytes)) {
|
|
1382
|
+
this.flush();
|
|
1383
|
+
this.push(event);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
let detached: AgentEvent;
|
|
1387
|
+
try {
|
|
1388
|
+
detached = this.#losslessAgentEventSnapshot(snapshot);
|
|
1389
|
+
} catch {
|
|
1390
|
+
this.discard();
|
|
1391
|
+
throw new ManagedAttemptSnapshotError("staging.losslessSnapshot");
|
|
1392
|
+
}
|
|
1393
|
+
let detachedBytes: number;
|
|
1394
|
+
try {
|
|
1395
|
+
detachedBytes = managedAttemptTextEncoder.encode(JSON.stringify(detached)).byteLength;
|
|
1396
|
+
} catch {
|
|
1397
|
+
this.discard();
|
|
1398
|
+
throw new ManagedAttemptSnapshotError("staging.measure");
|
|
1399
|
+
}
|
|
1400
|
+
if (this.#wouldOverflow(detachedBytes)) {
|
|
1401
|
+
this.flush();
|
|
1402
|
+
this.push(detached);
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
this.#batch.push({ type: "event", event: detached });
|
|
1406
|
+
this.#stagedEventCount++;
|
|
1407
|
+
this.#stagedBytes += detachedBytes;
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
990
1410
|
// Measure the raw event FIRST so an oversized payload is rejected
|
|
991
1411
|
// before the snapshot duplicates it — the staged-byte cap exists to
|
|
992
1412
|
// bound memory, so cloning ahead of the check would defeat it.
|
|
@@ -1001,37 +1421,28 @@ class ManagedAttemptTransaction {
|
|
|
1001
1421
|
}
|
|
1002
1422
|
if (bytes !== undefined && this.#wouldOverflow(bytes)) {
|
|
1003
1423
|
this.discard();
|
|
1004
|
-
throw new ManagedAttemptBufferOverflowError();
|
|
1424
|
+
throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
|
|
1005
1425
|
}
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// dedicated local error: it carries no transport facts, so it is
|
|
1027
|
-
// non-retryable and can never be misattributed to the provider.
|
|
1028
|
-
this.discard();
|
|
1029
|
-
throw new ManagedAttemptSnapshotError();
|
|
1030
|
-
}
|
|
1031
|
-
if (this.#wouldOverflow(bytes)) {
|
|
1032
|
-
this.discard();
|
|
1033
|
-
throw new ManagedAttemptBufferOverflowError();
|
|
1034
|
-
}
|
|
1426
|
+
const repaired = this.#repairAssistantEvent(event);
|
|
1427
|
+
const detailed = managedAttemptSnapshotDetailed(repaired);
|
|
1428
|
+
const snapshot = detailed.snapshot;
|
|
1429
|
+
// Always account the exact detached value. A live custom class can use
|
|
1430
|
+
// prototype `toJSON()` to serialize compactly while structuredClone
|
|
1431
|
+
// removes that serializer and exposes a larger or JSON-hostile own value.
|
|
1432
|
+
// Reusing the live pre-measure would therefore accept an unserializable
|
|
1433
|
+
// snapshot or undercount the retained bytes.
|
|
1434
|
+
bytes = detailed.jsonBytes;
|
|
1435
|
+
if (bytes === undefined) {
|
|
1436
|
+
// The sanitizer's output is total (detached, JSON-safe), so this is
|
|
1437
|
+
// unreachable unless the sanitizer itself regresses. Fail as a
|
|
1438
|
+
// dedicated local error: it carries no transport facts, so it is
|
|
1439
|
+
// non-retryable and can never be misattributed to the provider.
|
|
1440
|
+
this.discard();
|
|
1441
|
+
throw new ManagedAttemptSnapshotError("staging.sanitize");
|
|
1442
|
+
}
|
|
1443
|
+
if (this.#wouldOverflow(bytes)) {
|
|
1444
|
+
this.discard();
|
|
1445
|
+
throw new ManagedAttemptBufferOverflowError("overflow.staged");
|
|
1035
1446
|
}
|
|
1036
1447
|
this.#batch.push({ type: "event", event: snapshot });
|
|
1037
1448
|
this.#stagedEventCount += 1;
|
|
@@ -1040,6 +1451,7 @@ class ManagedAttemptTransaction {
|
|
|
1040
1451
|
}
|
|
1041
1452
|
|
|
1042
1453
|
#repairAssistantEvent(event: AgentEvent): AgentEvent {
|
|
1454
|
+
if (this.snapshotMode === "lossless") return event;
|
|
1043
1455
|
if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
|
|
1044
1456
|
return event.message.role === "assistant"
|
|
1045
1457
|
? { ...event, message: managedAssistantShell(event.message, this.model) }
|
|
@@ -1063,6 +1475,52 @@ class ManagedAttemptTransaction {
|
|
|
1063
1475
|
}
|
|
1064
1476
|
return event;
|
|
1065
1477
|
}
|
|
1478
|
+
|
|
1479
|
+
#losslessSnapshot<T>(value: T): T {
|
|
1480
|
+
return losslessDetachedClone(value);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
#losslessAgentEventSnapshot(event: AgentEvent): AgentEvent {
|
|
1484
|
+
switch (event.type) {
|
|
1485
|
+
case "message_start":
|
|
1486
|
+
case "message_end":
|
|
1487
|
+
return { ...event, message: this.#losslessSnapshot(event.message) };
|
|
1488
|
+
case "message_update": {
|
|
1489
|
+
const message = this.#losslessSnapshot(event.message);
|
|
1490
|
+
if (message.role !== "assistant") return { ...event, message };
|
|
1491
|
+
const assistantMessageEvent = this.#assistantEventSnapshot(event.assistantMessageEvent, message);
|
|
1492
|
+
return { ...event, message, assistantMessageEvent };
|
|
1493
|
+
}
|
|
1494
|
+
case "turn_end":
|
|
1495
|
+
return {
|
|
1496
|
+
...event,
|
|
1497
|
+
message: this.#losslessSnapshot(event.message),
|
|
1498
|
+
toolResults: this.#losslessSnapshot(event.toolResults),
|
|
1499
|
+
};
|
|
1500
|
+
default:
|
|
1501
|
+
return this.#losslessSnapshot(event);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
#assistantSnapshot(message: AssistantMessage): AssistantMessage {
|
|
1506
|
+
return this.snapshotMode === "lossless"
|
|
1507
|
+
? this.#losslessSnapshot(message)
|
|
1508
|
+
: managedAssistantShell(message, this.model);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
#assistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
|
|
1512
|
+
if (this.snapshotMode === "managed") return managedAssistantEventSnapshot(event, message);
|
|
1513
|
+
const snapshot = this.#losslessSnapshot(event);
|
|
1514
|
+
if (snapshot.type === "done") return { ...snapshot, message };
|
|
1515
|
+
if (snapshot.type === "error") return { ...snapshot, error: message };
|
|
1516
|
+
if (snapshot.type === "toolChoiceIncapability") return snapshot;
|
|
1517
|
+
return { ...snapshot, partial: message };
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
#isTerminalItem(item: ManagedAttemptBatchItem): boolean {
|
|
1521
|
+
if (item.type === "assistant_event") return item.event.type === "done" || item.event.type === "error";
|
|
1522
|
+
return item.event.type === "message_end" || item.event.type === "turn_end";
|
|
1523
|
+
}
|
|
1066
1524
|
}
|
|
1067
1525
|
|
|
1068
1526
|
/**
|
|
@@ -1480,6 +1938,15 @@ async function runLoopBody(
|
|
|
1480
1938
|
// Fires at most one repaired resend per run for the reasoning-content replay
|
|
1481
1939
|
// breaker below (DeepSeek "reasoning_content ... must be passed back").
|
|
1482
1940
|
let reasoningContentRepairAttempted = false;
|
|
1941
|
+
// Consecutive resamples spent on the current turn because its tool arguments
|
|
1942
|
+
// arrived `\uXXXX`-escaped. Reset once a turn gets past the check, so every
|
|
1943
|
+
// turn is judged on its own wire bytes.
|
|
1944
|
+
let escapedNonAsciiResampleAttempt = 0;
|
|
1945
|
+
// A queue-backed dynamic tool choice belongs to the logical turn, not to one
|
|
1946
|
+
// wire attempt. Capture it once and replay it across escaped-argument
|
|
1947
|
+
// resamples; reset only after a response is accepted for normal processing.
|
|
1948
|
+
let escapedNonAsciiToolChoiceCaptured = false;
|
|
1949
|
+
let escapedNonAsciiToolChoice: ToolChoice | undefined;
|
|
1483
1950
|
let previousMalformedToolSignatures = new Set<string>();
|
|
1484
1951
|
type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
|
|
1485
1952
|
let pendingRecovery:
|
|
@@ -1509,11 +1976,15 @@ async function runLoopBody(
|
|
|
1509
1976
|
const scope =
|
|
1510
1977
|
initialScope ?? (firstTurn ? config.initialScope : undefined) ?? config.attemptMinter?.mint("main");
|
|
1511
1978
|
initialScope = undefined;
|
|
1512
|
-
const
|
|
1979
|
+
const managedTransaction =
|
|
1513
1980
|
initialTransaction ??
|
|
1514
1981
|
(config.fallbackManaged
|
|
1515
1982
|
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
|
|
1516
1983
|
: undefined);
|
|
1984
|
+
const escapedToolTransaction = config.fallbackManaged
|
|
1985
|
+
? undefined
|
|
1986
|
+
: new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope, "lossless");
|
|
1987
|
+
const transaction = managedTransaction ?? escapedToolTransaction;
|
|
1517
1988
|
initialTransaction = undefined;
|
|
1518
1989
|
const attemptScope = transaction?.scope ?? scope;
|
|
1519
1990
|
lastAttemptScope = attemptScope;
|
|
@@ -1591,10 +2062,16 @@ async function runLoopBody(
|
|
|
1591
2062
|
// Stream assistant response
|
|
1592
2063
|
let recovered: HarmonyRecoveredToolCall | undefined;
|
|
1593
2064
|
let message: AssistantMessage;
|
|
1594
|
-
const attemptTransaction =
|
|
2065
|
+
const attemptTransaction = managedTransaction;
|
|
1595
2066
|
const recoveryAttempt = pendingRecovery;
|
|
1596
2067
|
const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
|
|
1597
2068
|
try {
|
|
2069
|
+
const getLogicalTurnToolChoice = (): ToolChoice | undefined => {
|
|
2070
|
+
if (escapedNonAsciiToolChoiceCaptured) return escapedNonAsciiToolChoice;
|
|
2071
|
+
escapedNonAsciiToolChoice = config.getToolChoice?.();
|
|
2072
|
+
escapedNonAsciiToolChoiceCaptured = true;
|
|
2073
|
+
return escapedNonAsciiToolChoice;
|
|
2074
|
+
};
|
|
1598
2075
|
const attemptConfig = attemptTransaction
|
|
1599
2076
|
? {
|
|
1600
2077
|
...config,
|
|
@@ -1623,7 +2100,7 @@ async function runLoopBody(
|
|
|
1623
2100
|
currentContext,
|
|
1624
2101
|
attemptConfig,
|
|
1625
2102
|
loopSignal,
|
|
1626
|
-
|
|
2103
|
+
transaction ? (transaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
|
|
1627
2104
|
telemetry,
|
|
1628
2105
|
invokeAgentSpan,
|
|
1629
2106
|
stepCounter,
|
|
@@ -1637,6 +2114,8 @@ async function runLoopBody(
|
|
|
1637
2114
|
forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
|
|
1638
2115
|
}
|
|
1639
2116
|
: undefined,
|
|
2117
|
+
escapedToolTransaction,
|
|
2118
|
+
recoveryAttempt ? undefined : { value: getLogicalTurnToolChoice() },
|
|
1640
2119
|
);
|
|
1641
2120
|
const detection = detectHarmonyLeakInAssistantMessage(message);
|
|
1642
2121
|
if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
|
|
@@ -1649,6 +2128,7 @@ async function runLoopBody(
|
|
|
1649
2128
|
} catch (err) {
|
|
1650
2129
|
if (!(err instanceof HarmonyLeakInterruption)) {
|
|
1651
2130
|
const failureMessage = managedFailureMessage(err, config);
|
|
2131
|
+
if (config.fallbackManaged) warnManagedSnapshotFailure(err, config, transaction);
|
|
1652
2132
|
if (config.fallbackManaged && transaction && managedContextOverflow(failureMessage, config)) {
|
|
1653
2133
|
transaction.discard();
|
|
1654
2134
|
currentContext.messages.splice(contextMessageCount);
|
|
@@ -1694,6 +2174,18 @@ async function runLoopBody(
|
|
|
1694
2174
|
}
|
|
1695
2175
|
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
|
|
1696
2176
|
} else {
|
|
2177
|
+
if (escapedToolTransaction?.committed) {
|
|
2178
|
+
const contaminated = currentContext.messages.at(-1);
|
|
2179
|
+
if (contaminated?.role !== "assistant") throw err;
|
|
2180
|
+
const sanitized = escapedToolTransaction.acceptedAssistantSnapshot({
|
|
2181
|
+
...contaminated,
|
|
2182
|
+
content: [],
|
|
2183
|
+
stopReason: "aborted",
|
|
2184
|
+
providerPayload: undefined,
|
|
2185
|
+
});
|
|
2186
|
+
escapedToolTransaction.replacePendingAssistantMessage(sanitized);
|
|
2187
|
+
escapedToolTransaction.flush();
|
|
2188
|
+
}
|
|
1697
2189
|
if (harmonyRetryAttempt >= 2) {
|
|
1698
2190
|
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
1699
2191
|
throw new Error(
|
|
@@ -1784,7 +2276,7 @@ async function runLoopBody(
|
|
|
1784
2276
|
}
|
|
1785
2277
|
}
|
|
1786
2278
|
|
|
1787
|
-
// Escaped-non-ASCII tool arguments:
|
|
2279
|
+
// Escaped-non-ASCII tool arguments: bounded turn resample.
|
|
1788
2280
|
//
|
|
1789
2281
|
// Arguments that spell a printable non-ASCII character as `\uXXXX`
|
|
1790
2282
|
// instead of literal UTF-8 are a wire-format defect, not a decision the
|
|
@@ -1792,26 +2284,50 @@ async function runLoopBody(
|
|
|
1792
2284
|
// mistyped nibble decodes to a different, equally valid character, so it
|
|
1793
2285
|
// can never be verified or repaired after the fact. Reporting it as a
|
|
1794
2286
|
// tool error spends the whole turn and writes the literal escape syntax
|
|
1795
|
-
// back into the context the model samples from next.
|
|
1796
|
-
//
|
|
1797
|
-
//
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
2287
|
+
// back into the context the model samples from next. Drop the defective
|
|
2288
|
+
// turn and re-request instead; the per-call rejection in
|
|
2289
|
+
// `executeToolCalls` stays as the terminal answer once this budget is
|
|
2290
|
+
// spent. Managed fallback reports the discarded attempt through the
|
|
2291
|
+
// typed `escaped_arguments_discarded` outcome so the session policy
|
|
2292
|
+
// owns a bounded same-model retry; the defect is never treated as
|
|
2293
|
+
// provider evidence, so the fallback chain never advances on it.
|
|
1802
2294
|
if (
|
|
1803
|
-
config.fallbackManaged &&
|
|
1804
2295
|
message.stopReason !== "error" &&
|
|
1805
2296
|
message.stopReason !== "aborted" &&
|
|
2297
|
+
escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
|
|
2298
|
+
!escapedToolTransaction?.committed &&
|
|
1806
2299
|
hasEscapedNonAsciiToolCall(message)
|
|
1807
2300
|
) {
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2301
|
+
escapedNonAsciiResampleAttempt++;
|
|
2302
|
+
escapedToolTransaction?.discard();
|
|
2303
|
+
// The defective turn was already committed to the context by the
|
|
2304
|
+
// streaming path. Remove that exact object rather than assuming it is
|
|
2305
|
+
// still the tail: callbacks may append user/system history while the
|
|
2306
|
+
// response settles, and none of that history belongs to this retry.
|
|
2307
|
+
removeCommittedAssistantMessage(currentContext.messages, message);
|
|
2308
|
+
// A managed invocation ends the run here and reports the discarded
|
|
2309
|
+
// attempt to the session's fallback policy through the typed
|
|
2310
|
+
// outcome below; the policy owns the same-model bounded retry and
|
|
2311
|
+
// only falls back once it declines. The wire defect is not provider
|
|
2312
|
+
// evidence, so the outcome deliberately carries no transport facts
|
|
2313
|
+
// and the fallback chain never advances on it.
|
|
2314
|
+
if (config.fallbackManaged) {
|
|
2315
|
+
transaction?.discard();
|
|
2316
|
+
currentContext.messages.splice(contextMessageCount);
|
|
2317
|
+
newMessages.splice(newMessageCount);
|
|
2318
|
+
await config.onManagedAttemptOutcome?.({
|
|
2319
|
+
type: "escaped_arguments_discarded",
|
|
2320
|
+
message,
|
|
2321
|
+
scope: transaction?.scope,
|
|
2322
|
+
});
|
|
2323
|
+
stream.end(newMessages);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
continue;
|
|
1814
2327
|
}
|
|
2328
|
+
escapedNonAsciiResampleAttempt = 0;
|
|
2329
|
+
escapedNonAsciiToolChoiceCaptured = false;
|
|
2330
|
+
escapedNonAsciiToolChoice = undefined;
|
|
1815
2331
|
|
|
1816
2332
|
const overflow = managedContextOverflow(message, config);
|
|
1817
2333
|
if (config.fallbackManaged && overflow) {
|
|
@@ -1866,7 +2382,28 @@ async function runLoopBody(
|
|
|
1866
2382
|
}
|
|
1867
2383
|
|
|
1868
2384
|
// One provider invocation is committed before any tool can run.
|
|
1869
|
-
|
|
2385
|
+
if (escapedToolTransaction) {
|
|
2386
|
+
const acceptedMessage = escapedToolTransaction.acceptedAssistantSnapshot(message);
|
|
2387
|
+
const contextIndex = currentContext.messages.lastIndexOf(message);
|
|
2388
|
+
if (contextIndex >= 0) currentContext.messages[contextIndex] = acceptedMessage;
|
|
2389
|
+
const producedIndex = newMessages.lastIndexOf(message);
|
|
2390
|
+
if (producedIndex >= 0) newMessages[producedIndex] = acceptedMessage;
|
|
2391
|
+
message = acceptedMessage;
|
|
2392
|
+
escapedToolTransaction.flushNonTerminal();
|
|
2393
|
+
// Tool-call updates are staged so an escaped turn can disappear
|
|
2394
|
+
// atomically. Once accepted, drain every published update through the
|
|
2395
|
+
// Agent/AgentSession consumers before dispatch: streaming edit guards
|
|
2396
|
+
// can then abort the run before any tool execute() is entered.
|
|
2397
|
+
if (message.stopReason !== "aborted" && message.stopReason !== "error") {
|
|
2398
|
+
if (loopSignal.aborted) message.stopReason = "aborted";
|
|
2399
|
+
if (stream.hasActiveConsumer) await stream.waitForConsumerDrain(new AbortController().signal);
|
|
2400
|
+
if (loopSignal.aborted) message.stopReason = "aborted";
|
|
2401
|
+
}
|
|
2402
|
+
escapedToolTransaction.replacePendingAssistantMessage(message);
|
|
2403
|
+
escapedToolTransaction.flush();
|
|
2404
|
+
} else {
|
|
2405
|
+
transaction?.flush();
|
|
2406
|
+
}
|
|
1870
2407
|
if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
|
|
1871
2408
|
await config.onManagedAttemptAccepted?.();
|
|
1872
2409
|
}
|
|
@@ -2112,6 +2649,8 @@ async function streamAssistantResponse(
|
|
|
2112
2649
|
disableTools?: boolean;
|
|
2113
2650
|
forceAutoToolChoice?: boolean;
|
|
2114
2651
|
},
|
|
2652
|
+
provisionalToolTransaction?: ManagedAttemptTransaction,
|
|
2653
|
+
toolChoiceOverride?: { value: ToolChoice | undefined },
|
|
2115
2654
|
): Promise<AssistantMessage> {
|
|
2116
2655
|
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
|
|
2117
2656
|
let messages = context.messages;
|
|
@@ -2174,7 +2713,11 @@ async function streamAssistantResponse(
|
|
|
2174
2713
|
|
|
2175
2714
|
// Synthetic recovery requests choose their tool mode explicitly below and
|
|
2176
2715
|
// must never consume a queued dynamic choice intended for an ordinary turn.
|
|
2177
|
-
const dynamicToolChoice = recoveryMode
|
|
2716
|
+
const dynamicToolChoice = recoveryMode
|
|
2717
|
+
? undefined
|
|
2718
|
+
: toolChoiceOverride
|
|
2719
|
+
? toolChoiceOverride.value
|
|
2720
|
+
: config.getToolChoice?.();
|
|
2178
2721
|
const dynamicReasoning = config.getReasoning?.();
|
|
2179
2722
|
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
|
|
2180
2723
|
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
|
|
@@ -2437,6 +2980,9 @@ async function streamAssistantResponse(
|
|
|
2437
2980
|
: event.partial;
|
|
2438
2981
|
context.messages.push(partialMessage);
|
|
2439
2982
|
addedPartial = true;
|
|
2983
|
+
if (provisionalToolTransaction) {
|
|
2984
|
+
config.onProvisionalAssistantMessageEvent?.(partialMessage, event);
|
|
2985
|
+
}
|
|
2440
2986
|
stream.push({ type: "message_start", message: { ...partialMessage }, scope });
|
|
2441
2987
|
break;
|
|
2442
2988
|
|
|
@@ -2460,9 +3006,23 @@ async function streamAssistantResponse(
|
|
|
2460
3006
|
partialMessage = config.fallbackManaged
|
|
2461
3007
|
? managedAssistantShell(event.partial, config.model)
|
|
2462
3008
|
: event.partial;
|
|
2463
|
-
|
|
3009
|
+
// Normalize through the managed event snapshot instead of a
|
|
3010
|
+
// naive `{ ...event }` spread: spreading copies only own
|
|
3011
|
+
// enumerable properties, so a payload-class event carrying its
|
|
3012
|
+
// fields on prototype getters would lose `type`/`delta` here and
|
|
3013
|
+
// deterministically fail the whole run as `event.unknownType`
|
|
3014
|
+
// downstream. The snapshot repairs benign class/prototype shapes
|
|
3015
|
+
// and keeps the named fail-fast diagnostics for hostile ones.
|
|
3016
|
+
const partialEvent = config.fallbackManaged
|
|
3017
|
+
? managedAssistantEventSnapshot(event, partialMessage)
|
|
3018
|
+
: event;
|
|
2464
3019
|
context.messages[context.messages.length - 1] = partialMessage;
|
|
2465
|
-
|
|
3020
|
+
if (provisionalToolTransaction) {
|
|
3021
|
+
config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent);
|
|
3022
|
+
provisionalToolTransaction.stageAssistantMessageEvent(partialMessage, partialEvent);
|
|
3023
|
+
} else {
|
|
3024
|
+
config.onAssistantMessageEvent?.(partialMessage, partialEvent);
|
|
3025
|
+
}
|
|
2466
3026
|
if (signal?.aborted) continue;
|
|
2467
3027
|
stream.push({
|
|
2468
3028
|
type: "message_update",
|
|
@@ -2470,6 +3030,13 @@ async function streamAssistantResponse(
|
|
|
2470
3030
|
message: { ...partialMessage },
|
|
2471
3031
|
scope,
|
|
2472
3032
|
});
|
|
3033
|
+
// Preserve ordinary text streaming. Once visible text is
|
|
3034
|
+
// published, this response can no longer be silently resampled;
|
|
3035
|
+
// a later escaped tool call therefore falls through to the
|
|
3036
|
+
// existing terminal per-call rejection instead.
|
|
3037
|
+
if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
|
|
3038
|
+
provisionalToolTransaction?.commitCallbacksAndUpdates();
|
|
3039
|
+
}
|
|
2473
3040
|
}
|
|
2474
3041
|
break;
|
|
2475
3042
|
|
|
@@ -2571,19 +3138,16 @@ function toolCallNames(tool: { name: string; customWireName?: string }): string[
|
|
|
2571
3138
|
const TOOL_DISCOVERY_NAME = "search_tool_bm25";
|
|
2572
3139
|
|
|
2573
3140
|
/**
|
|
2574
|
-
* Active tool a call name dispatches to.
|
|
2575
|
-
*
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
3141
|
+
* Active tool a call name dispatches to.
|
|
3142
|
+
*
|
|
3143
|
+
* The rule itself lives with the dispatch-identity binding so execution and the identity
|
|
3144
|
+
* bound onto the emitted event can never diverge.
|
|
2578
3145
|
*/
|
|
2579
3146
|
function findActiveTool<T extends { name: string; customWireName?: string }>(
|
|
2580
3147
|
tools: ReadonlyArray<T> | undefined,
|
|
2581
3148
|
callName: string,
|
|
2582
3149
|
): T | undefined {
|
|
2583
|
-
return (
|
|
2584
|
-
tools?.find(tool => tool.name === callName) ??
|
|
2585
|
-
tools?.find(tool => tool.customWireName !== undefined && tool.customWireName === callName)
|
|
2586
|
-
);
|
|
3150
|
+
return activeToolForCallName(tools, callName);
|
|
2587
3151
|
}
|
|
2588
3152
|
|
|
2589
3153
|
/**
|
|
@@ -2645,6 +3209,7 @@ async function executeToolCalls(
|
|
|
2645
3209
|
toolCall,
|
|
2646
3210
|
tool: findActiveTool(tools, toolCall.name),
|
|
2647
3211
|
args: toolCall.arguments as Record<string, unknown>,
|
|
3212
|
+
eventFields: undefined as { toolCallId: string; toolName: string; intent: string | undefined } | undefined,
|
|
2648
3213
|
started: false,
|
|
2649
3214
|
result: undefined as AgentToolResult<any> | undefined,
|
|
2650
3215
|
isError: false,
|
|
@@ -2678,29 +3243,44 @@ async function executeToolCalls(
|
|
|
2678
3243
|
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
|
2679
3244
|
if (record.resultEmitted) return;
|
|
2680
3245
|
const { toolCall } = record;
|
|
2681
|
-
|
|
2682
|
-
|
|
3246
|
+
const eventFields =
|
|
3247
|
+
record.eventFields ?? ({ toolCallId: toolCall.id, toolName: toolCall.name, intent: toolCall.intent } as const);
|
|
3248
|
+
// A call that was skipped or aborted before dispatch still owes the stream a
|
|
3249
|
+
// start/end PAIR, because every consumer downstream is built around results
|
|
3250
|
+
// arriving in pairs. Both halves are marked as what they are — pairing only — so a
|
|
3251
|
+
// consumer that publishes "this tool is running" can leave them out while relays,
|
|
3252
|
+
// history, and result handling keep seeing the same events they always did.
|
|
3253
|
+
const dispatched = record.started;
|
|
3254
|
+
if (!dispatched) {
|
|
3255
|
+
// No dispatch provenance is bound here. `record.tool` is the object this call
|
|
3256
|
+
// WOULD have run, and binding it would let a consumer resolve a canonical
|
|
3257
|
+
// built-in label for a tool whose `execute` was never entered.
|
|
3258
|
+
const startEvent: AgentEvent = {
|
|
2683
3259
|
type: "tool_execution_start",
|
|
2684
|
-
toolCallId:
|
|
2685
|
-
toolName:
|
|
3260
|
+
toolCallId: eventFields.toolCallId,
|
|
3261
|
+
toolName: eventFields.toolName,
|
|
2686
3262
|
args: record.args,
|
|
2687
|
-
intent:
|
|
3263
|
+
intent: eventFields.intent,
|
|
2688
3264
|
scope,
|
|
2689
|
-
}
|
|
3265
|
+
};
|
|
3266
|
+
markNonDispatchedToolEvent(startEvent);
|
|
3267
|
+
stream.push(startEvent);
|
|
2690
3268
|
}
|
|
2691
|
-
|
|
3269
|
+
const endEvent: AgentEvent = {
|
|
2692
3270
|
type: "tool_execution_end",
|
|
2693
|
-
toolCallId:
|
|
2694
|
-
toolName:
|
|
3271
|
+
toolCallId: eventFields.toolCallId,
|
|
3272
|
+
toolName: eventFields.toolName,
|
|
2695
3273
|
result,
|
|
2696
3274
|
isError,
|
|
2697
3275
|
scope,
|
|
2698
|
-
}
|
|
3276
|
+
};
|
|
3277
|
+
if (!dispatched) markNonDispatchedToolEvent(endEvent);
|
|
3278
|
+
stream.push(endEvent);
|
|
2699
3279
|
|
|
2700
3280
|
const toolResultMessage: ToolResultMessage = {
|
|
2701
3281
|
role: "toolResult",
|
|
2702
|
-
toolCallId:
|
|
2703
|
-
toolName:
|
|
3282
|
+
toolCallId: eventFields.toolCallId,
|
|
3283
|
+
toolName: eventFields.toolName,
|
|
2704
3284
|
content: result.content,
|
|
2705
3285
|
details: result.details,
|
|
2706
3286
|
isError,
|
|
@@ -2716,6 +3296,62 @@ async function executeToolCalls(
|
|
|
2716
3296
|
stream.push({ type: "message_end", message: toolResultMessage, scope });
|
|
2717
3297
|
};
|
|
2718
3298
|
|
|
3299
|
+
/**
|
|
3300
|
+
* Prepare every value needed to publish and invoke one dispatch before claiming that it
|
|
3301
|
+
* started. Once this returns, the path from a successful start publication to intrinsic
|
|
3302
|
+
* invocation contains only trusted local bindings and values.
|
|
3303
|
+
*/
|
|
3304
|
+
const prepareToolDispatch = (
|
|
3305
|
+
record: (typeof records)[number],
|
|
3306
|
+
startArgs: Record<string, unknown>,
|
|
3307
|
+
executionArgs: Record<string, unknown>,
|
|
3308
|
+
executionSignal: AbortSignal | undefined,
|
|
3309
|
+
effectiveArgs: Record<string, unknown>,
|
|
3310
|
+
toolContext: AgentToolContext | undefined,
|
|
3311
|
+
): { startEvent: AgentEvent; invocationArguments: Parameters<AgentTool["execute"]> } => {
|
|
3312
|
+
const eventFields = {
|
|
3313
|
+
toolCallId: record.toolCall.id,
|
|
3314
|
+
toolName: record.toolCall.name,
|
|
3315
|
+
intent: record.toolCall.intent,
|
|
3316
|
+
};
|
|
3317
|
+
const startEvent: AgentEvent = {
|
|
3318
|
+
type: "tool_execution_start",
|
|
3319
|
+
toolCallId: eventFields.toolCallId,
|
|
3320
|
+
toolName: eventFields.toolName,
|
|
3321
|
+
args: startArgs,
|
|
3322
|
+
intent: eventFields.intent,
|
|
3323
|
+
scope,
|
|
3324
|
+
};
|
|
3325
|
+
// Retain the exact values the start/end/result pair must share. No later event in
|
|
3326
|
+
// this dispatch needs to re-read a stateful ToolCall property.
|
|
3327
|
+
record.eventFields = eventFields;
|
|
3328
|
+
bindDispatchedToolIdentity(startEvent, record.tool);
|
|
3329
|
+
const onUpdate: NonNullable<Parameters<AgentTool["execute"]>[3]> = partialResult => {
|
|
3330
|
+
stream.push({
|
|
3331
|
+
type: "tool_execution_update",
|
|
3332
|
+
toolCallId: eventFields.toolCallId,
|
|
3333
|
+
toolName: eventFields.toolName,
|
|
3334
|
+
args: effectiveArgs,
|
|
3335
|
+
partialResult: coerceToolResult(partialResult).result,
|
|
3336
|
+
scope,
|
|
3337
|
+
});
|
|
3338
|
+
};
|
|
3339
|
+
const invocationArguments = [
|
|
3340
|
+
eventFields.toolCallId,
|
|
3341
|
+
executionArgs,
|
|
3342
|
+
executionSignal,
|
|
3343
|
+
onUpdate,
|
|
3344
|
+
toolContext,
|
|
3345
|
+
] as Parameters<AgentTool["execute"]>;
|
|
3346
|
+
return { startEvent, invocationArguments };
|
|
3347
|
+
};
|
|
3348
|
+
|
|
3349
|
+
/** Mark dispatch only after the fully prepared start was successfully published. */
|
|
3350
|
+
const publishToolDispatch = (record: (typeof records)[number], startEvent: AgentEvent): void => {
|
|
3351
|
+
stream.push(startEvent);
|
|
3352
|
+
record.started = true;
|
|
3353
|
+
};
|
|
3354
|
+
|
|
2719
3355
|
const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
|
|
2720
3356
|
if (interruptState.triggered) {
|
|
2721
3357
|
// Skip both span emission and the collector orphan record here. The
|
|
@@ -2744,15 +3380,6 @@ async function executeToolCalls(
|
|
|
2744
3380
|
}
|
|
2745
3381
|
}
|
|
2746
3382
|
record.args = argsForExecution;
|
|
2747
|
-
record.started = true;
|
|
2748
|
-
stream.push({
|
|
2749
|
-
type: "tool_execution_start",
|
|
2750
|
-
toolCallId: toolCall.id,
|
|
2751
|
-
toolName: toolCall.name,
|
|
2752
|
-
args: argsForExecution,
|
|
2753
|
-
intent: toolCall.intent,
|
|
2754
|
-
scope,
|
|
2755
|
-
});
|
|
2756
3383
|
|
|
2757
3384
|
const toolSpan = startExecuteToolSpan(telemetry, {
|
|
2758
3385
|
tool,
|
|
@@ -2773,15 +3400,19 @@ async function executeToolCalls(
|
|
|
2773
3400
|
try {
|
|
2774
3401
|
if (toolCall.incompleteArguments) {
|
|
2775
3402
|
record.argumentValidationFailed = true;
|
|
2776
|
-
// The provider flagged this call's
|
|
2777
|
-
//
|
|
2778
|
-
//
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
`
|
|
2783
|
-
|
|
2784
|
-
|
|
3403
|
+
// The provider flagged this call's arguments as unsafe to execute.
|
|
3404
|
+
// The typed reason selects accurate recovery guidance; callers that
|
|
3405
|
+
// only read the boolean still get a safe, actionable rejection.
|
|
3406
|
+
const reason = toolCall.incompleteArgumentsReason;
|
|
3407
|
+
const detail =
|
|
3408
|
+
reason === "malformed"
|
|
3409
|
+
? `The terminal arguments for tool call "${toolCall.name}" did not decode to a valid JSON object. The arguments cannot be executed. Re-issue the call with valid, complete arguments.`
|
|
3410
|
+
: reason === "conflicting"
|
|
3411
|
+
? `The streamed and terminal arguments for tool call "${toolCall.name}" disagree. The arguments cannot be executed safely. Re-issue the call with consistent arguments.`
|
|
3412
|
+
: reason === "ambiguous"
|
|
3413
|
+
? `The identity of tool call "${toolCall.name}" was ambiguous on the wire (duplicate call id or id/call_id collision), so its arguments cannot be safely attributed. Re-issue the call.`
|
|
3414
|
+
: `Tool call "${toolCall.name}" was cut off before its arguments finished streaming (the response hit its output token limit). The partial arguments cannot be executed. Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`;
|
|
3415
|
+
throw new Error(detail);
|
|
2785
3416
|
}
|
|
2786
3417
|
if (toolCall.escapedNonAsciiArguments) {
|
|
2787
3418
|
record.argumentValidationFailed = true;
|
|
@@ -2860,22 +3491,25 @@ async function executeToolCalls(
|
|
|
2860
3491
|
const toolContext = scope
|
|
2861
3492
|
? (Object.assign(baseToolContext ?? {}, { attemptScope: scope }) as AgentToolContext)
|
|
2862
3493
|
: baseToolContext;
|
|
2863
|
-
const
|
|
2864
|
-
toolCall.
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
},
|
|
3494
|
+
const executionArgs = transformToolCallArguments
|
|
3495
|
+
? transformToolCallArguments(effectiveArgs, toolCall.name)
|
|
3496
|
+
: effectiveArgs;
|
|
3497
|
+
const executionSignal = tool.nonAbortable ? undefined : toolSignal;
|
|
3498
|
+
const execute = tool.execute;
|
|
3499
|
+
if (typeof execute !== "function")
|
|
3500
|
+
throw new Error(`Tool ${toolCall.name} has no executable implementation`);
|
|
3501
|
+
const { startEvent, invocationArguments } = prepareToolDispatch(
|
|
3502
|
+
record,
|
|
3503
|
+
argsForExecution,
|
|
3504
|
+
executionArgs,
|
|
3505
|
+
executionSignal,
|
|
3506
|
+
effectiveArgs,
|
|
2877
3507
|
toolContext,
|
|
2878
3508
|
);
|
|
3509
|
+
// Preparation is complete. A successful publication is the only transition
|
|
3510
|
+
// that marks this record dispatched; intrinsic invocation then consumes locals.
|
|
3511
|
+
publishToolDispatch(record, startEvent);
|
|
3512
|
+
const execution = intrinsicReflectApply(execute, tool, invocationArguments);
|
|
2879
3513
|
const rawResult = await execution;
|
|
2880
3514
|
const coerced = coerceToolResult(rawResult);
|
|
2881
3515
|
result = coerced.result;
|
|
@@ -3067,20 +3701,30 @@ function createAbortedToolResult(
|
|
|
3067
3701
|
details: {},
|
|
3068
3702
|
};
|
|
3069
3703
|
|
|
3070
|
-
|
|
3704
|
+
// Nothing was dispatched for this call: the turn errored or was aborted before any
|
|
3705
|
+
// `Tool.execute` could be entered, and this pair exists only so the stream keeps
|
|
3706
|
+
// delivering results in start/end PAIRS. Both halves say so, and neither binds the
|
|
3707
|
+
// tool the call would have run, so a consumer that publishes "this tool is running"
|
|
3708
|
+
// can leave them out while relays, history, and result handling see what they always
|
|
3709
|
+
// did.
|
|
3710
|
+
const startEvent: AgentEvent = {
|
|
3071
3711
|
type: "tool_execution_start",
|
|
3072
3712
|
toolCallId: toolCall.id,
|
|
3073
3713
|
toolName: toolCall.name,
|
|
3074
3714
|
args: toolCall.arguments,
|
|
3075
3715
|
intent: toolCall.intent,
|
|
3076
|
-
}
|
|
3077
|
-
|
|
3716
|
+
};
|
|
3717
|
+
markNonDispatchedToolEvent(startEvent);
|
|
3718
|
+
stream.push(startEvent);
|
|
3719
|
+
const endEvent: AgentEvent = {
|
|
3078
3720
|
type: "tool_execution_end",
|
|
3079
3721
|
toolCallId: toolCall.id,
|
|
3080
3722
|
toolName: toolCall.name,
|
|
3081
3723
|
result,
|
|
3082
3724
|
isError: true,
|
|
3083
|
-
}
|
|
3725
|
+
};
|
|
3726
|
+
markNonDispatchedToolEvent(endEvent);
|
|
3727
|
+
stream.push(endEvent);
|
|
3084
3728
|
|
|
3085
3729
|
const toolResultMessage: ToolResultMessage = {
|
|
3086
3730
|
role: "toolResult",
|