@qwen-code/arena-agent 0.0.10-dev.1 → 0.0.10-dev.11

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 (162) hide show
  1. package/.env.example +68 -14
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +307 -57
  4. package/dist/agent.d.ts +28 -2
  5. package/dist/agent.js +389 -109
  6. package/dist/agent.js.map +1 -1
  7. package/dist/anthropic.js +32 -35
  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.d.ts +2 -0
  16. package/dist/canonical-data.js +4 -2
  17. package/dist/canonical-data.js.map +1 -1
  18. package/dist/cli.js +184 -36
  19. package/dist/cli.js.map +1 -1
  20. package/dist/code-tools.d.ts +8 -2
  21. package/dist/code-tools.js +709 -117
  22. package/dist/code-tools.js.map +1 -1
  23. package/dist/compaction.d.ts +50 -3
  24. package/dist/compaction.js +390 -185
  25. package/dist/compaction.js.map +1 -1
  26. package/dist/config.d.ts +23 -2
  27. package/dist/config.js +52 -5
  28. package/dist/config.js.map +1 -1
  29. package/dist/connector-mcp.d.ts +47 -0
  30. package/dist/connector-mcp.js +314 -0
  31. package/dist/connector-mcp.js.map +1 -0
  32. package/dist/connectors.d.ts +8 -0
  33. package/dist/connectors.js +57 -0
  34. package/dist/connectors.js.map +1 -0
  35. package/dist/fetch-page.js +9 -33
  36. package/dist/fetch-page.js.map +1 -1
  37. package/dist/file-edit.d.ts +8 -0
  38. package/dist/file-edit.js +63 -0
  39. package/dist/file-edit.js.map +1 -0
  40. package/dist/history.d.ts +16 -3
  41. package/dist/history.js +117 -19
  42. package/dist/history.js.map +1 -1
  43. package/dist/midturn-checkpoint-hook.d.ts +44 -0
  44. package/dist/midturn-checkpoint-hook.js +38 -0
  45. package/dist/midturn-checkpoint-hook.js.map +1 -0
  46. package/dist/mock.js +5 -0
  47. package/dist/mock.js.map +1 -1
  48. package/dist/mode.js +4 -2
  49. package/dist/mode.js.map +1 -1
  50. package/dist/model-router-media.d.ts +1 -0
  51. package/dist/model-router-media.js +121 -1
  52. package/dist/model-router-media.js.map +1 -1
  53. package/dist/openai-responses.js +2 -0
  54. package/dist/openai-responses.js.map +1 -1
  55. package/dist/process-manager.js +2 -1
  56. package/dist/process-manager.js.map +1 -1
  57. package/dist/prompts.d.ts +2 -1
  58. package/dist/prompts.js +15 -37
  59. package/dist/prompts.js.map +1 -1
  60. package/dist/protocol.d.ts +5 -1
  61. package/dist/protocol.js +24 -2
  62. package/dist/protocol.js.map +1 -1
  63. package/dist/provider.d.ts +12 -5
  64. package/dist/provider.js +63 -52
  65. package/dist/provider.js.map +1 -1
  66. package/dist/repetitive-output.d.ts +12 -0
  67. package/dist/repetitive-output.js +80 -0
  68. package/dist/repetitive-output.js.map +1 -0
  69. package/dist/replay-responses.d.ts +17 -0
  70. package/dist/replay-responses.js +100 -0
  71. package/dist/replay-responses.js.map +1 -0
  72. package/dist/routify-web-fetch.js +20 -7
  73. package/dist/routify-web-fetch.js.map +1 -1
  74. package/dist/sandbox-file-write.d.ts +10 -0
  75. package/dist/sandbox-file-write.js +58 -0
  76. package/dist/sandbox-file-write.js.map +1 -0
  77. package/dist/server.js +17 -5
  78. package/dist/server.js.map +1 -1
  79. package/dist/session-memory-prompts.d.ts +5 -0
  80. package/dist/session-memory-prompts.js +7 -0
  81. package/dist/session-memory-prompts.js.map +1 -0
  82. package/dist/snapshot-client.d.ts +10 -0
  83. package/dist/snapshot-client.js +39 -0
  84. package/dist/snapshot-client.js.map +1 -0
  85. package/dist/spec.d.ts +5 -4
  86. package/dist/spec.js +31 -17
  87. package/dist/spec.js.map +1 -1
  88. package/dist/speech-audio.d.ts +9 -0
  89. package/dist/speech-audio.js +130 -0
  90. package/dist/speech-audio.js.map +1 -0
  91. package/dist/speech-model.d.ts +3 -0
  92. package/dist/speech-model.js +8 -0
  93. package/dist/speech-model.js.map +1 -0
  94. package/dist/summary-compaction.d.ts +21 -0
  95. package/dist/summary-compaction.js +211 -0
  96. package/dist/summary-compaction.js.map +1 -0
  97. package/dist/tools.d.ts +8 -1
  98. package/dist/tools.js +407 -135
  99. package/dist/tools.js.map +1 -1
  100. package/dist/tui.js +13 -10
  101. package/dist/tui.js.map +1 -1
  102. package/dist/types.d.ts +30 -3
  103. package/dist/voice-registry.d.ts +2 -0
  104. package/dist/voice-registry.js +10 -2
  105. package/dist/voice-registry.js.map +1 -1
  106. package/dist/web-fetch-output.d.ts +9 -0
  107. package/dist/web-fetch-output.js +38 -0
  108. package/dist/web-fetch-output.js.map +1 -0
  109. package/dist/workspace.d.ts +2 -0
  110. package/dist/workspace.js +11 -6
  111. package/dist/workspace.js.map +1 -1
  112. package/docs/BINARY_DISTRIBUTION.md +27 -16
  113. package/docs/MODES_AND_RELEASES.md +60 -6
  114. package/docs/SDK.md +5 -0
  115. package/modes/code-arena/capture-provenance.json +57 -0
  116. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  117. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  118. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  119. package/modes/code-arena/full-stack/tools.json +186 -186
  120. package/modes/code-arena/html/system-prompt.txt +1 -1
  121. package/modes/code-arena/html/tools.json +123 -123
  122. package/modes/code-arena/react/system-prompt.txt +4 -3
  123. package/modes/code-arena/react/template/package.json +4 -4
  124. package/modes/code-arena/react/template/src/App.tsx +1 -1
  125. package/modes/code-arena/react/template/src/main.tsx +1 -1
  126. package/modes/code-arena/react/template/tsconfig.json +6 -0
  127. package/modes/code-arena/react/template/vite.config.ts +1 -0
  128. package/modes/code-arena/react/tools.json +178 -184
  129. package/package.json +15 -4
  130. package/scripts/README.md +80 -0
  131. package/scripts/check-changelog.mjs +66 -0
  132. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  133. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  134. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  135. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  136. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  137. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  138. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  139. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  140. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  141. package/vendor/audio/darwin-x64/manifest.json +39 -0
  142. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  143. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  144. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  145. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  146. package/vendor/audio/linux-arm64/manifest.json +39 -0
  147. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  148. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  149. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  150. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  151. package/vendor/audio/linux-x64/manifest.json +39 -0
  152. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  153. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  154. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  155. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  156. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  157. package/vendor/audio/sources/sources.lock.json +30 -0
  158. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  159. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  160. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  161. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  162. 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,59 @@ 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 this.tools.closeConnectors?.();
393
+ await settleSessionMemory(this.memoryCache);
394
+ await this.checkpointQueue;
395
+ }
396
+ }
397
+ async runTurn(prompt, history, runOptions) {
318
398
  if (runOptions.resumeFromHistory && !history?.length) {
319
399
  throw new Error("resumeFromHistory requires a non-empty history");
320
400
  }
@@ -328,69 +408,75 @@ export class Agent {
328
408
  : structuredClone(baseUserMessage);
329
409
  const clientContext = this.options.clientContext || defaultClientContext();
330
410
  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]);
411
+ // System instructions belong to this invocation, never to the saved dialogue.
412
+ // Older local histories included a system message; accept them without
413
+ // carrying the old runtime configuration into the next request.
414
+ const dialogue = withoutLeadingSystemMessage(history || []).map((message) => normalizeToolCallHistory(message, this.config.mode));
415
+ const trajectory = completeInterruptedToolCalls(this.options.reuseHistoryMessages ? [...dialogue] : structuredClone(dialogue));
416
+ const connectors = this.config.disallowedTools?.includes("list_connector_tools")
417
+ ? undefined : this.config.connectors;
418
+ const runtimeSystemMessage = repoContext
419
+ ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns, connectors)
420
+ : systemMessage(clientContext, this.workspaceContext, this.config.mode, this.config.maxTurns, connectors);
421
+ applySystemPromptConfig(runtimeSystemMessage, this.config);
422
+ if (runOptions.resumeFromHistory) {
423
+ if (!isContinuableHistory(dialogue)) {
424
+ throw new Error("resumeFromHistory requires an unfinished user or tool boundary");
425
+ }
426
+ }
427
+ else {
428
+ trajectory.push(userMessage);
340
429
  }
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);
430
+ // A new human reply requires discovery again. Same-turn continuation only
431
+ // reloads schemas from paired discovery results; it never repeats writes.
432
+ await this.tools.beginConnectorTurn?.(runOptions.resumeFromHistory ? dialogue : undefined);
433
+ // Compaction projects model input with a runtime system prefix; the lossless
434
+ // trajectory and its checkpoints remain in the official dialogue format.
435
+ const messages = modelContextFromTrajectory([runtimeSystemMessage, ...trajectory]);
436
+ if (!isCodeArenaMode(this.config.mode)) {
437
+ const saved = this.persistedMemory ?? this.options.storedSessionMemory ?? storedSessionMemory(this.memoryCache);
438
+ preflightUnsupportedMedia(this.config, messages);
439
+ // Opting out of restoration must not delete a still-valid durable snapshot.
440
+ this.persistedMemory = saved?.version === 1 ? structuredClone(saved) : undefined;
441
+ if (this.persistedMemory?.latest && !sessionMemoryMatchesHistory(this.persistedMemory.latest, messages)) {
442
+ this.persistedMemory.latest = undefined;
349
443
  }
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
- }
444
+ if (this.persistedMemory?.update?.status === "pending") {
445
+ this.persistedMemory.update = { status: "cancelled", code: "MEMORY_INTERRUPTED",
446
+ error: "Previous process ended during a memory update" };
363
447
  }
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");
448
+ this.memoryCache.latest = undefined;
449
+ this.memoryCache.state = undefined;
450
+ this.memoryCache.latestUsage = undefined;
451
+ this.memoryCache.attemptedPrefix = undefined;
452
+ this.memoryCache.update = undefined;
453
+ if (this.config.compactionMode !== "summary" &&
454
+ (runOptions.restoreSessionMemory ?? runOptions.singleStep) && saved?.version === 1) {
455
+ if (sessionMemoryMatchesHistory(saved.latest, messages)) {
456
+ this.memoryCache.latest = structuredClone(saved.latest);
368
457
  }
369
- }
370
- else {
371
- trajectory.push(userMessage);
458
+ this.memoryCache.update = this.persistedMemory?.update;
459
+ if (saved.latest && !this.memoryCache.latest) {
460
+ this.memoryCache.update = { status: "error", code: "MEMORY_STALE_HISTORY",
461
+ error: "Saved memory does not match the current history prefix" };
462
+ }
463
+ if (this.memoryCache.update)
464
+ this.sink.emit({ type: "memory.update", session_id: this.sessionId, ...this.memoryCache.update });
372
465
  }
373
466
  }
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
467
  const recordMessage = async (message) => {
385
- messages.push(message);
386
- const recorded = structuredClone(message);
468
+ const normalized = normalizeToolCallHistory(message, this.config.mode);
469
+ messages.push(normalized);
470
+ const recorded = structuredClone(normalized);
387
471
  trajectory.push(recorded);
388
472
  this.sink.message(this.sessionId, recorded, trajectory);
389
473
  await this.checkpoint(trajectory);
390
474
  };
391
- await this.maybeCompact(messages);
392
- this.sink.start(this.sessionId, trajectory);
393
475
  try {
476
+ const compaction = await this.maybeCompact(messages, trajectory);
477
+ this.sink.start(this.sessionId, trajectory);
478
+ if (compaction === "checkpoint")
479
+ return await this.pause(trajectory, "checkpoint");
394
480
  await this.checkpoint(trajectory);
395
481
  const maxTurns = this.config.maxTurns
396
482
  ? Math.max(1, Math.floor(this.config.maxTurns))
@@ -405,15 +491,23 @@ export class Agent {
405
491
  // Every transport is replayable from local history. Native Responses
406
492
  // stores its encrypted reasoning output items there, so compaction is
407
493
  // safe at the same tool-step boundaries as the other protocols.
408
- if (step > 1)
409
- await this.maybeCompact(messages);
494
+ if (step > 1 && await this.maybeCompact(messages, trajectory, "inner") === "checkpoint") {
495
+ return await this.pause(trajectory, "checkpoint");
496
+ }
410
497
  const offeredTools = this.tools.availableTools();
411
498
  this.sink.modelStart(this.sessionId, step, offeredTools.map((tool) => tool.function.name));
412
499
  let assistant;
413
500
  try {
414
- assistant = await this.assistantTurn(messages, offeredTools);
501
+ assistant = await this.assistantTurn(messages, offeredTools, trajectory);
415
502
  }
416
503
  catch (error) {
504
+ if (error instanceof CheckpointExit) {
505
+ this.sink.modelComplete(this.sessionId, step, "checkpoint");
506
+ await this.makeCheckpoint(trajectory, offeredTools, error.event, "snapshot_exit");
507
+ return await this.pause(trajectory, "checkpoint");
508
+ }
509
+ if (error instanceof CheckpointFailure)
510
+ throw error.cause;
417
511
  if (error instanceof PartialAssistantTurnError) {
418
512
  this.options.onModelFailure?.(trajectory, error.cause);
419
513
  if (error.assistant) {
@@ -426,9 +520,24 @@ export class Agent {
426
520
  }
427
521
  const finishReason = assistant.tool_calls?.length ? "tool_calls" : "stop";
428
522
  this.sink.modelComplete(this.sessionId, step, finishReason, assistant.usage);
523
+ if (assistant.tool_calls?.length &&
524
+ this.options.midturnCheckpointHook) {
525
+ const event = {
526
+ type: "tool_validation",
527
+ sessionId: this.sessionId,
528
+ toolCalls: assistant.tool_calls.map((call) => this.tools.inspect(call)),
529
+ };
530
+ const action = checkpointAction(await this.options.midturnCheckpointHook(event));
531
+ if (action !== "continue") {
532
+ await this.makeCheckpoint(trajectory, offeredTools, event, action);
533
+ if (action === "snapshot_exit") {
534
+ return await this.pause(trajectory, "checkpoint");
535
+ }
536
+ }
537
+ }
429
538
  await recordMessage(assistant);
430
539
  if (!assistant.tool_calls?.length) {
431
- return this.sink.result(this.sessionId, "success", trajectory);
540
+ return await this.finish(trajectory, "success");
432
541
  }
433
542
  const completedToolCallIds = [];
434
543
  for (const call of assistant.tool_calls) {
@@ -460,7 +569,7 @@ export class Agent {
460
569
  ? toolResult.questions
461
570
  : [];
462
571
  this.sink.inputRequired(this.sessionId, toolCallId, questions);
463
- return this.sink.result(this.sessionId, "needs_input", trajectory);
572
+ return await this.finish(trajectory, "needs_input");
464
573
  }
465
574
  }
466
575
  if (this.options.signal?.aborted) {
@@ -471,40 +580,151 @@ export class Agent {
471
580
  if (visualMessage) {
472
581
  await recordMessage(visualMessage);
473
582
  }
583
+ if (runOptions.singleStep) {
584
+ return await this.pause(trajectory, "single_step");
585
+ }
474
586
  }
475
587
  }
476
588
  catch (error) {
477
589
  const message = error instanceof Error ? error.message : String(error);
478
- return this.sink.result(this.sessionId, "error", trajectory, message);
590
+ return await this.finish(trajectory, "error", message);
479
591
  }
480
592
  }
481
593
  async checkpoint(messages) {
482
- await this.options.onCheckpoint?.(messages, this.tools.availableTools());
594
+ const snapshot = [...messages];
595
+ const tools = this.tools.availableTools();
596
+ const currentMemory = storedSessionMemory(this.memoryCache);
597
+ const memory = isCodeArenaMode(this.config.mode) ? undefined : (currentMemory || this.persistedMemory
598
+ ? { version: 1, latest: currentMemory?.latest ?? this.persistedMemory?.latest,
599
+ update: currentMemory?.update ?? this.persistedMemory?.update }
600
+ : null);
601
+ if (memory !== undefined)
602
+ this.persistedMemory = memory ?? undefined;
603
+ const write = this.checkpointQueue.then(() => this.options.onCheckpoint?.(snapshot, tools, memory));
604
+ this.checkpointQueue = write.catch(error => { this.checkpointError ??= error; });
605
+ await write;
606
+ if (this.checkpointError)
607
+ throw this.checkpointError;
608
+ }
609
+ async finish(messages, status, error) {
610
+ await settleSessionMemory(this.memoryCache);
611
+ try {
612
+ await this.checkpoint(messages);
613
+ }
614
+ catch (failure) {
615
+ status = "error";
616
+ error ??= failure instanceof Error ? failure.message : String(failure);
617
+ }
618
+ return this.sink.result(this.sessionId, status, messages, error, undefined, this.memoryCache.update);
619
+ }
620
+ async makeCheckpoint(messages, tools, event, action) {
621
+ await settleSessionMemory(this.memoryCache, event.type !== "before_compaction" || action !== "snapshot_continue");
622
+ await this.checkpoint(messages);
623
+ if (!this.options.onMidturnCheckpoint) {
624
+ throw new Error("mid-turn checkpoint hook triggered without a snapshot handler");
625
+ }
626
+ await this.options.onMidturnCheckpoint(messages, tools, event, action);
483
627
  }
484
- async maybeCompact(messages) {
628
+ async pause(messages, reason) {
629
+ this.options.signal?.throwIfAborted();
630
+ await settleSessionMemory(this.memoryCache, reason !== "single_step");
631
+ if (reason === "single_step")
632
+ await this.checkpoint(messages);
633
+ await this.options.onPause?.(messages, this.tools.availableTools());
634
+ this.options.signal?.throwIfAborted();
635
+ return this.sink.result(this.sessionId, "paused", messages, undefined, reason, this.memoryCache.update);
636
+ }
637
+ async maybeCompact(messages, trajectory, source = "outer") {
485
638
  preflightUnsupportedMedia(this.config, messages);
486
639
  try {
487
- const result = await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, {
640
+ const settings = {
488
641
  contextWindow: this.config.contextWindow,
489
642
  triggerRatio: this.config.contextCompactionRatio,
490
643
  summaryMaxTokens: this.config.compactionSummaryMaxTokens,
491
- }, this.options.signal);
644
+ reasoningEffort: this.config.reasoningEffort,
645
+ compactionThresholdTokens: isCodeArenaMode(this.config.mode) ? undefined
646
+ : this.options.compactionThresholdTokens ?? this.config.compactionThresholdTokens,
647
+ legacy: isCodeArenaMode(this.config.mode) || this.config.compactionMode === "summary",
648
+ onBeforeCompaction: !isCodeArenaMode(this.config.mode) && this.options.midturnCheckpointHook
649
+ ? async (estimatedTokensBefore, thresholdTokens) => {
650
+ const event = {
651
+ type: "before_compaction", sessionId: this.sessionId, toolCalls: [],
652
+ compaction: {
653
+ count: trajectory.filter(message => message.role === "compaction" && !message.undone).length + 1,
654
+ mode: this.config.compactionMode === "summary" ? "summary" : "rolling",
655
+ source, estimatedTokensBefore, thresholdTokens,
656
+ },
657
+ };
658
+ let action;
659
+ try {
660
+ action = checkpointAction(await this.options.midturnCheckpointHook(event));
661
+ if (action !== "continue") {
662
+ await this.makeCheckpoint(trajectory, this.tools.availableTools(), event, action);
663
+ }
664
+ }
665
+ catch (error) {
666
+ throw new CheckpointFailure(error);
667
+ }
668
+ if (action === "snapshot_exit")
669
+ throw new CheckpointExit(event);
670
+ }
671
+ : undefined,
672
+ };
673
+ const contextLength = messages.length;
674
+ const result = settings.legacy
675
+ ? await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, settings, this.options.signal)
676
+ : await maintainSessionMemory(messages, messages, this.memoryCache, this.tools.availableTools(), this.client, this.config.model, settings, this.config.memoryUpdateTokens, this.options.signal, update => {
677
+ this.sink.emit({ type: "memory.update", session_id: this.sessionId, ...update });
678
+ // Serialized with message checkpoints; persistence failures surface at
679
+ // the next checkpoint/finish instead of becoming unhandled rejections.
680
+ return this.checkpoint(trajectory).catch(() => undefined);
681
+ });
492
682
  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.
683
+ if (!isCodeArenaMode(this.config.mode)) {
684
+ const covered = settings.legacy ? result.coveredMessages : this.memoryCache.state.coveredMessages;
685
+ const retained = contextLength - covered;
686
+ // Insert at the covered boundary, before the retained original tail.
687
+ // Marker position is the resume boundary; no separate index is stored.
688
+ let insertion = trajectory.length;
689
+ let remaining = retained;
690
+ while (remaining > 0 && insertion > 0) {
691
+ if (trajectory[--insertion].role !== "compaction")
692
+ remaining--;
693
+ }
694
+ trajectory.splice(insertion, 0, {
695
+ role: "compaction", content: [{ type: "text", text: result.memory }],
696
+ undone: false,
697
+ compaction: {
698
+ ratio: result.estimatedTokensBefore / this.config.contextWindow,
699
+ tokens_at_compaction: result.estimatedTokensBefore,
700
+ source,
701
+ },
702
+ });
703
+ // Coverage indices referred to the pre-compaction history. The next
704
+ // update reads its prior memory from the newly embedded block.
705
+ this.memoryCache.state = undefined;
706
+ this.memoryCache.latest = undefined;
707
+ this.memoryCache.latestUsage = undefined;
708
+ this.memoryCache.attemptedPrefix = undefined;
709
+ this.persistedMemory = undefined;
710
+ }
711
+ await this.checkpoint(trajectory);
496
712
  this.sink.compaction(this.sessionId, result.usage);
497
713
  }
498
714
  return result.compacted;
499
715
  }
500
- catch {
716
+ catch (error) {
717
+ if (error instanceof CheckpointExit)
718
+ return "checkpoint";
719
+ if (error instanceof CheckpointFailure)
720
+ throw error.cause;
501
721
  // Compaction is protective rather than task-critical. If its dedicated
502
722
  // summary request fails, keep the original history and let the normal
503
723
  // model request (and its retry policy) decide whether it still fits.
504
724
  return false;
505
725
  }
506
726
  }
507
- async assistantTurn(messages, offeredTools) {
727
+ async assistantTurn(messages, offeredTools, trajectory) {
508
728
  // A mid-stream network fault loses only the tokens produced so far, not the
509
729
  // conversation: the request is idempotent, so the whole turn is re-issued
510
730
  // from scratch. Accumulators live inside the loop and reset each attempt, so
@@ -525,6 +745,51 @@ export class Agent {
525
745
  const responseItems = new Map();
526
746
  let usage;
527
747
  let reasoningDetails;
748
+ const detectors = this.options.midturnCheckpointHook ? {
749
+ content: new RepetitiveOutputDetector(),
750
+ reasoning: new RepetitiveOutputDetector(),
751
+ } : undefined;
752
+ const controller = detectors ? new AbortController() : undefined;
753
+ const signal = controller
754
+ ? this.options.signal
755
+ ? AbortSignal.any([this.options.signal, controller.signal])
756
+ : controller.signal
757
+ : this.options.signal;
758
+ let repetitionReported = false;
759
+ let checkpointControl;
760
+ const inspectOutput = async (channel, text) => {
761
+ if (!detectors || repetitionReported)
762
+ return;
763
+ const sample = detectors[channel].append(text);
764
+ if (sample === undefined)
765
+ return;
766
+ repetitionReported = true;
767
+ const event = {
768
+ type: "repetitive_output",
769
+ sessionId: this.sessionId,
770
+ toolCalls: [],
771
+ repetition: { channel, sample },
772
+ };
773
+ try {
774
+ const action = checkpointAction(await this.options.midturnCheckpointHook(event));
775
+ if (action === "snapshot_exit") {
776
+ checkpointControl = new CheckpointExit(event);
777
+ }
778
+ else if (action === "snapshot_continue") {
779
+ await this.makeCheckpoint(trajectory, offeredTools, event, action);
780
+ }
781
+ }
782
+ catch (error) {
783
+ checkpointControl = new CheckpointFailure(error);
784
+ }
785
+ if (checkpointControl) {
786
+ // Abort the transport, then let for-await close its iterator before
787
+ // the caller snapshots or reports an error. Never save this partial
788
+ // assistant or retry a policy decision as a network failure.
789
+ controller.abort(checkpointControl);
790
+ throw checkpointControl;
791
+ }
792
+ };
528
793
  try {
529
794
  const stream = this.client.stream({
530
795
  model: this.config.model,
@@ -539,7 +804,7 @@ export class Agent {
539
804
  enableThinking: this.config.enableThinking,
540
805
  thinkingBudget: this.config.thinkingBudget,
541
806
  preserveThinking: this.config.preserveThinking,
542
- signal: this.options.signal,
807
+ signal,
543
808
  });
544
809
  for await (const chunk of stream) {
545
810
  // With stream_options.include_usage the usage arrives on the final
@@ -558,12 +823,16 @@ export class Agent {
558
823
  const content = String(delta.content);
559
824
  contentParts.push(content);
560
825
  this.sink.delta(this.sessionId, { content });
826
+ if (detectors)
827
+ await inspectOutput("content", content);
561
828
  }
562
829
  const reasoning = delta.reasoning_content || delta.reasoning;
563
830
  if (reasoning) {
564
831
  const value = String(reasoning);
565
832
  reasoningParts.push(value);
566
833
  this.sink.delta(this.sessionId, { reasoning_content: value });
834
+ if (detectors)
835
+ await inspectOutput("reasoning", value);
567
836
  }
568
837
  for (const [position, fragment] of (delta.tool_calls || []).entries()) {
569
838
  const index = Number(fragment.index ?? position);
@@ -581,6 +850,11 @@ export class Agent {
581
850
  if (fragment.function?.arguments) {
582
851
  call.function.arguments += String(fragment.function.arguments);
583
852
  }
853
+ // Metadata is an opaque value for this index, not a text delta.
854
+ // Later fragments that omit it must not erase an earlier signature.
855
+ if (fragment.extra_content !== undefined) {
856
+ call.extra_content = structuredClone(fragment.extra_content);
857
+ }
584
858
  calls.set(index, call);
585
859
  this.sink.delta(this.sessionId, {
586
860
  tool_calls: [
@@ -592,6 +866,9 @@ export class Agent {
592
866
  name: String(fragment.function?.name || ""),
593
867
  arguments: String(fragment.function?.arguments || ""),
594
868
  },
869
+ ...(fragment.extra_content !== undefined
870
+ ? { extra_content: structuredClone(fragment.extra_content) }
871
+ : {}),
595
872
  },
596
873
  ],
597
874
  });
@@ -599,6 +876,9 @@ export class Agent {
599
876
  }
600
877
  }
601
878
  catch (error) {
879
+ // Iterator cleanup may itself report the intentional transport abort.
880
+ if (checkpointControl)
881
+ throw checkpointControl;
602
882
  // A user cancellation is not a transient fault: skip the retry, but
603
883
  // still fall through so any partial output is preserved in history.
604
884
  const aborted = Boolean(this.options.signal?.aborted);