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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.env.example +61 -10
  2. package/CHANGELOG.md +148 -17
  3. package/README.md +309 -46
  4. package/dist/agent.d.ts +20 -6
  5. package/dist/agent.js +353 -72
  6. package/dist/agent.js.map +1 -1
  7. package/dist/anthropic.d.ts +1 -1
  8. package/dist/anthropic.js +46 -57
  9. package/dist/anthropic.js.map +1 -1
  10. package/dist/arena-history.d.ts +12 -0
  11. package/dist/arena-history.js +119 -0
  12. package/dist/arena-history.js.map +1 -0
  13. package/dist/ask-user.js +44 -2
  14. package/dist/ask-user.js.map +1 -1
  15. package/dist/attachment-limits.d.ts +4 -0
  16. package/dist/attachment-limits.js +10 -0
  17. package/dist/attachment-limits.js.map +1 -0
  18. package/dist/attachments.d.ts +4 -2
  19. package/dist/attachments.js +9 -11
  20. package/dist/attachments.js.map +1 -1
  21. package/dist/brave-search.js +11 -7
  22. package/dist/brave-search.js.map +1 -1
  23. package/dist/canonical-data.d.ts +9 -0
  24. package/dist/canonical-data.js +12 -3
  25. package/dist/canonical-data.js.map +1 -1
  26. package/dist/cli.js +140 -40
  27. package/dist/cli.js.map +1 -1
  28. package/dist/code-tool-schema.d.ts +3 -0
  29. package/dist/code-tool-schema.js +92 -0
  30. package/dist/code-tool-schema.js.map +1 -0
  31. package/dist/code-tools.d.ts +2 -1
  32. package/dist/code-tools.js +103 -31
  33. package/dist/code-tools.js.map +1 -1
  34. package/dist/compaction-threshold.d.ts +3 -0
  35. package/dist/compaction-threshold.js +6 -0
  36. package/dist/compaction-threshold.js.map +1 -0
  37. package/dist/compaction.d.ts +24 -7
  38. package/dist/compaction.js +242 -66
  39. package/dist/compaction.js.map +1 -1
  40. package/dist/config.d.ts +17 -0
  41. package/dist/config.js +30 -5
  42. package/dist/config.js.map +1 -1
  43. package/dist/connector-errors.d.ts +16 -0
  44. package/dist/connector-errors.js +69 -0
  45. package/dist/connector-errors.js.map +1 -0
  46. package/dist/connector-files.d.ts +31 -0
  47. package/dist/connector-files.js +249 -0
  48. package/dist/connector-files.js.map +1 -0
  49. package/dist/connector-input.d.ts +10 -0
  50. package/dist/connector-input.js +79 -0
  51. package/dist/connector-input.js.map +1 -0
  52. package/dist/connector-mcp.d.ts +60 -0
  53. package/dist/connector-mcp.js +474 -0
  54. package/dist/connector-mcp.js.map +1 -0
  55. package/dist/connectors.d.ts +13 -0
  56. package/dist/connectors.js +69 -0
  57. package/dist/connectors.js.map +1 -0
  58. package/dist/fetch-page.js +2 -2
  59. package/dist/fetch-page.js.map +1 -1
  60. package/dist/history.d.ts +16 -5
  61. package/dist/history.js +73 -14
  62. package/dist/history.js.map +1 -1
  63. package/dist/image-choice.d.ts +23 -0
  64. package/dist/image-choice.js +190 -0
  65. package/dist/image-choice.js.map +1 -0
  66. package/dist/image-input-fallback.js +5 -0
  67. package/dist/image-input-fallback.js.map +1 -1
  68. package/dist/image-model.d.ts +6 -0
  69. package/dist/image-model.js +32 -0
  70. package/dist/image-model.js.map +1 -0
  71. package/dist/midturn-checkpoint-hook.d.ts +14 -1
  72. package/dist/midturn-checkpoint-hook.js.map +1 -1
  73. package/dist/mode.js +1 -1
  74. package/dist/mode.js.map +1 -1
  75. package/dist/model-capabilities.js +18 -8
  76. package/dist/model-capabilities.js.map +1 -1
  77. package/dist/model-router-media.js +55 -3
  78. package/dist/model-router-media.js.map +1 -1
  79. package/dist/model-stream.d.ts +16 -0
  80. package/dist/model-stream.js +129 -0
  81. package/dist/model-stream.js.map +1 -0
  82. package/dist/openai-responses.d.ts +2 -1
  83. package/dist/openai-responses.js +85 -88
  84. package/dist/openai-responses.js.map +1 -1
  85. package/dist/process-manager.js +9 -9
  86. package/dist/process-manager.js.map +1 -1
  87. package/dist/prompts.d.ts +0 -2
  88. package/dist/prompts.js +10 -60
  89. package/dist/prompts.js.map +1 -1
  90. package/dist/protocol.d.ts +5 -1
  91. package/dist/protocol.js +23 -2
  92. package/dist/protocol.js.map +1 -1
  93. package/dist/provider.d.ts +18 -0
  94. package/dist/provider.js +120 -51
  95. package/dist/provider.js.map +1 -1
  96. package/dist/repo.d.ts +1 -1
  97. package/dist/repo.js +26 -4
  98. package/dist/repo.js.map +1 -1
  99. package/dist/routify-web-fetch.js +2 -2
  100. package/dist/routify-web-fetch.js.map +1 -1
  101. package/dist/server.js +55 -27
  102. package/dist/server.js.map +1 -1
  103. package/dist/session-memory-prompts.d.ts +3 -1
  104. package/dist/session-memory-prompts.js +4 -2
  105. package/dist/session-memory-prompts.js.map +1 -1
  106. package/dist/side-query.js +5 -0
  107. package/dist/side-query.js.map +1 -1
  108. package/dist/spec.d.ts +5 -4
  109. package/dist/spec.js +31 -23
  110. package/dist/spec.js.map +1 -1
  111. package/dist/startup-config.d.ts +39 -0
  112. package/dist/startup-config.js +347 -0
  113. package/dist/startup-config.js.map +1 -0
  114. package/dist/startup-main-config.d.ts +63 -0
  115. package/dist/startup-main-config.js +94 -0
  116. package/dist/startup-main-config.js.map +1 -0
  117. package/dist/startup-tool-config.d.ts +8 -0
  118. package/dist/startup-tool-config.js +116 -0
  119. package/dist/startup-tool-config.js.map +1 -0
  120. package/dist/summary-compaction.d.ts +29 -0
  121. package/dist/{legacy-compaction.js → summary-compaction.js} +79 -14
  122. package/dist/summary-compaction.js.map +1 -0
  123. package/dist/tools.d.ts +5 -1
  124. package/dist/tools.js +195 -68
  125. package/dist/tools.js.map +1 -1
  126. package/dist/tui.js +26 -6
  127. package/dist/tui.js.map +1 -1
  128. package/dist/types.d.ts +21 -1
  129. package/dist/web/app.js +30 -5
  130. package/dist/web/app.js.map +1 -1
  131. package/dist/workspace.js +61 -13
  132. package/dist/workspace.js.map +1 -1
  133. package/docs/BINARY_DISTRIBUTION.md +44 -8
  134. package/docs/MODES_AND_RELEASES.md +79 -6
  135. package/docs/SDK.md +1 -0
  136. package/modes/code-arena/capture-provenance.json +57 -0
  137. package/modes/code-arena/full-stack/dependencies.lock.json +8670 -0
  138. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  139. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  140. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  141. package/modes/code-arena/full-stack/tools.json +186 -186
  142. package/modes/code-arena/html/system-prompt.txt +1 -1
  143. package/modes/code-arena/html/tools.json +123 -123
  144. package/modes/code-arena/react/dependencies.lock.json +2707 -0
  145. package/modes/code-arena/react/system-prompt.txt +4 -3
  146. package/modes/code-arena/react/tools.json +178 -184
  147. package/package.json +7 -2
  148. package/scripts/README.md +23 -4
  149. package/web/styles.css +22 -0
  150. package/dist/legacy-compaction.d.ts +0 -16
  151. package/dist/legacy-compaction.js.map +0 -1
package/dist/agent.js CHANGED
@@ -1,20 +1,78 @@
1
1
  import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { agentArenaCompactionThreshold } from "./compaction-threshold.js";
2
4
  import { setTimeout as delay } from "node:timers/promises";
3
5
  import { supportsModelInput, } from "./config.js";
4
- import { compactMessagesIfNeeded, maintainSessionMemory, contextFromHistory } from "./compaction.js";
6
+ import { compactMessagesIfNeeded, maintainSessionMemory, contextFromHistory, sessionMemoryMatchesHistory, settleSessionMemory, storedSessionMemory } from "./compaction.js";
5
7
  import { ANTHROPIC_INCOMPLETE_STREAM_ERROR_CODE } from "./anthropic.js";
8
+ import { rememberRestoredCalls, restoredArenaHistory } from "./arena-history.js";
6
9
  import { modelCallLogPath, withoutLeadingSystemMessage } from "./history.js";
7
10
  import { MockModelClient } from "./mock.js";
11
+ import { CAPACITY_MODEL_ERROR, INCOMPLETE_MODEL_RESPONSE, TRANSIENT_MODEL_ERROR, incompleteModelResponse } from "./model-stream.js";
8
12
  import { contentPartMediaTypes, JsonlModelCallDiagnostics, } from "./model-diagnostics.js";
9
- import { RESPONSES_CAPACITY_ERROR_CODE, RESPONSES_TRANSIENT_ERROR_CODE, } from "./openai-responses.js";
13
+ import { RESPONSES_INCOMPLETE_STREAM_ERROR_CODE } from "./openai-responses.js";
10
14
  import { HttpModelClient, ModelCapabilityError } from "./provider.js";
11
15
  import { RepetitiveOutputDetector } from "./repetitive-output.js";
12
16
  import { detectRepoContext } from "./repo.js";
13
- import { isCodeArenaMode } from "./mode.js";
17
+ import { defaultStepLimit, isCodeArenaMode } from "./mode.js";
14
18
  import { defaultClientContext, interruptedUserMessage, repoSystemMessage, systemMessage, } from "./spec.js";
15
19
  import { imageInputFallbackConnection, ToolExecutor, } from "./tools.js";
16
20
  import { initializeWorkspace } from "./workspace.js";
17
21
  import { checkpointAction, } from "./midturn-checkpoint-hook.js";
22
+ function wrapMalformedToolArguments(argumentsText) {
23
+ try {
24
+ JSON.parse(argumentsText);
25
+ return argumentsText;
26
+ }
27
+ catch {
28
+ return JSON.stringify({ __malformedToolArguments: argumentsText });
29
+ }
30
+ }
31
+ /** Keep Agent Arena history replayable without losing the model's invalid input. */
32
+ function normalizeToolCallHistory(message, mode, historyCall) {
33
+ if (isCodeArenaMode(mode) || message.role !== "assistant")
34
+ return message;
35
+ // A failed Responses stream can retain completed native items without the
36
+ // assembled tool_calls batch. Restore their identities so resume can pair
37
+ // them with interrupted results instead of replaying orphaned calls.
38
+ const calls = message.tool_calls?.length ? message.tool_calls
39
+ : message.openai_response_items?.flatMap((item) => item.type === "function_call" && typeof item.call_id === "string" &&
40
+ typeof item.name === "string" && typeof item.arguments === "string"
41
+ ? [{ id: item.call_id, type: "function", function: { name: item.name, arguments: item.arguments } }]
42
+ : []) ?? message.tool_calls;
43
+ let changed = Boolean(calls?.length && calls !== message.tool_calls);
44
+ const toolCalls = calls?.map((original) => {
45
+ const call = historyCall ? historyCall(original) : original;
46
+ if (call !== original)
47
+ changed = true;
48
+ if (typeof call.function?.arguments !== "string")
49
+ return call;
50
+ const argumentsText = wrapMalformedToolArguments(call.function.arguments);
51
+ if (argumentsText === call.function.arguments)
52
+ return call;
53
+ changed = true;
54
+ return { ...call, function: { ...call.function, arguments: argumentsText } };
55
+ });
56
+ // Native Responses replays these items instead of tool_calls. Normalize both
57
+ // representations while leaving reasoning and other opaque items intact.
58
+ const responseItems = message.openai_response_items?.map((item) => {
59
+ if (item.type !== "function_call" || typeof item.arguments !== "string")
60
+ return item;
61
+ const paired = toolCalls?.find(call => call.id === item.call_id);
62
+ const argumentsText = paired?.function.arguments ?? wrapMalformedToolArguments(item.arguments);
63
+ if (argumentsText === item.arguments)
64
+ return item;
65
+ changed = true;
66
+ return { ...item, arguments: argumentsText };
67
+ });
68
+ return changed
69
+ ? {
70
+ ...message,
71
+ ...(toolCalls ? { tool_calls: toolCalls } : {}),
72
+ ...(responseItems ? { openai_response_items: responseItems } : {}),
73
+ }
74
+ : message;
75
+ }
18
76
  function parseToolResult(content) {
19
77
  try {
20
78
  return JSON.parse(content);
@@ -23,6 +81,41 @@ function parseToolResult(content) {
23
81
  return {};
24
82
  }
25
83
  }
84
+ /** Live Agent Arena results arrive in completion order. Checkpoint writes stay
85
+ * serialized by the consumer, and every started operation settles before exit.
86
+ * Code Arena retains its sequential execution path.
87
+ */
88
+ async function* completedTools(calls, parallel, execute) {
89
+ if (!parallel) {
90
+ for (const call of calls)
91
+ yield { call, message: await execute(call) };
92
+ return;
93
+ }
94
+ const queue = [];
95
+ let wake;
96
+ const settled = Promise.all(calls.map(async (call) => {
97
+ try {
98
+ queue.push({ status: "fulfilled", value: { call, message: await execute(call) } });
99
+ }
100
+ catch (reason) {
101
+ queue.push({ status: "rejected", reason });
102
+ }
103
+ wake?.();
104
+ }));
105
+ try {
106
+ for (let count = 0; count < calls.length; count++) {
107
+ if (!queue.length)
108
+ await new Promise(resolve => { wake = resolve; });
109
+ const result = queue.shift();
110
+ if (result.status === "rejected")
111
+ throw result.reason;
112
+ yield result.value;
113
+ }
114
+ }
115
+ finally {
116
+ await settled;
117
+ }
118
+ }
26
119
  // Network faults that abort an in-flight stream (a dropped socket, a reset, a
27
120
  // DNS blip). undici surfaces these mid-stream as `TypeError: fetch failed` or
28
121
  // `TypeError: terminated`; some are also reachable via an errno-style code on
@@ -43,8 +136,11 @@ const retryableStreamCodes = new Set([
43
136
  "UND_ERR_BODY_TIMEOUT",
44
137
  "ARENA_STREAM_IDLE_TIMEOUT",
45
138
  ANTHROPIC_INCOMPLETE_STREAM_ERROR_CODE,
46
- RESPONSES_CAPACITY_ERROR_CODE,
47
- RESPONSES_TRANSIENT_ERROR_CODE,
139
+ CAPACITY_MODEL_ERROR,
140
+ TRANSIENT_MODEL_ERROR,
141
+ RESPONSES_INCOMPLETE_STREAM_ERROR_CODE,
142
+ "ARENA_EMPTY_MODEL_RESPONSE",
143
+ INCOMPLETE_MODEL_RESPONSE,
48
144
  ]);
49
145
  const capacityErrorMaxRetries = 5;
50
146
  function describeError(error) {
@@ -64,7 +160,7 @@ function isRetryableStreamError(error) {
64
160
  return code !== undefined && retryableStreamCodes.has(code);
65
161
  }
66
162
  function streamRetryLimit(error, configuredLimit) {
67
- return errorCode(error) === RESPONSES_CAPACITY_ERROR_CODE
163
+ return errorCode(error) === CAPACITY_MODEL_ERROR
68
164
  ? Math.min(configuredLimit, capacityErrorMaxRetries)
69
165
  : configuredLimit;
70
166
  }
@@ -270,15 +366,20 @@ class CheckpointFailure extends Error {
270
366
  this.cause = cause;
271
367
  }
272
368
  }
273
- // Rebuild the runtime repo context from the current workspace on every run.
369
+ // Refresh live Git state on every run, using the saved target when available.
274
370
  // The history argument is retained for callers; it never supplies runtime state.
275
- export function resolveRepoContext(workspace, _history, mode = "arena") {
276
- return isCodeArenaMode(mode) ? undefined : detectRepoContext(workspace);
371
+ export function resolveRepoContext(workspace, _history, mode = "arena", repoDirectory) {
372
+ return isCodeArenaMode(mode) ? undefined : detectRepoContext(workspace, repoDirectory);
277
373
  }
278
374
  export class Agent {
279
375
  config;
280
376
  sink;
281
377
  options;
378
+ checkpointQueue = Promise.resolve();
379
+ restoredToolCalls = new Set();
380
+ checkpointError;
381
+ persistedMemory;
382
+ repoDirectory;
282
383
  memoryCache;
283
384
  client;
284
385
  tools;
@@ -290,6 +391,7 @@ export class Agent {
290
391
  this.sink = sink;
291
392
  this.options = options;
292
393
  this.memoryCache = options.sessionMemory || {};
394
+ this.repoDirectory = options.repoDirectory;
293
395
  this.sessionId =
294
396
  options.sessionId || crypto.randomUUID().replaceAll("-", "");
295
397
  this.sessionAffinityId = options.sessionAffinityId || crypto.randomUUID();
@@ -305,19 +407,24 @@ export class Agent {
305
407
  (!needsMainHttpClient && !needsFallbackHttpClient)
306
408
  ? undefined
307
409
  : new JsonlModelCallDiagnostics(modelCallLogPath(config.stateRoot, config.tenantId, this.sessionId));
308
- this.client =
309
- options.client ||
310
- (config.model.startsWith("mock/")
311
- ? new MockModelClient()
312
- : new HttpModelClient(config, {
313
- sessionAffinityId: this.sessionAffinityId,
314
- ...(diagnostics ? { diagnostics } : {}),
315
- }));
410
+ const client = options.client ||
411
+ (config.model.startsWith("mock/")
412
+ ? new MockModelClient()
413
+ : new HttpModelClient(config, {
414
+ sessionAffinityId: this.sessionAffinityId,
415
+ onRequest: options.onModelRequest,
416
+ ...(diagnostics ? { diagnostics } : {}),
417
+ }));
418
+ this.client = isCodeArenaMode(config.mode) ? client : {
419
+ stream: request => client.stream({ ...request,
420
+ messages: restoredArenaHistory(request.messages, this.restoredToolCalls) }),
421
+ };
316
422
  const imageInputFallbackClient = options.imageInputFallbackClient ||
317
423
  (fallbackConnection
318
424
  ? new HttpModelClient(config, {
319
425
  connection: fallbackConnection,
320
426
  sessionAffinityId: this.sessionAffinityId,
427
+ onRequest: options.onModelRequest,
321
428
  ...(diagnostics ? { diagnostics } : {}),
322
429
  })
323
430
  : undefined);
@@ -332,6 +439,16 @@ export class Agent {
332
439
  });
333
440
  }
334
441
  async run(prompt, history, runOptions = {}) {
442
+ try {
443
+ return await this.runTurn(prompt, history, runOptions);
444
+ }
445
+ finally {
446
+ await this.tools.closeConnectors?.();
447
+ await settleSessionMemory(this.memoryCache);
448
+ await this.checkpointQueue;
449
+ }
450
+ }
451
+ async runTurn(prompt, history, runOptions) {
335
452
  if (runOptions.resumeFromHistory && !history?.length) {
336
453
  throw new Error("resumeFromHistory requires a non-empty history");
337
454
  }
@@ -344,15 +461,25 @@ export class Agent {
344
461
  ? interruptedUserMessage(baseUserMessage, this.config.mode)
345
462
  : structuredClone(baseUserMessage);
346
463
  const clientContext = this.options.clientContext || defaultClientContext();
347
- const repoContext = resolveRepoContext(this.config.workspace, history, this.config.mode);
464
+ const repoContext = resolveRepoContext(this.config.workspace, history, this.config.mode, this.repoDirectory);
465
+ if (repoContext) {
466
+ this.repoDirectory = path.relative(this.config.workspace, repoContext.checkoutPath);
467
+ }
348
468
  // System instructions belong to this invocation, never to the saved dialogue.
349
469
  // Older local histories included a system message; accept them without
350
470
  // carrying the old runtime configuration into the next request.
351
- const dialogue = withoutLeadingSystemMessage(history || []);
471
+ const dialogue = withoutLeadingSystemMessage(history || []).map((message) => normalizeToolCallHistory(message, this.config.mode));
472
+ this.restoredToolCalls.clear();
473
+ if (!isCodeArenaMode(this.config.mode))
474
+ rememberRestoredCalls(dialogue, this.restoredToolCalls);
352
475
  const trajectory = completeInterruptedToolCalls(this.options.reuseHistoryMessages ? [...dialogue] : structuredClone(dialogue));
476
+ const connectors = this.config.disallowedTools?.includes("list_connector_tools")
477
+ ? undefined : this.config.connectors;
478
+ const connectedConnectors = this.config.disallowedTools?.includes("list_connector_tools")
479
+ ? undefined : this.config.connectedConnectors;
353
480
  const runtimeSystemMessage = repoContext
354
- ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns)
355
- : systemMessage(clientContext, this.workspaceContext, this.config.mode, this.config.maxTurns);
481
+ ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns, connectors, connectedConnectors)
482
+ : systemMessage(clientContext, this.workspaceContext, this.config.mode, this.config.maxTurns, connectors, connectedConnectors);
356
483
  applySystemPromptConfig(runtimeSystemMessage, this.config);
357
484
  if (runOptions.resumeFromHistory) {
358
485
  if (!isContinuableHistory(dialogue)) {
@@ -362,25 +489,65 @@ export class Agent {
362
489
  else {
363
490
  trajectory.push(userMessage);
364
491
  }
492
+ // A new human reply requires discovery again. Same-turn continuation only
493
+ // reloads schemas from paired discovery results; it never repeats writes.
494
+ await this.tools.beginConnectorTurn?.(runOptions.resumeFromHistory ? dialogue : undefined);
365
495
  // Compaction projects model input with a runtime system prefix; the lossless
366
496
  // trajectory and its checkpoints remain in the official dialogue format.
367
497
  const messages = modelContextFromTrajectory([runtimeSystemMessage, ...trajectory]);
498
+ if (!isCodeArenaMode(this.config.mode)) {
499
+ const saved = this.persistedMemory ?? this.options.storedSessionMemory ?? storedSessionMemory(this.memoryCache);
500
+ preflightUnsupportedMedia(this.config, messages);
501
+ // Opting out of restoration must not delete a still-valid durable snapshot.
502
+ this.persistedMemory = saved?.version === 1 ? structuredClone(saved) : undefined;
503
+ if (this.persistedMemory?.latest && !sessionMemoryMatchesHistory(this.persistedMemory.latest, messages)) {
504
+ this.persistedMemory.latest = undefined;
505
+ }
506
+ if (this.persistedMemory?.update?.status === "pending") {
507
+ this.persistedMemory.update = { status: "cancelled", code: "MEMORY_INTERRUPTED",
508
+ error: "Previous process ended during a memory update" };
509
+ }
510
+ this.memoryCache.latest = undefined;
511
+ this.memoryCache.state = undefined;
512
+ this.memoryCache.latestUsage = undefined;
513
+ this.memoryCache.attemptedPrefix = undefined;
514
+ this.memoryCache.update = undefined;
515
+ if (this.config.compactionMode !== "summary" &&
516
+ (runOptions.restoreSessionMemory ?? this.config.restoreSessionMemory ?? true) && saved?.version === 1) {
517
+ if (sessionMemoryMatchesHistory(saved.latest, messages)) {
518
+ this.memoryCache.latest = structuredClone(saved.latest);
519
+ }
520
+ this.memoryCache.update = this.persistedMemory?.update;
521
+ if (saved.latest && !this.memoryCache.latest) {
522
+ this.memoryCache.update = { status: "error", code: "MEMORY_STALE_HISTORY",
523
+ error: "Saved memory does not match the current history prefix" };
524
+ }
525
+ if (this.memoryCache.update)
526
+ this.sink.emit({ type: "memory.update", session_id: this.sessionId, ...this.memoryCache.update });
527
+ }
528
+ }
368
529
  const recordMessage = async (message) => {
369
- messages.push(message);
370
- const recorded = structuredClone(message);
530
+ const normalized = normalizeToolCallHistory(message, this.config.mode, call => this.tools.historyCall(call));
531
+ messages.push(normalized);
532
+ const recorded = structuredClone(normalized);
371
533
  trajectory.push(recorded);
372
534
  this.sink.message(this.sessionId, recorded, trajectory);
373
535
  await this.checkpoint(trajectory);
374
536
  };
375
- await this.maybeCompact(messages, trajectory);
376
- this.sink.start(this.sessionId, trajectory);
377
537
  try {
538
+ const compaction = await this.maybeCompact(messages, trajectory);
539
+ this.sink.start(this.sessionId, trajectory);
540
+ if (compaction === "checkpoint")
541
+ return await this.pause(trajectory, "checkpoint");
378
542
  await this.checkpoint(trajectory);
379
543
  const maxTurns = this.config.maxTurns
380
544
  ? Math.max(1, Math.floor(this.config.maxTurns))
381
- : undefined;
545
+ : isCodeArenaMode(this.config.mode) ? undefined : defaultStepLimit("arena");
382
546
  for (let step = 1;; step += 1) {
383
547
  if (maxTurns !== undefined && step > maxTurns) {
548
+ if (!isCodeArenaMode(this.config.mode)) {
549
+ return await this.pause(trajectory, "step_limit");
550
+ }
384
551
  throw new Error(`agent reached max_turns limit (${maxTurns})`);
385
552
  }
386
553
  if (this.options.signal?.aborted) {
@@ -389,13 +556,15 @@ export class Agent {
389
556
  // Every transport is replayable from local history. Native Responses
390
557
  // stores its encrypted reasoning output items there, so compaction is
391
558
  // safe at the same tool-step boundaries as the other protocols.
392
- if (step > 1)
393
- await this.maybeCompact(messages, trajectory, "inner");
559
+ if (step > 1 && await this.maybeCompact(messages, trajectory, "inner") === "checkpoint") {
560
+ return await this.pause(trajectory, "checkpoint");
561
+ }
394
562
  const offeredTools = this.tools.availableTools();
395
563
  this.sink.modelStart(this.sessionId, step, offeredTools.map((tool) => tool.function.name));
396
564
  let assistant;
565
+ let finishReason;
397
566
  try {
398
- assistant = await this.assistantTurn(messages, offeredTools, trajectory);
567
+ ({ assistant, finishReason } = await this.assistantTurn(messages, offeredTools, trajectory));
399
568
  }
400
569
  catch (error) {
401
570
  if (error instanceof CheckpointExit) {
@@ -415,8 +584,19 @@ export class Agent {
415
584
  this.options.onModelFailure?.(trajectory, error);
416
585
  throw error;
417
586
  }
418
- const finishReason = assistant.tool_calls?.length ? "tool_calls" : "stop";
419
587
  this.sink.modelComplete(this.sessionId, step, finishReason, assistant.usage);
588
+ if (finishReason === "length") {
589
+ // A completed but truncated request is not a transport failure. Keep
590
+ // its evidence without retrying it or executing unfinished tool calls.
591
+ await recordMessage(assistant);
592
+ throw new Error("Model response was truncated (finish_reason=length). " +
593
+ "The turn did not complete; any partial output has been preserved.");
594
+ }
595
+ if (finishReason === "incomplete" || finishReason === "content_filter") {
596
+ await recordMessage(assistant);
597
+ throw new Error(`Model response was incomplete (finish_reason=${finishReason}). ` +
598
+ "The turn did not complete; any partial output has been preserved.");
599
+ }
420
600
  if (assistant.tool_calls?.length &&
421
601
  this.options.midturnCheckpointHook) {
422
602
  const event = {
@@ -434,49 +614,65 @@ export class Agent {
434
614
  }
435
615
  await recordMessage(assistant);
436
616
  if (!assistant.tool_calls?.length) {
437
- return this.sink.result(this.sessionId, "success", trajectory);
617
+ return await this.finish(trajectory, "success");
438
618
  }
439
619
  const completedToolCallIds = [];
440
- for (const call of assistant.tool_calls) {
620
+ let needsInput = false;
621
+ const execute = async (call) => {
441
622
  const toolCallId = String(call.id || "");
442
623
  const toolName = String(call.function?.name || "tool");
443
624
  this.sink.toolStart(this.sessionId, toolCallId, toolName);
444
- const toolMessage = await this.tools.execute(call, toolName === "ask_user" || toolName === "add_voice"
625
+ return this.tools.execute(call, toolName === "ask_user" || toolName === "add_voice" ||
626
+ (!isCodeArenaMode(this.config.mode) && toolName === "generate_image")
445
627
  ? {
446
628
  sessionId: this.sessionId,
447
629
  messages: structuredClone(trajectory),
448
630
  signal: this.options.signal,
449
631
  }
450
632
  : undefined);
633
+ };
634
+ for await (const { call, message: toolMessage } of completedTools(assistant.tool_calls, !isCodeArenaMode(this.config.mode), execute)) {
635
+ const toolCallId = String(call.id || "");
636
+ const toolName = String(call.function?.name || "tool");
451
637
  const toolResult = parseToolResult(toolMessage.content);
452
638
  const skippedAskUser = toolName === "ask_user" && toolResult.skipped === true;
453
639
  if (this.options.signal?.aborted && !skippedAskUser) {
454
640
  throw this.options.signal.reason || new Error("operation cancelled");
455
641
  }
456
642
  await recordMessage(toolMessage);
643
+ if (this.tools.takeInteractionBoundary(toolCallId)) {
644
+ rememberRestoredCalls(messages, this.restoredToolCalls);
645
+ }
457
646
  completedToolCallIds.push(toolCallId);
458
647
  this.sink.toolComplete(this.sessionId, toolCallId, toolName);
459
- if (toolName === "ask_user" || toolName === "add_voice") {
648
+ if (toolName === "ask_user" || toolName === "add_voice" ||
649
+ (!isCodeArenaMode(this.config.mode) && toolName === "generate_image")) {
460
650
  if (toolResult.status === "needs_input") {
461
- const visualMessage = this.tools.takeVisualMessage(completedToolCallIds);
462
- if (visualMessage) {
463
- await recordMessage(visualMessage);
464
- }
465
651
  const questions = Array.isArray(toolResult.questions)
466
652
  ? toolResult.questions
467
653
  : [];
468
654
  this.sink.inputRequired(this.sessionId, toolCallId, questions);
469
- return this.sink.result(this.sessionId, "needs_input", trajectory);
655
+ if (isCodeArenaMode(this.config.mode)) {
656
+ const visualMessage = this.tools.takeVisualMessage(completedToolCallIds);
657
+ if (visualMessage)
658
+ await recordMessage(visualMessage);
659
+ return await this.finish(trajectory, "needs_input");
660
+ }
661
+ needsInput = true;
470
662
  }
471
663
  }
472
664
  if (this.options.signal?.aborted) {
473
665
  throw this.options.signal.reason || new Error("operation cancelled");
474
666
  }
475
667
  }
476
- const visualMessage = this.tools.takeVisualMessage(completedToolCallIds);
668
+ // Image parts retain the assistant's declaration order independently
669
+ // of which tool result completed first.
670
+ const visualMessage = this.tools.takeVisualMessage(assistant.tool_calls.map(call => call.id));
477
671
  if (visualMessage) {
478
672
  await recordMessage(visualMessage);
479
673
  }
674
+ if (needsInput)
675
+ return await this.finish(trajectory, "needs_input");
480
676
  if (runOptions.singleStep) {
481
677
  return await this.pause(trajectory, "single_step");
482
678
  }
@@ -484,13 +680,38 @@ export class Agent {
484
680
  }
485
681
  catch (error) {
486
682
  const message = error instanceof Error ? error.message : String(error);
487
- return this.sink.result(this.sessionId, "error", trajectory, message);
683
+ return await this.finish(trajectory, "error", message);
488
684
  }
489
685
  }
490
686
  async checkpoint(messages) {
491
- await this.options.onCheckpoint?.(messages, this.tools.availableTools());
687
+ const snapshot = [...messages];
688
+ const tools = this.tools.availableTools();
689
+ const currentMemory = storedSessionMemory(this.memoryCache);
690
+ const memory = isCodeArenaMode(this.config.mode) ? undefined : (currentMemory || this.persistedMemory
691
+ ? { version: 1, latest: currentMemory?.latest ?? this.persistedMemory?.latest,
692
+ update: currentMemory?.update ?? this.persistedMemory?.update }
693
+ : null);
694
+ if (memory !== undefined)
695
+ this.persistedMemory = memory ?? undefined;
696
+ const write = this.checkpointQueue.then(() => this.options.onCheckpoint?.(snapshot, tools, memory));
697
+ this.checkpointQueue = write.catch(error => { this.checkpointError ??= error; });
698
+ await write;
699
+ if (this.checkpointError)
700
+ throw this.checkpointError;
701
+ }
702
+ async finish(messages, status, error) {
703
+ await settleSessionMemory(this.memoryCache);
704
+ try {
705
+ await this.checkpoint(messages);
706
+ }
707
+ catch (failure) {
708
+ status = "error";
709
+ error ??= failure instanceof Error ? failure.message : String(failure);
710
+ }
711
+ return this.sink.result(this.sessionId, status, messages, error, undefined, this.memoryCache.update);
492
712
  }
493
713
  async makeCheckpoint(messages, tools, event, action) {
714
+ await settleSessionMemory(this.memoryCache, event.type !== "before_compaction" || action !== "snapshot_continue");
494
715
  await this.checkpoint(messages);
495
716
  if (!this.options.onMidturnCheckpoint) {
496
717
  throw new Error("mid-turn checkpoint hook triggered without a snapshot handler");
@@ -499,9 +720,12 @@ export class Agent {
499
720
  }
500
721
  async pause(messages, reason) {
501
722
  this.options.signal?.throwIfAborted();
723
+ await settleSessionMemory(this.memoryCache, reason !== "single_step");
724
+ if (reason === "single_step" || reason === "step_limit")
725
+ await this.checkpoint(messages);
502
726
  await this.options.onPause?.(messages, this.tools.availableTools());
503
727
  this.options.signal?.throwIfAborted();
504
- return this.sink.result(this.sessionId, "paused", messages, undefined, reason);
728
+ return this.sink.result(this.sessionId, "paused", messages, undefined, reason, this.memoryCache.update);
505
729
  }
506
730
  async maybeCompact(messages, trajectory, source = "outer") {
507
731
  preflightUnsupportedMedia(this.config, messages);
@@ -511,50 +735,83 @@ export class Agent {
511
735
  triggerRatio: this.config.contextCompactionRatio,
512
736
  summaryMaxTokens: this.config.compactionSummaryMaxTokens,
513
737
  reasoningEffort: this.config.reasoningEffort,
514
- ...(this.options.compactionThresholdTokens === undefined ? {} : {
515
- compactionThresholdTokens: this.options.compactionThresholdTokens,
516
- }),
517
- legacy: isCodeArenaMode(this.config.mode),
738
+ compactionThresholdTokens: isCodeArenaMode(this.config.mode) ? undefined
739
+ : agentArenaCompactionThreshold(this.config.contextWindow, this.config.contextCompactionRatio, this.options.compactionThresholdTokens ?? this.config.compactionThresholdTokens),
740
+ legacy: isCodeArenaMode(this.config.mode) || this.config.compactionMode === "summary",
741
+ onBeforeCompaction: !isCodeArenaMode(this.config.mode) && this.options.midturnCheckpointHook
742
+ ? async (estimatedTokensBefore, thresholdTokens) => {
743
+ const event = {
744
+ type: "before_compaction", sessionId: this.sessionId, toolCalls: [],
745
+ compaction: {
746
+ count: trajectory.filter(message => message.role === "compaction" && !message.undone).length + 1,
747
+ mode: this.config.compactionMode === "summary" ? "summary" : "rolling",
748
+ source, estimatedTokensBefore, thresholdTokens,
749
+ },
750
+ };
751
+ let action;
752
+ try {
753
+ action = checkpointAction(await this.options.midturnCheckpointHook(event));
754
+ if (action !== "continue") {
755
+ await this.makeCheckpoint(trajectory, this.tools.availableTools(), event, action);
756
+ }
757
+ }
758
+ catch (error) {
759
+ throw new CheckpointFailure(error);
760
+ }
761
+ if (action === "snapshot_exit")
762
+ throw new CheckpointExit(event);
763
+ }
764
+ : undefined,
518
765
  };
519
766
  const contextLength = messages.length;
520
767
  const result = settings.legacy
521
768
  ? await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, settings, this.options.signal)
522
- : await maintainSessionMemory(messages, messages, this.memoryCache, this.tools.availableTools(), this.client, this.config.model, settings, this.config.memoryUpdateTokens, this.options.signal, (usage, success) => this.sink.emit({ type: "memory.update", session_id: this.sessionId,
523
- status: success ? "success" : "invalid", ...(usage ? { usage } : {}) }));
769
+ : await maintainSessionMemory(messages, messages, this.memoryCache, this.tools.availableTools(), this.client, this.config.model, settings, this.config.memoryUpdateTokens, this.options.signal, update => {
770
+ this.sink.emit({ type: "memory.update", session_id: this.sessionId, ...update });
771
+ // Serialized with message checkpoints; persistence failures surface at
772
+ // the next checkpoint/finish instead of becoming unhandled rejections.
773
+ return this.checkpoint(trajectory).catch(() => undefined);
774
+ });
524
775
  if (result.compacted) {
525
- if (!settings.legacy) {
526
- const covered = this.memoryCache.state.coveredMessages;
527
- const retained = contextLength - covered;
528
- // Insert at the covered boundary, before the retained original tail.
529
- // Marker position is the resume boundary; no separate index is stored.
530
- let insertion = trajectory.length;
531
- let remaining = retained;
532
- while (remaining > 0 && insertion > 0) {
533
- if (trajectory[--insertion].role !== "compaction")
534
- remaining--;
535
- }
536
- trajectory.splice(insertion, 0, {
537
- role: "compaction", content: [{ type: "text", text: result.memory }],
538
- undone: false,
539
- compaction: {
540
- ratio: result.estimatedTokensBefore / this.config.contextWindow,
541
- tokens_at_compaction: result.estimatedTokensBefore,
542
- source,
543
- },
544
- });
776
+ const covered = settings.legacy ? result.coveredMessages : this.memoryCache.state.coveredMessages;
777
+ const retained = contextLength - covered;
778
+ // Insert at the covered boundary, before the retained original tail.
779
+ // Marker position is the resume boundary; no separate index is stored.
780
+ let insertion = trajectory.length;
781
+ let remaining = retained;
782
+ while (remaining > 0 && insertion > 0) {
783
+ if (trajectory[--insertion].role !== "compaction")
784
+ remaining--;
785
+ }
786
+ trajectory.splice(insertion, 0, {
787
+ role: "compaction", content: [{ type: "text", text: result.memory }],
788
+ undone: false,
789
+ compaction: {
790
+ ratio: result.estimatedTokensBefore / this.config.contextWindow,
791
+ tokens_at_compaction: result.estimatedTokensBefore,
792
+ source,
793
+ retained_messages: retained,
794
+ },
795
+ });
796
+ if (!isCodeArenaMode(this.config.mode)) {
545
797
  // Coverage indices referred to the pre-compaction history. The next
546
798
  // update reads its prior memory from the newly embedded block.
547
799
  this.memoryCache.state = undefined;
548
800
  this.memoryCache.latest = undefined;
549
801
  this.memoryCache.latestUsage = undefined;
550
802
  this.memoryCache.attemptedPrefix = undefined;
803
+ this.persistedMemory = undefined;
551
804
  }
552
805
  await this.checkpoint(trajectory);
553
806
  this.sink.compaction(this.sessionId, result.usage);
554
807
  }
555
808
  return result.compacted;
556
809
  }
557
- catch {
810
+ catch (error) {
811
+ if (error instanceof CheckpointExit)
812
+ return "checkpoint";
813
+ if (error instanceof CheckpointFailure)
814
+ throw error.cause;
558
815
  // Compaction is protective rather than task-critical. If its dedicated
559
816
  // summary request fails, keep the original history and let the normal
560
817
  // model request (and its retry policy) decide whether it still fits.
@@ -581,6 +838,7 @@ export class Agent {
581
838
  const calls = new Map();
582
839
  const responseItems = new Map();
583
840
  let usage;
841
+ let finishReason;
584
842
  let reasoningDetails;
585
843
  const detectors = this.options.midturnCheckpointHook ? {
586
844
  content: new RepetitiveOutputDetector(),
@@ -649,6 +907,9 @@ export class Agent {
649
907
  // letting the last non-empty value win.
650
908
  if (chunk.usage)
651
909
  usage = chunk.usage;
910
+ if (chunk.choices?.[0]?.finish_reason) {
911
+ finishReason = chunk.choices[0].finish_reason;
912
+ }
652
913
  if (chunk.reasoning_details?.length) {
653
914
  reasoningDetails = chunk.reasoning_details;
654
915
  }
@@ -687,6 +948,11 @@ export class Agent {
687
948
  if (fragment.function?.arguments) {
688
949
  call.function.arguments += String(fragment.function.arguments);
689
950
  }
951
+ // Metadata is an opaque value for this index, not a text delta.
952
+ // Later fragments that omit it must not erase an earlier signature.
953
+ if (fragment.extra_content !== undefined) {
954
+ call.extra_content = structuredClone(fragment.extra_content);
955
+ }
690
956
  calls.set(index, call);
691
957
  this.sink.delta(this.sessionId, {
692
958
  tool_calls: [
@@ -698,11 +964,23 @@ export class Agent {
698
964
  name: String(fragment.function?.name || ""),
699
965
  arguments: String(fragment.function?.arguments || ""),
700
966
  },
967
+ ...(fragment.extra_content !== undefined
968
+ ? { extra_content: structuredClone(fragment.extra_content) }
969
+ : {}),
701
970
  },
702
971
  ],
703
972
  });
704
973
  }
705
974
  }
975
+ if (finishReason === "tool_calls" && !calls.size) {
976
+ throw incompleteModelResponse("Model stopped for tool calls but returned no tool calls");
977
+ }
978
+ if (finishReason !== "length" && finishReason !== "incomplete" && finishReason !== "content_filter" &&
979
+ !calls.size && !contentParts.join("").trim()) {
980
+ throw Object.assign(new Error("Model returned an empty response without tool calls"), {
981
+ code: "ARENA_EMPTY_MODEL_RESPONSE",
982
+ });
983
+ }
706
984
  }
707
985
  catch (error) {
708
986
  // Iterator cleanup may itself report the intentional transport abort.
@@ -734,7 +1012,7 @@ export class Agent {
734
1012
  const openaiResponseItems = [...responseItems.entries()]
735
1013
  .sort(([left], [right]) => left - right)
736
1014
  .map(([, item]) => item);
737
- const partial = content || reasoningContent || openaiResponseItems.length
1015
+ const partial = content || reasoningContent || openaiResponseItems.length || usage
738
1016
  ? {
739
1017
  role: "assistant",
740
1018
  content,
@@ -770,7 +1048,10 @@ export class Agent {
770
1048
  .sort(([left], [right]) => left - right)
771
1049
  .map(([, call]) => call);
772
1050
  }
773
- return assistant;
1051
+ return {
1052
+ assistant,
1053
+ finishReason: finishReason ?? (assistant.tool_calls?.length ? "tool_calls" : "stop"),
1054
+ };
774
1055
  }
775
1056
  }
776
1057
  async retryBackoff(attempt) {