@gajae-code/agent-core 0.13.2 → 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 +27 -0
- package/dist/types/agent-loop.d.ts +2 -1
- package/dist/types/agent.d.ts +36 -5
- package/dist/types/compaction/compaction.d.ts +5 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/tool-dispatch-identity.d.ts +27 -0
- package/dist/types/types.d.ts +16 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +833 -135
- package/src/agent.ts +116 -18
- package/src/compaction/compaction.ts +12 -1
- package/src/index.ts +2 -0
- package/src/proxy.ts +3 -1
- package/src/tool-dispatch-identity.ts +87 -0
- package/src/types.ts +17 -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 &&
|
|
@@ -266,6 +334,7 @@ function managedContextOverflowOutcome(message: AssistantMessage, scope?: Attemp
|
|
|
266
334
|
function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
|
|
267
335
|
const errorMessage = managedProperty(error, "message");
|
|
268
336
|
const transportFailure = managedTransportFailure(error);
|
|
337
|
+
const errorKind = managedProperty(error, "errorKind");
|
|
269
338
|
let fallbackMessage = "Managed fallback attempt failed";
|
|
270
339
|
if (typeof errorMessage === "string") fallbackMessage = errorMessage;
|
|
271
340
|
else {
|
|
@@ -292,6 +361,7 @@ function managedFailureMessage(error: unknown, config: AgentLoopConfig): Assista
|
|
|
292
361
|
stopReason: "error",
|
|
293
362
|
errorMessage: fallbackMessage,
|
|
294
363
|
...(transportFailure ? { transportFailure } : {}),
|
|
364
|
+
...(errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow" ? { errorKind } : {}),
|
|
295
365
|
timestamp: Date.now(),
|
|
296
366
|
};
|
|
297
367
|
}
|
|
@@ -583,6 +653,19 @@ function publishAgentEnd(
|
|
|
583
653
|
*/
|
|
584
654
|
export const MANAGED_SNAPSHOT_MAX_NODES = 100_000;
|
|
585
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
|
+
|
|
586
669
|
/**
|
|
587
670
|
* Cycle-aware deep clone that always returns a detached, JSON-serializable
|
|
588
671
|
* value. Used whenever a detached snapshot cannot be safely obtained or
|
|
@@ -717,12 +800,33 @@ export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_S
|
|
|
717
800
|
* (e.g. a live `Headers` inside a provider error's `transportFailure` from a
|
|
718
801
|
* legacy payload), and a thrown `DataCloneError` here would mask the real
|
|
719
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.
|
|
720
810
|
*/
|
|
721
|
-
function
|
|
811
|
+
function managedSnapshotJsonBytes(value: unknown): number | undefined {
|
|
722
812
|
try {
|
|
723
|
-
|
|
813
|
+
const serialized = JSON.stringify(value);
|
|
814
|
+
return serialized === undefined ? undefined : managedAttemptTextEncoder.encode(serialized).byteLength;
|
|
724
815
|
} catch {
|
|
725
|
-
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) };
|
|
726
830
|
}
|
|
727
831
|
}
|
|
728
832
|
|
|
@@ -730,6 +834,93 @@ function managedAttemptSnapshot<T>(value: T): T {
|
|
|
730
834
|
return managedAttemptSnapshotDetailed(value).snapshot;
|
|
731
835
|
}
|
|
732
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
|
+
|
|
733
924
|
/**
|
|
734
925
|
* Recover the required assistant-message shell when a managed snapshot degrades
|
|
735
926
|
* at its root (notably for Proxy-wrapped provider messages). Only known fields
|
|
@@ -738,14 +929,39 @@ function managedAttemptSnapshot<T>(value: T): T {
|
|
|
738
929
|
*/
|
|
739
930
|
function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
|
|
740
931
|
const detailed = managedAttemptSnapshotDetailed(value);
|
|
741
|
-
const
|
|
742
|
-
|
|
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");
|
|
743
947
|
const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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);
|
|
749
965
|
const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
|
|
750
966
|
const api = managedProperty(source, "api");
|
|
751
967
|
const provider = managedProperty(source, "provider");
|
|
@@ -785,6 +1001,11 @@ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]):
|
|
|
785
1001
|
};
|
|
786
1002
|
}
|
|
787
1003
|
|
|
1004
|
+
function managedContentBlock(block: unknown): AssistantMessage["content"] {
|
|
1005
|
+
const normalized = managedAssistantContent(block);
|
|
1006
|
+
return normalized ? [normalized] : [];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
788
1009
|
function managedAssistantContent(value: unknown): AssistantMessage["content"][number] | undefined {
|
|
789
1010
|
if (!isManagedPlainRecord(value)) return undefined;
|
|
790
1011
|
const type = managedProperty(value, "type");
|
|
@@ -809,6 +1030,8 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
|
|
|
809
1030
|
const intent = managedProperty(value, "intent");
|
|
810
1031
|
const customWireName = managedProperty(value, "customWireName");
|
|
811
1032
|
const incompleteArguments = managedProperty(value, "incompleteArguments");
|
|
1033
|
+
const incompleteArgumentsReason = managedProperty(value, "incompleteArgumentsReason");
|
|
1034
|
+
const escapedNonAsciiArguments = managedProperty(value, "escapedNonAsciiArguments");
|
|
812
1035
|
return {
|
|
813
1036
|
type,
|
|
814
1037
|
id,
|
|
@@ -818,6 +1041,16 @@ function managedAssistantContent(value: unknown): AssistantMessage["content"][nu
|
|
|
818
1041
|
...(typeof intent === "string" ? { intent } : {}),
|
|
819
1042
|
...(typeof customWireName === "string" ? { customWireName } : {}),
|
|
820
1043
|
...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
|
|
1044
|
+
...(typeof incompleteArgumentsReason === "string"
|
|
1045
|
+
? {
|
|
1046
|
+
incompleteArgumentsReason: incompleteArgumentsReason as
|
|
1047
|
+
| "truncated"
|
|
1048
|
+
| "malformed"
|
|
1049
|
+
| "conflicting"
|
|
1050
|
+
| "ambiguous",
|
|
1051
|
+
}
|
|
1052
|
+
: {}),
|
|
1053
|
+
...(typeof escapedNonAsciiArguments === "boolean" ? { escapedNonAsciiArguments } : {}),
|
|
821
1054
|
};
|
|
822
1055
|
}
|
|
823
1056
|
|
|
@@ -847,13 +1080,30 @@ function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
|
|
|
847
1080
|
};
|
|
848
1081
|
}
|
|
849
1082
|
|
|
850
|
-
function managedAssistantEventSnapshot(
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
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");
|
|
855
1103
|
const indexed = () => {
|
|
856
|
-
if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0)
|
|
1104
|
+
if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) {
|
|
1105
|
+
throw new ManagedAttemptSnapshotError("event.contentIndex");
|
|
1106
|
+
}
|
|
857
1107
|
return contentIndex as number;
|
|
858
1108
|
};
|
|
859
1109
|
if (type === "start") return { type, partial: message };
|
|
@@ -870,31 +1120,36 @@ function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: As
|
|
|
870
1120
|
type === "reasoning_summary_delta" ||
|
|
871
1121
|
type === "toolcall_delta"
|
|
872
1122
|
) {
|
|
873
|
-
const delta = managedProperty(
|
|
874
|
-
if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
|
|
1123
|
+
const delta = managedProperty(source, "delta");
|
|
1124
|
+
if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta");
|
|
875
1125
|
return { type, contentIndex: indexed(), delta, partial: message };
|
|
876
1126
|
}
|
|
877
1127
|
if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") {
|
|
878
|
-
const content = managedProperty(
|
|
879
|
-
if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
|
|
1128
|
+
const content = managedProperty(source, "content");
|
|
1129
|
+
if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content");
|
|
880
1130
|
return { type, contentIndex: indexed(), content, partial: message };
|
|
881
1131
|
}
|
|
882
1132
|
if (type === "toolcall_end") {
|
|
883
|
-
const toolCall = managedAssistantContent(managedProperty(
|
|
884
|
-
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");
|
|
885
1135
|
return { type, contentIndex: indexed(), toolCall, partial: message };
|
|
886
1136
|
}
|
|
887
1137
|
if (type === "done") {
|
|
888
|
-
const reason = managedProperty(
|
|
889
|
-
|
|
890
|
-
|
|
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 };
|
|
891
1143
|
}
|
|
892
1144
|
if (type === "error") {
|
|
893
|
-
const reason = managedProperty(
|
|
894
|
-
|
|
895
|
-
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 };
|
|
896
1148
|
}
|
|
897
|
-
|
|
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");
|
|
898
1153
|
}
|
|
899
1154
|
|
|
900
1155
|
function isManagedPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -902,17 +1157,62 @@ function isManagedPlainRecord(value: unknown): value is Record<string, unknown>
|
|
|
902
1157
|
}
|
|
903
1158
|
|
|
904
1159
|
/**
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
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.
|
|
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.
|
|
908
1205
|
*/
|
|
1206
|
+
type ManagedAttemptBatchItem =
|
|
1207
|
+
| { type: "event"; event: AgentEvent }
|
|
1208
|
+
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };
|
|
1209
|
+
|
|
909
1210
|
class ManagedAttemptTransaction {
|
|
910
|
-
#batch:
|
|
911
|
-
| { type: "event"; event: AgentEvent }
|
|
912
|
-
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
|
|
913
|
-
> = [];
|
|
1211
|
+
#batch: ManagedAttemptBatchItem[] = [];
|
|
914
1212
|
#stagedEventCount = 0;
|
|
915
1213
|
#stagedBytes = 0;
|
|
1214
|
+
/** Shape snapshot retained across discard() for bounded failure diagnostics. */
|
|
1215
|
+
#lastStagedShape: { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } | undefined;
|
|
916
1216
|
#discarded = false;
|
|
917
1217
|
#committed = false;
|
|
918
1218
|
|
|
@@ -923,10 +1223,15 @@ class ManagedAttemptTransaction {
|
|
|
923
1223
|
| undefined,
|
|
924
1224
|
private readonly model: AgentLoopConfig["model"],
|
|
925
1225
|
readonly scope?: AttemptScope,
|
|
1226
|
+
readonly snapshotMode: "managed" | "lossless" = "managed",
|
|
926
1227
|
) {}
|
|
927
1228
|
|
|
928
1229
|
push(event: AgentEvent): void {
|
|
929
1230
|
if (this.#committed) {
|
|
1231
|
+
if (event.type === "message_end" || event.type === "turn_end") {
|
|
1232
|
+
this.#batch.push({ type: "event", event });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
930
1235
|
this.stream.push(event);
|
|
931
1236
|
return;
|
|
932
1237
|
}
|
|
@@ -938,16 +1243,20 @@ class ManagedAttemptTransaction {
|
|
|
938
1243
|
}
|
|
939
1244
|
|
|
940
1245
|
stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
941
|
-
const partial =
|
|
1246
|
+
const partial = this.#assistantSnapshot(message);
|
|
1247
|
+
if (this.#committed) {
|
|
1248
|
+
this.onAssistantMessageEvent?.(partial, this.#assistantEventSnapshot(event, partial));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
942
1251
|
this.#batch.push({
|
|
943
1252
|
type: "assistant_event",
|
|
944
1253
|
message: partial,
|
|
945
|
-
event:
|
|
1254
|
+
event: this.#assistantEventSnapshot(event, partial),
|
|
946
1255
|
});
|
|
947
1256
|
}
|
|
948
1257
|
|
|
949
1258
|
flush(): void {
|
|
950
|
-
if (this.#discarded
|
|
1259
|
+
if (this.#discarded) return;
|
|
951
1260
|
for (const item of this.#batch) {
|
|
952
1261
|
if (item.type === "assistant_event") {
|
|
953
1262
|
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
@@ -961,13 +1270,98 @@ class ManagedAttemptTransaction {
|
|
|
961
1270
|
this.#committed = true;
|
|
962
1271
|
}
|
|
963
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
|
+
|
|
964
1322
|
discard(): void {
|
|
1323
|
+
if (!this.#discarded) {
|
|
1324
|
+
this.#lastStagedShape = {
|
|
1325
|
+
stagedEventCount: this.#stagedEventCount,
|
|
1326
|
+
stagedBytes: this.#stagedBytes,
|
|
1327
|
+
contentBlockCount: this.#stagedContentBlockCount(),
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
965
1330
|
this.#batch = [];
|
|
966
1331
|
this.#stagedBytes = 0;
|
|
967
1332
|
this.#stagedEventCount = 0;
|
|
968
1333
|
this.#discarded = true;
|
|
969
1334
|
}
|
|
970
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
|
+
}
|
|
971
1365
|
#wouldOverflow(bytes: number): boolean {
|
|
972
1366
|
return (
|
|
973
1367
|
this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
|
|
@@ -976,6 +1370,43 @@ class ManagedAttemptTransaction {
|
|
|
976
1370
|
}
|
|
977
1371
|
|
|
978
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
|
+
}
|
|
979
1410
|
// Measure the raw event FIRST so an oversized payload is rejected
|
|
980
1411
|
// before the snapshot duplicates it — the staged-byte cap exists to
|
|
981
1412
|
// bound memory, so cloning ahead of the check would defeat it.
|
|
@@ -990,37 +1421,28 @@ class ManagedAttemptTransaction {
|
|
|
990
1421
|
}
|
|
991
1422
|
if (bytes !== undefined && this.#wouldOverflow(bytes)) {
|
|
992
1423
|
this.discard();
|
|
993
|
-
throw new ManagedAttemptBufferOverflowError();
|
|
1424
|
+
throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
|
|
994
1425
|
}
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
// dedicated local error: it carries no transport facts, so it is
|
|
1016
|
-
// non-retryable and can never be misattributed to the provider.
|
|
1017
|
-
this.discard();
|
|
1018
|
-
throw new ManagedAttemptSnapshotError();
|
|
1019
|
-
}
|
|
1020
|
-
if (this.#wouldOverflow(bytes)) {
|
|
1021
|
-
this.discard();
|
|
1022
|
-
throw new ManagedAttemptBufferOverflowError();
|
|
1023
|
-
}
|
|
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");
|
|
1024
1446
|
}
|
|
1025
1447
|
this.#batch.push({ type: "event", event: snapshot });
|
|
1026
1448
|
this.#stagedEventCount += 1;
|
|
@@ -1029,6 +1451,7 @@ class ManagedAttemptTransaction {
|
|
|
1029
1451
|
}
|
|
1030
1452
|
|
|
1031
1453
|
#repairAssistantEvent(event: AgentEvent): AgentEvent {
|
|
1454
|
+
if (this.snapshotMode === "lossless") return event;
|
|
1032
1455
|
if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
|
|
1033
1456
|
return event.message.role === "assistant"
|
|
1034
1457
|
? { ...event, message: managedAssistantShell(event.message, this.model) }
|
|
@@ -1052,6 +1475,52 @@ class ManagedAttemptTransaction {
|
|
|
1052
1475
|
}
|
|
1053
1476
|
return event;
|
|
1054
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
|
+
}
|
|
1055
1524
|
}
|
|
1056
1525
|
|
|
1057
1526
|
/**
|
|
@@ -1469,6 +1938,15 @@ async function runLoopBody(
|
|
|
1469
1938
|
// Fires at most one repaired resend per run for the reasoning-content replay
|
|
1470
1939
|
// breaker below (DeepSeek "reasoning_content ... must be passed back").
|
|
1471
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;
|
|
1472
1950
|
let previousMalformedToolSignatures = new Set<string>();
|
|
1473
1951
|
type SyntheticRecoveryKind = "malformed-tool-call" | "composer-bash-policy" | "provider";
|
|
1474
1952
|
let pendingRecovery:
|
|
@@ -1498,11 +1976,15 @@ async function runLoopBody(
|
|
|
1498
1976
|
const scope =
|
|
1499
1977
|
initialScope ?? (firstTurn ? config.initialScope : undefined) ?? config.attemptMinter?.mint("main");
|
|
1500
1978
|
initialScope = undefined;
|
|
1501
|
-
const
|
|
1979
|
+
const managedTransaction =
|
|
1502
1980
|
initialTransaction ??
|
|
1503
1981
|
(config.fallbackManaged
|
|
1504
1982
|
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
|
|
1505
1983
|
: undefined);
|
|
1984
|
+
const escapedToolTransaction = config.fallbackManaged
|
|
1985
|
+
? undefined
|
|
1986
|
+
: new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope, "lossless");
|
|
1987
|
+
const transaction = managedTransaction ?? escapedToolTransaction;
|
|
1506
1988
|
initialTransaction = undefined;
|
|
1507
1989
|
const attemptScope = transaction?.scope ?? scope;
|
|
1508
1990
|
lastAttemptScope = attemptScope;
|
|
@@ -1580,10 +2062,16 @@ async function runLoopBody(
|
|
|
1580
2062
|
// Stream assistant response
|
|
1581
2063
|
let recovered: HarmonyRecoveredToolCall | undefined;
|
|
1582
2064
|
let message: AssistantMessage;
|
|
1583
|
-
const attemptTransaction =
|
|
2065
|
+
const attemptTransaction = managedTransaction;
|
|
1584
2066
|
const recoveryAttempt = pendingRecovery;
|
|
1585
2067
|
const wasMalformedToolRecoveryAttempt = recoveryAttempt?.kind === "malformed-tool-call";
|
|
1586
2068
|
try {
|
|
2069
|
+
const getLogicalTurnToolChoice = (): ToolChoice | undefined => {
|
|
2070
|
+
if (escapedNonAsciiToolChoiceCaptured) return escapedNonAsciiToolChoice;
|
|
2071
|
+
escapedNonAsciiToolChoice = config.getToolChoice?.();
|
|
2072
|
+
escapedNonAsciiToolChoiceCaptured = true;
|
|
2073
|
+
return escapedNonAsciiToolChoice;
|
|
2074
|
+
};
|
|
1587
2075
|
const attemptConfig = attemptTransaction
|
|
1588
2076
|
? {
|
|
1589
2077
|
...config,
|
|
@@ -1612,7 +2100,7 @@ async function runLoopBody(
|
|
|
1612
2100
|
currentContext,
|
|
1613
2101
|
attemptConfig,
|
|
1614
2102
|
loopSignal,
|
|
1615
|
-
|
|
2103
|
+
transaction ? (transaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
|
|
1616
2104
|
telemetry,
|
|
1617
2105
|
invokeAgentSpan,
|
|
1618
2106
|
stepCounter,
|
|
@@ -1626,6 +2114,8 @@ async function runLoopBody(
|
|
|
1626
2114
|
forceAutoToolChoice: !wasMalformedToolRecoveryAttempt,
|
|
1627
2115
|
}
|
|
1628
2116
|
: undefined,
|
|
2117
|
+
escapedToolTransaction,
|
|
2118
|
+
recoveryAttempt ? undefined : { value: getLogicalTurnToolChoice() },
|
|
1629
2119
|
);
|
|
1630
2120
|
const detection = detectHarmonyLeakInAssistantMessage(message);
|
|
1631
2121
|
if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
|
|
@@ -1638,6 +2128,7 @@ async function runLoopBody(
|
|
|
1638
2128
|
} catch (err) {
|
|
1639
2129
|
if (!(err instanceof HarmonyLeakInterruption)) {
|
|
1640
2130
|
const failureMessage = managedFailureMessage(err, config);
|
|
2131
|
+
if (config.fallbackManaged) warnManagedSnapshotFailure(err, config, transaction);
|
|
1641
2132
|
if (config.fallbackManaged && transaction && managedContextOverflow(failureMessage, config)) {
|
|
1642
2133
|
transaction.discard();
|
|
1643
2134
|
currentContext.messages.splice(contextMessageCount);
|
|
@@ -1683,6 +2174,18 @@ async function runLoopBody(
|
|
|
1683
2174
|
}
|
|
1684
2175
|
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
|
|
1685
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
|
+
}
|
|
1686
2189
|
if (harmonyRetryAttempt >= 2) {
|
|
1687
2190
|
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
1688
2191
|
throw new Error(
|
|
@@ -1773,6 +2276,59 @@ async function runLoopBody(
|
|
|
1773
2276
|
}
|
|
1774
2277
|
}
|
|
1775
2278
|
|
|
2279
|
+
// Escaped-non-ASCII tool arguments: bounded turn resample.
|
|
2280
|
+
//
|
|
2281
|
+
// Arguments that spell a printable non-ASCII character as `\uXXXX`
|
|
2282
|
+
// instead of literal UTF-8 are a wire-format defect, not a decision the
|
|
2283
|
+
// model needs to be told about. The payload parses cleanly, but one
|
|
2284
|
+
// mistyped nibble decodes to a different, equally valid character, so it
|
|
2285
|
+
// can never be verified or repaired after the fact. Reporting it as a
|
|
2286
|
+
// tool error spends the whole turn and writes the literal escape syntax
|
|
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.
|
|
2294
|
+
if (
|
|
2295
|
+
message.stopReason !== "error" &&
|
|
2296
|
+
message.stopReason !== "aborted" &&
|
|
2297
|
+
escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
|
|
2298
|
+
!escapedToolTransaction?.committed &&
|
|
2299
|
+
hasEscapedNonAsciiToolCall(message)
|
|
2300
|
+
) {
|
|
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;
|
|
2327
|
+
}
|
|
2328
|
+
escapedNonAsciiResampleAttempt = 0;
|
|
2329
|
+
escapedNonAsciiToolChoiceCaptured = false;
|
|
2330
|
+
escapedNonAsciiToolChoice = undefined;
|
|
2331
|
+
|
|
1776
2332
|
const overflow = managedContextOverflow(message, config);
|
|
1777
2333
|
if (config.fallbackManaged && overflow) {
|
|
1778
2334
|
transaction?.discard();
|
|
@@ -1826,7 +2382,28 @@ async function runLoopBody(
|
|
|
1826
2382
|
}
|
|
1827
2383
|
|
|
1828
2384
|
// One provider invocation is committed before any tool can run.
|
|
1829
|
-
|
|
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
|
+
}
|
|
1830
2407
|
if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
|
|
1831
2408
|
await config.onManagedAttemptAccepted?.();
|
|
1832
2409
|
}
|
|
@@ -2072,6 +2649,8 @@ async function streamAssistantResponse(
|
|
|
2072
2649
|
disableTools?: boolean;
|
|
2073
2650
|
forceAutoToolChoice?: boolean;
|
|
2074
2651
|
},
|
|
2652
|
+
provisionalToolTransaction?: ManagedAttemptTransaction,
|
|
2653
|
+
toolChoiceOverride?: { value: ToolChoice | undefined },
|
|
2075
2654
|
): Promise<AssistantMessage> {
|
|
2076
2655
|
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
|
|
2077
2656
|
let messages = context.messages;
|
|
@@ -2134,7 +2713,11 @@ async function streamAssistantResponse(
|
|
|
2134
2713
|
|
|
2135
2714
|
// Synthetic recovery requests choose their tool mode explicitly below and
|
|
2136
2715
|
// must never consume a queued dynamic choice intended for an ordinary turn.
|
|
2137
|
-
const dynamicToolChoice = recoveryMode
|
|
2716
|
+
const dynamicToolChoice = recoveryMode
|
|
2717
|
+
? undefined
|
|
2718
|
+
: toolChoiceOverride
|
|
2719
|
+
? toolChoiceOverride.value
|
|
2720
|
+
: config.getToolChoice?.();
|
|
2138
2721
|
const dynamicReasoning = config.getReasoning?.();
|
|
2139
2722
|
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
|
|
2140
2723
|
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
|
|
@@ -2397,6 +2980,9 @@ async function streamAssistantResponse(
|
|
|
2397
2980
|
: event.partial;
|
|
2398
2981
|
context.messages.push(partialMessage);
|
|
2399
2982
|
addedPartial = true;
|
|
2983
|
+
if (provisionalToolTransaction) {
|
|
2984
|
+
config.onProvisionalAssistantMessageEvent?.(partialMessage, event);
|
|
2985
|
+
}
|
|
2400
2986
|
stream.push({ type: "message_start", message: { ...partialMessage }, scope });
|
|
2401
2987
|
break;
|
|
2402
2988
|
|
|
@@ -2420,9 +3006,23 @@ async function streamAssistantResponse(
|
|
|
2420
3006
|
partialMessage = config.fallbackManaged
|
|
2421
3007
|
? managedAssistantShell(event.partial, config.model)
|
|
2422
3008
|
: event.partial;
|
|
2423
|
-
|
|
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;
|
|
2424
3019
|
context.messages[context.messages.length - 1] = partialMessage;
|
|
2425
|
-
|
|
3020
|
+
if (provisionalToolTransaction) {
|
|
3021
|
+
config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent);
|
|
3022
|
+
provisionalToolTransaction.stageAssistantMessageEvent(partialMessage, partialEvent);
|
|
3023
|
+
} else {
|
|
3024
|
+
config.onAssistantMessageEvent?.(partialMessage, partialEvent);
|
|
3025
|
+
}
|
|
2426
3026
|
if (signal?.aborted) continue;
|
|
2427
3027
|
stream.push({
|
|
2428
3028
|
type: "message_update",
|
|
@@ -2430,6 +3030,13 @@ async function streamAssistantResponse(
|
|
|
2430
3030
|
message: { ...partialMessage },
|
|
2431
3031
|
scope,
|
|
2432
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
|
+
}
|
|
2433
3040
|
}
|
|
2434
3041
|
break;
|
|
2435
3042
|
|
|
@@ -2531,19 +3138,16 @@ function toolCallNames(tool: { name: string; customWireName?: string }): string[
|
|
|
2531
3138
|
const TOOL_DISCOVERY_NAME = "search_tool_bm25";
|
|
2532
3139
|
|
|
2533
3140
|
/**
|
|
2534
|
-
* Active tool a call name dispatches to.
|
|
2535
|
-
*
|
|
2536
|
-
*
|
|
2537
|
-
*
|
|
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.
|
|
2538
3145
|
*/
|
|
2539
3146
|
function findActiveTool<T extends { name: string; customWireName?: string }>(
|
|
2540
3147
|
tools: ReadonlyArray<T> | undefined,
|
|
2541
3148
|
callName: string,
|
|
2542
3149
|
): T | undefined {
|
|
2543
|
-
return (
|
|
2544
|
-
tools?.find(tool => tool.name === callName) ??
|
|
2545
|
-
tools?.find(tool => tool.customWireName !== undefined && tool.customWireName === callName)
|
|
2546
|
-
);
|
|
3150
|
+
return activeToolForCallName(tools, callName);
|
|
2547
3151
|
}
|
|
2548
3152
|
|
|
2549
3153
|
/**
|
|
@@ -2605,6 +3209,7 @@ async function executeToolCalls(
|
|
|
2605
3209
|
toolCall,
|
|
2606
3210
|
tool: findActiveTool(tools, toolCall.name),
|
|
2607
3211
|
args: toolCall.arguments as Record<string, unknown>,
|
|
3212
|
+
eventFields: undefined as { toolCallId: string; toolName: string; intent: string | undefined } | undefined,
|
|
2608
3213
|
started: false,
|
|
2609
3214
|
result: undefined as AgentToolResult<any> | undefined,
|
|
2610
3215
|
isError: false,
|
|
@@ -2638,29 +3243,44 @@ async function executeToolCalls(
|
|
|
2638
3243
|
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
|
2639
3244
|
if (record.resultEmitted) return;
|
|
2640
3245
|
const { toolCall } = record;
|
|
2641
|
-
|
|
2642
|
-
|
|
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 = {
|
|
2643
3259
|
type: "tool_execution_start",
|
|
2644
|
-
toolCallId:
|
|
2645
|
-
toolName:
|
|
3260
|
+
toolCallId: eventFields.toolCallId,
|
|
3261
|
+
toolName: eventFields.toolName,
|
|
2646
3262
|
args: record.args,
|
|
2647
|
-
intent:
|
|
3263
|
+
intent: eventFields.intent,
|
|
2648
3264
|
scope,
|
|
2649
|
-
}
|
|
3265
|
+
};
|
|
3266
|
+
markNonDispatchedToolEvent(startEvent);
|
|
3267
|
+
stream.push(startEvent);
|
|
2650
3268
|
}
|
|
2651
|
-
|
|
3269
|
+
const endEvent: AgentEvent = {
|
|
2652
3270
|
type: "tool_execution_end",
|
|
2653
|
-
toolCallId:
|
|
2654
|
-
toolName:
|
|
3271
|
+
toolCallId: eventFields.toolCallId,
|
|
3272
|
+
toolName: eventFields.toolName,
|
|
2655
3273
|
result,
|
|
2656
3274
|
isError,
|
|
2657
3275
|
scope,
|
|
2658
|
-
}
|
|
3276
|
+
};
|
|
3277
|
+
if (!dispatched) markNonDispatchedToolEvent(endEvent);
|
|
3278
|
+
stream.push(endEvent);
|
|
2659
3279
|
|
|
2660
3280
|
const toolResultMessage: ToolResultMessage = {
|
|
2661
3281
|
role: "toolResult",
|
|
2662
|
-
toolCallId:
|
|
2663
|
-
toolName:
|
|
3282
|
+
toolCallId: eventFields.toolCallId,
|
|
3283
|
+
toolName: eventFields.toolName,
|
|
2664
3284
|
content: result.content,
|
|
2665
3285
|
details: result.details,
|
|
2666
3286
|
isError,
|
|
@@ -2676,6 +3296,62 @@ async function executeToolCalls(
|
|
|
2676
3296
|
stream.push({ type: "message_end", message: toolResultMessage, scope });
|
|
2677
3297
|
};
|
|
2678
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
|
+
|
|
2679
3355
|
const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
|
|
2680
3356
|
if (interruptState.triggered) {
|
|
2681
3357
|
// Skip both span emission and the collector orphan record here. The
|
|
@@ -2704,15 +3380,6 @@ async function executeToolCalls(
|
|
|
2704
3380
|
}
|
|
2705
3381
|
}
|
|
2706
3382
|
record.args = argsForExecution;
|
|
2707
|
-
record.started = true;
|
|
2708
|
-
stream.push({
|
|
2709
|
-
type: "tool_execution_start",
|
|
2710
|
-
toolCallId: toolCall.id,
|
|
2711
|
-
toolName: toolCall.name,
|
|
2712
|
-
args: argsForExecution,
|
|
2713
|
-
intent: toolCall.intent,
|
|
2714
|
-
scope,
|
|
2715
|
-
});
|
|
2716
3383
|
|
|
2717
3384
|
const toolSpan = startExecuteToolSpan(telemetry, {
|
|
2718
3385
|
tool,
|
|
@@ -2733,14 +3400,32 @@ async function executeToolCalls(
|
|
|
2733
3400
|
try {
|
|
2734
3401
|
if (toolCall.incompleteArguments) {
|
|
2735
3402
|
record.argumentValidationFailed = true;
|
|
2736
|
-
// The provider flagged this call's
|
|
2737
|
-
//
|
|
2738
|
-
//
|
|
2739
|
-
|
|
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);
|
|
3416
|
+
}
|
|
3417
|
+
if (toolCall.escapedNonAsciiArguments) {
|
|
3418
|
+
record.argumentValidationFailed = true;
|
|
3419
|
+
// The arguments decoded cleanly, but they were spelled as `\uXXXX`
|
|
3420
|
+
// escapes rather than literal UTF-8. Hand-written hex is where models
|
|
3421
|
+
// mistype digits, and every mistyped nibble decodes to a different but
|
|
3422
|
+
// equally valid character — the payload is unverifiable and cannot be
|
|
3423
|
+
// repaired after parsing, so it is rejected rather than executed on
|
|
3424
|
+
// silently corrupted text.
|
|
2740
3425
|
throw new Error(
|
|
2741
|
-
`Tool call "${toolCall.name}"
|
|
2742
|
-
`
|
|
2743
|
-
`
|
|
3426
|
+
`Tool call "${toolCall.name}" spelled non-ASCII text as \\uXXXX escapes instead of literal UTF-8. ` +
|
|
3427
|
+
`Escaped text cannot be verified — a single wrong hex digit silently becomes a different character — ` +
|
|
3428
|
+
`so the call was not executed. Re-issue it writing every non-ASCII character literally.`,
|
|
2744
3429
|
);
|
|
2745
3430
|
}
|
|
2746
3431
|
if (!tool) {
|
|
@@ -2806,22 +3491,25 @@ async function executeToolCalls(
|
|
|
2806
3491
|
const toolContext = scope
|
|
2807
3492
|
? (Object.assign(baseToolContext ?? {}, { attemptScope: scope }) as AgentToolContext)
|
|
2808
3493
|
: baseToolContext;
|
|
2809
|
-
const
|
|
2810
|
-
toolCall.
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
},
|
|
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,
|
|
2823
3507
|
toolContext,
|
|
2824
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);
|
|
2825
3513
|
const rawResult = await execution;
|
|
2826
3514
|
const coerced = coerceToolResult(rawResult);
|
|
2827
3515
|
result = coerced.result;
|
|
@@ -3013,20 +3701,30 @@ function createAbortedToolResult(
|
|
|
3013
3701
|
details: {},
|
|
3014
3702
|
};
|
|
3015
3703
|
|
|
3016
|
-
|
|
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 = {
|
|
3017
3711
|
type: "tool_execution_start",
|
|
3018
3712
|
toolCallId: toolCall.id,
|
|
3019
3713
|
toolName: toolCall.name,
|
|
3020
3714
|
args: toolCall.arguments,
|
|
3021
3715
|
intent: toolCall.intent,
|
|
3022
|
-
}
|
|
3023
|
-
|
|
3716
|
+
};
|
|
3717
|
+
markNonDispatchedToolEvent(startEvent);
|
|
3718
|
+
stream.push(startEvent);
|
|
3719
|
+
const endEvent: AgentEvent = {
|
|
3024
3720
|
type: "tool_execution_end",
|
|
3025
3721
|
toolCallId: toolCall.id,
|
|
3026
3722
|
toolName: toolCall.name,
|
|
3027
3723
|
result,
|
|
3028
3724
|
isError: true,
|
|
3029
|
-
}
|
|
3725
|
+
};
|
|
3726
|
+
markNonDispatchedToolEvent(endEvent);
|
|
3727
|
+
stream.push(endEvent);
|
|
3030
3728
|
|
|
3031
3729
|
const toolResultMessage: ToolResultMessage = {
|
|
3032
3730
|
role: "toolResult",
|