@qwen-code/arena-agent 0.0.10-dev.9 → 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 (144) hide show
  1. package/.env.example +61 -10
  2. package/CHANGELOG.md +140 -11
  3. package/README.md +309 -46
  4. package/dist/agent.d.ts +20 -6
  5. package/dist/agent.js +303 -73
  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.js +63 -26
  32. package/dist/code-tools.js.map +1 -1
  33. package/dist/compaction-threshold.d.ts +3 -0
  34. package/dist/compaction-threshold.js +6 -0
  35. package/dist/compaction-threshold.js.map +1 -0
  36. package/dist/compaction.d.ts +24 -7
  37. package/dist/compaction.js +242 -66
  38. package/dist/compaction.js.map +1 -1
  39. package/dist/config.d.ts +17 -0
  40. package/dist/config.js +30 -5
  41. package/dist/config.js.map +1 -1
  42. package/dist/connector-errors.d.ts +16 -0
  43. package/dist/connector-errors.js +69 -0
  44. package/dist/connector-errors.js.map +1 -0
  45. package/dist/connector-files.d.ts +31 -0
  46. package/dist/connector-files.js +249 -0
  47. package/dist/connector-files.js.map +1 -0
  48. package/dist/connector-input.d.ts +10 -0
  49. package/dist/connector-input.js +79 -0
  50. package/dist/connector-input.js.map +1 -0
  51. package/dist/connector-mcp.d.ts +60 -0
  52. package/dist/connector-mcp.js +474 -0
  53. package/dist/connector-mcp.js.map +1 -0
  54. package/dist/connectors.d.ts +13 -0
  55. package/dist/connectors.js +69 -0
  56. package/dist/connectors.js.map +1 -0
  57. package/dist/fetch-page.js +2 -2
  58. package/dist/fetch-page.js.map +1 -1
  59. package/dist/history.d.ts +15 -5
  60. package/dist/history.js +59 -14
  61. package/dist/history.js.map +1 -1
  62. package/dist/image-choice.d.ts +23 -0
  63. package/dist/image-choice.js +190 -0
  64. package/dist/image-choice.js.map +1 -0
  65. package/dist/image-input-fallback.js +5 -0
  66. package/dist/image-input-fallback.js.map +1 -1
  67. package/dist/image-model.d.ts +6 -0
  68. package/dist/image-model.js +32 -0
  69. package/dist/image-model.js.map +1 -0
  70. package/dist/midturn-checkpoint-hook.d.ts +14 -1
  71. package/dist/midturn-checkpoint-hook.js.map +1 -1
  72. package/dist/mode.js +1 -1
  73. package/dist/mode.js.map +1 -1
  74. package/dist/model-capabilities.js +18 -8
  75. package/dist/model-capabilities.js.map +1 -1
  76. package/dist/model-router-media.js +55 -3
  77. package/dist/model-router-media.js.map +1 -1
  78. package/dist/model-stream.d.ts +16 -0
  79. package/dist/model-stream.js +129 -0
  80. package/dist/model-stream.js.map +1 -0
  81. package/dist/openai-responses.d.ts +2 -1
  82. package/dist/openai-responses.js +85 -88
  83. package/dist/openai-responses.js.map +1 -1
  84. package/dist/process-manager.js +9 -9
  85. package/dist/process-manager.js.map +1 -1
  86. package/dist/prompts.d.ts +0 -2
  87. package/dist/prompts.js +10 -60
  88. package/dist/prompts.js.map +1 -1
  89. package/dist/protocol.d.ts +5 -1
  90. package/dist/protocol.js +23 -2
  91. package/dist/protocol.js.map +1 -1
  92. package/dist/provider.d.ts +18 -0
  93. package/dist/provider.js +120 -51
  94. package/dist/provider.js.map +1 -1
  95. package/dist/repo.d.ts +1 -1
  96. package/dist/repo.js +26 -4
  97. package/dist/repo.js.map +1 -1
  98. package/dist/routify-web-fetch.js +2 -2
  99. package/dist/routify-web-fetch.js.map +1 -1
  100. package/dist/server.js +55 -27
  101. package/dist/server.js.map +1 -1
  102. package/dist/session-memory-prompts.d.ts +3 -1
  103. package/dist/session-memory-prompts.js +4 -2
  104. package/dist/session-memory-prompts.js.map +1 -1
  105. package/dist/side-query.js +5 -0
  106. package/dist/side-query.js.map +1 -1
  107. package/dist/spec.d.ts +5 -4
  108. package/dist/spec.js +30 -11
  109. package/dist/spec.js.map +1 -1
  110. package/dist/startup-config.d.ts +39 -0
  111. package/dist/startup-config.js +347 -0
  112. package/dist/startup-config.js.map +1 -0
  113. package/dist/startup-main-config.d.ts +63 -0
  114. package/dist/startup-main-config.js +94 -0
  115. package/dist/startup-main-config.js.map +1 -0
  116. package/dist/startup-tool-config.d.ts +8 -0
  117. package/dist/startup-tool-config.js +116 -0
  118. package/dist/startup-tool-config.js.map +1 -0
  119. package/dist/summary-compaction.d.ts +29 -0
  120. package/dist/{legacy-compaction.js → summary-compaction.js} +79 -14
  121. package/dist/summary-compaction.js.map +1 -0
  122. package/dist/tools.d.ts +5 -1
  123. package/dist/tools.js +164 -54
  124. package/dist/tools.js.map +1 -1
  125. package/dist/tui.js +26 -6
  126. package/dist/tui.js.map +1 -1
  127. package/dist/types.d.ts +21 -1
  128. package/dist/web/app.js +30 -5
  129. package/dist/web/app.js.map +1 -1
  130. package/dist/workspace.js +61 -14
  131. package/dist/workspace.js.map +1 -1
  132. package/docs/BINARY_DISTRIBUTION.md +44 -8
  133. package/docs/MODES_AND_RELEASES.md +79 -6
  134. package/docs/SDK.md +1 -0
  135. package/modes/code-arena/capture-provenance.json +9 -6
  136. package/modes/code-arena/full-stack/dependencies.lock.json +8670 -0
  137. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  138. package/modes/code-arena/html/system-prompt.txt +1 -1
  139. package/modes/code-arena/react/dependencies.lock.json +2707 -0
  140. package/package.json +7 -2
  141. package/scripts/README.md +23 -4
  142. package/web/styles.css +22 -0
  143. package/dist/legacy-compaction.d.ts +0 -16
  144. package/dist/legacy-compaction.js.map +0 -1
package/dist/agent.js CHANGED
@@ -1,16 +1,20 @@
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";
@@ -25,7 +29,7 @@ function wrapMalformedToolArguments(argumentsText) {
25
29
  }
26
30
  }
27
31
  /** Keep Agent Arena history replayable without losing the model's invalid input. */
28
- function normalizeToolCallHistory(message, mode) {
32
+ function normalizeToolCallHistory(message, mode, historyCall) {
29
33
  if (isCodeArenaMode(mode) || message.role !== "assistant")
30
34
  return message;
31
35
  // A failed Responses stream can retain completed native items without the
@@ -37,7 +41,10 @@ function normalizeToolCallHistory(message, mode) {
37
41
  ? [{ id: item.call_id, type: "function", function: { name: item.name, arguments: item.arguments } }]
38
42
  : []) ?? message.tool_calls;
39
43
  let changed = Boolean(calls?.length && calls !== message.tool_calls);
40
- const toolCalls = calls?.map((call) => {
44
+ const toolCalls = calls?.map((original) => {
45
+ const call = historyCall ? historyCall(original) : original;
46
+ if (call !== original)
47
+ changed = true;
41
48
  if (typeof call.function?.arguments !== "string")
42
49
  return call;
43
50
  const argumentsText = wrapMalformedToolArguments(call.function.arguments);
@@ -51,7 +58,8 @@ function normalizeToolCallHistory(message, mode) {
51
58
  const responseItems = message.openai_response_items?.map((item) => {
52
59
  if (item.type !== "function_call" || typeof item.arguments !== "string")
53
60
  return item;
54
- const argumentsText = wrapMalformedToolArguments(item.arguments);
61
+ const paired = toolCalls?.find(call => call.id === item.call_id);
62
+ const argumentsText = paired?.function.arguments ?? wrapMalformedToolArguments(item.arguments);
55
63
  if (argumentsText === item.arguments)
56
64
  return item;
57
65
  changed = true;
@@ -73,6 +81,41 @@ function parseToolResult(content) {
73
81
  return {};
74
82
  }
75
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
+ }
76
119
  // Network faults that abort an in-flight stream (a dropped socket, a reset, a
77
120
  // DNS blip). undici surfaces these mid-stream as `TypeError: fetch failed` or
78
121
  // `TypeError: terminated`; some are also reachable via an errno-style code on
@@ -93,8 +136,11 @@ const retryableStreamCodes = new Set([
93
136
  "UND_ERR_BODY_TIMEOUT",
94
137
  "ARENA_STREAM_IDLE_TIMEOUT",
95
138
  ANTHROPIC_INCOMPLETE_STREAM_ERROR_CODE,
96
- RESPONSES_CAPACITY_ERROR_CODE,
97
- 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,
98
144
  ]);
99
145
  const capacityErrorMaxRetries = 5;
100
146
  function describeError(error) {
@@ -114,7 +160,7 @@ function isRetryableStreamError(error) {
114
160
  return code !== undefined && retryableStreamCodes.has(code);
115
161
  }
116
162
  function streamRetryLimit(error, configuredLimit) {
117
- return errorCode(error) === RESPONSES_CAPACITY_ERROR_CODE
163
+ return errorCode(error) === CAPACITY_MODEL_ERROR
118
164
  ? Math.min(configuredLimit, capacityErrorMaxRetries)
119
165
  : configuredLimit;
120
166
  }
@@ -320,15 +366,20 @@ class CheckpointFailure extends Error {
320
366
  this.cause = cause;
321
367
  }
322
368
  }
323
- // 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.
324
370
  // 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);
371
+ export function resolveRepoContext(workspace, _history, mode = "arena", repoDirectory) {
372
+ return isCodeArenaMode(mode) ? undefined : detectRepoContext(workspace, repoDirectory);
327
373
  }
328
374
  export class Agent {
329
375
  config;
330
376
  sink;
331
377
  options;
378
+ checkpointQueue = Promise.resolve();
379
+ restoredToolCalls = new Set();
380
+ checkpointError;
381
+ persistedMemory;
382
+ repoDirectory;
332
383
  memoryCache;
333
384
  client;
334
385
  tools;
@@ -340,6 +391,7 @@ export class Agent {
340
391
  this.sink = sink;
341
392
  this.options = options;
342
393
  this.memoryCache = options.sessionMemory || {};
394
+ this.repoDirectory = options.repoDirectory;
343
395
  this.sessionId =
344
396
  options.sessionId || crypto.randomUUID().replaceAll("-", "");
345
397
  this.sessionAffinityId = options.sessionAffinityId || crypto.randomUUID();
@@ -355,19 +407,24 @@ export class Agent {
355
407
  (!needsMainHttpClient && !needsFallbackHttpClient)
356
408
  ? undefined
357
409
  : new JsonlModelCallDiagnostics(modelCallLogPath(config.stateRoot, config.tenantId, this.sessionId));
358
- this.client =
359
- options.client ||
360
- (config.model.startsWith("mock/")
361
- ? new MockModelClient()
362
- : new HttpModelClient(config, {
363
- sessionAffinityId: this.sessionAffinityId,
364
- ...(diagnostics ? { diagnostics } : {}),
365
- }));
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
+ };
366
422
  const imageInputFallbackClient = options.imageInputFallbackClient ||
367
423
  (fallbackConnection
368
424
  ? new HttpModelClient(config, {
369
425
  connection: fallbackConnection,
370
426
  sessionAffinityId: this.sessionAffinityId,
427
+ onRequest: options.onModelRequest,
371
428
  ...(diagnostics ? { diagnostics } : {}),
372
429
  })
373
430
  : undefined);
@@ -382,6 +439,16 @@ export class Agent {
382
439
  });
383
440
  }
384
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) {
385
452
  if (runOptions.resumeFromHistory && !history?.length) {
386
453
  throw new Error("resumeFromHistory requires a non-empty history");
387
454
  }
@@ -394,15 +461,25 @@ export class Agent {
394
461
  ? interruptedUserMessage(baseUserMessage, this.config.mode)
395
462
  : structuredClone(baseUserMessage);
396
463
  const clientContext = this.options.clientContext || defaultClientContext();
397
- 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
+ }
398
468
  // System instructions belong to this invocation, never to the saved dialogue.
399
469
  // Older local histories included a system message; accept them without
400
470
  // carrying the old runtime configuration into the next request.
401
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);
402
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;
403
480
  const runtimeSystemMessage = repoContext
404
- ? repoSystemMessage(clientContext, repoContext, this.config.mode, this.workspaceContext, this.config.maxTurns)
405
- : 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);
406
483
  applySystemPromptConfig(runtimeSystemMessage, this.config);
407
484
  if (runOptions.resumeFromHistory) {
408
485
  if (!isContinuableHistory(dialogue)) {
@@ -412,26 +489,65 @@ export class Agent {
412
489
  else {
413
490
  trajectory.push(userMessage);
414
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);
415
495
  // Compaction projects model input with a runtime system prefix; the lossless
416
496
  // trajectory and its checkpoints remain in the official dialogue format.
417
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
+ }
418
529
  const recordMessage = async (message) => {
419
- const normalized = normalizeToolCallHistory(message, this.config.mode);
530
+ const normalized = normalizeToolCallHistory(message, this.config.mode, call => this.tools.historyCall(call));
420
531
  messages.push(normalized);
421
532
  const recorded = structuredClone(normalized);
422
533
  trajectory.push(recorded);
423
534
  this.sink.message(this.sessionId, recorded, trajectory);
424
535
  await this.checkpoint(trajectory);
425
536
  };
426
- await this.maybeCompact(messages, trajectory);
427
- this.sink.start(this.sessionId, trajectory);
428
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");
429
542
  await this.checkpoint(trajectory);
430
543
  const maxTurns = this.config.maxTurns
431
544
  ? Math.max(1, Math.floor(this.config.maxTurns))
432
- : undefined;
545
+ : isCodeArenaMode(this.config.mode) ? undefined : defaultStepLimit("arena");
433
546
  for (let step = 1;; step += 1) {
434
547
  if (maxTurns !== undefined && step > maxTurns) {
548
+ if (!isCodeArenaMode(this.config.mode)) {
549
+ return await this.pause(trajectory, "step_limit");
550
+ }
435
551
  throw new Error(`agent reached max_turns limit (${maxTurns})`);
436
552
  }
437
553
  if (this.options.signal?.aborted) {
@@ -440,13 +556,15 @@ export class Agent {
440
556
  // Every transport is replayable from local history. Native Responses
441
557
  // stores its encrypted reasoning output items there, so compaction is
442
558
  // safe at the same tool-step boundaries as the other protocols.
443
- if (step > 1)
444
- 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
+ }
445
562
  const offeredTools = this.tools.availableTools();
446
563
  this.sink.modelStart(this.sessionId, step, offeredTools.map((tool) => tool.function.name));
447
564
  let assistant;
565
+ let finishReason;
448
566
  try {
449
- assistant = await this.assistantTurn(messages, offeredTools, trajectory);
567
+ ({ assistant, finishReason } = await this.assistantTurn(messages, offeredTools, trajectory));
450
568
  }
451
569
  catch (error) {
452
570
  if (error instanceof CheckpointExit) {
@@ -466,8 +584,19 @@ export class Agent {
466
584
  this.options.onModelFailure?.(trajectory, error);
467
585
  throw error;
468
586
  }
469
- const finishReason = assistant.tool_calls?.length ? "tool_calls" : "stop";
470
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
+ }
471
600
  if (assistant.tool_calls?.length &&
472
601
  this.options.midturnCheckpointHook) {
473
602
  const event = {
@@ -485,49 +614,65 @@ export class Agent {
485
614
  }
486
615
  await recordMessage(assistant);
487
616
  if (!assistant.tool_calls?.length) {
488
- return this.sink.result(this.sessionId, "success", trajectory);
617
+ return await this.finish(trajectory, "success");
489
618
  }
490
619
  const completedToolCallIds = [];
491
- for (const call of assistant.tool_calls) {
620
+ let needsInput = false;
621
+ const execute = async (call) => {
492
622
  const toolCallId = String(call.id || "");
493
623
  const toolName = String(call.function?.name || "tool");
494
624
  this.sink.toolStart(this.sessionId, toolCallId, toolName);
495
- 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")
496
627
  ? {
497
628
  sessionId: this.sessionId,
498
629
  messages: structuredClone(trajectory),
499
630
  signal: this.options.signal,
500
631
  }
501
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");
502
637
  const toolResult = parseToolResult(toolMessage.content);
503
638
  const skippedAskUser = toolName === "ask_user" && toolResult.skipped === true;
504
639
  if (this.options.signal?.aborted && !skippedAskUser) {
505
640
  throw this.options.signal.reason || new Error("operation cancelled");
506
641
  }
507
642
  await recordMessage(toolMessage);
643
+ if (this.tools.takeInteractionBoundary(toolCallId)) {
644
+ rememberRestoredCalls(messages, this.restoredToolCalls);
645
+ }
508
646
  completedToolCallIds.push(toolCallId);
509
647
  this.sink.toolComplete(this.sessionId, toolCallId, toolName);
510
- if (toolName === "ask_user" || toolName === "add_voice") {
648
+ if (toolName === "ask_user" || toolName === "add_voice" ||
649
+ (!isCodeArenaMode(this.config.mode) && toolName === "generate_image")) {
511
650
  if (toolResult.status === "needs_input") {
512
- const visualMessage = this.tools.takeVisualMessage(completedToolCallIds);
513
- if (visualMessage) {
514
- await recordMessage(visualMessage);
515
- }
516
651
  const questions = Array.isArray(toolResult.questions)
517
652
  ? toolResult.questions
518
653
  : [];
519
654
  this.sink.inputRequired(this.sessionId, toolCallId, questions);
520
- 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;
521
662
  }
522
663
  }
523
664
  if (this.options.signal?.aborted) {
524
665
  throw this.options.signal.reason || new Error("operation cancelled");
525
666
  }
526
667
  }
527
- 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));
528
671
  if (visualMessage) {
529
672
  await recordMessage(visualMessage);
530
673
  }
674
+ if (needsInput)
675
+ return await this.finish(trajectory, "needs_input");
531
676
  if (runOptions.singleStep) {
532
677
  return await this.pause(trajectory, "single_step");
533
678
  }
@@ -535,13 +680,38 @@ export class Agent {
535
680
  }
536
681
  catch (error) {
537
682
  const message = error instanceof Error ? error.message : String(error);
538
- return this.sink.result(this.sessionId, "error", trajectory, message);
683
+ return await this.finish(trajectory, "error", message);
539
684
  }
540
685
  }
541
686
  async checkpoint(messages) {
542
- 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);
543
712
  }
544
713
  async makeCheckpoint(messages, tools, event, action) {
714
+ await settleSessionMemory(this.memoryCache, event.type !== "before_compaction" || action !== "snapshot_continue");
545
715
  await this.checkpoint(messages);
546
716
  if (!this.options.onMidturnCheckpoint) {
547
717
  throw new Error("mid-turn checkpoint hook triggered without a snapshot handler");
@@ -550,9 +720,12 @@ export class Agent {
550
720
  }
551
721
  async pause(messages, reason) {
552
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);
553
726
  await this.options.onPause?.(messages, this.tools.availableTools());
554
727
  this.options.signal?.throwIfAborted();
555
- return this.sink.result(this.sessionId, "paused", messages, undefined, reason);
728
+ return this.sink.result(this.sessionId, "paused", messages, undefined, reason, this.memoryCache.update);
556
729
  }
557
730
  async maybeCompact(messages, trajectory, source = "outer") {
558
731
  preflightUnsupportedMedia(this.config, messages);
@@ -562,50 +735,83 @@ export class Agent {
562
735
  triggerRatio: this.config.contextCompactionRatio,
563
736
  summaryMaxTokens: this.config.compactionSummaryMaxTokens,
564
737
  reasoningEffort: this.config.reasoningEffort,
565
- ...(this.options.compactionThresholdTokens === undefined ? {} : {
566
- compactionThresholdTokens: this.options.compactionThresholdTokens,
567
- }),
568
- 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,
569
765
  };
570
766
  const contextLength = messages.length;
571
767
  const result = settings.legacy
572
768
  ? await compactMessagesIfNeeded(messages, this.tools.availableTools(), this.client, this.config.model, settings, this.options.signal)
573
- : 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,
574
- 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
+ });
575
775
  if (result.compacted) {
576
- if (!settings.legacy) {
577
- const covered = this.memoryCache.state.coveredMessages;
578
- const retained = contextLength - covered;
579
- // Insert at the covered boundary, before the retained original tail.
580
- // Marker position is the resume boundary; no separate index is stored.
581
- let insertion = trajectory.length;
582
- let remaining = retained;
583
- while (remaining > 0 && insertion > 0) {
584
- if (trajectory[--insertion].role !== "compaction")
585
- remaining--;
586
- }
587
- trajectory.splice(insertion, 0, {
588
- role: "compaction", content: [{ type: "text", text: result.memory }],
589
- undone: false,
590
- compaction: {
591
- ratio: result.estimatedTokensBefore / this.config.contextWindow,
592
- tokens_at_compaction: result.estimatedTokensBefore,
593
- source,
594
- },
595
- });
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)) {
596
797
  // Coverage indices referred to the pre-compaction history. The next
597
798
  // update reads its prior memory from the newly embedded block.
598
799
  this.memoryCache.state = undefined;
599
800
  this.memoryCache.latest = undefined;
600
801
  this.memoryCache.latestUsage = undefined;
601
802
  this.memoryCache.attemptedPrefix = undefined;
803
+ this.persistedMemory = undefined;
602
804
  }
603
805
  await this.checkpoint(trajectory);
604
806
  this.sink.compaction(this.sessionId, result.usage);
605
807
  }
606
808
  return result.compacted;
607
809
  }
608
- catch {
810
+ catch (error) {
811
+ if (error instanceof CheckpointExit)
812
+ return "checkpoint";
813
+ if (error instanceof CheckpointFailure)
814
+ throw error.cause;
609
815
  // Compaction is protective rather than task-critical. If its dedicated
610
816
  // summary request fails, keep the original history and let the normal
611
817
  // model request (and its retry policy) decide whether it still fits.
@@ -632,6 +838,7 @@ export class Agent {
632
838
  const calls = new Map();
633
839
  const responseItems = new Map();
634
840
  let usage;
841
+ let finishReason;
635
842
  let reasoningDetails;
636
843
  const detectors = this.options.midturnCheckpointHook ? {
637
844
  content: new RepetitiveOutputDetector(),
@@ -700,6 +907,9 @@ export class Agent {
700
907
  // letting the last non-empty value win.
701
908
  if (chunk.usage)
702
909
  usage = chunk.usage;
910
+ if (chunk.choices?.[0]?.finish_reason) {
911
+ finishReason = chunk.choices[0].finish_reason;
912
+ }
703
913
  if (chunk.reasoning_details?.length) {
704
914
  reasoningDetails = chunk.reasoning_details;
705
915
  }
@@ -738,6 +948,11 @@ export class Agent {
738
948
  if (fragment.function?.arguments) {
739
949
  call.function.arguments += String(fragment.function.arguments);
740
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
+ }
741
956
  calls.set(index, call);
742
957
  this.sink.delta(this.sessionId, {
743
958
  tool_calls: [
@@ -749,11 +964,23 @@ export class Agent {
749
964
  name: String(fragment.function?.name || ""),
750
965
  arguments: String(fragment.function?.arguments || ""),
751
966
  },
967
+ ...(fragment.extra_content !== undefined
968
+ ? { extra_content: structuredClone(fragment.extra_content) }
969
+ : {}),
752
970
  },
753
971
  ],
754
972
  });
755
973
  }
756
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
+ }
757
984
  }
758
985
  catch (error) {
759
986
  // Iterator cleanup may itself report the intentional transport abort.
@@ -785,7 +1012,7 @@ export class Agent {
785
1012
  const openaiResponseItems = [...responseItems.entries()]
786
1013
  .sort(([left], [right]) => left - right)
787
1014
  .map(([, item]) => item);
788
- const partial = content || reasoningContent || openaiResponseItems.length
1015
+ const partial = content || reasoningContent || openaiResponseItems.length || usage
789
1016
  ? {
790
1017
  role: "assistant",
791
1018
  content,
@@ -821,7 +1048,10 @@ export class Agent {
821
1048
  .sort(([left], [right]) => left - right)
822
1049
  .map(([, call]) => call);
823
1050
  }
824
- return assistant;
1051
+ return {
1052
+ assistant,
1053
+ finishReason: finishReason ?? (assistant.tool_calls?.length ? "tool_calls" : "stop"),
1054
+ };
825
1055
  }
826
1056
  }
827
1057
  async retryBackoff(attempt) {