@qwen-code/arena-agent 0.0.10-dev.0 → 0.0.10-dev.10

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.
Files changed (158) hide show
  1. package/.env.example +57 -14
  2. package/CHANGELOG.md +228 -0
  3. package/README.md +240 -57
  4. package/dist/agent.d.ts +30 -2
  5. package/dist/agent.js +351 -104
  6. package/dist/agent.js.map +1 -1
  7. package/dist/anthropic.js +23 -22
  8. package/dist/anthropic.js.map +1 -1
  9. package/dist/ask-user.js +2 -0
  10. package/dist/ask-user.js.map +1 -1
  11. package/dist/attachments.js +4 -3
  12. package/dist/attachments.js.map +1 -1
  13. package/dist/brave-search.js +1 -3
  14. package/dist/brave-search.js.map +1 -1
  15. package/dist/canonical-data.js +2 -2
  16. package/dist/canonical-data.js.map +1 -1
  17. package/dist/cli.js +197 -34
  18. package/dist/cli.js.map +1 -1
  19. package/dist/code-tools.d.ts +8 -2
  20. package/dist/code-tools.js +709 -117
  21. package/dist/code-tools.js.map +1 -1
  22. package/dist/compaction.d.ts +49 -3
  23. package/dist/compaction.js +383 -170
  24. package/dist/compaction.js.map +1 -1
  25. package/dist/config.d.ts +17 -2
  26. package/dist/config.js +47 -5
  27. package/dist/config.js.map +1 -1
  28. package/dist/fetch-page.js +9 -33
  29. package/dist/fetch-page.js.map +1 -1
  30. package/dist/file-edit.d.ts +8 -0
  31. package/dist/file-edit.js +63 -0
  32. package/dist/file-edit.js.map +1 -0
  33. package/dist/history.d.ts +35 -1
  34. package/dist/history.js +400 -16
  35. package/dist/history.js.map +1 -1
  36. package/dist/legacy-compaction.d.ts +20 -0
  37. package/dist/legacy-compaction.js +209 -0
  38. package/dist/legacy-compaction.js.map +1 -0
  39. package/dist/midturn-checkpoint-hook.d.ts +31 -0
  40. package/dist/midturn-checkpoint-hook.js +38 -0
  41. package/dist/midturn-checkpoint-hook.js.map +1 -0
  42. package/dist/mock.js +5 -0
  43. package/dist/mock.js.map +1 -1
  44. package/dist/mode.js +4 -2
  45. package/dist/mode.js.map +1 -1
  46. package/dist/model-router-media.d.ts +1 -0
  47. package/dist/model-router-media.js +71 -1
  48. package/dist/model-router-media.js.map +1 -1
  49. package/dist/openai-responses.js +2 -0
  50. package/dist/openai-responses.js.map +1 -1
  51. package/dist/process-manager.js +18 -11
  52. package/dist/process-manager.js.map +1 -1
  53. package/dist/prompts.d.ts +2 -0
  54. package/dist/prompts.js +6 -2
  55. package/dist/prompts.js.map +1 -1
  56. package/dist/protocol.d.ts +5 -1
  57. package/dist/protocol.js +24 -2
  58. package/dist/protocol.js.map +1 -1
  59. package/dist/provider.d.ts +12 -5
  60. package/dist/provider.js +60 -52
  61. package/dist/provider.js.map +1 -1
  62. package/dist/repetitive-output.d.ts +12 -0
  63. package/dist/repetitive-output.js +80 -0
  64. package/dist/repetitive-output.js.map +1 -0
  65. package/dist/replay-responses.d.ts +17 -0
  66. package/dist/replay-responses.js +100 -0
  67. package/dist/replay-responses.js.map +1 -0
  68. package/dist/routify-web-fetch.js +20 -7
  69. package/dist/routify-web-fetch.js.map +1 -1
  70. package/dist/sandbox-file-write.d.ts +10 -0
  71. package/dist/sandbox-file-write.js +58 -0
  72. package/dist/sandbox-file-write.js.map +1 -0
  73. package/dist/sdk.js +9 -1
  74. package/dist/sdk.js.map +1 -1
  75. package/dist/server.js +127 -44
  76. package/dist/server.js.map +1 -1
  77. package/dist/session-memory-prompts.d.ts +5 -0
  78. package/dist/session-memory-prompts.js +7 -0
  79. package/dist/session-memory-prompts.js.map +1 -0
  80. package/dist/snapshot-client.d.ts +10 -0
  81. package/dist/snapshot-client.js +39 -0
  82. package/dist/snapshot-client.js.map +1 -0
  83. package/dist/spec.js +18 -10
  84. package/dist/spec.js.map +1 -1
  85. package/dist/speech-audio.d.ts +9 -0
  86. package/dist/speech-audio.js +130 -0
  87. package/dist/speech-audio.js.map +1 -0
  88. package/dist/speech-model.d.ts +3 -0
  89. package/dist/speech-model.js +8 -0
  90. package/dist/speech-model.js.map +1 -0
  91. package/dist/tools.d.ts +5 -0
  92. package/dist/tools.js +744 -121
  93. package/dist/tools.js.map +1 -1
  94. package/dist/tui.js +20 -12
  95. package/dist/tui.js.map +1 -1
  96. package/dist/types.d.ts +28 -3
  97. package/dist/voice-registry.d.ts +2 -0
  98. package/dist/voice-registry.js +10 -2
  99. package/dist/voice-registry.js.map +1 -1
  100. package/dist/web/app.js +17 -3
  101. package/dist/web/app.js.map +1 -1
  102. package/dist/web-fetch-output.d.ts +9 -0
  103. package/dist/web-fetch-output.js +38 -0
  104. package/dist/web-fetch-output.js.map +1 -0
  105. package/dist/workspace.d.ts +2 -0
  106. package/dist/workspace.js +11 -6
  107. package/dist/workspace.js.map +1 -1
  108. package/docs/BINARY_DISTRIBUTION.md +27 -16
  109. package/docs/MODES_AND_RELEASES.md +28 -5
  110. package/docs/SDK.md +10 -4
  111. package/modes/code-arena/capture-provenance.json +57 -0
  112. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  113. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  114. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  115. package/modes/code-arena/full-stack/tools.json +186 -186
  116. package/modes/code-arena/html/system-prompt.txt +1 -1
  117. package/modes/code-arena/html/tools.json +123 -123
  118. package/modes/code-arena/react/system-prompt.txt +4 -3
  119. package/modes/code-arena/react/template/package.json +4 -4
  120. package/modes/code-arena/react/template/src/App.tsx +1 -1
  121. package/modes/code-arena/react/template/src/main.tsx +1 -1
  122. package/modes/code-arena/react/template/tsconfig.json +6 -0
  123. package/modes/code-arena/react/template/vite.config.ts +1 -0
  124. package/modes/code-arena/react/tools.json +178 -184
  125. package/package.json +15 -4
  126. package/scripts/README.md +62 -0
  127. package/scripts/check-changelog.mjs +66 -0
  128. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  129. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  130. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  131. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  132. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  133. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  134. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  135. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  136. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  137. package/vendor/audio/darwin-x64/manifest.json +39 -0
  138. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  139. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  140. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  141. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  142. package/vendor/audio/linux-arm64/manifest.json +39 -0
  143. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  144. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  145. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  146. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  147. package/vendor/audio/linux-x64/manifest.json +39 -0
  148. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  149. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  150. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  151. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  152. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  153. package/vendor/audio/sources/sources.lock.json +30 -0
  154. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  155. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  156. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  157. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  158. package/vendor/audio/win32-x64/manifest.json +39 -0
package/dist/agent.js CHANGED
@@ -1,18 +1,70 @@
1
1
  import crypto from "node:crypto";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { supportsModelInput, } from "./config.js";
4
- import { compactMessagesIfNeeded } from "./compaction.js";
4
+ import { compactMessagesIfNeeded, maintainSessionMemory, contextFromHistory, sessionMemoryMatchesHistory, settleSessionMemory, storedSessionMemory } from "./compaction.js";
5
5
  import { ANTHROPIC_INCOMPLETE_STREAM_ERROR_CODE } from "./anthropic.js";
6
- import { modelCallLogPath } from "./history.js";
6
+ import { modelCallLogPath, withoutLeadingSystemMessage } from "./history.js";
7
7
  import { MockModelClient } from "./mock.js";
8
8
  import { contentPartMediaTypes, JsonlModelCallDiagnostics, } from "./model-diagnostics.js";
9
9
  import { RESPONSES_CAPACITY_ERROR_CODE, RESPONSES_TRANSIENT_ERROR_CODE, } from "./openai-responses.js";
10
10
  import { HttpModelClient, ModelCapabilityError } from "./provider.js";
11
+ import { RepetitiveOutputDetector } from "./repetitive-output.js";
11
12
  import { detectRepoContext } from "./repo.js";
12
13
  import { isCodeArenaMode } from "./mode.js";
13
- import { defaultClientContext, initialMessages, interruptedUserMessage, isCanonicalSystemMessage, repoContextFromSystemMessage, repoSystemMessage, systemMessage, } from "./spec.js";
14
- import { ToolExecutor } from "./tools.js";
14
+ import { defaultClientContext, interruptedUserMessage, repoSystemMessage, systemMessage, } from "./spec.js";
15
+ import { imageInputFallbackConnection, ToolExecutor, } from "./tools.js";
15
16
  import { initializeWorkspace } from "./workspace.js";
17
+ import { checkpointAction, } from "./midturn-checkpoint-hook.js";
18
+ function wrapMalformedToolArguments(argumentsText) {
19
+ try {
20
+ JSON.parse(argumentsText);
21
+ return argumentsText;
22
+ }
23
+ catch {
24
+ return JSON.stringify({ __malformedToolArguments: argumentsText });
25
+ }
26
+ }
27
+ /** Keep Agent Arena history replayable without losing the model's invalid input. */
28
+ function normalizeToolCallHistory(message, mode) {
29
+ if (isCodeArenaMode(mode) || message.role !== "assistant")
30
+ return message;
31
+ // A failed Responses stream can retain completed native items without the
32
+ // assembled tool_calls batch. Restore their identities so resume can pair
33
+ // them with interrupted results instead of replaying orphaned calls.
34
+ const calls = message.tool_calls?.length ? message.tool_calls
35
+ : message.openai_response_items?.flatMap((item) => item.type === "function_call" && typeof item.call_id === "string" &&
36
+ typeof item.name === "string" && typeof item.arguments === "string"
37
+ ? [{ id: item.call_id, type: "function", function: { name: item.name, arguments: item.arguments } }]
38
+ : []) ?? message.tool_calls;
39
+ let changed = Boolean(calls?.length && calls !== message.tool_calls);
40
+ const toolCalls = calls?.map((call) => {
41
+ if (typeof call.function?.arguments !== "string")
42
+ return call;
43
+ const argumentsText = wrapMalformedToolArguments(call.function.arguments);
44
+ if (argumentsText === call.function.arguments)
45
+ return call;
46
+ changed = true;
47
+ return { ...call, function: { ...call.function, arguments: argumentsText } };
48
+ });
49
+ // Native Responses replays these items instead of tool_calls. Normalize both
50
+ // representations while leaving reasoning and other opaque items intact.
51
+ const responseItems = message.openai_response_items?.map((item) => {
52
+ if (item.type !== "function_call" || typeof item.arguments !== "string")
53
+ return item;
54
+ const argumentsText = wrapMalformedToolArguments(item.arguments);
55
+ if (argumentsText === item.arguments)
56
+ return item;
57
+ changed = true;
58
+ return { ...item, arguments: argumentsText };
59
+ });
60
+ return changed
61
+ ? {
62
+ ...message,
63
+ ...(toolCalls ? { tool_calls: toolCalls } : {}),
64
+ ...(responseItems ? { openai_response_items: responseItems } : {}),
65
+ }
66
+ : message;
67
+ }
16
68
  function parseToolResult(content) {
17
69
  try {
18
70
  return JSON.parse(content);
@@ -142,6 +194,11 @@ function preflightUnsupportedMedia(config, messages) {
142
194
  !isCodeArenaMode(config.mode);
143
195
  return replaceUnsupportedMedia(messages, mediaTypes, imageReadFileFallback);
144
196
  }
197
+ function modelContextFromTrajectory(messages) {
198
+ return contextFromHistory(messages).map((message) => message.role === "user" && Array.isArray(message.content)
199
+ ? { ...message, content: structuredClone(message.content) }
200
+ : message);
201
+ }
145
202
  function completeInterruptedToolCalls(messages) {
146
203
  const completed = [];
147
204
  let pending = [];
@@ -176,6 +233,13 @@ function completeInterruptedToolCalls(messages) {
176
233
  flushPending();
177
234
  return completed;
178
235
  }
236
+ export function isContinuableHistory(messages) {
237
+ const last = messages.at(-1);
238
+ return Boolean(last &&
239
+ (last.role === "user" ||
240
+ last.role === "tool" ||
241
+ (last.role === "assistant" && Boolean(last.tool_calls?.length))));
242
+ }
179
243
  const identityModelsWithQwen = "Claude, ChatGPT, Gemini, Grok, Qwen, and Kimi";
180
244
  const identityModelsWithoutQwen = "Claude, ChatGPT, Gemini, Grok, and Kimi";
181
245
  const arenaWorkspaceIdentityOpening = "You are a helpful agentic assistant with tool access running on Arena.ai's Agent Mode.";
@@ -209,19 +273,12 @@ function withAppendedSystemPrompt(content, appendix) {
209
273
  : "\n\n";
210
274
  return `${content}${separator}${appendix}`;
211
275
  }
212
- function applySystemPromptConfig(message, config, preserveExistingAppendix = false) {
276
+ function applySystemPromptConfig(message, config) {
213
277
  if (message?.role !== "system" || typeof message.content !== "string") {
214
278
  return;
215
279
  }
216
280
  const appendix = config.appendSystemPrompt;
217
- const existingAppendix = preserveExistingAppendix &&
218
- appendix &&
219
- message.content.endsWith(`\n\n${appendix}`)
220
- ? appendix
221
- : undefined;
222
- let content = existingAppendix
223
- ? message.content.slice(0, -existingAppendix.length)
224
- : message.content;
281
+ let content = message.content;
225
282
  if (typeof config.systemPrompt === "string") {
226
283
  content = config.systemPrompt;
227
284
  }
@@ -234,11 +291,7 @@ function applySystemPromptConfig(message, config, preserveExistingAppendix = fal
234
291
  ? content.replace(identityModelsWithQwen, identityModelsWithoutQwen)
235
292
  : content.replace(identityModelsWithoutQwen, identityModelsWithQwen);
236
293
  }
237
- const reuseExistingAppendix = existingAppendix && typeof config.systemPrompt !== "string";
238
- if (reuseExistingAppendix) {
239
- content += existingAppendix;
240
- }
241
- if (appendix !== undefined && !reuseExistingAppendix) {
294
+ if (appendix !== undefined) {
242
295
  content = withAppendedSystemPrompt(content, appendix);
243
296
  }
244
297
  message.content = content;
@@ -253,20 +306,33 @@ class PartialAssistantTurnError extends Error {
253
306
  this.name = "PartialAssistantTurnError";
254
307
  }
255
308
  }
256
- // Mode is fixed at the first turn: resume reads it back from the history's
257
- // system message, so a mid-session clone or detached HEAD cannot flip it.
258
- // Exported so the replay script checks the same decision instead of redoing it.
259
- export function resolveRepoContext(workspace, history, mode = "arena") {
260
- if (isCodeArenaMode(mode))
261
- return undefined;
262
- return history?.length
263
- ? repoContextFromSystemMessage(history[0])
264
- : detectRepoContext(workspace);
309
+ class CheckpointExit extends Error {
310
+ event;
311
+ constructor(event) {
312
+ super("model generation stopped by checkpoint hook");
313
+ this.event = event;
314
+ }
315
+ }
316
+ class CheckpointFailure extends Error {
317
+ cause;
318
+ constructor(cause) {
319
+ super(describeError(cause));
320
+ this.cause = cause;
321
+ }
322
+ }
323
+ // Rebuild the runtime repo context from the current workspace on every run.
324
+ // The history argument is retained for callers; it never supplies runtime state.
325
+ export function resolveRepoContext(workspace, _history, mode = "arena") {
326
+ return isCodeArenaMode(mode) ? undefined : detectRepoContext(workspace);
265
327
  }
266
328
  export class Agent {
267
329
  config;
268
330
  sink;
269
331
  options;
332
+ checkpointQueue = Promise.resolve();
333
+ checkpointError;
334
+ persistedMemory;
335
+ memoryCache;
270
336
  client;
271
337
  tools;
272
338
  workspaceContext;
@@ -276,40 +342,58 @@ export class Agent {
276
342
  this.config = config;
277
343
  this.sink = sink;
278
344
  this.options = options;
345
+ this.memoryCache = options.sessionMemory || {};
279
346
  this.sessionId =
280
347
  options.sessionId || crypto.randomUUID().replaceAll("-", "");
281
348
  this.sessionAffinityId = options.sessionAffinityId || crypto.randomUUID();
282
- this.workspaceContext =
283
- options.workspaceContext ||
284
- (isCodeArenaMode(config.mode)
285
- ? {
286
- root: config.workspace,
287
- cwd: config.workspace,
288
- presentFile: false,
289
- }
290
- : undefined);
349
+ // Local workspace guidance is explicit opt-in. Code Arena defaults must
350
+ // send the captured prompt; Agent Arena already used this same opt-in path.
351
+ this.workspaceContext = options.workspaceContext;
352
+ const fallbackConnection = config.imageInputFallbackModel
353
+ ? imageInputFallbackConnection(config)
354
+ : undefined;
355
+ const needsMainHttpClient = !options.client && !config.model.startsWith("mock/");
356
+ const needsFallbackHttpClient = Boolean(fallbackConnection) && !options.imageInputFallbackClient;
357
+ const diagnostics = config.modelCallLogging === false ||
358
+ (!needsMainHttpClient && !needsFallbackHttpClient)
359
+ ? undefined
360
+ : new JsonlModelCallDiagnostics(modelCallLogPath(config.stateRoot, config.tenantId, this.sessionId));
291
361
  this.client =
292
362
  options.client ||
293
363
  (config.model.startsWith("mock/")
294
364
  ? new MockModelClient()
295
365
  : new HttpModelClient(config, {
296
366
  sessionAffinityId: this.sessionAffinityId,
297
- ...(config.modelCallLogging === false
298
- ? {}
299
- : {
300
- diagnostics: new JsonlModelCallDiagnostics(modelCallLogPath(config.stateRoot, config.tenantId, this.sessionId)),
301
- }),
367
+ ...(diagnostics ? { diagnostics } : {}),
302
368
  }));
369
+ const imageInputFallbackClient = options.imageInputFallbackClient ||
370
+ (fallbackConnection
371
+ ? new HttpModelClient(config, {
372
+ connection: fallbackConnection,
373
+ sessionAffinityId: this.sessionAffinityId,
374
+ ...(diagnostics ? { diagnostics } : {}),
375
+ })
376
+ : undefined);
303
377
  this.tools =
304
378
  options.tools ||
305
379
  new ToolExecutor(config, {
306
380
  modelClient: this.client,
381
+ imageInputFallbackClient,
307
382
  signal: options.signal,
308
383
  askUserHandler: options.askUserHandler,
309
384
  workspaceContext: this.workspaceContext,
310
385
  });
311
386
  }
312
387
  async run(prompt, history, runOptions = {}) {
388
+ try {
389
+ return await this.runTurn(prompt, history, runOptions);
390
+ }
391
+ finally {
392
+ await settleSessionMemory(this.memoryCache);
393
+ await this.checkpointQueue;
394
+ }
395
+ }
396
+ async runTurn(prompt, history, runOptions) {
313
397
  if (runOptions.resumeFromHistory && !history?.length) {
314
398
  throw new Error("resumeFromHistory requires a non-empty history");
315
399
  }
@@ -323,57 +407,66 @@ export class Agent {
323
407
  : structuredClone(baseUserMessage);
324
408
  const clientContext = this.options.clientContext || defaultClientContext();
325
409
  const repoContext = resolveRepoContext(this.config.workspace, history, this.config.mode);
326
- const trajectory = history?.length
327
- ? completeInterruptedToolCalls(structuredClone(history))
328
- : initialMessages(prompt, clientContext, this.workspaceContext, repoContext, this.config.mode, this.config.maxTurns);
329
- applySystemPromptConfig(trajectory[0], this.config, Boolean(history?.length));
330
- if (history?.length) {
331
- const expected = repoContext
332
- ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns)
333
- : systemMessage(clientContext, this.workspaceContext, this.config.mode, this.config.maxTurns);
334
- applySystemPromptConfig(expected, this.config);
335
- if (typeof this.config.systemPrompt === "object") {
336
- trajectory[0] = structuredClone(expected);
410
+ // System instructions belong to this invocation, never to the saved dialogue.
411
+ // Older local histories included a system message; accept them without
412
+ // carrying the old runtime configuration into the next request.
413
+ const dialogue = withoutLeadingSystemMessage(history || []).map((message) => normalizeToolCallHistory(message, this.config.mode));
414
+ const trajectory = completeInterruptedToolCalls(this.options.reuseHistoryMessages ? [...dialogue] : structuredClone(dialogue));
415
+ const runtimeSystemMessage = repoContext
416
+ ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns)
417
+ : systemMessage(clientContext, this.workspaceContext, this.config.mode, this.config.maxTurns);
418
+ applySystemPromptConfig(runtimeSystemMessage, this.config);
419
+ if (runOptions.resumeFromHistory) {
420
+ if (!isContinuableHistory(dialogue)) {
421
+ throw new Error("resumeFromHistory requires an unfinished user or tool boundary");
337
422
  }
338
- if (!isCanonicalSystemMessage(trajectory[0], expected, this.config.mode, this.config.maxTurns)) {
339
- const portableExpected = systemMessage(clientContext, undefined, this.config.mode, this.config.maxTurns);
340
- applySystemPromptConfig(portableExpected, this.config);
341
- if (!repoContext &&
342
- this.workspaceContext &&
343
- isCanonicalSystemMessage(trajectory[0], portableExpected, this.config.mode, this.config.maxTurns)) {
344
- const runtimeSystemMessage = initialMessages("", this.options.clientContext, this.workspaceContext, undefined, this.config.mode, this.config.maxTurns)[0];
345
- applySystemPromptConfig(runtimeSystemMessage, this.config);
346
- trajectory[0] = runtimeSystemMessage;
347
- }
348
- else {
349
- throw new Error("history does not match the current workspace runtime context");
350
- }
423
+ }
424
+ else {
425
+ trajectory.push(userMessage);
426
+ }
427
+ // Compaction projects model input with a runtime system prefix; the lossless
428
+ // trajectory and its checkpoints remain in the official dialogue format.
429
+ const messages = modelContextFromTrajectory([runtimeSystemMessage, ...trajectory]);
430
+ if (!isCodeArenaMode(this.config.mode)) {
431
+ const saved = this.persistedMemory ?? this.options.storedSessionMemory ?? storedSessionMemory(this.memoryCache);
432
+ preflightUnsupportedMedia(this.config, messages);
433
+ // Opting out of restoration must not delete a still-valid durable snapshot.
434
+ this.persistedMemory = saved?.version === 1 ? structuredClone(saved) : undefined;
435
+ if (this.persistedMemory?.latest && !sessionMemoryMatchesHistory(this.persistedMemory.latest, messages)) {
436
+ this.persistedMemory.latest = undefined;
351
437
  }
352
- if (runOptions.resumeFromHistory) {
353
- const lastRole = trajectory.at(-1)?.role;
354
- if (lastRole !== "user" && lastRole !== "tool") {
355
- throw new Error("resumeFromHistory history must end with a user or tool message");
356
- }
438
+ if (this.persistedMemory?.update?.status === "pending") {
439
+ this.persistedMemory.update = { status: "cancelled", code: "MEMORY_INTERRUPTED",
440
+ error: "Previous process ended during a memory update" };
357
441
  }
358
- else {
359
- trajectory.push(userMessage);
442
+ this.memoryCache.latest = undefined;
443
+ this.memoryCache.state = undefined;
444
+ this.memoryCache.latestUsage = undefined;
445
+ this.memoryCache.attemptedPrefix = undefined;
446
+ this.memoryCache.update = undefined;
447
+ if (this.config.compactionMode !== "summary" &&
448
+ (runOptions.restoreSessionMemory ?? runOptions.singleStep) && saved?.version === 1) {
449
+ if (sessionMemoryMatchesHistory(saved.latest, messages)) {
450
+ this.memoryCache.latest = structuredClone(saved.latest);
451
+ }
452
+ this.memoryCache.update = this.persistedMemory?.update;
453
+ if (saved.latest && !this.memoryCache.latest) {
454
+ this.memoryCache.update = { status: "error", code: "MEMORY_STALE_HISTORY",
455
+ error: "Saved memory does not match the current history prefix" };
456
+ }
457
+ if (this.memoryCache.update)
458
+ this.sink.emit({ type: "memory.update", session_id: this.sessionId, ...this.memoryCache.update });
360
459
  }
361
460
  }
362
- else {
363
- trajectory[1] = userMessage;
364
- }
365
- // Persisted history is the lossless source of truth for replay and
366
- // training. Compaction may rewrite only this private model-context copy;
367
- // every real user, assistant, and tool message is appended to both.
368
- const messages = structuredClone(trajectory);
369
461
  const recordMessage = async (message) => {
370
- messages.push(message);
371
- const recorded = structuredClone(message);
462
+ const normalized = normalizeToolCallHistory(message, this.config.mode);
463
+ messages.push(normalized);
464
+ const recorded = structuredClone(normalized);
372
465
  trajectory.push(recorded);
373
466
  this.sink.message(this.sessionId, recorded, trajectory);
374
467
  await this.checkpoint(trajectory);
375
468
  };
376
- await this.maybeCompact(messages);
469
+ await this.maybeCompact(messages, trajectory);
377
470
  this.sink.start(this.sessionId, trajectory);
378
471
  try {
379
472
  await this.checkpoint(trajectory);
@@ -391,14 +484,21 @@ export class Agent {
391
484
  // stores its encrypted reasoning output items there, so compaction is
392
485
  // safe at the same tool-step boundaries as the other protocols.
393
486
  if (step > 1)
394
- await this.maybeCompact(messages);
487
+ await this.maybeCompact(messages, trajectory, "inner");
395
488
  const offeredTools = this.tools.availableTools();
396
489
  this.sink.modelStart(this.sessionId, step, offeredTools.map((tool) => tool.function.name));
397
490
  let assistant;
398
491
  try {
399
- assistant = await this.assistantTurn(messages, offeredTools);
492
+ assistant = await this.assistantTurn(messages, offeredTools, trajectory);
400
493
  }
401
494
  catch (error) {
495
+ if (error instanceof CheckpointExit) {
496
+ this.sink.modelComplete(this.sessionId, step, "checkpoint");
497
+ await this.makeCheckpoint(trajectory, offeredTools, error.event, "snapshot_exit");
498
+ return await this.pause(trajectory, "checkpoint");
499
+ }
500
+ if (error instanceof CheckpointFailure)
501
+ throw error.cause;
402
502
  if (error instanceof PartialAssistantTurnError) {
403
503
  this.options.onModelFailure?.(trajectory, error.cause);
404
504
  if (error.assistant) {
@@ -411,9 +511,24 @@ export class Agent {
411
511
  }
412
512
  const finishReason = assistant.tool_calls?.length ? "tool_calls" : "stop";
413
513
  this.sink.modelComplete(this.sessionId, step, finishReason, assistant.usage);
514
+ if (assistant.tool_calls?.length &&
515
+ this.options.midturnCheckpointHook) {
516
+ const event = {
517
+ type: "tool_validation",
518
+ sessionId: this.sessionId,
519
+ toolCalls: assistant.tool_calls.map((call) => this.tools.inspect(call)),
520
+ };
521
+ const action = checkpointAction(await this.options.midturnCheckpointHook(event));
522
+ if (action !== "continue") {
523
+ await this.makeCheckpoint(trajectory, offeredTools, event, action);
524
+ if (action === "snapshot_exit") {
525
+ return await this.pause(trajectory, "checkpoint");
526
+ }
527
+ }
528
+ }
414
529
  await recordMessage(assistant);
415
530
  if (!assistant.tool_calls?.length) {
416
- return this.sink.result(this.sessionId, "success", trajectory);
531
+ return await this.finish(trajectory, "success");
417
532
  }
418
533
  const completedToolCallIds = [];
419
534
  for (const call of assistant.tool_calls) {
@@ -445,7 +560,7 @@ export class Agent {
445
560
  ? toolResult.questions
446
561
  : [];
447
562
  this.sink.inputRequired(this.sessionId, toolCallId, questions);
448
- return this.sink.result(this.sessionId, "needs_input", trajectory);
563
+ return await this.finish(trajectory, "needs_input");
449
564
  }
450
565
  }
451
566
  if (this.options.signal?.aborted) {
@@ -456,28 +571,111 @@ export class Agent {
456
571
  if (visualMessage) {
457
572
  await recordMessage(visualMessage);
458
573
  }
574
+ if (runOptions.singleStep) {
575
+ return await this.pause(trajectory, "single_step");
576
+ }
459
577
  }
460
578
  }
461
579
  catch (error) {
462
580
  const message = error instanceof Error ? error.message : String(error);
463
- return this.sink.result(this.sessionId, "error", trajectory, message);
581
+ return await this.finish(trajectory, "error", message);
464
582
  }
465
583
  }
466
584
  async checkpoint(messages) {
467
- await this.options.onCheckpoint?.(messages, this.tools.availableTools());
585
+ const snapshot = [...messages];
586
+ const tools = this.tools.availableTools();
587
+ const currentMemory = storedSessionMemory(this.memoryCache);
588
+ const memory = isCodeArenaMode(this.config.mode) ? undefined : (currentMemory || this.persistedMemory
589
+ ? { version: 1, latest: currentMemory?.latest ?? this.persistedMemory?.latest,
590
+ update: currentMemory?.update ?? this.persistedMemory?.update }
591
+ : null);
592
+ if (memory !== undefined)
593
+ this.persistedMemory = memory ?? undefined;
594
+ const write = this.checkpointQueue.then(() => this.options.onCheckpoint?.(snapshot, tools, memory));
595
+ this.checkpointQueue = write.catch(error => { this.checkpointError ??= error; });
596
+ await write;
597
+ if (this.checkpointError)
598
+ throw this.checkpointError;
599
+ }
600
+ async finish(messages, status, error) {
601
+ await settleSessionMemory(this.memoryCache);
602
+ try {
603
+ await this.checkpoint(messages);
604
+ }
605
+ catch (failure) {
606
+ status = "error";
607
+ error ??= failure instanceof Error ? failure.message : String(failure);
608
+ }
609
+ return this.sink.result(this.sessionId, status, messages, error, undefined, this.memoryCache.update);
610
+ }
611
+ async makeCheckpoint(messages, tools, event, action) {
612
+ await settleSessionMemory(this.memoryCache);
613
+ await this.checkpoint(messages);
614
+ if (!this.options.onMidturnCheckpoint) {
615
+ throw new Error("mid-turn checkpoint hook triggered without a snapshot handler");
616
+ }
617
+ await this.options.onMidturnCheckpoint(messages, tools, event, action);
618
+ }
619
+ async pause(messages, reason) {
620
+ this.options.signal?.throwIfAborted();
621
+ await settleSessionMemory(this.memoryCache, reason !== "single_step");
622
+ if (reason === "single_step")
623
+ await this.checkpoint(messages);
624
+ await this.options.onPause?.(messages, this.tools.availableTools());
625
+ this.options.signal?.throwIfAborted();
626
+ return this.sink.result(this.sessionId, "paused", messages, undefined, reason, this.memoryCache.update);
468
627
  }
469
- async maybeCompact(messages) {
628
+ async maybeCompact(messages, trajectory, source = "outer") {
470
629
  preflightUnsupportedMedia(this.config, messages);
471
630
  try {
472
- const result = await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, {
631
+ const settings = {
473
632
  contextWindow: this.config.contextWindow,
474
633
  triggerRatio: this.config.contextCompactionRatio,
475
634
  summaryMaxTokens: this.config.compactionSummaryMaxTokens,
476
- }, this.options.signal);
635
+ reasoningEffort: this.config.reasoningEffort,
636
+ compactionThresholdTokens: isCodeArenaMode(this.config.mode) ? undefined
637
+ : this.options.compactionThresholdTokens ?? this.config.compactionThresholdTokens,
638
+ legacy: isCodeArenaMode(this.config.mode) || this.config.compactionMode === "summary",
639
+ };
640
+ const contextLength = messages.length;
641
+ const result = settings.legacy
642
+ ? await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, settings, this.options.signal)
643
+ : await maintainSessionMemory(messages, messages, this.memoryCache, this.tools.availableTools(), this.client, this.config.model, settings, this.config.memoryUpdateTokens, this.options.signal, update => {
644
+ this.sink.emit({ type: "memory.update", session_id: this.sessionId, ...update });
645
+ // Serialized with message checkpoints; persistence failures surface at
646
+ // the next checkpoint/finish instead of becoming unhandled rejections.
647
+ return this.checkpoint(trajectory).catch(() => undefined);
648
+ });
477
649
  if (result.compacted) {
478
- // The summary exists only in the private model context, not as a real
479
- // trajectory message, so the event stream is the only place its usage
480
- // can be accounted for.
650
+ if (!isCodeArenaMode(this.config.mode)) {
651
+ const covered = settings.legacy ? result.coveredMessages : this.memoryCache.state.coveredMessages;
652
+ const retained = contextLength - covered;
653
+ // Insert at the covered boundary, before the retained original tail.
654
+ // Marker position is the resume boundary; no separate index is stored.
655
+ let insertion = trajectory.length;
656
+ let remaining = retained;
657
+ while (remaining > 0 && insertion > 0) {
658
+ if (trajectory[--insertion].role !== "compaction")
659
+ remaining--;
660
+ }
661
+ trajectory.splice(insertion, 0, {
662
+ role: "compaction", content: [{ type: "text", text: result.memory }],
663
+ undone: false,
664
+ compaction: {
665
+ ratio: result.estimatedTokensBefore / this.config.contextWindow,
666
+ tokens_at_compaction: result.estimatedTokensBefore,
667
+ source,
668
+ },
669
+ });
670
+ // Coverage indices referred to the pre-compaction history. The next
671
+ // update reads its prior memory from the newly embedded block.
672
+ this.memoryCache.state = undefined;
673
+ this.memoryCache.latest = undefined;
674
+ this.memoryCache.latestUsage = undefined;
675
+ this.memoryCache.attemptedPrefix = undefined;
676
+ this.persistedMemory = undefined;
677
+ }
678
+ await this.checkpoint(trajectory);
481
679
  this.sink.compaction(this.sessionId, result.usage);
482
680
  }
483
681
  return result.compacted;
@@ -489,7 +687,7 @@ export class Agent {
489
687
  return false;
490
688
  }
491
689
  }
492
- async assistantTurn(messages, offeredTools) {
690
+ async assistantTurn(messages, offeredTools, trajectory) {
493
691
  // A mid-stream network fault loses only the tokens produced so far, not the
494
692
  // conversation: the request is idempotent, so the whole turn is re-issued
495
693
  // from scratch. Accumulators live inside the loop and reset each attempt, so
@@ -499,11 +697,9 @@ export class Agent {
499
697
  // original content, and route-specific rejections keep the runtime fallback.
500
698
  preflightUnsupportedMedia(this.config, messages);
501
699
  const maxRetries = Math.max(0, Math.floor(this.config.modelMaxRetries ?? 10));
502
- // Keep an immutable request snapshot for automatic retries. Each attempt
503
- // receives its own clone so even a stateful ModelClient cannot contaminate
504
- // the next request after a partial or failed stream.
505
- let requestMessages = structuredClone(messages);
506
- const requestTools = structuredClone(offeredTools);
700
+ // Each attempt receives one fresh clone, so a stateful ModelClient cannot
701
+ // contaminate the retry. Keeping an additional immutable clone of the same
702
+ // request doubled the largest live allocation without adding isolation.
507
703
  let multimodalFallbackApplied = false;
508
704
  for (let attempt = 0;; attempt += 1) {
509
705
  const contentParts = [];
@@ -512,11 +708,56 @@ export class Agent {
512
708
  const responseItems = new Map();
513
709
  let usage;
514
710
  let reasoningDetails;
711
+ const detectors = this.options.midturnCheckpointHook ? {
712
+ content: new RepetitiveOutputDetector(),
713
+ reasoning: new RepetitiveOutputDetector(),
714
+ } : undefined;
715
+ const controller = detectors ? new AbortController() : undefined;
716
+ const signal = controller
717
+ ? this.options.signal
718
+ ? AbortSignal.any([this.options.signal, controller.signal])
719
+ : controller.signal
720
+ : this.options.signal;
721
+ let repetitionReported = false;
722
+ let checkpointControl;
723
+ const inspectOutput = async (channel, text) => {
724
+ if (!detectors || repetitionReported)
725
+ return;
726
+ const sample = detectors[channel].append(text);
727
+ if (sample === undefined)
728
+ return;
729
+ repetitionReported = true;
730
+ const event = {
731
+ type: "repetitive_output",
732
+ sessionId: this.sessionId,
733
+ toolCalls: [],
734
+ repetition: { channel, sample },
735
+ };
736
+ try {
737
+ const action = checkpointAction(await this.options.midturnCheckpointHook(event));
738
+ if (action === "snapshot_exit") {
739
+ checkpointControl = new CheckpointExit(event);
740
+ }
741
+ else if (action === "snapshot_continue") {
742
+ await this.makeCheckpoint(trajectory, offeredTools, event, action);
743
+ }
744
+ }
745
+ catch (error) {
746
+ checkpointControl = new CheckpointFailure(error);
747
+ }
748
+ if (checkpointControl) {
749
+ // Abort the transport, then let for-await close its iterator before
750
+ // the caller snapshots or reports an error. Never save this partial
751
+ // assistant or retry a policy decision as a network failure.
752
+ controller.abort(checkpointControl);
753
+ throw checkpointControl;
754
+ }
755
+ };
515
756
  try {
516
757
  const stream = this.client.stream({
517
758
  model: this.config.model,
518
- messages: structuredClone(requestMessages),
519
- tools: structuredClone(requestTools),
759
+ messages: structuredClone(messages),
760
+ tools: structuredClone(offeredTools),
520
761
  toolChoice: this.config.toolChoice,
521
762
  temperature: this.config.temperature,
522
763
  maxTokens: this.config.maxTokens,
@@ -526,7 +767,7 @@ export class Agent {
526
767
  enableThinking: this.config.enableThinking,
527
768
  thinkingBudget: this.config.thinkingBudget,
528
769
  preserveThinking: this.config.preserveThinking,
529
- signal: this.options.signal,
770
+ signal,
530
771
  });
531
772
  for await (const chunk of stream) {
532
773
  // With stream_options.include_usage the usage arrives on the final
@@ -545,12 +786,16 @@ export class Agent {
545
786
  const content = String(delta.content);
546
787
  contentParts.push(content);
547
788
  this.sink.delta(this.sessionId, { content });
789
+ if (detectors)
790
+ await inspectOutput("content", content);
548
791
  }
549
792
  const reasoning = delta.reasoning_content || delta.reasoning;
550
793
  if (reasoning) {
551
794
  const value = String(reasoning);
552
795
  reasoningParts.push(value);
553
796
  this.sink.delta(this.sessionId, { reasoning_content: value });
797
+ if (detectors)
798
+ await inspectOutput("reasoning", value);
554
799
  }
555
800
  for (const [position, fragment] of (delta.tool_calls || []).entries()) {
556
801
  const index = Number(fragment.index ?? position);
@@ -586,6 +831,9 @@ export class Agent {
586
831
  }
587
832
  }
588
833
  catch (error) {
834
+ // Iterator cleanup may itself report the intentional transport abort.
835
+ if (checkpointControl)
836
+ throw checkpointControl;
589
837
  // A user cancellation is not a transient fault: skip the retry, but
590
838
  // still fall through so any partial output is preserved in history.
591
839
  const aborted = Boolean(this.options.signal?.aborted);
@@ -595,7 +843,6 @@ export class Agent {
595
843
  const replacedMedia = replaceUnsupportedMedia(messages, error.mediaTypes);
596
844
  if (replacedMedia > 0) {
597
845
  multimodalFallbackApplied = true;
598
- requestMessages = structuredClone(messages);
599
846
  this.sink.modelRetry(this.sessionId, attempt + 1, `model rejected ${error.mediaTypes.join(", ")} input; replaced ` +
600
847
  `${replacedMedia} media part(s) with an unsupported-input notice`);
601
848
  continue;