@qwen-code/arena-agent 0.0.10-dev.1 → 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 (154) hide show
  1. package/.env.example +57 -14
  2. package/CHANGELOG.md +228 -0
  3. package/README.md +228 -50
  4. package/dist/agent.d.ts +28 -2
  5. package/dist/agent.js +341 -106
  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 +184 -36
  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 +382 -186
  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 +16 -3
  34. package/dist/history.js +117 -19
  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 +2 -1
  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/server.js +17 -5
  74. package/dist/server.js.map +1 -1
  75. package/dist/session-memory-prompts.d.ts +5 -0
  76. package/dist/session-memory-prompts.js +7 -0
  77. package/dist/session-memory-prompts.js.map +1 -0
  78. package/dist/snapshot-client.d.ts +10 -0
  79. package/dist/snapshot-client.js +39 -0
  80. package/dist/snapshot-client.js.map +1 -0
  81. package/dist/spec.js +18 -10
  82. package/dist/spec.js.map +1 -1
  83. package/dist/speech-audio.d.ts +9 -0
  84. package/dist/speech-audio.js +130 -0
  85. package/dist/speech-audio.js.map +1 -0
  86. package/dist/speech-model.d.ts +3 -0
  87. package/dist/speech-model.js +8 -0
  88. package/dist/speech-model.js.map +1 -0
  89. package/dist/tools.d.ts +5 -0
  90. package/dist/tools.js +378 -132
  91. package/dist/tools.js.map +1 -1
  92. package/dist/tui.js +13 -10
  93. package/dist/tui.js.map +1 -1
  94. package/dist/types.d.ts +28 -3
  95. package/dist/voice-registry.d.ts +2 -0
  96. package/dist/voice-registry.js +10 -2
  97. package/dist/voice-registry.js.map +1 -1
  98. package/dist/web-fetch-output.d.ts +9 -0
  99. package/dist/web-fetch-output.js +38 -0
  100. package/dist/web-fetch-output.js.map +1 -0
  101. package/dist/workspace.d.ts +2 -0
  102. package/dist/workspace.js +11 -6
  103. package/dist/workspace.js.map +1 -1
  104. package/docs/BINARY_DISTRIBUTION.md +27 -16
  105. package/docs/MODES_AND_RELEASES.md +28 -5
  106. package/docs/SDK.md +5 -0
  107. package/modes/code-arena/capture-provenance.json +57 -0
  108. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  109. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  110. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  111. package/modes/code-arena/full-stack/tools.json +186 -186
  112. package/modes/code-arena/html/system-prompt.txt +1 -1
  113. package/modes/code-arena/html/tools.json +123 -123
  114. package/modes/code-arena/react/system-prompt.txt +4 -3
  115. package/modes/code-arena/react/template/package.json +4 -4
  116. package/modes/code-arena/react/template/src/App.tsx +1 -1
  117. package/modes/code-arena/react/template/src/main.tsx +1 -1
  118. package/modes/code-arena/react/template/tsconfig.json +6 -0
  119. package/modes/code-arena/react/template/vite.config.ts +1 -0
  120. package/modes/code-arena/react/tools.json +178 -184
  121. package/package.json +14 -4
  122. package/scripts/README.md +62 -0
  123. package/scripts/check-changelog.mjs +66 -0
  124. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  125. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  126. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  127. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  128. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  129. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  130. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  131. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  132. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  133. package/vendor/audio/darwin-x64/manifest.json +39 -0
  134. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  135. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  136. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  137. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  138. package/vendor/audio/linux-arm64/manifest.json +39 -0
  139. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  140. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  141. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  142. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  143. package/vendor/audio/linux-x64/manifest.json +39 -0
  144. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  145. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  146. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  147. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  148. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  149. package/vendor/audio/sources/sources.lock.json +30 -0
  150. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  151. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  152. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  153. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  154. 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);
@@ -143,7 +195,7 @@ function preflightUnsupportedMedia(config, messages) {
143
195
  return replaceUnsupportedMedia(messages, mediaTypes, imageReadFileFallback);
144
196
  }
145
197
  function modelContextFromTrajectory(messages) {
146
- return messages.map((message) => message.role === "user" && Array.isArray(message.content)
198
+ return contextFromHistory(messages).map((message) => message.role === "user" && Array.isArray(message.content)
147
199
  ? { ...message, content: structuredClone(message.content) }
148
200
  : message);
149
201
  }
@@ -181,6 +233,13 @@ function completeInterruptedToolCalls(messages) {
181
233
  flushPending();
182
234
  return completed;
183
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
+ }
184
243
  const identityModelsWithQwen = "Claude, ChatGPT, Gemini, Grok, Qwen, and Kimi";
185
244
  const identityModelsWithoutQwen = "Claude, ChatGPT, Gemini, Grok, and Kimi";
186
245
  const arenaWorkspaceIdentityOpening = "You are a helpful agentic assistant with tool access running on Arena.ai's Agent Mode.";
@@ -214,19 +273,12 @@ function withAppendedSystemPrompt(content, appendix) {
214
273
  : "\n\n";
215
274
  return `${content}${separator}${appendix}`;
216
275
  }
217
- function applySystemPromptConfig(message, config, preserveExistingAppendix = false) {
276
+ function applySystemPromptConfig(message, config) {
218
277
  if (message?.role !== "system" || typeof message.content !== "string") {
219
278
  return;
220
279
  }
221
280
  const appendix = config.appendSystemPrompt;
222
- const existingAppendix = preserveExistingAppendix &&
223
- appendix &&
224
- message.content.endsWith(`\n\n${appendix}`)
225
- ? appendix
226
- : undefined;
227
- let content = existingAppendix
228
- ? message.content.slice(0, -existingAppendix.length)
229
- : message.content;
281
+ let content = message.content;
230
282
  if (typeof config.systemPrompt === "string") {
231
283
  content = config.systemPrompt;
232
284
  }
@@ -239,11 +291,7 @@ function applySystemPromptConfig(message, config, preserveExistingAppendix = fal
239
291
  ? content.replace(identityModelsWithQwen, identityModelsWithoutQwen)
240
292
  : content.replace(identityModelsWithoutQwen, identityModelsWithQwen);
241
293
  }
242
- const reuseExistingAppendix = existingAppendix && typeof config.systemPrompt !== "string";
243
- if (reuseExistingAppendix) {
244
- content += existingAppendix;
245
- }
246
- if (appendix !== undefined && !reuseExistingAppendix) {
294
+ if (appendix !== undefined) {
247
295
  content = withAppendedSystemPrompt(content, appendix);
248
296
  }
249
297
  message.content = content;
@@ -258,20 +306,33 @@ class PartialAssistantTurnError extends Error {
258
306
  this.name = "PartialAssistantTurnError";
259
307
  }
260
308
  }
261
- // Mode is fixed at the first turn: resume reads it back from the history's
262
- // system message, so a mid-session clone or detached HEAD cannot flip it.
263
- // Exported so the replay script checks the same decision instead of redoing it.
264
- export function resolveRepoContext(workspace, history, mode = "arena") {
265
- if (isCodeArenaMode(mode))
266
- return undefined;
267
- return history?.length
268
- ? repoContextFromSystemMessage(history[0])
269
- : 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);
270
327
  }
271
328
  export class Agent {
272
329
  config;
273
330
  sink;
274
331
  options;
332
+ checkpointQueue = Promise.resolve();
333
+ checkpointError;
334
+ persistedMemory;
335
+ memoryCache;
275
336
  client;
276
337
  tools;
277
338
  workspaceContext;
@@ -281,40 +342,58 @@ export class Agent {
281
342
  this.config = config;
282
343
  this.sink = sink;
283
344
  this.options = options;
345
+ this.memoryCache = options.sessionMemory || {};
284
346
  this.sessionId =
285
347
  options.sessionId || crypto.randomUUID().replaceAll("-", "");
286
348
  this.sessionAffinityId = options.sessionAffinityId || crypto.randomUUID();
287
- this.workspaceContext =
288
- options.workspaceContext ||
289
- (isCodeArenaMode(config.mode)
290
- ? {
291
- root: config.workspace,
292
- cwd: config.workspace,
293
- presentFile: false,
294
- }
295
- : 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));
296
361
  this.client =
297
362
  options.client ||
298
363
  (config.model.startsWith("mock/")
299
364
  ? new MockModelClient()
300
365
  : new HttpModelClient(config, {
301
366
  sessionAffinityId: this.sessionAffinityId,
302
- ...(config.modelCallLogging === false
303
- ? {}
304
- : {
305
- diagnostics: new JsonlModelCallDiagnostics(modelCallLogPath(config.stateRoot, config.tenantId, this.sessionId)),
306
- }),
367
+ ...(diagnostics ? { diagnostics } : {}),
307
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);
308
377
  this.tools =
309
378
  options.tools ||
310
379
  new ToolExecutor(config, {
311
380
  modelClient: this.client,
381
+ imageInputFallbackClient,
312
382
  signal: options.signal,
313
383
  askUserHandler: options.askUserHandler,
314
384
  workspaceContext: this.workspaceContext,
315
385
  });
316
386
  }
317
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) {
318
397
  if (runOptions.resumeFromHistory && !history?.length) {
319
398
  throw new Error("resumeFromHistory requires a non-empty history");
320
399
  }
@@ -328,67 +407,66 @@ export class Agent {
328
407
  : structuredClone(baseUserMessage);
329
408
  const clientContext = this.options.clientContext || defaultClientContext();
330
409
  const repoContext = resolveRepoContext(this.config.workspace, history, this.config.mode);
331
- const trajectory = history?.length
332
- ? completeInterruptedToolCalls(this.options.reuseHistoryMessages
333
- ? [...history]
334
- : structuredClone(history))
335
- : initialMessages(prompt, clientContext, this.workspaceContext, repoContext, this.config.mode, this.config.maxTurns);
336
- if (history?.length && this.options.reuseHistoryMessages && trajectory[0]) {
337
- // Runtime prompt overrides mutate only the system message. Keep the
338
- // caller's loaded history immutable while sharing the large old prefix.
339
- trajectory[0] = structuredClone(trajectory[0]);
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");
422
+ }
423
+ }
424
+ else {
425
+ trajectory.push(userMessage);
340
426
  }
341
- applySystemPromptConfig(trajectory[0], this.config, Boolean(history?.length));
342
- if (history?.length) {
343
- const expected = repoContext
344
- ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns)
345
- : systemMessage(clientContext, this.workspaceContext, this.config.mode, this.config.maxTurns);
346
- applySystemPromptConfig(expected, this.config);
347
- if (typeof this.config.systemPrompt === "object") {
348
- trajectory[0] = structuredClone(expected);
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;
349
437
  }
350
- if (!isCanonicalSystemMessage(trajectory[0], expected, this.config.mode, this.config.maxTurns)) {
351
- const portableExpected = systemMessage(clientContext, undefined, this.config.mode, this.config.maxTurns);
352
- applySystemPromptConfig(portableExpected, this.config);
353
- if (!repoContext &&
354
- this.workspaceContext &&
355
- isCanonicalSystemMessage(trajectory[0], portableExpected, this.config.mode, this.config.maxTurns)) {
356
- const runtimeSystemMessage = initialMessages("", this.options.clientContext, this.workspaceContext, undefined, this.config.mode, this.config.maxTurns)[0];
357
- applySystemPromptConfig(runtimeSystemMessage, this.config);
358
- trajectory[0] = runtimeSystemMessage;
359
- }
360
- else {
361
- throw new Error("history does not match the current workspace runtime context");
362
- }
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" };
363
441
  }
364
- if (runOptions.resumeFromHistory) {
365
- const lastRole = trajectory.at(-1)?.role;
366
- if (lastRole !== "user" && lastRole !== "tool") {
367
- throw new Error("resumeFromHistory history must end with a user or tool message");
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);
368
451
  }
369
- }
370
- else {
371
- trajectory.push(userMessage);
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 });
372
459
  }
373
460
  }
374
- else {
375
- trajectory[1] = userMessage;
376
- }
377
- // Persisted history is the lossless source of truth for replay and
378
- // training. Compaction may rewrite only this private model-context copy;
379
- // every real user, assistant, and tool message is appended to both.
380
- // Compaction replaces array entries, and media fallback can rewrite only
381
- // multimodal user content. Share the immutable lossless prefix and isolate
382
- // just that mutable content instead of cloning every historical string.
383
- const messages = modelContextFromTrajectory(trajectory);
384
461
  const recordMessage = async (message) => {
385
- messages.push(message);
386
- const recorded = structuredClone(message);
462
+ const normalized = normalizeToolCallHistory(message, this.config.mode);
463
+ messages.push(normalized);
464
+ const recorded = structuredClone(normalized);
387
465
  trajectory.push(recorded);
388
466
  this.sink.message(this.sessionId, recorded, trajectory);
389
467
  await this.checkpoint(trajectory);
390
468
  };
391
- await this.maybeCompact(messages);
469
+ await this.maybeCompact(messages, trajectory);
392
470
  this.sink.start(this.sessionId, trajectory);
393
471
  try {
394
472
  await this.checkpoint(trajectory);
@@ -406,14 +484,21 @@ export class Agent {
406
484
  // stores its encrypted reasoning output items there, so compaction is
407
485
  // safe at the same tool-step boundaries as the other protocols.
408
486
  if (step > 1)
409
- await this.maybeCompact(messages);
487
+ await this.maybeCompact(messages, trajectory, "inner");
410
488
  const offeredTools = this.tools.availableTools();
411
489
  this.sink.modelStart(this.sessionId, step, offeredTools.map((tool) => tool.function.name));
412
490
  let assistant;
413
491
  try {
414
- assistant = await this.assistantTurn(messages, offeredTools);
492
+ assistant = await this.assistantTurn(messages, offeredTools, trajectory);
415
493
  }
416
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;
417
502
  if (error instanceof PartialAssistantTurnError) {
418
503
  this.options.onModelFailure?.(trajectory, error.cause);
419
504
  if (error.assistant) {
@@ -426,9 +511,24 @@ export class Agent {
426
511
  }
427
512
  const finishReason = assistant.tool_calls?.length ? "tool_calls" : "stop";
428
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
+ }
429
529
  await recordMessage(assistant);
430
530
  if (!assistant.tool_calls?.length) {
431
- return this.sink.result(this.sessionId, "success", trajectory);
531
+ return await this.finish(trajectory, "success");
432
532
  }
433
533
  const completedToolCallIds = [];
434
534
  for (const call of assistant.tool_calls) {
@@ -460,7 +560,7 @@ export class Agent {
460
560
  ? toolResult.questions
461
561
  : [];
462
562
  this.sink.inputRequired(this.sessionId, toolCallId, questions);
463
- return this.sink.result(this.sessionId, "needs_input", trajectory);
563
+ return await this.finish(trajectory, "needs_input");
464
564
  }
465
565
  }
466
566
  if (this.options.signal?.aborted) {
@@ -471,28 +571,111 @@ export class Agent {
471
571
  if (visualMessage) {
472
572
  await recordMessage(visualMessage);
473
573
  }
574
+ if (runOptions.singleStep) {
575
+ return await this.pause(trajectory, "single_step");
576
+ }
474
577
  }
475
578
  }
476
579
  catch (error) {
477
580
  const message = error instanceof Error ? error.message : String(error);
478
- return this.sink.result(this.sessionId, "error", trajectory, message);
581
+ return await this.finish(trajectory, "error", message);
479
582
  }
480
583
  }
481
584
  async checkpoint(messages) {
482
- 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);
483
618
  }
484
- async maybeCompact(messages) {
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);
627
+ }
628
+ async maybeCompact(messages, trajectory, source = "outer") {
485
629
  preflightUnsupportedMedia(this.config, messages);
486
630
  try {
487
- const result = await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, {
631
+ const settings = {
488
632
  contextWindow: this.config.contextWindow,
489
633
  triggerRatio: this.config.contextCompactionRatio,
490
634
  summaryMaxTokens: this.config.compactionSummaryMaxTokens,
491
- }, 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
+ });
492
649
  if (result.compacted) {
493
- // The summary exists only in the private model context, not as a real
494
- // trajectory message, so the event stream is the only place its usage
495
- // 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);
496
679
  this.sink.compaction(this.sessionId, result.usage);
497
680
  }
498
681
  return result.compacted;
@@ -504,7 +687,7 @@ export class Agent {
504
687
  return false;
505
688
  }
506
689
  }
507
- async assistantTurn(messages, offeredTools) {
690
+ async assistantTurn(messages, offeredTools, trajectory) {
508
691
  // A mid-stream network fault loses only the tokens produced so far, not the
509
692
  // conversation: the request is idempotent, so the whole turn is re-issued
510
693
  // from scratch. Accumulators live inside the loop and reset each attempt, so
@@ -525,6 +708,51 @@ export class Agent {
525
708
  const responseItems = new Map();
526
709
  let usage;
527
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
+ };
528
756
  try {
529
757
  const stream = this.client.stream({
530
758
  model: this.config.model,
@@ -539,7 +767,7 @@ export class Agent {
539
767
  enableThinking: this.config.enableThinking,
540
768
  thinkingBudget: this.config.thinkingBudget,
541
769
  preserveThinking: this.config.preserveThinking,
542
- signal: this.options.signal,
770
+ signal,
543
771
  });
544
772
  for await (const chunk of stream) {
545
773
  // With stream_options.include_usage the usage arrives on the final
@@ -558,12 +786,16 @@ export class Agent {
558
786
  const content = String(delta.content);
559
787
  contentParts.push(content);
560
788
  this.sink.delta(this.sessionId, { content });
789
+ if (detectors)
790
+ await inspectOutput("content", content);
561
791
  }
562
792
  const reasoning = delta.reasoning_content || delta.reasoning;
563
793
  if (reasoning) {
564
794
  const value = String(reasoning);
565
795
  reasoningParts.push(value);
566
796
  this.sink.delta(this.sessionId, { reasoning_content: value });
797
+ if (detectors)
798
+ await inspectOutput("reasoning", value);
567
799
  }
568
800
  for (const [position, fragment] of (delta.tool_calls || []).entries()) {
569
801
  const index = Number(fragment.index ?? position);
@@ -599,6 +831,9 @@ export class Agent {
599
831
  }
600
832
  }
601
833
  catch (error) {
834
+ // Iterator cleanup may itself report the intentional transport abort.
835
+ if (checkpointControl)
836
+ throw checkpointControl;
602
837
  // A user cancellation is not a transient fault: skip the retry, but
603
838
  // still fall through so any partial output is preserved in history.
604
839
  const aborted = Boolean(this.options.signal?.aborted);