@sayknow-cli/agent-core 0.3.16 → 0.4.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 +23 -0
- package/package.json +7 -8
- package/src/agent-loop.ts +826 -40
- package/src/agent.ts +260 -22
- package/src/compaction/compaction.ts +89 -95
- package/src/compaction/entries.ts +19 -0
- package/src/compaction/openai.ts +24 -30
- package/src/compaction/prompts/handoff-document.md +7 -0
- package/src/compaction/pruning.ts +144 -11
- package/src/proxy.ts +82 -2
- package/src/types.ts +106 -3
- package/dist/types/agent-loop.d.ts +0 -56
- package/dist/types/agent.d.ts +0 -403
- package/dist/types/append-only-context.d.ts +0 -137
- package/dist/types/compaction/branch-summarization.d.ts +0 -103
- package/dist/types/compaction/compaction.d.ts +0 -298
- package/dist/types/compaction/entries.d.ts +0 -109
- package/dist/types/compaction/errors.d.ts +0 -26
- package/dist/types/compaction/index.d.ts +0 -11
- package/dist/types/compaction/messages.d.ts +0 -61
- package/dist/types/compaction/openai.d.ts +0 -63
- package/dist/types/compaction/pruning.d.ts +0 -69
- package/dist/types/compaction/utils.d.ts +0 -32
- package/dist/types/compaction.d.ts +0 -1
- package/dist/types/harmony-leak.d.ts +0 -100
- package/dist/types/image-placeholder-guard.d.ts +0 -4
- package/dist/types/index.d.ts +0 -11
- package/dist/types/proxy.d.ts +0 -84
- package/dist/types/run-collector.d.ts +0 -196
- package/dist/types/telemetry.d.ts +0 -596
- package/dist/types/thinking.d.ts +0 -18
- package/dist/types/types.d.ts +0 -430
package/src/agent-loop.ts
CHANGED
|
@@ -2,19 +2,24 @@
|
|
|
2
2
|
* Agent loop that works with AgentMessage throughout.
|
|
3
3
|
* Transforms to Message[] only at the LLM call boundary.
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
import { types as nodeUtilTypes } from "node:util";
|
|
5
7
|
import {
|
|
6
8
|
type AssistantMessage,
|
|
7
9
|
type AssistantMessageEvent,
|
|
8
10
|
type Context,
|
|
11
|
+
classifyContextOverflow,
|
|
12
|
+
classifyFallbackTrigger,
|
|
9
13
|
EventStream,
|
|
10
|
-
isContextOverflow,
|
|
11
14
|
isZodSchema,
|
|
12
15
|
streamSimple,
|
|
13
16
|
type ToolResultMessage,
|
|
14
17
|
type TSchema,
|
|
18
|
+
transportFailureFacts,
|
|
15
19
|
validateToolArguments,
|
|
16
20
|
zodToWireSchema,
|
|
17
21
|
} from "@sayknow-cli/ai";
|
|
22
|
+
import { isInvalidPromptError, neutralizeReservedControlTokens } from "@sayknow-cli/ai/utils";
|
|
18
23
|
import { sanitizeText } from "@sayknow-cli/utils";
|
|
19
24
|
import {
|
|
20
25
|
createHarmonyAuditEvent,
|
|
@@ -51,19 +56,160 @@ import type {
|
|
|
51
56
|
AgentMessage,
|
|
52
57
|
AgentTool,
|
|
53
58
|
AgentToolResult,
|
|
59
|
+
ManagedAttemptOutcome,
|
|
54
60
|
StreamFn,
|
|
55
61
|
} from "./types";
|
|
56
62
|
|
|
57
63
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
64
|
+
/**
|
|
65
|
+
* Defensive caps for a provisional managed attempt. These are intentionally
|
|
66
|
+
* well above ordinary streamed responses; they only bound memory when an
|
|
67
|
+
* upstream emits an unbounded event stream before the attempt can commit.
|
|
68
|
+
*/
|
|
69
|
+
export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
|
|
70
|
+
export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Local staging failure: the provisional buffer limit was exceeded. Carries
|
|
74
|
+
* NO transport facts or status by design — only original typed provider
|
|
75
|
+
* transport facts may authorize provider fallback, so local buffer machinery
|
|
76
|
+
* must never masquerade as provider evidence or consume the fallback chain.
|
|
77
|
+
* It is therefore non-retryable and surfaces as an explicit local error.
|
|
78
|
+
*/
|
|
79
|
+
class ManagedAttemptBufferOverflowError extends Error {
|
|
80
|
+
constructor() {
|
|
81
|
+
super("Managed fallback attempt exceeded the provisional event buffer limit");
|
|
82
|
+
this.name = "ManagedAttemptBufferOverflowError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Local snapshot-machinery failure. Deliberately carries no transport facts
|
|
88
|
+
* or status, so managed fallback classification never treats it as a provider
|
|
89
|
+
* retry trigger — it fails fast instead of burning the fallback chain.
|
|
90
|
+
*/
|
|
91
|
+
class ManagedAttemptSnapshotError extends Error {
|
|
92
|
+
constructor() {
|
|
93
|
+
super(
|
|
94
|
+
"Managed fallback attempt could not produce a serializable event snapshot (local snapshot bug, not a provider failure)",
|
|
95
|
+
);
|
|
96
|
+
this.name = "ManagedAttemptSnapshotError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const managedAttemptTextEncoder = new TextEncoder();
|
|
101
|
+
|
|
58
102
|
const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
103
|
+
function managedContextOverflow(message: AssistantMessage, config: AgentLoopConfig): boolean {
|
|
104
|
+
const transportFailure = managedTransportFailure(message);
|
|
105
|
+
// Managed empty-stop responses may be repaired by the managed shell below; only
|
|
106
|
+
// typed/error overflows are discardable before that normalization boundary.
|
|
107
|
+
if (config.fallbackManaged && message.stopReason !== "error") return false;
|
|
108
|
+
return classifyContextOverflow(message, transportFailure, config.model.contextWindow);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Managed fallback owns retry policy; only attached typed transport facts may discard an attempt. */
|
|
112
|
+
function managedProperty(value: unknown, key: string): unknown {
|
|
113
|
+
if (!value || typeof value !== "object") return undefined;
|
|
114
|
+
try {
|
|
115
|
+
return Reflect.get(value, key);
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function managedTransportFailure(failure: unknown) {
|
|
122
|
+
const facts = managedProperty(failure, "transportFailure");
|
|
123
|
+
return facts && typeof facts === "object" ? transportFailureFacts(facts) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function managedRetryableFailure(failure: unknown): boolean {
|
|
127
|
+
const facts = managedTransportFailure(failure);
|
|
128
|
+
if (!facts) return false;
|
|
129
|
+
const trigger = classifyFallbackTrigger(facts);
|
|
130
|
+
return (
|
|
131
|
+
trigger.class === "rate_limit" ||
|
|
132
|
+
trigger.class === "quota" ||
|
|
133
|
+
trigger.class === "auth" ||
|
|
134
|
+
trigger.class === "server"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
59
138
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
139
|
+
* Neutralize leaked reserved control tokens in-place across the outgoing
|
|
140
|
+
* history so a re-send no longer carries the poison that triggered
|
|
141
|
+
* `Request blocked (code=invalid_prompt)`. Only string text fields are
|
|
142
|
+
* rewritten; no history item is ever dropped or reordered. Returns whether any
|
|
143
|
+
* byte actually changed — the circuit breaker uses this to decide between a
|
|
144
|
+
* single repaired resend (changed) and immediate fail-fast (unchanged).
|
|
64
145
|
*/
|
|
65
|
-
function
|
|
66
|
-
|
|
146
|
+
function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
|
|
147
|
+
let changed = false;
|
|
148
|
+
const repairString = (value: string): string => {
|
|
149
|
+
const next = neutralizeReservedControlTokens(value);
|
|
150
|
+
if (next !== value) changed = true;
|
|
151
|
+
return next;
|
|
152
|
+
};
|
|
153
|
+
for (const message of messages) {
|
|
154
|
+
const content = (message as { content?: unknown }).content;
|
|
155
|
+
if (typeof content === "string") {
|
|
156
|
+
(message as { content: string }).content = repairString(content);
|
|
157
|
+
} else if (Array.isArray(content)) {
|
|
158
|
+
for (const block of content) {
|
|
159
|
+
if (!block || typeof block !== "object") continue;
|
|
160
|
+
const record = block as Record<string, unknown>;
|
|
161
|
+
for (const key of ["text", "thinking"]) {
|
|
162
|
+
const value = record[key];
|
|
163
|
+
if (typeof value === "string") record[key] = repairString(value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return changed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
|
|
172
|
+
return {
|
|
173
|
+
type: "retryable_discarded",
|
|
174
|
+
failure: { message, transportFailure: managedTransportFailure(message) },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function managedContextOverflowOutcome(message: AssistantMessage): ManagedAttemptOutcome {
|
|
179
|
+
return { type: "context_overflow_discarded", message };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
|
|
183
|
+
const errorMessage = managedProperty(error, "message");
|
|
184
|
+
const transportFailure = managedTransportFailure(error);
|
|
185
|
+
let fallbackMessage = "Managed fallback attempt failed";
|
|
186
|
+
if (typeof errorMessage === "string") fallbackMessage = errorMessage;
|
|
187
|
+
else {
|
|
188
|
+
try {
|
|
189
|
+
fallbackMessage = String(error);
|
|
190
|
+
} catch {
|
|
191
|
+
// Keep the stable local message for hostile wrappers.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
role: "assistant",
|
|
196
|
+
content: [],
|
|
197
|
+
api: config.model.api,
|
|
198
|
+
provider: config.model.provider,
|
|
199
|
+
model: config.model.id,
|
|
200
|
+
usage: {
|
|
201
|
+
input: 0,
|
|
202
|
+
output: 0,
|
|
203
|
+
cacheRead: 0,
|
|
204
|
+
cacheWrite: 0,
|
|
205
|
+
totalTokens: 0,
|
|
206
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
207
|
+
},
|
|
208
|
+
stopReason: "error",
|
|
209
|
+
errorMessage: fallbackMessage,
|
|
210
|
+
...(transportFailure ? { transportFailure } : {}),
|
|
211
|
+
timestamp: Date.now(),
|
|
212
|
+
};
|
|
67
213
|
}
|
|
68
214
|
|
|
69
215
|
class HarmonyLeakInterruption extends Error {
|
|
@@ -132,6 +278,7 @@ export function agentLoop(
|
|
|
132
278
|
config: AgentLoopConfig,
|
|
133
279
|
signal?: AbortSignal,
|
|
134
280
|
streamFn?: StreamFn,
|
|
281
|
+
emitManagedAgentStart = true,
|
|
135
282
|
): EventStream<AgentEvent, AgentMessage[]> {
|
|
136
283
|
const stream = createAgentStream();
|
|
137
284
|
|
|
@@ -141,16 +288,19 @@ export function agentLoop(
|
|
|
141
288
|
...context,
|
|
142
289
|
messages: [...context.messages, ...prompts],
|
|
143
290
|
};
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
291
|
+
const transaction = config.fallbackManaged
|
|
292
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
|
|
293
|
+
: undefined;
|
|
294
|
+
const attemptStream = transaction ?? stream;
|
|
295
|
+
if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
|
|
296
|
+
attemptStream.push({ type: "turn_start" });
|
|
147
297
|
for (const prompt of prompts) {
|
|
148
298
|
stream.push({ type: "message_start", message: prompt });
|
|
149
299
|
stream.push({ type: "message_end", message: prompt });
|
|
150
300
|
}
|
|
151
301
|
|
|
152
302
|
try {
|
|
153
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
303
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
|
|
154
304
|
} catch (err) {
|
|
155
305
|
stream.fail(err);
|
|
156
306
|
}
|
|
@@ -172,6 +322,7 @@ export function agentLoopContinue(
|
|
|
172
322
|
config: AgentLoopConfig,
|
|
173
323
|
signal?: AbortSignal,
|
|
174
324
|
streamFn?: StreamFn,
|
|
325
|
+
emitManagedAgentStart = true,
|
|
175
326
|
): EventStream<AgentEvent, AgentMessage[]> {
|
|
176
327
|
if (context.messages.length === 0) {
|
|
177
328
|
throw new Error("Cannot continue: no messages in context");
|
|
@@ -186,12 +337,15 @@ export function agentLoopContinue(
|
|
|
186
337
|
(async () => {
|
|
187
338
|
const newMessages: AgentMessage[] = [];
|
|
188
339
|
const currentContext: AgentContext = { ...context };
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
340
|
+
const transaction = config.fallbackManaged
|
|
341
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
|
|
342
|
+
: undefined;
|
|
343
|
+
const attemptStream = transaction ?? stream;
|
|
344
|
+
if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
|
|
345
|
+
attemptStream.push({ type: "turn_start" });
|
|
192
346
|
|
|
193
347
|
try {
|
|
194
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
348
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
|
|
195
349
|
} catch (err) {
|
|
196
350
|
stream.fail(err);
|
|
197
351
|
}
|
|
@@ -207,6 +361,475 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
|
|
|
207
361
|
);
|
|
208
362
|
}
|
|
209
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Hard work budget for one degraded snapshot: every visited node AND every
|
|
366
|
+
* enumerated own key is debited against this budget before it is processed
|
|
367
|
+
* (accessor keys and re-visits of shared objects included), and any remainder
|
|
368
|
+
* collapses to the deterministic `"[truncated]"` placeholder. Well above
|
|
369
|
+
* ordinary streamed events; it only bounds hostile graphs.
|
|
370
|
+
*/
|
|
371
|
+
export const MANAGED_SNAPSHOT_MAX_NODES = 100_000;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Cycle-aware deep clone that always returns a detached, JSON-serializable
|
|
375
|
+
* value. Used whenever a detached snapshot cannot be safely obtained or
|
|
376
|
+
* measured: after `structuredClone` fails, and again when a (successfully
|
|
377
|
+
* cloned) snapshot cannot be serialized for byte accounting.
|
|
378
|
+
*
|
|
379
|
+
* Totality rules — the walk must never dispatch through payload-controlled
|
|
380
|
+
* code, throw, or do unbounded work:
|
|
381
|
+
* - proxies (revoked or live) are collapsed to `"[unserializable]"` BEFORE
|
|
382
|
+
* any reflective operation, so `ownKeys`/descriptor traps are never
|
|
383
|
+
* dispatched (`util.types.isProxy` identifies proxies without touching
|
|
384
|
+
* their handlers);
|
|
385
|
+
* - only intrinsics are used on the remaining ordinary objects (no
|
|
386
|
+
* `input.map`, no `input.getTime()`, no `input.length` reads);
|
|
387
|
+
* - arrays are enumerated through their own present keys, never their
|
|
388
|
+
* declared length, so a sparse array cannot force a dense allocation
|
|
389
|
+
* proportional to `length`; sparse/exotic arrays degrade to a null-proto
|
|
390
|
+
* record of their present indices, and the dense-shape decision verifies
|
|
391
|
+
* every index against its ordinal;
|
|
392
|
+
* - the walk debits `maxNodes` budget per visited node and per enumerated
|
|
393
|
+
* key before processing it; anything beyond the budget becomes
|
|
394
|
+
* `"[truncated]"` (the one linear primitive per visited node is a single
|
|
395
|
+
* `Object.keys` call on a non-proxy object the process already holds);
|
|
396
|
+
* - property values are read via own-property descriptors, so accessors are
|
|
397
|
+
* never invoked (a snapshot must not cause observable side effects) and are
|
|
398
|
+
* replaced with `"[accessor]"`;
|
|
399
|
+
* - functions/symbols and any property that cannot be read safely become
|
|
400
|
+
* short placeholders, `bigint` becomes its decimal string, and references
|
|
401
|
+
* back into the current path collapse to `"[Circular]"`;
|
|
402
|
+
* - records are built on a null prototype so a `__proto__` key cannot mutate
|
|
403
|
+
* the clone's prototype chain.
|
|
404
|
+
*
|
|
405
|
+
* Exported for direct regression coverage of the budget accounting; runtime
|
|
406
|
+
* callers use the default budget via {@link managedAttemptSnapshot}.
|
|
407
|
+
*/
|
|
408
|
+
export function sanitizedDetachedClone<T>(value: T, maxNodes: number = MANAGED_SNAPSHOT_MAX_NODES): T {
|
|
409
|
+
const path = new Set<object>();
|
|
410
|
+
let budget = maxNodes;
|
|
411
|
+
const takeBudget = (units: number): boolean => {
|
|
412
|
+
if (budget < units) {
|
|
413
|
+
budget = 0;
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
budget -= units;
|
|
417
|
+
return true;
|
|
418
|
+
};
|
|
419
|
+
const walk = (input: unknown): unknown => {
|
|
420
|
+
if (!takeBudget(1)) return "[truncated]";
|
|
421
|
+
if (typeof input === "bigint") return String(input);
|
|
422
|
+
if (typeof input === "function" || typeof input === "symbol") return "[unserializable]";
|
|
423
|
+
if (input === null || typeof input !== "object") return input;
|
|
424
|
+
if (nodeUtilTypes.isProxy(input)) return "[unserializable]";
|
|
425
|
+
if (path.has(input)) return "[Circular]";
|
|
426
|
+
path.add(input);
|
|
427
|
+
const readOwnValue = (key: string): unknown => {
|
|
428
|
+
try {
|
|
429
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
430
|
+
return descriptor === undefined
|
|
431
|
+
? "[unserializable]"
|
|
432
|
+
: "value" in descriptor
|
|
433
|
+
? walk(descriptor.value)
|
|
434
|
+
: "[accessor]";
|
|
435
|
+
} catch {
|
|
436
|
+
return "[unserializable]";
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
try {
|
|
440
|
+
if (Array.isArray(input)) {
|
|
441
|
+
// Own present keys only: iterating the declared length would
|
|
442
|
+
// densify holes, and `Object.keys` is proportional to the
|
|
443
|
+
// elements that actually exist.
|
|
444
|
+
const keys = Object.keys(input);
|
|
445
|
+
if (!takeBudget(keys.length)) return "[truncated]";
|
|
446
|
+
const indexKeys: string[] = [];
|
|
447
|
+
let hasExtraProps = false;
|
|
448
|
+
for (const key of keys) {
|
|
449
|
+
const index = Number(key);
|
|
450
|
+
if (String(index) === key && index >= 0) indexKeys.push(key);
|
|
451
|
+
else hasExtraProps = true;
|
|
452
|
+
}
|
|
453
|
+
let dense = !hasExtraProps;
|
|
454
|
+
if (dense) {
|
|
455
|
+
for (let ordinal = 0; ordinal < indexKeys.length; ordinal++) {
|
|
456
|
+
if (Number(indexKeys[ordinal]) !== ordinal) {
|
|
457
|
+
dense = false;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (dense) {
|
|
463
|
+
const out: unknown[] = [];
|
|
464
|
+
for (const key of indexKeys) out.push(readOwnValue(key));
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
const sparse: Record<string, unknown> = Object.create(null);
|
|
468
|
+
for (const key of indexKeys) sparse[key] = readOwnValue(key);
|
|
469
|
+
return sparse;
|
|
470
|
+
}
|
|
471
|
+
let dateTime: number | undefined;
|
|
472
|
+
try {
|
|
473
|
+
// `isDate` checks the [[DateValue]] internal slot without walking
|
|
474
|
+
// the prototype chain — `instanceof Date` would dispatch a proxy
|
|
475
|
+
// prototype's getPrototypeOf trap and do unbudgeted linear work
|
|
476
|
+
// on deep ordinary chains.
|
|
477
|
+
dateTime = nodeUtilTypes.isDate(input) ? Date.prototype.getTime.call(input) : undefined;
|
|
478
|
+
} catch {
|
|
479
|
+
dateTime = undefined;
|
|
480
|
+
}
|
|
481
|
+
if (dateTime !== undefined) return new Date(dateTime);
|
|
482
|
+
const keys = Object.keys(input);
|
|
483
|
+
if (!takeBudget(keys.length)) return "[truncated]";
|
|
484
|
+
const record: Record<string, unknown> = Object.create(null);
|
|
485
|
+
for (const key of keys) record[key] = readOwnValue(key);
|
|
486
|
+
return record;
|
|
487
|
+
} catch {
|
|
488
|
+
// Brand checks / key enumeration on exotic objects can throw;
|
|
489
|
+
// collapse only this node, not its ancestors.
|
|
490
|
+
return "[unserializable]";
|
|
491
|
+
} finally {
|
|
492
|
+
path.delete(input);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
return walk(value) as T;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Capture an event-time value because providers commonly mutate partial
|
|
500
|
+
* messages in place. The snapshot MUST always be detached from the caller's
|
|
501
|
+
* object graph — replaying a live reference would surface the final mutation
|
|
502
|
+
* instead of the event-time value. It must also never throw: staged payloads
|
|
503
|
+
* can carry non-cloneable objects during provisional assistant streaming
|
|
504
|
+
* (e.g. a live `Headers` inside a provider error's `transportFailure` from a
|
|
505
|
+
* legacy payload), and a thrown `DataCloneError` here would mask the real
|
|
506
|
+
* provider outcome and burn the whole fallback chain.
|
|
507
|
+
*/
|
|
508
|
+
function managedAttemptSnapshotDetailed<T>(value: T): { snapshot: T; degraded: boolean } {
|
|
509
|
+
try {
|
|
510
|
+
return { snapshot: structuredClone(value), degraded: false };
|
|
511
|
+
} catch {
|
|
512
|
+
return { snapshot: sanitizedDetachedClone(value), degraded: true };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function managedAttemptSnapshot<T>(value: T): T {
|
|
517
|
+
return managedAttemptSnapshotDetailed(value).snapshot;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Recover the required assistant-message shell when a managed snapshot degrades
|
|
522
|
+
* at its root (notably for Proxy-wrapped provider messages). Only known fields
|
|
523
|
+
* are read, and executable content is retained only when it has its complete
|
|
524
|
+
* discriminant shape.
|
|
525
|
+
*/
|
|
526
|
+
function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage {
|
|
527
|
+
const detailed = managedAttemptSnapshotDetailed(value);
|
|
528
|
+
const source = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : value;
|
|
529
|
+
if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError();
|
|
530
|
+
const rawContent = managedAttemptSnapshot(managedProperty(source, "content"));
|
|
531
|
+
if (!Array.isArray(rawContent)) throw new ManagedAttemptSnapshotError();
|
|
532
|
+
const content = rawContent.flatMap(block => {
|
|
533
|
+
const normalized = managedAssistantContent(block);
|
|
534
|
+
return normalized ? [normalized] : [];
|
|
535
|
+
});
|
|
536
|
+
const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
|
|
537
|
+
const api = managedProperty(source, "api");
|
|
538
|
+
const provider = managedProperty(source, "provider");
|
|
539
|
+
const messageModel = managedProperty(source, "model");
|
|
540
|
+
const stopReasonValue = managedProperty(source, "stopReason");
|
|
541
|
+
const stopReason =
|
|
542
|
+
stopReasonValue === "stop" ||
|
|
543
|
+
stopReasonValue === "length" ||
|
|
544
|
+
stopReasonValue === "toolUse" ||
|
|
545
|
+
stopReasonValue === "error" ||
|
|
546
|
+
stopReasonValue === "aborted"
|
|
547
|
+
? stopReasonValue
|
|
548
|
+
: "stop";
|
|
549
|
+
const timestamp = managedProperty(source, "timestamp");
|
|
550
|
+
const transportFailure = managedTransportFailure(value);
|
|
551
|
+
const errorMessage = managedProperty(source, "errorMessage");
|
|
552
|
+
const errorStatus = managedProperty(source, "errorStatus");
|
|
553
|
+
const safeMetadata: Record<string, unknown> = isManagedPlainRecord(detailed.snapshot)
|
|
554
|
+
? { ...detailed.snapshot }
|
|
555
|
+
: {};
|
|
556
|
+
delete safeMetadata.errorMessage;
|
|
557
|
+
delete safeMetadata.errorStatus;
|
|
558
|
+
delete safeMetadata.transportFailure;
|
|
559
|
+
return {
|
|
560
|
+
...safeMetadata,
|
|
561
|
+
role: "assistant",
|
|
562
|
+
content,
|
|
563
|
+
api: typeof api === "string" ? (api as AssistantMessage["api"]) : model.api,
|
|
564
|
+
provider: typeof provider === "string" ? (provider as AssistantMessage["provider"]) : model.provider,
|
|
565
|
+
model: typeof messageModel === "string" ? messageModel : model.id,
|
|
566
|
+
usage,
|
|
567
|
+
stopReason,
|
|
568
|
+
timestamp: typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : Date.now(),
|
|
569
|
+
...(transportFailure ? { transportFailure } : {}),
|
|
570
|
+
...(typeof errorMessage === "string" ? { errorMessage } : {}),
|
|
571
|
+
...(typeof errorStatus === "number" && Number.isFinite(errorStatus) ? { errorStatus } : {}),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function managedAssistantContent(value: unknown): AssistantMessage["content"][number] | undefined {
|
|
576
|
+
if (!isManagedPlainRecord(value)) return undefined;
|
|
577
|
+
const type = managedProperty(value, "type");
|
|
578
|
+
if (type === "text") {
|
|
579
|
+
const text = managedProperty(value, "text");
|
|
580
|
+
return typeof text === "string" ? { type, text } : undefined;
|
|
581
|
+
}
|
|
582
|
+
if (type === "thinking") {
|
|
583
|
+
const thinking = managedProperty(value, "thinking");
|
|
584
|
+
return typeof thinking === "string" ? { type, thinking } : undefined;
|
|
585
|
+
}
|
|
586
|
+
if (type === "redactedThinking") {
|
|
587
|
+
const data = managedProperty(value, "data");
|
|
588
|
+
return typeof data === "string" ? { type, data } : undefined;
|
|
589
|
+
}
|
|
590
|
+
if (type !== "toolCall") return undefined;
|
|
591
|
+
const id = managedProperty(value, "id");
|
|
592
|
+
const name = managedProperty(value, "name");
|
|
593
|
+
const argumentsValue = managedProperty(value, "arguments");
|
|
594
|
+
if (typeof id !== "string" || typeof name !== "string" || !isManagedPlainRecord(argumentsValue)) return undefined;
|
|
595
|
+
const thoughtSignature = managedProperty(value, "thoughtSignature");
|
|
596
|
+
const intent = managedProperty(value, "intent");
|
|
597
|
+
const customWireName = managedProperty(value, "customWireName");
|
|
598
|
+
const incompleteArguments = managedProperty(value, "incompleteArguments");
|
|
599
|
+
return {
|
|
600
|
+
type,
|
|
601
|
+
id,
|
|
602
|
+
name,
|
|
603
|
+
arguments: argumentsValue,
|
|
604
|
+
...(typeof thoughtSignature === "string" ? { thoughtSignature } : {}),
|
|
605
|
+
...(typeof intent === "string" ? { intent } : {}),
|
|
606
|
+
...(typeof customWireName === "string" ? { customWireName } : {}),
|
|
607
|
+
...(typeof incompleteArguments === "boolean" ? { incompleteArguments } : {}),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function managedAssistantUsage(value: unknown): AssistantMessage["usage"] {
|
|
612
|
+
const number = (key: string): number => {
|
|
613
|
+
const candidate = managedProperty(value, key);
|
|
614
|
+
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : 0;
|
|
615
|
+
};
|
|
616
|
+
const costValue = managedProperty(value, "cost");
|
|
617
|
+
const costNumber = (key: string): number => {
|
|
618
|
+
const candidate = managedProperty(costValue, key);
|
|
619
|
+
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : 0;
|
|
620
|
+
};
|
|
621
|
+
return {
|
|
622
|
+
input: number("input"),
|
|
623
|
+
output: number("output"),
|
|
624
|
+
cacheRead: number("cacheRead"),
|
|
625
|
+
cacheWrite: number("cacheWrite"),
|
|
626
|
+
totalTokens: number("totalTokens"),
|
|
627
|
+
cost: {
|
|
628
|
+
input: costNumber("input"),
|
|
629
|
+
output: costNumber("output"),
|
|
630
|
+
cacheRead: costNumber("cacheRead"),
|
|
631
|
+
cacheWrite: costNumber("cacheWrite"),
|
|
632
|
+
total: costNumber("total"),
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent {
|
|
638
|
+
const snapshot = managedAttemptSnapshot(event);
|
|
639
|
+
if (!isManagedPlainRecord(snapshot)) throw new ManagedAttemptSnapshotError();
|
|
640
|
+
const type = managedProperty(snapshot, "type");
|
|
641
|
+
const contentIndex = managedProperty(snapshot, "contentIndex");
|
|
642
|
+
const indexed = () => {
|
|
643
|
+
if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) throw new ManagedAttemptSnapshotError();
|
|
644
|
+
return contentIndex as number;
|
|
645
|
+
};
|
|
646
|
+
if (type === "start") return { type, partial: message };
|
|
647
|
+
if (type === "text_start" || type === "thinking_start" || type === "toolcall_start")
|
|
648
|
+
return { type, contentIndex: indexed(), partial: message };
|
|
649
|
+
if (type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta") {
|
|
650
|
+
const delta = managedProperty(snapshot, "delta");
|
|
651
|
+
if (typeof delta !== "string") throw new ManagedAttemptSnapshotError();
|
|
652
|
+
return { type, contentIndex: indexed(), delta, partial: message };
|
|
653
|
+
}
|
|
654
|
+
if (type === "text_end" || type === "thinking_end") {
|
|
655
|
+
const content = managedProperty(snapshot, "content");
|
|
656
|
+
if (typeof content !== "string") throw new ManagedAttemptSnapshotError();
|
|
657
|
+
return { type, contentIndex: indexed(), content, partial: message };
|
|
658
|
+
}
|
|
659
|
+
if (type === "toolcall_end") {
|
|
660
|
+
const toolCall = managedAssistantContent(managedProperty(snapshot, "toolCall"));
|
|
661
|
+
if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError();
|
|
662
|
+
return { type, contentIndex: indexed(), toolCall, partial: message };
|
|
663
|
+
}
|
|
664
|
+
if (type === "done") {
|
|
665
|
+
const reason = managedProperty(snapshot, "reason");
|
|
666
|
+
if (reason !== "stop" && reason !== "length" && reason !== "toolUse") throw new ManagedAttemptSnapshotError();
|
|
667
|
+
return { type, reason, message };
|
|
668
|
+
}
|
|
669
|
+
if (type === "error") {
|
|
670
|
+
const reason = managedProperty(snapshot, "reason");
|
|
671
|
+
if (reason !== "aborted" && reason !== "error") throw new ManagedAttemptSnapshotError();
|
|
672
|
+
return { type, reason, error: message };
|
|
673
|
+
}
|
|
674
|
+
throw new ManagedAttemptSnapshotError();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function isManagedPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
678
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && !nodeUtilTypes.isProxy(value);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Holds managed-attempt assistant output above the public event stream. A
|
|
683
|
+
* cancelled provider attempt is therefore unobservable to sessions and their
|
|
684
|
+
* side-effect consumers. Non-managed streams bypass this object entirely.
|
|
685
|
+
*/
|
|
686
|
+
class ManagedAttemptTransaction {
|
|
687
|
+
#batch: Array<
|
|
688
|
+
| { type: "event"; event: AgentEvent }
|
|
689
|
+
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
|
|
690
|
+
> = [];
|
|
691
|
+
#stagedEventCount = 0;
|
|
692
|
+
#stagedBytes = 0;
|
|
693
|
+
#discarded = false;
|
|
694
|
+
#committed = false;
|
|
695
|
+
|
|
696
|
+
constructor(
|
|
697
|
+
private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
698
|
+
private readonly onAssistantMessageEvent:
|
|
699
|
+
| ((message: AssistantMessage, event: AssistantMessageEvent) => void)
|
|
700
|
+
| undefined,
|
|
701
|
+
private readonly model: AgentLoopConfig["model"],
|
|
702
|
+
) {}
|
|
703
|
+
|
|
704
|
+
push(event: AgentEvent): void {
|
|
705
|
+
if (this.#committed) {
|
|
706
|
+
this.stream.push(event);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
this.#stage(event);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
end(messages: AgentMessage[]): void {
|
|
713
|
+
this.stream.end(messages);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
717
|
+
const partial = managedAssistantShell(message, this.model);
|
|
718
|
+
this.#batch.push({
|
|
719
|
+
type: "assistant_event",
|
|
720
|
+
message: partial,
|
|
721
|
+
event: managedAssistantEventSnapshot(event, partial),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
flush(): void {
|
|
726
|
+
if (this.#discarded || this.#committed) return;
|
|
727
|
+
for (const item of this.#batch) {
|
|
728
|
+
if (item.type === "assistant_event") {
|
|
729
|
+
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
730
|
+
} else {
|
|
731
|
+
this.stream.push(item.event);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
this.#batch = [];
|
|
735
|
+
this.#stagedBytes = 0;
|
|
736
|
+
this.#stagedEventCount = 0;
|
|
737
|
+
this.#committed = true;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
discard(): void {
|
|
741
|
+
this.#batch = [];
|
|
742
|
+
this.#stagedBytes = 0;
|
|
743
|
+
this.#stagedEventCount = 0;
|
|
744
|
+
this.#discarded = true;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
#wouldOverflow(bytes: number): boolean {
|
|
748
|
+
return (
|
|
749
|
+
this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
|
|
750
|
+
this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
#stage(event: AgentEvent): void {
|
|
755
|
+
// Measure the raw event FIRST so an oversized payload is rejected
|
|
756
|
+
// before the snapshot duplicates it — the staged-byte cap exists to
|
|
757
|
+
// bound memory, so cloning ahead of the check would defeat it.
|
|
758
|
+
// Cyclic/JSON-hostile events cannot be pre-measured; only those fall
|
|
759
|
+
// through to snapshot-then-measure, where the sanitized detached form
|
|
760
|
+
// is the cycle-safe estimator.
|
|
761
|
+
let bytes: number | undefined;
|
|
762
|
+
try {
|
|
763
|
+
bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
|
|
764
|
+
} catch {
|
|
765
|
+
bytes = undefined;
|
|
766
|
+
}
|
|
767
|
+
if (bytes !== undefined && this.#wouldOverflow(bytes)) {
|
|
768
|
+
this.discard();
|
|
769
|
+
throw new ManagedAttemptBufferOverflowError();
|
|
770
|
+
}
|
|
771
|
+
const detailed = managedAttemptSnapshotDetailed(this.#repairAssistantEvent(event));
|
|
772
|
+
let snapshot = detailed.snapshot;
|
|
773
|
+
if (bytes === undefined || detailed.degraded) {
|
|
774
|
+
// Account the bytes of what is actually retained: a degraded
|
|
775
|
+
// snapshot replaces non-JSON leaves with placeholders, so the raw
|
|
776
|
+
// pre-measure (which omits e.g. function-valued properties) can
|
|
777
|
+
// undercount the staged form.
|
|
778
|
+
try {
|
|
779
|
+
bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
|
|
780
|
+
} catch {
|
|
781
|
+
try {
|
|
782
|
+
snapshot = sanitizedDetachedClone(snapshot);
|
|
783
|
+
bytes = managedAttemptTextEncoder.encode(JSON.stringify(snapshot)).byteLength;
|
|
784
|
+
} catch {
|
|
785
|
+
bytes = undefined;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (bytes === undefined) {
|
|
789
|
+
// The sanitizer's output is total (detached, JSON-safe), so this
|
|
790
|
+
// is unreachable unless the sanitizer itself regresses. Fail as a
|
|
791
|
+
// dedicated local error: it carries no transport facts, so it is
|
|
792
|
+
// non-retryable and can never be misattributed to the provider.
|
|
793
|
+
this.discard();
|
|
794
|
+
throw new ManagedAttemptSnapshotError();
|
|
795
|
+
}
|
|
796
|
+
if (this.#wouldOverflow(bytes)) {
|
|
797
|
+
this.discard();
|
|
798
|
+
throw new ManagedAttemptBufferOverflowError();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
this.#batch.push({ type: "event", event: snapshot });
|
|
802
|
+
this.#stagedEventCount += 1;
|
|
803
|
+
|
|
804
|
+
this.#stagedBytes += bytes;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
#repairAssistantEvent(event: AgentEvent): AgentEvent {
|
|
808
|
+
if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_end") {
|
|
809
|
+
return event.message.role === "assistant"
|
|
810
|
+
? { ...event, message: managedAssistantShell(event.message, this.model) }
|
|
811
|
+
: event;
|
|
812
|
+
}
|
|
813
|
+
if (event.type === "message_update") {
|
|
814
|
+
const message = managedAssistantShell(event.message, this.model);
|
|
815
|
+
return {
|
|
816
|
+
...event,
|
|
817
|
+
message,
|
|
818
|
+
assistantMessageEvent: managedAssistantEventSnapshot(event.assistantMessageEvent, message),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (event.type === "agent_end") {
|
|
822
|
+
return {
|
|
823
|
+
...event,
|
|
824
|
+
messages: event.messages.map(message =>
|
|
825
|
+
message.role === "assistant" ? managedAssistantShell(message, this.model) : message,
|
|
826
|
+
),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
return event;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
210
833
|
/**
|
|
211
834
|
* Build the `agent_end` event payload. When telemetry is enabled, snapshots
|
|
212
835
|
* the run collector so consumers receive {@link AgentRunSummary} +
|
|
@@ -549,7 +1172,10 @@ async function runLoop(
|
|
|
549
1172
|
signal: AbortSignal | undefined,
|
|
550
1173
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
551
1174
|
streamFn?: StreamFn,
|
|
1175
|
+
initialTransaction?: ManagedAttemptTransaction,
|
|
552
1176
|
): Promise<void> {
|
|
1177
|
+
const loopSignal = signal ?? new AbortController().signal;
|
|
1178
|
+
|
|
553
1179
|
const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
|
|
554
1180
|
const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
|
|
555
1181
|
const stepCounter = { count: 0 };
|
|
@@ -560,12 +1186,14 @@ async function runLoop(
|
|
|
560
1186
|
currentContext,
|
|
561
1187
|
newMessages,
|
|
562
1188
|
config,
|
|
563
|
-
|
|
1189
|
+
loopSignal,
|
|
1190
|
+
|
|
564
1191
|
stream,
|
|
565
1192
|
telemetry,
|
|
566
1193
|
invokeAgentSpan,
|
|
567
1194
|
stepCounter,
|
|
568
1195
|
streamFn,
|
|
1196
|
+
initialTransaction,
|
|
569
1197
|
),
|
|
570
1198
|
);
|
|
571
1199
|
} catch (err) {
|
|
@@ -587,18 +1215,28 @@ async function runLoopBody(
|
|
|
587
1215
|
currentContext: AgentContext,
|
|
588
1216
|
newMessages: AgentMessage[],
|
|
589
1217
|
config: AgentLoopConfig,
|
|
590
|
-
|
|
1218
|
+
loopSignal: AbortSignal,
|
|
1219
|
+
|
|
591
1220
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
592
1221
|
telemetry: AgentTelemetry | undefined,
|
|
593
1222
|
invokeAgentSpan: Span | undefined,
|
|
594
1223
|
stepCounter: StepCounter,
|
|
595
1224
|
streamFn?: StreamFn,
|
|
1225
|
+
initialTransaction?: ManagedAttemptTransaction,
|
|
596
1226
|
): Promise<void> {
|
|
597
1227
|
let firstTurn = true;
|
|
598
1228
|
// Check for steering messages at start (user may have typed while waiting)
|
|
599
1229
|
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
|
600
1230
|
let harmonyRetryAttempt = 0;
|
|
1231
|
+
// Whether at least one assistant response has been produced in THIS run. The
|
|
1232
|
+
// mid-run maintenance checkpoint only fires between tool iterations (after a
|
|
1233
|
+
// model response); pre-turn maintenance is the pre-prompt check's job, so the
|
|
1234
|
+
// first iteration is skipped to avoid duplicating/racing it.
|
|
1235
|
+
let modelHasResponded = false;
|
|
601
1236
|
let harmonyTruncateResumeCount = 0;
|
|
1237
|
+
// Fires at most one repaired resend per run for the poisoned-history
|
|
1238
|
+
// `invalid_prompt` circuit breaker below.
|
|
1239
|
+
let invalidPromptRepairAttempted = false;
|
|
602
1240
|
|
|
603
1241
|
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
604
1242
|
while (true) {
|
|
@@ -606,13 +1244,21 @@ async function runLoopBody(
|
|
|
606
1244
|
|
|
607
1245
|
// Inner loop: process tool calls and steering messages
|
|
608
1246
|
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
|
1247
|
+
const transaction =
|
|
1248
|
+
initialTransaction ??
|
|
1249
|
+
(config.fallbackManaged
|
|
1250
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
|
|
1251
|
+
: undefined);
|
|
1252
|
+
initialTransaction = undefined;
|
|
1253
|
+
const attemptStream = transaction ?? stream;
|
|
609
1254
|
if (!firstTurn) {
|
|
610
|
-
|
|
1255
|
+
attemptStream.push({ type: "turn_start" });
|
|
611
1256
|
} else {
|
|
612
1257
|
firstTurn = false;
|
|
613
1258
|
}
|
|
614
1259
|
|
|
615
|
-
//
|
|
1260
|
+
// Commit queued user input outside the provisional assistant transaction so a
|
|
1261
|
+
// discarded managed attempt cannot lose it before its retry continuation.
|
|
616
1262
|
if (pendingMessages.length > 0) {
|
|
617
1263
|
for (const message of pendingMessages) {
|
|
618
1264
|
stream.push({ type: "message_start", message });
|
|
@@ -623,20 +1269,63 @@ async function runLoopBody(
|
|
|
623
1269
|
pendingMessages = [];
|
|
624
1270
|
}
|
|
625
1271
|
|
|
1272
|
+
// Cooperative mid-run context maintenance. Runs after pending
|
|
1273
|
+
// tool/steering messages are materialized into durable context and
|
|
1274
|
+
// before syncContextBeforeModelCall / the model call — the only
|
|
1275
|
+
// boundary where the full unsent context is already durable. A
|
|
1276
|
+
// non-"not-needed" outcome means context was (or was attempted to be)
|
|
1277
|
+
// rewritten, so end the run WITHOUT the lossy agent_end finalization;
|
|
1278
|
+
// the maintenance owner resumes the run on the rewritten context.
|
|
1279
|
+
// "not-needed" falls through to the model call.
|
|
1280
|
+
if (config.maintainContext && modelHasResponded && !loopSignal.aborted) {
|
|
1281
|
+
const lifecycle = {
|
|
1282
|
+
signal: loopSignal,
|
|
1283
|
+
awaitEventDrain: (invocationSignal: AbortSignal) =>
|
|
1284
|
+
stream.waitForConsumerDrain(AbortSignal.any([loopSignal, invocationSignal])),
|
|
1285
|
+
};
|
|
1286
|
+
const maintenanceOutcome = await config.maintainContext(currentContext, lifecycle);
|
|
1287
|
+
// A callback can settle after its loop has been cancelled. Never let a
|
|
1288
|
+
// stale "not-needed" fall through to streamAssistantResponse, which
|
|
1289
|
+
// invokes the provider before it observes the aborted signal.
|
|
1290
|
+
const outcome = loopSignal.aborted ? "aborted" : maintenanceOutcome;
|
|
1291
|
+
|
|
1292
|
+
if (outcome !== "not-needed") {
|
|
1293
|
+
stream.push({
|
|
1294
|
+
type: "agent_end",
|
|
1295
|
+
messages: newMessages,
|
|
1296
|
+
stopReason: "maintenance",
|
|
1297
|
+
maintenanceOutcome: outcome,
|
|
1298
|
+
});
|
|
1299
|
+
stream.end(newMessages);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
626
1304
|
// Refresh prompt/tool context from live state before each model call
|
|
627
1305
|
if (config.syncContextBeforeModelCall) {
|
|
628
1306
|
await config.syncContextBeforeModelCall(currentContext);
|
|
629
1307
|
}
|
|
630
1308
|
|
|
1309
|
+
const contextMessageCount = currentContext.messages.length;
|
|
1310
|
+
const newMessageCount = newMessages.length;
|
|
1311
|
+
|
|
631
1312
|
// Stream assistant response
|
|
632
1313
|
let recovered: HarmonyRecoveredToolCall | undefined;
|
|
633
1314
|
let message: AssistantMessage;
|
|
1315
|
+
const attemptTransaction = transaction;
|
|
634
1316
|
try {
|
|
1317
|
+
const attemptConfig = attemptTransaction
|
|
1318
|
+
? {
|
|
1319
|
+
...config,
|
|
1320
|
+
onAssistantMessageEvent: (partial: AssistantMessage, event: AssistantMessageEvent) =>
|
|
1321
|
+
attemptTransaction.stageAssistantMessageEvent(partial, event),
|
|
1322
|
+
}
|
|
1323
|
+
: config;
|
|
635
1324
|
message = await streamAssistantResponse(
|
|
636
1325
|
currentContext,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
stream,
|
|
1326
|
+
attemptConfig,
|
|
1327
|
+
loopSignal,
|
|
1328
|
+
attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
|
|
640
1329
|
telemetry,
|
|
641
1330
|
invokeAgentSpan,
|
|
642
1331
|
stepCounter,
|
|
@@ -652,7 +1341,30 @@ async function runLoopBody(
|
|
|
652
1341
|
harmonyRetryAttempt = 0;
|
|
653
1342
|
harmonyTruncateResumeCount = 0;
|
|
654
1343
|
} catch (err) {
|
|
655
|
-
if (!(err instanceof HarmonyLeakInterruption))
|
|
1344
|
+
if (!(err instanceof HarmonyLeakInterruption)) {
|
|
1345
|
+
const failureMessage = managedFailureMessage(err, config);
|
|
1346
|
+
if (config.fallbackManaged && transaction && managedContextOverflow(failureMessage, config)) {
|
|
1347
|
+
transaction.discard();
|
|
1348
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1349
|
+
newMessages.splice(newMessageCount);
|
|
1350
|
+
await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(failureMessage));
|
|
1351
|
+
stream.end(newMessages);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
|
|
1355
|
+
transaction.discard();
|
|
1356
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1357
|
+
newMessages.splice(newMessageCount);
|
|
1358
|
+
await config.onManagedAttemptOutcome?.(managedFailureOutcome(failureMessage));
|
|
1359
|
+
stream.end(newMessages);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
throw err;
|
|
1363
|
+
}
|
|
1364
|
+
if (config.fallbackManaged) {
|
|
1365
|
+
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
1366
|
+
throw err;
|
|
1367
|
+
}
|
|
656
1368
|
if (err.recovered) {
|
|
657
1369
|
if (harmonyTruncateResumeCount >= 2) {
|
|
658
1370
|
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
@@ -694,22 +1406,84 @@ async function runLoopBody(
|
|
|
694
1406
|
continue;
|
|
695
1407
|
}
|
|
696
1408
|
}
|
|
1409
|
+
// Session-level invalid_prompt circuit breaker (bounded, neutralize-only).
|
|
1410
|
+
// A poisoned-history rejection (`Request blocked (code=invalid_prompt)`) is
|
|
1411
|
+
// a deterministic content fault: re-sending the same history re-triggers it,
|
|
1412
|
+
// so naive session auto-retry would burn its whole budget re-poisoning the
|
|
1413
|
+
// model. On the first invalid_prompt of this run, neutralize leaked control
|
|
1414
|
+
// tokens in history IN PLACE (never dropping items). If that changed the
|
|
1415
|
+
// outgoing bytes, resend exactly once with the repaired history; if
|
|
1416
|
+
// neutralization cannot change anything (nothing left to repair), fall
|
|
1417
|
+
// through to terminal handling and fail fast. Budget = one repaired resend.
|
|
1418
|
+
// Runs before the response is committed so the resend is a clean retry;
|
|
1419
|
+
// managed fallback owns its own retry policy, so this is scoped to the
|
|
1420
|
+
// non-managed session path where uncontrolled auto-retry would recur.
|
|
1421
|
+
if (
|
|
1422
|
+
!config.fallbackManaged &&
|
|
1423
|
+
message.stopReason === "error" &&
|
|
1424
|
+
!invalidPromptRepairAttempted &&
|
|
1425
|
+
isInvalidPromptError(message)
|
|
1426
|
+
) {
|
|
1427
|
+
invalidPromptRepairAttempted = true;
|
|
1428
|
+
if (repairInvalidPromptHistory(currentContext.messages)) {
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const overflow = managedContextOverflow(message, config);
|
|
1434
|
+
if (config.fallbackManaged && overflow) {
|
|
1435
|
+
transaction?.discard();
|
|
1436
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1437
|
+
newMessages.splice(newMessageCount);
|
|
1438
|
+
await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(message));
|
|
1439
|
+
stream.end(newMessages);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
697
1443
|
newMessages.push(message);
|
|
1444
|
+
modelHasResponded = true;
|
|
698
1445
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
699
1446
|
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
// window is exceeded, fabricating a near-zero usage instead of surfacing an
|
|
703
|
-
// error. Without this guard the agent loop treats the empty response as a
|
|
704
|
-
// natural turn completion and stops, leaving the user with a frozen session.
|
|
705
|
-
// Promote it to an error so the overflow/compaction recovery path can fire.
|
|
706
|
-
if (message.stopReason === "stop" && message.content.length === 0 && isEmptyResponseOverflow(message)) {
|
|
1447
|
+
// Preserve the historical public error conversion for unmanaged proxy overflows.
|
|
1448
|
+
if (!config.fallbackManaged && message.stopReason === "stop" && message.content.length === 0 && overflow) {
|
|
707
1449
|
message.stopReason = "error";
|
|
708
1450
|
message.errorMessage = message.errorMessage
|
|
709
1451
|
? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
|
|
710
1452
|
: "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
|
|
711
1453
|
}
|
|
712
1454
|
|
|
1455
|
+
if (config.fallbackManaged && message.stopReason === "error" && managedRetryableFailure(message)) {
|
|
1456
|
+
transaction?.discard();
|
|
1457
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1458
|
+
newMessages.splice(newMessageCount);
|
|
1459
|
+
await config.onManagedAttemptOutcome?.(managedFailureOutcome(message));
|
|
1460
|
+
stream.end(newMessages);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (config.fallbackManaged && message.stopReason === "aborted") {
|
|
1465
|
+
transaction?.discard();
|
|
1466
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1467
|
+
newMessages.splice(newMessageCount);
|
|
1468
|
+
await config.onManagedAttemptOutcome?.({ type: "run_terminal", reason: "cancelled" });
|
|
1469
|
+
stream.end(newMessages);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (attemptTransaction) {
|
|
1473
|
+
message = managedAssistantShell(message, config.model);
|
|
1474
|
+
const index = currentContext.messages.length - 1;
|
|
1475
|
+
if (index >= 0 && currentContext.messages[index]?.role === "assistant") {
|
|
1476
|
+
currentContext.messages[index] = message;
|
|
1477
|
+
}
|
|
1478
|
+
newMessages[newMessages.length - 1] = message;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// One provider invocation is committed before any tool can run.
|
|
1482
|
+
transaction?.flush();
|
|
1483
|
+
if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
|
|
1484
|
+
await config.onManagedAttemptAccepted?.();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
713
1487
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
714
1488
|
// Create placeholder tool results for any tool calls in the aborted message
|
|
715
1489
|
// This maintains the tool_use/tool_result pairing that the API requires
|
|
@@ -747,7 +1521,7 @@ async function runLoopBody(
|
|
|
747
1521
|
const executionResult = await executeToolCalls(
|
|
748
1522
|
currentContext,
|
|
749
1523
|
message,
|
|
750
|
-
|
|
1524
|
+
loopSignal,
|
|
751
1525
|
stream,
|
|
752
1526
|
config,
|
|
753
1527
|
telemetry,
|
|
@@ -925,8 +1699,10 @@ async function streamAssistantResponse(
|
|
|
925
1699
|
|
|
926
1700
|
try {
|
|
927
1701
|
return await runInActiveSpan(chatSpan, async () => {
|
|
1702
|
+
const fallbackAttempt = config.fallbackManaged ? config.nextFallbackAttempt?.(config.model) : undefined;
|
|
928
1703
|
const response = await streamFunction(config.model, llmContext, {
|
|
929
1704
|
...config,
|
|
1705
|
+
fallbackAttempt,
|
|
930
1706
|
apiKey: resolvedApiKey,
|
|
931
1707
|
authCredentialType,
|
|
932
1708
|
metadata: resolvedMetadata,
|
|
@@ -987,7 +1763,9 @@ async function streamAssistantResponse(
|
|
|
987
1763
|
|
|
988
1764
|
switch (event.type) {
|
|
989
1765
|
case "start":
|
|
990
|
-
partialMessage =
|
|
1766
|
+
partialMessage = config.fallbackManaged
|
|
1767
|
+
? managedAssistantShell(event.partial, config.model)
|
|
1768
|
+
: event.partial;
|
|
991
1769
|
context.messages.push(partialMessage);
|
|
992
1770
|
addedPartial = true;
|
|
993
1771
|
stream.push({ type: "message_start", message: { ...partialMessage } });
|
|
@@ -1003,19 +1781,23 @@ async function streamAssistantResponse(
|
|
|
1003
1781
|
case "thinking_start":
|
|
1004
1782
|
case "thinking_delta":
|
|
1005
1783
|
case "thinking_end":
|
|
1784
|
+
case "reasoning_summary_start":
|
|
1785
|
+
case "reasoning_summary_delta":
|
|
1786
|
+
case "reasoning_summary_end":
|
|
1006
1787
|
case "toolcall_start":
|
|
1007
1788
|
case "toolcall_delta":
|
|
1008
1789
|
case "toolcall_end":
|
|
1009
1790
|
if (partialMessage) {
|
|
1010
|
-
partialMessage =
|
|
1791
|
+
partialMessage = config.fallbackManaged
|
|
1792
|
+
? managedAssistantShell(event.partial, config.model)
|
|
1793
|
+
: event.partial;
|
|
1794
|
+
const partialEvent = config.fallbackManaged ? { ...event, partial: partialMessage } : event;
|
|
1011
1795
|
context.messages[context.messages.length - 1] = partialMessage;
|
|
1012
|
-
config.onAssistantMessageEvent?.(partialMessage,
|
|
1013
|
-
if (signal?.aborted)
|
|
1014
|
-
continue;
|
|
1015
|
-
}
|
|
1796
|
+
config.onAssistantMessageEvent?.(partialMessage, partialEvent);
|
|
1797
|
+
if (signal?.aborted) continue;
|
|
1016
1798
|
stream.push({
|
|
1017
1799
|
type: "message_update",
|
|
1018
|
-
assistantMessageEvent:
|
|
1800
|
+
assistantMessageEvent: partialEvent,
|
|
1019
1801
|
message: { ...partialMessage },
|
|
1020
1802
|
});
|
|
1021
1803
|
}
|
|
@@ -1023,7 +1805,9 @@ async function streamAssistantResponse(
|
|
|
1023
1805
|
|
|
1024
1806
|
case "done":
|
|
1025
1807
|
case "error": {
|
|
1026
|
-
const finalMessage =
|
|
1808
|
+
const finalMessage = config.fallbackManaged
|
|
1809
|
+
? managedAssistantShell(await response.result(), config.model)
|
|
1810
|
+
: await response.result();
|
|
1027
1811
|
if (addedPartial) {
|
|
1028
1812
|
context.messages[context.messages.length - 1] = finalMessage;
|
|
1029
1813
|
} else {
|
|
@@ -1042,7 +1826,9 @@ async function streamAssistantResponse(
|
|
|
1042
1826
|
detachAbortListener?.();
|
|
1043
1827
|
}
|
|
1044
1828
|
|
|
1045
|
-
const trailing =
|
|
1829
|
+
const trailing = config.fallbackManaged
|
|
1830
|
+
? managedAssistantShell(await response.result(), config.model)
|
|
1831
|
+
: await response.result();
|
|
1046
1832
|
await finishChat(trailing);
|
|
1047
1833
|
return trailing;
|
|
1048
1834
|
});
|