newmark-agent 0.5.14 → 0.6.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 (68) hide show
  1. package/dist/conversation-utility-host.bundle.cjs +4565 -2998
  2. package/dist/conversation-utility-host.js +1 -1
  3. package/dist/core/agent.d.ts +108 -16
  4. package/dist/core/agent.js +698 -195
  5. package/dist/core/agentKernel/agent.d.ts +1 -0
  6. package/dist/core/agentKernel/agent.js +9 -0
  7. package/dist/core/agentKernelDiagnostics.d.ts +4 -6
  8. package/dist/core/agentKernelDiagnostics.js +12 -9
  9. package/dist/core/agentKernelRunner.d.ts +5 -0
  10. package/dist/core/agentKernelRunner.js +250 -107
  11. package/dist/core/autoRouter.js +17 -27
  12. package/dist/core/config.d.ts +2 -0
  13. package/dist/core/config.js +7 -1
  14. package/dist/core/conversationCommandState.d.ts +17 -0
  15. package/dist/core/conversationCommandState.js +140 -0
  16. package/dist/core/conversationKernel.d.ts +25 -4
  17. package/dist/core/conversationKernel.js +360 -89
  18. package/dist/core/conversationListEvent.d.ts +6 -0
  19. package/dist/core/conversationListEvent.js +14 -0
  20. package/dist/core/electronUtilityAgentClient.d.ts +4 -1
  21. package/dist/core/electronUtilityAgentClient.js +4 -4
  22. package/dist/core/electronUtilityRuntimePool.d.ts +8 -2
  23. package/dist/core/electronUtilityRuntimePool.js +11 -5
  24. package/dist/core/installUpdate.js +41 -29
  25. package/dist/core/modelResponseHealth.d.ts +17 -0
  26. package/dist/core/modelResponseHealth.js +193 -0
  27. package/dist/core/providerUsageAccounting.d.ts +47 -0
  28. package/dist/core/providerUsageAccounting.js +71 -0
  29. package/dist/core/requestContextEstimate.d.ts +18 -0
  30. package/dist/core/requestContextEstimate.js +54 -0
  31. package/dist/core/subagent.d.ts +89 -5
  32. package/dist/core/subagent.js +264 -41
  33. package/dist/core/subagentCommunication.d.ts +43 -0
  34. package/dist/core/subagentCommunication.js +167 -0
  35. package/dist/core/types.d.ts +34 -1
  36. package/dist/core/utilityAgentProtocol.d.ts +4 -0
  37. package/dist/core/workEventCoalescer.js +4 -1
  38. package/dist/core/wslAgentClient.d.ts +18 -5
  39. package/dist/core/wslAgentClient.js +79 -27
  40. package/dist/core/wslAgentProtocol.d.ts +7 -0
  41. package/dist/core/wslAgentRuntimePool.d.ts +8 -2
  42. package/dist/core/wslAgentRuntimePool.js +19 -6
  43. package/dist/core/wslRuntimeProcessTree.d.ts +10 -0
  44. package/dist/core/wslRuntimeProcessTree.js +104 -0
  45. package/dist/llm/provider.d.ts +5 -12
  46. package/dist/llm/provider.js +150 -269
  47. package/dist/main.js +582 -356
  48. package/dist/preload.js +3 -3
  49. package/dist/providers/chat-completions.adapter.d.ts +2 -0
  50. package/dist/providers/chat-completions.adapter.js +84 -36
  51. package/dist/providers/provider-adapter.d.ts +2 -0
  52. package/dist/providers/provider-events.d.ts +23 -14
  53. package/dist/providers/provider-events.js +148 -43
  54. package/dist/providers/provider-headers.d.ts +7 -3
  55. package/dist/providers/provider-headers.js +109 -39
  56. package/dist/providers/provider-request-compat.d.ts +6 -0
  57. package/dist/providers/provider-request-compat.js +37 -0
  58. package/dist/providers/responses.adapter.d.ts +2 -0
  59. package/dist/providers/responses.adapter.js +192 -126
  60. package/dist/server.d.ts +9 -2
  61. package/dist/server.js +100 -24
  62. package/dist/tools/index.js +17 -1
  63. package/dist/ui/index.html +2515 -693
  64. package/dist/ui/lucide-sprite.svg +7 -0
  65. package/dist/ui/startup.html +6 -6
  66. package/dist/wsl-agent-host.bundle.cjs +4575 -3006
  67. package/dist/wsl-agent-host.js +9 -4
  68. package/package.json +23 -4
@@ -35,9 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Agent = exports.ROOT_AGENT_ACTOR_ID = void 0;
37
37
  exports.normalizeIntelligenceTier = normalizeIntelligenceTier;
38
+ const modelResponseHealth_1 = require("./modelResponseHealth");
38
39
  const fs = __importStar(require("fs"));
39
40
  const path = __importStar(require("path"));
40
41
  const crypto = __importStar(require("crypto"));
42
+ const promises_1 = require("node:timers/promises");
43
+ const providerUsageAccounting_1 = require("./providerUsageAccounting");
44
+ const requestContextEstimate_1 = require("./requestContextEstimate");
41
45
  const imageInspect_1 = require("./imageInspect");
42
46
  const conversationAttachments_1 = require("./conversationAttachments");
43
47
  const displayImages_1 = require("./displayImages");
@@ -48,6 +52,7 @@ const index_1 = require("../tools/index");
48
52
  const workspace_1 = require("./workspace");
49
53
  const ssh_1 = require("./ssh");
50
54
  const subagent_1 = require("./subagent");
55
+ const subagentCommunication_1 = require("./subagentCommunication");
51
56
  const compat_1 = require("./compat");
52
57
  const skills_1 = require("./skills");
53
58
  const flow_1 = require("./flow");
@@ -222,6 +227,8 @@ class Agent {
222
227
  lastCompression = null;
223
228
  providerUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
224
229
  lastProviderUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
230
+ providerUsageAccounting = (0, providerUsageAccounting_1.createAccounting)();
231
+ lastRequestContext = null;
225
232
  compressionCache = [];
226
233
  pendingHistoryRemovals = [];
227
234
  branchMailbox = [];
@@ -264,15 +271,25 @@ class Agent {
264
271
  activeAgentKernelRuntime = null;
265
272
  modelSwitchCompressionPromise = null;
266
273
  activePeerAgents = new Map();
274
+ // Bounded caller-owned peer slots retain protocol compatibility learning
275
+ // between mailbox jobs. They never retain the child Agent or go to disk.
276
+ peerProviderCaches = new Map();
267
277
  awaitingAgentKernelRuntime = false;
268
278
  pendingAgentKernelQueue = [];
269
279
  linkedPlanAccess;
270
280
  subagentContextPersist;
281
+ subagentPersistedHistory = [];
282
+ subagentPersistedCompression = null;
271
283
  agentKernelUserMessageStartSubscribers = [];
272
284
  rootInboxWakeSubscribers = [];
285
+ directRootInboxQueuedIds = new Set();
286
+ directRootWakePending = false;
287
+ rootInboxRetiredSubscribers = [];
273
288
  activeWorkRunId = '';
274
289
  loadedWorkspaceConversationKey = '';
275
290
  managedWorkRunIds = new Set();
291
+ /** Public deltas since the last completed response, retained only until its boundary. */
292
+ pendingPublicWorkText = new Map();
276
293
  finalizingWorkRunId = '';
277
294
  agentRunService = null;
278
295
  agentRunByWorkRunId = new Map();
@@ -308,6 +325,7 @@ class Agent {
308
325
  recentChecks: [],
309
326
  };
310
327
  rootInboxListener = (message) => this.deliverRootInboxMessage(message);
328
+ boundSubagentManagerKey = '';
311
329
  agentOnly;
312
330
  runtimeActorId;
313
331
  runtimeLifecycleRole;
@@ -430,6 +448,12 @@ class Agent {
430
448
  this.toolDefinitionCache.clear();
431
449
  this.status = 'idle';
432
450
  }
451
+ /** Selecting the composer mode does not start a Goal or reset a running Flow. */
452
+ selectConversationMode(mode) {
453
+ this.mode = ['build', 'plan', 'chat', 'goal', 'flow'].includes(mode) ? mode : 'build';
454
+ this.invalidateSystemPrompt();
455
+ this.saveWorkspaceConversationState(true);
456
+ }
433
457
  currentConversationFlowSelection() {
434
458
  return this.flow?.name
435
459
  ? { name: this.flow.name, pc: Math.max(0, Math.floor(Number(this.flowPc) || 0)) }
@@ -916,13 +940,12 @@ class Agent {
916
940
  return this.modelSelectionValue();
917
941
  }
918
942
  defaultModelCandidate() {
919
- const rejected = new Set(['unavailable', 'auth_error', 'invalid_config']);
920
943
  return this.config.allModels()
921
944
  .map((model, index) => {
922
945
  const validationStatus = effectiveModelValidationStatus(model);
923
946
  const evaluationStatus = validationStatus === 'degraded' ? 'degraded' : String(model.evaluation?.status || '').toLowerCase();
924
947
  const level = String(model.validation?.level || '').toLowerCase();
925
- if (model.enabled === false || rejected.has(validationStatus) || rejected.has(evaluationStatus) || evaluationStatus.startsWith('error')) {
948
+ if (model.enabled === false) {
926
949
  return null;
927
950
  }
928
951
  let score = 0;
@@ -1036,18 +1059,20 @@ class Agent {
1036
1059
  this.routeAttemptStartedAt = Date.now();
1037
1060
  }
1038
1061
  async waitForPlannedRouteRetry(explicitDelayMs) {
1039
- if (explicitDelayMs !== undefined) {
1040
- if (explicitDelayMs <= 0)
1041
- return;
1042
- await new Promise(resolve => setTimeout(resolve, explicitDelayMs));
1043
- return;
1044
- }
1062
+ // Keep the original Build owner through the entire backoff. Stopping a
1063
+ // healthy request between attempts must not wait out a 60-second timer.
1064
+ const signal = this.activeProcessSignal();
1065
+ throwIfAgentAborted(signal);
1045
1066
  const waitBudgetMs = Math.max(0, Math.min(15_000, this.lastRouteDecision?.retryBudgetMs ?? 5_000));
1046
- const delay = Math.max(0, Math.min(waitBudgetMs, this.lastRouteRetryDelayMs));
1047
- this.lastRouteRetryDelayMs = 0;
1067
+ const delay = explicitDelayMs === undefined
1068
+ ? Math.max(0, Math.min(waitBudgetMs, this.lastRouteRetryDelayMs))
1069
+ : Math.max(0, explicitDelayMs);
1070
+ if (explicitDelayMs === undefined)
1071
+ this.lastRouteRetryDelayMs = 0;
1048
1072
  if (!delay)
1049
1073
  return;
1050
- await new Promise(resolve => setTimeout(resolve, delay));
1074
+ await (0, promises_1.setTimeout)(delay, undefined, { signal });
1075
+ throwIfAgentAborted(signal);
1051
1076
  }
1052
1077
  recordRouteSuccess(latencyMs, throughput) {
1053
1078
  const deployment = this.activeDeployment();
@@ -1395,8 +1420,8 @@ class Agent {
1395
1420
  const reference = parsed.image;
1396
1421
  const hydrated = this.hydrateDisplayImage(reference);
1397
1422
  const model = this.activeModelConfig();
1398
- const provider = model?.vision ? this.engineModel() : null;
1399
- if (!provider || !model?.vision || !hydrated?.dataUrl || !hydrated.sha256)
1423
+ const provider = this.engineModel();
1424
+ if (!provider || !hydrated?.dataUrl || !hydrated.sha256)
1400
1425
  return raw;
1401
1426
  throwIfAgentAborted(signal);
1402
1427
  const deployment = this.activeDeployment();
@@ -1411,7 +1436,7 @@ class Agent {
1411
1436
  const instruction = useChinese
1412
1437
  ? `请观察图片并生成一个准确、具体的中文短标题,描述图片实际内容。只输出标题,不要加“图片”“示意图”“标题”等前缀,不要使用 Markdown,最多60个汉字。${captionHint ? `调用方提示:${captionHint}` : ''}`
1413
1438
  : `Inspect the image and return one accurate, concrete short title describing its actual visual content. Output only the title, with no "image", "diagram", or "title" prefix, no Markdown, and at most 100 characters.${captionHint ? ` Caller hint: ${captionHint}` : ''}`;
1414
- descriptionPromise = this.withTimeout(provider.chat(this.activeModelName(), [{ role: 'user', content: [
1439
+ descriptionPromise = this.withTimeout(this.chatWithConversationUsage(provider, this.activeModelName(), [{ role: 'user', content: [
1415
1440
  { type: 'text', text: instruction },
1416
1441
  { type: 'image_url', image_url: { url: hydrated.dataUrl } },
1417
1442
  ] }], useChinese ? '你是视觉图片标题生成器,只返回忠实、简洁的标题。' : 'You generate faithful concise titles for visual images. Return only the title.', 0, 120, signal), 30000)
@@ -1535,9 +1560,16 @@ class Agent {
1535
1560
  mode: String(event.mode || this.modeName()),
1536
1561
  model: String(event.model || this.model),
1537
1562
  timestamp: String(event.timestamp || this.nowLabel()),
1563
+ toolCallId: isToolEvent && event.toolCallId ? String(event.toolCallId) : undefined,
1538
1564
  toolName,
1539
1565
  toolArgs: type === 'tool_call' && event.toolArgs ? this.visibleToolArgs(event.toolArgs) : undefined,
1540
1566
  queue: isToolEvent ? undefined : event.queue,
1567
+ queueItems: isToolEvent ? undefined : event.queueItems?.map(item => ({
1568
+ id: item.id, text: item.text, queueMode: item.queueMode, requestedMode: item.requestedMode,
1569
+ images: item.images?.map(image => ({ ...image })),
1570
+ goalObjective: item.goalObjective, runId: item.runId, createdAt: item.createdAt,
1571
+ })),
1572
+ queuePaused: isToolEvent ? undefined : event.queuePaused,
1541
1573
  workspaceId: target.workspaceId,
1542
1574
  workspaceKey: event.workspaceKey,
1543
1575
  runtimeKey: String(raw.runtimeKey || (0, conversationTarget_1.conversationRuntimeKey)(target)),
@@ -2250,6 +2282,9 @@ class Agent {
2250
2282
  const run = this.workRuns.find(item => item.runId === String(runId || ''));
2251
2283
  if (!run)
2252
2284
  return false;
2285
+ if (status !== 'completed' && (run.status === 'running' || run.status === status || (run.status === 'interrupted' && status === 'force_interrupted'))) {
2286
+ this.persistInterruptedPublicWorkText(run);
2287
+ }
2253
2288
  this.syncAgentRunTerminal(run.runId, status, endedAt);
2254
2289
  this.flushPendingHistoryRemovals();
2255
2290
  if (run.status !== 'running') {
@@ -2346,11 +2381,23 @@ class Agent {
2346
2381
  conversationId: run.target.conversationId,
2347
2382
  });
2348
2383
  }
2384
+ persistInterruptedPublicWorkText(run) {
2385
+ const chunks = this.pendingPublicWorkText.get(run.runId);
2386
+ this.pendingPublicWorkText.delete(run.runId);
2387
+ const content = chunks?.join('');
2388
+ if (!content?.trim())
2389
+ return;
2390
+ // This is an incomplete public reply, not a successful final answer or a
2391
+ // model-history message. A single durable boundary replaces live deltas.
2392
+ this.emitWorkEvent({ type: 'response', content, runId: run.runId, conversationId: run.target.conversationId });
2393
+ }
2349
2394
  emitWorkEvent(input) {
2350
2395
  if (input.type === 'start' && !this.workRuns.some(run => run.runId === this.activeWorkRunId && run.status === 'running')) {
2351
2396
  this.beginConversationWorkRun(input.runId || crypto.randomUUID(), this.currentConversationTarget(input.conversationId));
2352
2397
  }
2353
2398
  const activeRun = this.workRuns.find(run => run.runId === (input.runId || this.activeWorkRunId));
2399
+ if (activeRun && input.type === 'error')
2400
+ this.persistInterruptedPublicWorkText(activeRun);
2354
2401
  const managedTurnBoundary = input.type === 'done'
2355
2402
  && !!activeRun
2356
2403
  && this.managedWorkRunIds.has(activeRun.runId)
@@ -2374,9 +2421,16 @@ class Agent {
2374
2421
  mode: input.mode || this.modeName(),
2375
2422
  model: input.model || this.model,
2376
2423
  timestamp: input.timestamp || this.nowLabel(),
2424
+ toolCallId: isToolEvent ? input.toolCallId : undefined,
2377
2425
  toolName,
2378
2426
  toolArgs: publishedType === 'tool_call' && input.toolArgs ? this.visibleToolArgs(input.toolArgs) : undefined,
2379
2427
  queue: isToolEvent ? undefined : input.queue,
2428
+ queueItems: isToolEvent ? undefined : input.queueItems?.map(item => ({
2429
+ id: item.id, text: item.text, queueMode: item.queueMode, requestedMode: item.requestedMode,
2430
+ images: item.images?.map(image => ({ ...image })),
2431
+ goalObjective: item.goalObjective, runId: item.runId, createdAt: item.createdAt,
2432
+ })),
2433
+ queuePaused: isToolEvent ? undefined : input.queuePaused,
2380
2434
  workspaceId: input.workspaceId || activeRun?.target.workspaceId || this.currentConversationTarget(input.conversationId).workspaceId,
2381
2435
  workspaceKey: input.workspaceKey,
2382
2436
  runtimeKey: input.runtimeKey || activeRun?.runtimeKey,
@@ -2389,6 +2443,14 @@ class Agent {
2389
2443
  fallback: input.fallback,
2390
2444
  };
2391
2445
  if (activeRun && this.isPersistablePublicWorkEvent(event)) {
2446
+ if (event.type === 'text' && activeRun.status === 'running') {
2447
+ const chunks = this.pendingPublicWorkText.get(activeRun.runId) || [];
2448
+ chunks.push(event.content);
2449
+ this.pendingPublicWorkText.set(activeRun.runId, chunks);
2450
+ }
2451
+ else if (event.type === 'response' || event.type === 'final_response' || event.type === 'done' || event.type === 'error') {
2452
+ this.pendingPublicWorkText.delete(activeRun.runId);
2453
+ }
2392
2454
  activeRun.sequence = Number(sequence || activeRun.sequence + 1);
2393
2455
  // Streaming text is delivered live, while the complete sanitized API
2394
2456
  // response is persisted once at message_end. This avoids saving hundreds
@@ -2474,8 +2536,10 @@ class Agent {
2474
2536
  attachAgentKernelRuntime(runtime) {
2475
2537
  this.activeAgentKernelRuntime = runtime;
2476
2538
  this.awaitingAgentKernelRuntime = false;
2477
- if (!runtime)
2539
+ if (!runtime) {
2540
+ this.directRootInboxQueuedIds.clear();
2478
2541
  return;
2542
+ }
2479
2543
  // A user stop can arrive while the Native Kernel is still being loaded or
2480
2544
  // assembling its first context. In that handoff window
2481
2545
  // abortActiveKernelRun() can only abort the outer process signal; make a
@@ -2495,11 +2559,52 @@ class Agent {
2495
2559
  this.agentKernelUserMessageStartSubscribers = this.agentKernelUserMessageStartSubscribers.filter(sub => sub !== fn);
2496
2560
  };
2497
2561
  }
2498
- subscribeRootInboxWake(fn) {
2562
+ subscribeRootInboxWake(fn, retired) {
2499
2563
  this.rootInboxWakeSubscribers.push(fn);
2564
+ if (retired)
2565
+ this.rootInboxRetiredSubscribers.push(retired);
2500
2566
  return () => {
2501
2567
  this.rootInboxWakeSubscribers = this.rootInboxWakeSubscribers.filter(sub => sub !== fn);
2568
+ if (retired)
2569
+ this.rootInboxRetiredSubscribers = this.rootInboxRetiredSubscribers.filter(sub => sub !== retired);
2570
+ };
2571
+ }
2572
+ /** Called only after receipt-bearing tool history has been saved, or on cold replay. */
2573
+ acknowledgeSubagentSettlementReceipts() {
2574
+ if (this.isSubagentRuntime)
2575
+ return [];
2576
+ const receipts = this.history.flatMap(message => {
2577
+ const receipt = message.subagent_settlement_receipt;
2578
+ return message.role === 'tool' && ['subagent_result', 'subagent_read'].includes(String(message.name || ''))
2579
+ && receipt && typeof receipt.peerId === 'string' && Number.isSafeInteger(receipt.revision) && receipt.revision > 0
2580
+ ? [receipt] : [];
2581
+ });
2582
+ if (!receipts.length)
2583
+ return [];
2584
+ const ids = this.subagents.acknowledgeSettlementResults(receipts);
2585
+ if (!ids.length)
2586
+ return [];
2587
+ const retired = new Set(ids);
2588
+ const matches = (content, hidden, clientMessageId) => {
2589
+ if (!hidden || clientMessageId || typeof content !== 'string')
2590
+ return false;
2591
+ const id = content.match(/^\[Root subagent inbox id=([0-9a-f-]{36})\b/i)?.[1];
2592
+ return !!id && retired.has(id);
2502
2593
  };
2594
+ this.pendingAgentKernelQueue = this.pendingAgentKernelQueue.filter(item => item.queueMode !== 'followUp'
2595
+ || !matches(item.content, item.hiddenUserInput === true, item.clientMessageId));
2596
+ this.activeAgentKernelRuntime?.removeQueuedMessages?.((raw, mode) => {
2597
+ const message = raw;
2598
+ return mode === 'followUp' && matches(message.content, message.hiddenUserInput === true, message.clientMessageId);
2599
+ });
2600
+ const before = this.continuations.length;
2601
+ this.continuations = this.continuations.filter(item => item.queueMode !== 'followUp'
2602
+ || !matches(item.content, item.hiddenUserInput === true, item.clientMessageId));
2603
+ if (this.continuations.length !== before)
2604
+ this.saveWorkspaceConversationState(true);
2605
+ for (const subscriber of this.rootInboxRetiredSubscribers)
2606
+ subscriber(ids);
2607
+ return ids;
2503
2608
  }
2504
2609
  notifyAgentKernelUserMessageStart(content, clientMessageId) {
2505
2610
  const text = String(content || '');
@@ -2592,7 +2697,7 @@ class Agent {
2592
2697
  }
2593
2698
  consumeConversationContinuation(match) {
2594
2699
  const index = this.continuations.findIndex(item => match.clientMessageId
2595
- ? item.clientMessageId === match.clientMessageId
2700
+ ? item.clientMessageId === match.clientMessageId && item.queueMode === match.queueMode
2596
2701
  : item.queueMode === match.queueMode && item.content === match.content);
2597
2702
  if (index < 0)
2598
2703
  return false;
@@ -2636,6 +2741,9 @@ class Agent {
2636
2741
  attachments: attachments.length ? attachments : undefined,
2637
2742
  hiddenUserInput: raw.hiddenUserInput === true,
2638
2743
  createdAt: String(raw.createdAt || new Date().toISOString()),
2744
+ visibleUserInput: raw.visibleUserInput,
2745
+ visibleMode: raw.visibleMode,
2746
+ goalObjective: raw.goalObjective,
2639
2747
  });
2640
2748
  }
2641
2749
  return Array.from(deduped.values()).slice(-100);
@@ -2730,9 +2838,9 @@ class Agent {
2730
2838
  this.conversationStateDirty.delete(file);
2731
2839
  this.writeStoredConversationStateNow(pending.state, pending.ws);
2732
2840
  }
2733
- getStoredFlowSuspension(conversationId = this.activeConversationId) {
2734
- const stored = this.readStoredConversationState();
2735
- const stateKey = this.workspaceConversationStateKey(conversationId);
2841
+ getStoredFlowSuspension(conversationId = this.activeConversationId, ws = this.workspace.current) {
2842
+ const stored = this.readStoredConversationState(ws);
2843
+ const stateKey = this.workspaceConversationStateKeyFor(conversationId, ws);
2736
2844
  if (stateKey && stored.flowSuspensions && stored.flowSuspensions[stateKey])
2737
2845
  return stored.flowSuspensions[stateKey];
2738
2846
  const legacy = stored.flowSuspension || null;
@@ -2741,8 +2849,8 @@ class Agent {
2741
2849
  const legacyTargetsThis = !legacy.target || (legacy.target.conversationId === conversationId);
2742
2850
  return legacyTargetsThis ? legacy : null;
2743
2851
  }
2744
- saveStoredFlowSuspension(suspension, conversationId = this.activeConversationId) {
2745
- const stateKey = this.workspaceConversationStateKey(conversationId);
2852
+ saveStoredFlowSuspension(suspension, conversationId = this.activeConversationId, ws = this.workspace.current) {
2853
+ const stateKey = this.workspaceConversationStateKeyFor(conversationId, ws);
2746
2854
  if (!stateKey)
2747
2855
  return;
2748
2856
  // Use the lock-aware latest-state mutator directly. Passing a partial
@@ -2750,7 +2858,7 @@ class Agent {
2750
2858
  // merge the deleted key back from the latest disk snapshot, making Stop,
2751
2859
  // archive, and new-work handoff appear to clear a suspension in memory
2752
2860
  // while it immediately reappears after reload.
2753
- this.mutateStoredConversationState(this.workspace.current, latest => {
2861
+ this.mutateStoredConversationState(ws, latest => {
2754
2862
  const flowSuspensions = { ...(latest.flowSuspensions || {}) };
2755
2863
  if (suspension) {
2756
2864
  flowSuspensions[stateKey] = { ...suspension, updatedAt: suspension.updatedAt || new Date().toISOString() };
@@ -2763,8 +2871,8 @@ class Agent {
2763
2871
  return next;
2764
2872
  });
2765
2873
  }
2766
- clearStoredFlowSuspension(conversationId = this.activeConversationId) {
2767
- this.saveStoredFlowSuspension(null, conversationId);
2874
+ clearStoredFlowSuspension(conversationId = this.activeConversationId, ws = this.workspace.current) {
2875
+ this.saveStoredFlowSuspension(null, conversationId, ws);
2768
2876
  }
2769
2877
  getStoredConversationDraft(conversationId = this.activeConversationId) {
2770
2878
  const key = this.workspaceConversationStateKey(conversationId);
@@ -3070,8 +3178,10 @@ class Agent {
3070
3178
  if (this.isSubagentRuntime)
3071
3179
  return;
3072
3180
  const clean = this.safeConversationId(conversationId || 'default');
3073
- this.subagents.removeRootInboxListener(this.rootInboxListener);
3074
- this.subagents = (0, subagent_1.sharedSubagentManager)(this.subagentManagerKey(clean), {
3181
+ const managerKey = this.subagentManagerKey(clean);
3182
+ if (this.boundSubagentManagerKey !== managerKey)
3183
+ this.subagents.releaseOwnerBinding(this.rootInboxListener);
3184
+ this.subagents = (0, subagent_1.sharedSubagentManager)(managerKey, {
3075
3185
  conversationId: clean,
3076
3186
  rootAgentId: state?.rootAgentId || this.runtimeActorId,
3077
3187
  concurrency: this.subagentConcurrencyLimit(),
@@ -3086,6 +3196,22 @@ class Agent {
3086
3196
  this.saveWorkspaceConversationState();
3087
3197
  },
3088
3198
  });
3199
+ this.boundSubagentManagerKey = managerKey;
3200
+ }
3201
+ /** Release an idle facade; shared peer state remains available to its next owner. */
3202
+ releaseConversationRuntimeBindings() {
3203
+ if (this.activeProcessSignal() || (this.subagents.ownsExecutionBinding(this.rootInboxListener) && this.subagents.hasPendingWork())) {
3204
+ throw new Error('Cannot release a conversation owner with running or queued work');
3205
+ }
3206
+ this.flushWorkspaceConversationState();
3207
+ this.subagents.releaseOwnerBinding(this.rootInboxListener);
3208
+ }
3209
+ assertPeerOwnerCanSwitch(conversationId, workspaceId = this.workspace.current?.id) {
3210
+ const changesTarget = this.safeConversationId(conversationId || 'default') !== this.activeConversationId
3211
+ || workspaceId !== this.workspace.current?.id;
3212
+ if (changesTarget && this.subagents.ownsExecutionBinding(this.rootInboxListener) && this.subagents.hasPendingWork()) {
3213
+ throw new Error('Cannot switch an execution owner with running or queued subagents');
3214
+ }
3089
3215
  }
3090
3216
  persistSubagentState(conversationId, subagentState) {
3091
3217
  if (this.isSubagentRuntime)
@@ -3109,16 +3235,55 @@ class Agent {
3109
3235
  return child.queueActiveKernelMessage(`${marker}\n${message.body}`, 'steer', undefined, undefined, undefined, true);
3110
3236
  }
3111
3237
  deliverRootInboxMessage(message) {
3238
+ if (this.acknowledgeSubagentSettlementReceipts().includes(message.id))
3239
+ return true;
3112
3240
  const marker = `[Root subagent inbox id=${message.id} ${message.kind} from ${message.fromAgentId}]`;
3113
3241
  const prompt = `${marker}\n${message.body}\n\nReview this persisted peer result and summarize or continue the parent task as needed.`;
3242
+ // Stay in the already active kernel so hosted mailbox delivery shares the
3243
+ // same initialized system/tools instead of creating one Build per message.
3244
+ if (this.directRootInboxQueuedIds.has(message.id))
3245
+ return true;
3246
+ if (this.queueActiveKernelMessage(prompt, 'followUp', undefined, undefined, undefined, true)) {
3247
+ this.directRootInboxQueuedIds.add(message.id);
3248
+ return true;
3249
+ }
3114
3250
  for (const sub of this.rootInboxWakeSubscribers) {
3115
3251
  try {
3116
- if (sub(prompt))
3252
+ if (sub(prompt, { wakeup: message.wakeup !== false }))
3117
3253
  return true;
3118
3254
  }
3119
3255
  catch { /* ignore subscriber errors */ }
3120
3256
  }
3121
- return this.queueActiveKernelMessage(prompt, 'followUp', undefined, undefined, undefined, true);
3257
+ if (message.wakeup !== false && !this.rootInboxWakeSubscribers.length && !this.activeProcessSignal()
3258
+ && !this.subagents.isSchedulingPaused() && this.subagents.ownsExecutionBinding(this.rootInboxListener)
3259
+ && !this.directRootWakePending) {
3260
+ this.directRootWakePending = true;
3261
+ queueMicrotask(() => {
3262
+ this.directRootWakePending = false;
3263
+ if (this.rootInboxWakeSubscribers.length || this.activeProcessSignal() || this.subagents.isSchedulingPaused()
3264
+ || !this.subagents.ownsExecutionBinding(this.rootInboxListener))
3265
+ return;
3266
+ const next = this.subagents.readRootInbox().find(item => item.wakeup !== false);
3267
+ if (!next)
3268
+ return;
3269
+ const text = `[Root subagent inbox id=${next.id} ${next.kind} from ${next.fromAgentId}]\n${next.body}`;
3270
+ void this.process({ text, hiddenUserInput: true }).catch(error => {
3271
+ this.recordWorkStatus(`Subagent mailbox wake failed: ${error instanceof Error ? error.message : String(error)}`);
3272
+ });
3273
+ });
3274
+ }
3275
+ return false;
3276
+ }
3277
+ /** A normal activation consumes queued mail without changing its static request prefix. */
3278
+ flushDirectRootInbox() {
3279
+ if (this.isSubagentRuntime || this.rootInboxWakeSubscribers.length)
3280
+ return;
3281
+ for (const message of this.subagents.readRootInbox()) {
3282
+ const marker = `[Root subagent inbox id=${message.id} `;
3283
+ if (this.history.some(item => item.role === 'user' && String(item.content || '').startsWith(marker)))
3284
+ continue;
3285
+ this.deliverRootInboxMessage(message);
3286
+ }
3122
3287
  }
3123
3288
  deliverPeerSettlement(record) {
3124
3289
  const target = record.createdByAgentId === record.id ? this.subagents.rootAgentId : record.createdByAgentId;
@@ -3129,7 +3294,7 @@ class Agent {
3129
3294
  if (delivery.ok)
3130
3295
  return;
3131
3296
  }
3132
- this.subagents.sendRootMessage(record.id, body, 'result');
3297
+ this.subagents.sendRootMessage(record.id, body, 'result', record.settlementRevision);
3133
3298
  }
3134
3299
  recordsForState(state) {
3135
3300
  if (!state)
@@ -3187,7 +3352,8 @@ class Agent {
3187
3352
  // windows on demand ("load earlier messages").
3188
3353
  const windowSize = Math.max(1, Math.floor(Number(options.window) || 0) || 200);
3189
3354
  const totalMessages = fullChatMessages.length;
3190
- const before = Math.max(0, Math.floor(Number(options.before) || 0) || totalMessages);
3355
+ const before = options.before == null ? totalMessages
3356
+ : Math.min(totalMessages, Math.max(0, Math.floor(Number(options.before) || 0)));
3191
3357
  const windowStart = Math.max(0, before - windowSize);
3192
3358
  const chatMessages = fullChatMessages.slice(windowStart, before);
3193
3359
  const workRuns = this.normalizeWorkRuns(isActiveConversation && viewingRuntimeNode ? this.workRuns : (viewedNode?.workRuns || persisted?.workRuns || memory?.workRuns));
@@ -3267,7 +3433,7 @@ class Agent {
3267
3433
  branchIndexDirectory: { ...(tree.nodeIndex || {}) },
3268
3434
  };
3269
3435
  }
3270
- ensureConversationSnapshot(conversationId = this.activeConversationId) {
3436
+ ensureConversationSnapshot(conversationId = this.activeConversationId, options = {}) {
3271
3437
  const clean = this.safeConversationId(conversationId || 'default');
3272
3438
  const stateKey = this.workspaceConversationStateKey(clean);
3273
3439
  if (stateKey) {
@@ -3296,7 +3462,7 @@ class Agent {
3296
3462
  this.writeStoredConversationState(stored);
3297
3463
  }
3298
3464
  }
3299
- return this.getConversationSnapshot(clean);
3465
+ return this.getConversationSnapshot(clean, options);
3300
3466
  }
3301
3467
  rewindConversation(conversationId, messageIndex) {
3302
3468
  const clean = this.safeConversationId(conversationId || 'default');
@@ -3732,24 +3898,71 @@ class Agent {
3732
3898
  'No preamble, no explanation, no Markdown, no quotes, no trailing punctuation.',
3733
3899
  ].join('\n');
3734
3900
  const prompt = `First user input:\n${String(firstUserInput || '').slice(0, 4000)}\n\nConversation title (a few words):`;
3735
- const controller = new AbortController();
3736
- const timer = setTimeout(() => controller.abort(new Error('conversation rename timed out')), 15000);
3737
3901
  try {
3738
3902
  if (signal?.aborted)
3739
3903
  return '';
3740
3904
  const { temperature, reasoningEffort } = provider.intelligenceConfig(intelligence);
3741
- const generated = await provider.chat(modelName, [{ role: 'user', content: prompt }], system, temperature, 64, controller.signal, reasoningEffort);
3905
+ // The title request uses the same transport policy and cancellation owner
3906
+ // as the formal response. A healthy slow model must not be turned into
3907
+ // five failed requests by an unrelated 15-second title deadline.
3908
+ // Reasoning tokens share the completion budget. A 64-token cap can
3909
+ // truncate even a short title and block every first conversation request.
3910
+ const generated = await this.chatWithConversationUsage(provider, modelName, [{ role: 'user', content: prompt }], system, temperature, 2048, signal, reasoningEffort);
3911
+ if (signal?.aborted)
3912
+ return '';
3742
3913
  const title = this.normalizeConversationRenameTitle(generated);
3743
3914
  const source = String(firstUserInput || '').replace(/\s+/g, ' ').trim();
3744
3915
  return title && title !== source ? title : '';
3745
3916
  }
3746
- catch {
3747
- return '';
3748
- }
3749
- finally {
3750
- clearTimeout(timer);
3917
+ catch (error) {
3918
+ if (signal?.aborted)
3919
+ return '';
3920
+ throw error;
3751
3921
  }
3752
3922
  }
3923
+ conversationTitleFailureCause(error, provider) {
3924
+ const message = error instanceof Error ? error.message : '';
3925
+ const status = message.match(/^\[LLM Error:\s*([1-5]\d{2})\]/)?.[1];
3926
+ if (status) {
3927
+ // Extract only a short machine code. Never display the provider's raw
3928
+ // message/body, request URL, headers or echoed credentials in this gate.
3929
+ let code = '';
3930
+ try {
3931
+ const json = JSON.parse(message.slice(message.indexOf('{')));
3932
+ const candidate = typeof json.error?.code === 'string' ? json.error.code : '';
3933
+ if (/^[a-z][a-z0-9_.-]{0,63}$/i.test(candidate)
3934
+ && !(provider.apiKey && candidate.includes(provider.apiKey))
3935
+ && !(provider.baseUrl && candidate.includes(provider.baseUrl)))
3936
+ code = candidate;
3937
+ }
3938
+ catch { /* Non-JSON errors still retain their safe HTTP status. */ }
3939
+ const reason = code === 'get_channel_failed' ? 'no provider channel available' : {
3940
+ '400': 'request rejected', '401': 'authentication failed', '402': 'payment required',
3941
+ '403': 'access denied', '404': 'model or endpoint unavailable', '408': 'request timed out',
3942
+ '429': 'rate limited', '500': 'provider internal error', '502': 'provider gateway error',
3943
+ '503': 'service unavailable', '504': 'provider gateway timed out',
3944
+ }[status] || 'provider request failed';
3945
+ return `HTTP ${status}${code ? ` (${code})` : ''}: ${reason}`;
3946
+ }
3947
+ if (error instanceof Error && error.name === 'TimeoutError')
3948
+ return 'provider request timed out';
3949
+ if (error instanceof SyntaxError)
3950
+ return 'provider returned invalid JSON';
3951
+ return 'provider request failed';
3952
+ }
3953
+ async waitForConversationTitleRetry(delayMs, signal) {
3954
+ if (signal?.aborted || delayMs <= 0)
3955
+ return;
3956
+ await new Promise(resolve => {
3957
+ const finish = () => {
3958
+ clearTimeout(timer);
3959
+ signal?.removeEventListener('abort', finish);
3960
+ resolve();
3961
+ };
3962
+ const timer = setTimeout(finish, delayMs);
3963
+ signal?.addEventListener('abort', finish, { once: true });
3964
+ });
3965
+ }
3753
3966
  /** Legacy completion hook retained as a no-op; naming is pre-response only. */
3754
3967
  maybeAutoRenameConversationFromRun(_run) {
3755
3968
  // Compatibility no-op. Automatic naming is a hard gate on the first
@@ -3765,19 +3978,33 @@ class Agent {
3765
3978
  const retryDelaysMs = [0, 1000, 2000, 4000, 8000];
3766
3979
  if (!messageId || !firstUserInput.trim())
3767
3980
  return false;
3981
+ // Failure state belongs to this title attempt sequence, not the Agent or
3982
+ // another conversation. A later empty title replaces an earlier HTTP error.
3983
+ let lastFailure = 'provider returned an empty or unusable title';
3984
+ const requestTitle = async () => {
3985
+ lastFailure = 'provider returned an empty or unusable title';
3986
+ try {
3987
+ return await this.deriveConversationTitleFromProvider(firstUserInput, provider, modelName, intelligence, signal);
3988
+ }
3989
+ catch (error) {
3990
+ lastFailure = this.conversationTitleFailureCause(error, provider);
3991
+ return '';
3992
+ }
3993
+ };
3994
+ const exhaustedFailure = () => new Error(`Conversation title generation failed; ${lastFailure}. The first Agent request was not started. Retry the first input.`);
3768
3995
  if (!stateKey) {
3769
3996
  // Pure Agent/CLI mode has no workspace conversation file to rename, but
3770
3997
  // still uses the title request as the required first model-availability
3771
3998
  // probe before starting the formal response.
3772
3999
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
3773
- if (await this.deriveConversationTitleFromProvider(firstUserInput, provider, modelName, intelligence, signal))
4000
+ if (await requestTitle())
3774
4001
  return true;
3775
4002
  if (signal?.aborted)
3776
4003
  return false;
3777
4004
  if (attempt < maxAttempts - 1)
3778
- await new Promise(resolve => setTimeout(resolve, retryDelaysMs[attempt]));
4005
+ await this.waitForConversationTitleRetry(retryDelaysMs[attempt], signal);
3779
4006
  }
3780
- return false;
4007
+ throw exhaustedFailure();
3781
4008
  }
3782
4009
  const stored = this.readStoredConversationState(workspace);
3783
4010
  const entry = stored.conversations?.[stateKey];
@@ -3794,7 +4021,7 @@ class Agent {
3794
4021
  if (memory)
3795
4022
  memory.titleRequestMessageId = messageId;
3796
4023
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
3797
- const title = await this.deriveConversationTitleFromProvider(firstUserInput, provider, modelName, intelligence, signal);
4024
+ const title = await requestTitle();
3798
4025
  if (title) {
3799
4026
  const latest = this.readStoredConversationState(workspace);
3800
4027
  const current = latest.conversations?.[stateKey];
@@ -3812,9 +4039,9 @@ class Agent {
3812
4039
  if (signal?.aborted)
3813
4040
  return false;
3814
4041
  if (attempt < maxAttempts - 1)
3815
- await new Promise(resolve => setTimeout(resolve, retryDelaysMs[attempt]));
4042
+ await this.waitForConversationTitleRetry(retryDelaysMs[attempt], signal);
3816
4043
  }
3817
- return false;
4044
+ throw exhaustedFailure();
3818
4045
  }
3819
4046
  firstPersistedUserForTitleGate() {
3820
4047
  const firstUser = this.chatMessages.find(message => message.role === 'user');
@@ -4008,8 +4235,16 @@ class Agent {
4008
4235
  };
4009
4236
  }
4010
4237
  saveWorkspaceConversationState(flush = true) {
4011
- if (this.isSubagentRuntime)
4238
+ if (this.isSubagentRuntime) {
4239
+ if (this.subagentContextPersist && (this.history.length !== this.subagentPersistedHistory.length
4240
+ || this.lastCompression !== this.subagentPersistedCompression
4241
+ || this.history.some((message, index) => message !== this.subagentPersistedHistory[index]))) {
4242
+ this.subagentContextPersist(this.history, this.lastCompression);
4243
+ this.subagentPersistedHistory = this.history.slice();
4244
+ this.subagentPersistedCompression = this.lastCompression;
4245
+ }
4012
4246
  return;
4247
+ }
4013
4248
  const key = this.workspaceConversationKey();
4014
4249
  if (!key)
4015
4250
  return;
@@ -4018,6 +4253,7 @@ class Agent {
4018
4253
  this.workspaceConversations.set(key, {
4019
4254
  chatMessages: [...this.chatMessages],
4020
4255
  history: [...this.history],
4256
+ providerUsage: this.conversationProviderUsage(),
4021
4257
  compressionCache: [...this.compressionCache],
4022
4258
  branchMailbox: [...this.branchMailbox],
4023
4259
  branchCommunication: this.branchCommunicationEnabled,
@@ -4053,6 +4289,7 @@ class Agent {
4053
4289
  title,
4054
4290
  chatMessages: [...this.chatMessages],
4055
4291
  history: [...this.history],
4292
+ providerUsage: this.conversationProviderUsage(),
4056
4293
  compressionCache: [...this.compressionCache],
4057
4294
  branchMailbox: [...this.branchMailbox],
4058
4295
  branchCommunication: this.branchCommunicationEnabled,
@@ -4085,6 +4322,8 @@ class Agent {
4085
4322
  }
4086
4323
  loadWorkspaceConversationState() {
4087
4324
  this.managedWorkRunIds.clear();
4325
+ this.pendingPublicWorkText.clear();
4326
+ this.restoreProviderUsage();
4088
4327
  const key = this.workspaceConversationKey();
4089
4328
  this.loadedWorkspaceConversationKey = key || '';
4090
4329
  if (!key) {
@@ -4109,6 +4348,7 @@ class Agent {
4109
4348
  }
4110
4349
  const saved = this.workspaceConversations.get(key);
4111
4350
  if (saved) {
4351
+ this.restoreProviderUsage(saved.providerUsage);
4112
4352
  this.history = [...saved.history];
4113
4353
  this.compressionCache = saved.compressionCache ? saved.compressionCache.map(entry => ({ ...entry, messages: [...entry.messages] })) : [];
4114
4354
  this.nextCompressionCacheId = Math.max(1, ...this.compressionCache.map(entry => Number(entry.id.replace(/^ctx-cache-/, '')) || 0)) + 1;
@@ -4136,6 +4376,7 @@ class Agent {
4136
4376
  const stored = this.readStoredConversationState();
4137
4377
  const stateKey = this.workspaceConversationStateKey();
4138
4378
  const persisted = stateKey && stored.conversations ? stored.conversations[stateKey] : null;
4379
+ this.restoreProviderUsage(persisted?.providerUsage);
4139
4380
  const repairedHistory = this.repairDanglingToolCalls(persisted?.history ? [...persisted.history] : []);
4140
4381
  this.history = repairedHistory.messages;
4141
4382
  this.compressionCache = persisted?.compressionCache ? persisted.compressionCache.map(entry => ({ ...entry, messages: [...entry.messages] })) : [];
@@ -4182,6 +4423,7 @@ class Agent {
4182
4423
  this.workspaceConversations.set(key, {
4183
4424
  chatMessages: [...this.chatMessages],
4184
4425
  history: [...this.history],
4426
+ providerUsage: this.conversationProviderUsage(),
4185
4427
  compressionCache: [...this.compressionCache],
4186
4428
  branchMailbox: [...this.branchMailbox],
4187
4429
  branchCommunication: this.branchCommunicationEnabled,
@@ -4232,10 +4474,12 @@ class Agent {
4232
4474
  return ws;
4233
4475
  }
4234
4476
  selectWorkspace(id) {
4477
+ this.assertPeerOwnerCanSwitch(this.activeConversationId, id);
4235
4478
  this.saveWorkspaceConversationState();
4236
4479
  return this.applyWorkspaceContext(this.workspace.select(id));
4237
4480
  }
4238
4481
  selectWorkspaceFromStorage(id) {
4482
+ this.assertPeerOwnerCanSwitch(this.activeConversationId, id);
4239
4483
  const selected = this.workspace.select(id);
4240
4484
  if (selected)
4241
4485
  this.config.loadWorkspaceConfig(selected.path);
@@ -4273,6 +4517,7 @@ class Agent {
4273
4517
  }
4274
4518
  setConversation(id) {
4275
4519
  const clean = this.safeConversationId(id || 'default');
4520
+ this.assertPeerOwnerCanSwitch(clean);
4276
4521
  // Conversation runners may bind a target workspace directly before their
4277
4522
  // first setConversation(). Do not save state loaded for another workspace
4278
4523
  // under the new workspace key during that hand-off.
@@ -4285,6 +4530,7 @@ class Agent {
4285
4530
  return this.activeConversationId;
4286
4531
  }
4287
4532
  setConversationFromStorage(id) {
4533
+ this.assertPeerOwnerCanSwitch(id);
4288
4534
  this.activeConversationId = this.safeConversationId(id || 'default');
4289
4535
  const key = this.workspaceConversationKey();
4290
4536
  if (key)
@@ -4294,6 +4540,8 @@ class Agent {
4294
4540
  }
4295
4541
  persistActiveConversationSelection(id, ws = this.workspace.current) {
4296
4542
  const clean = this.safeConversationId(id || 'default');
4543
+ if (ws && this.workspace.current && path.resolve(ws.path) === path.resolve(this.workspace.current.path))
4544
+ this.assertPeerOwnerCanSwitch(clean);
4297
4545
  this.mutateStoredConversationState(ws, latest => ({
4298
4546
  version: 3,
4299
4547
  activeConversationId: clean,
@@ -4319,6 +4567,9 @@ class Agent {
4319
4567
  }
4320
4568
  abortActiveKernelRun(reason = 'unspecified') {
4321
4569
  let aborted = false;
4570
+ if (!this.isSubagentRuntime && (reason === 'user_stop' || reason === 'force_stop')) {
4571
+ this.subagents.broadcastStop(this.runtimeActorId, this.currentWorkRunId() || this.routeTransactionId || 'inactive', reason === 'force_stop');
4572
+ }
4322
4573
  this.subagents.pauseScheduling();
4323
4574
  if (this.activeProcessAbortController && !this.activeProcessAbortController.signal.aborted) {
4324
4575
  const abortError = new Error('Agent run aborted');
@@ -4339,6 +4590,7 @@ class Agent {
4339
4590
  this.pendingAgentKernelQueue = [];
4340
4591
  return aborted;
4341
4592
  }
4593
+ hasRunningSubagents() { return this.activePeerAgents.size > 0; }
4342
4594
  activeProcessSignal() {
4343
4595
  return this.activeProcessAbortController?.signal;
4344
4596
  }
@@ -4575,7 +4827,7 @@ class Agent {
4575
4827
  ].join('\n');
4576
4828
  const maxTokens = Math.max(1024, Math.min(8192, Math.ceil(content.length / 4)) + 512);
4577
4829
  const { temperature } = provider.intelligenceConfig('low');
4578
- const generated = await this.withTimeout(provider.chat(modelName, [{ role: 'user', content: prompt }], system, temperature, maxTokens, signal), 120000);
4830
+ const generated = await this.withTimeout(this.chatWithConversationUsage(provider, modelName, [{ role: 'user', content: prompt }], system, temperature, maxTokens, signal), 120000);
4579
4831
  const summary = String(generated || '').trim();
4580
4832
  if (!summary || /^\[LLM Error(?::|\])/i.test(summary)) {
4581
4833
  return {
@@ -5423,6 +5675,11 @@ class Agent {
5423
5675
  const stored = this.readStoredConversationState(ws);
5424
5676
  stored.conversations = stored.conversations || {};
5425
5677
  const previous = stateKey ? stored.conversations[stateKey] : undefined;
5678
+ const providerUsage = source.conversationProviderUsage ? source.conversationProviderUsage() : source.providerUsageTotals ? {
5679
+ version: 1,
5680
+ totals: this.normalizeProviderUsageCounters(source.providerUsageTotals),
5681
+ last: this.normalizeProviderUsageCounters(source.lastProviderUsage),
5682
+ } : previous?.providerUsage;
5426
5683
  const sourceTitleGate = source.getConversationTitleGateState?.() || source.titleGateState;
5427
5684
  const titleRequestMessageId = sourceTitleGate?.titleRequestMessageId === undefined
5428
5685
  ? String(previous?.titleRequestMessageId || '')
@@ -5434,6 +5691,7 @@ class Agent {
5434
5691
  this.workspaceConversations.set(key, {
5435
5692
  chatMessages: normalizedChatMessages,
5436
5693
  history: [...source.history],
5694
+ providerUsage,
5437
5695
  plan,
5438
5696
  linkedPlan,
5439
5697
  subagentState,
@@ -5459,6 +5717,7 @@ class Agent {
5459
5717
  title,
5460
5718
  chatMessages: normalizedChatMessages,
5461
5719
  history: [...source.history],
5720
+ providerUsage,
5462
5721
  plan,
5463
5722
  linkedPlan,
5464
5723
  subagentState,
@@ -5481,6 +5740,7 @@ class Agent {
5481
5740
  if (this.safeConversationId(this.activeConversationId || 'default') === clean) {
5482
5741
  this.chatMessages = normalizedChatMessages;
5483
5742
  this.history = [...source.history];
5743
+ this.restoreProviderUsage(providerUsage);
5484
5744
  this.conversationPlan = plan;
5485
5745
  this.linkedPlan = linkedPlan;
5486
5746
  if (source.subagents)
@@ -5728,22 +5988,143 @@ class Agent {
5728
5988
  buildBlockTokens: estimate(buildBlockAsciiChars, buildBlockNonAsciiChars, buildBlockStructuralChars, true),
5729
5989
  };
5730
5990
  }
5731
- recordProviderUsage(input) {
5732
- const bounded = (value) => Math.max(0, Math.floor(Number(value) || 0));
5733
- const usage = {
5734
- input: bounded(input.input),
5735
- output: bounded(input.output),
5736
- cacheRead: bounded(input.cacheRead),
5737
- cacheWrite: bounded(input.cacheWrite),
5991
+ normalizeProviderUsageCounters(input) {
5992
+ const bounded = (value) => typeof value === 'number' && Number.isFinite(value)
5993
+ ? Math.max(0, Math.floor(value)) : 0;
5994
+ return { input: bounded(input?.input), output: bounded(input?.output), cacheRead: bounded(input?.cacheRead), cacheWrite: bounded(input?.cacheWrite) };
5995
+ }
5996
+ conversationProviderUsage() {
5997
+ return {
5998
+ version: 1, totals: { ...this.providerUsageTotals }, last: { ...this.lastProviderUsage },
5999
+ accounting: { ...this.providerUsageAccounting },
6000
+ requestContext: this.lastRequestContext ? { ...this.lastRequestContext } : null,
6001
+ };
6002
+ }
6003
+ normalizedConversationUsage(saved) {
6004
+ const totals = this.normalizeProviderUsageCounters(saved?.version === 1 ? saved.totals : undefined);
6005
+ const accounting = (0, providerUsageAccounting_1.createAccounting)(saved?.version === 1 ? totals : undefined);
6006
+ if (saved?.version === 1 && saved.accounting) {
6007
+ for (const key of ['requests', 'inputReportedRequests', 'outputReportedRequests', 'cacheReportedRequests', 'cacheEligibleInputTokens', 'cacheReadTokens']) {
6008
+ const value = saved.accounting[key];
6009
+ accounting[key] = typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
6010
+ }
6011
+ accounting.hasLegacyTotals = saved.accounting.hasLegacyTotals === true;
6012
+ accounting.lastInputTokens = typeof saved.accounting.lastInputTokens === 'number' && Number.isFinite(saved.accounting.lastInputTokens)
6013
+ ? Math.max(0, Math.floor(saved.accounting.lastInputTokens)) : null;
6014
+ }
6015
+ let requestContext = null;
6016
+ const context = saved?.version === 1 ? saved.requestContext : null;
6017
+ if (context && typeof context.requestId === 'string' && typeof context.runId === 'string' && typeof context.at === 'string') {
6018
+ requestContext = { requestId: context.requestId, runId: context.runId, model: String(context.model || ''), at: context.at,
6019
+ estimatedTokens: 0, longHistoryTokens: 0, buildBlockTokens: 0, systemPromptTokens: 0, toolSchemaTokens: 0, messageCount: 0, hasImages: context.hasImages === true };
6020
+ for (const key of ['estimatedTokens', 'longHistoryTokens', 'buildBlockTokens', 'systemPromptTokens', 'toolSchemaTokens', 'messageCount', 'inputTokens', 'cacheReadTokens']) {
6021
+ const value = context[key];
6022
+ if (typeof value === 'number' && Number.isFinite(value))
6023
+ requestContext[key] = Math.max(0, Math.floor(value));
6024
+ }
6025
+ }
6026
+ return { version: 1, totals, last: this.normalizeProviderUsageCounters(saved?.version === 1 ? saved.last : undefined), accounting, requestContext };
6027
+ }
6028
+ restoreProviderUsage(saved) {
6029
+ // Legacy counters remain spend evidence, but cannot prove reporting coverage.
6030
+ const usage = this.normalizedConversationUsage(saved);
6031
+ this.providerUsageTotals = usage.totals;
6032
+ this.lastProviderUsage = usage.last;
6033
+ this.providerUsageAccounting = usage.accounting;
6034
+ this.lastRequestContext = usage.requestContext || null;
6035
+ }
6036
+ mutateRequestUsage(request, mutate) {
6037
+ const activeKey = this.workspaceConversationStateKey();
6038
+ if (request.stateKey === activeKey && request.conversationId === this.activeConversationId) {
6039
+ const usage = { version: 1, totals: this.providerUsageTotals, last: this.lastProviderUsage,
6040
+ accounting: this.providerUsageAccounting, requestContext: this.lastRequestContext };
6041
+ mutate(usage);
6042
+ this.lastProviderUsage = usage.last;
6043
+ this.lastRequestContext = usage.requestContext || null;
6044
+ this.saveWorkspaceConversationState(false);
6045
+ return;
6046
+ }
6047
+ // A late auxiliary response belongs to the captured target, not whichever
6048
+ // conversation the user selected while it was running. Do not revive deletion.
6049
+ if (!request.workspace || !request.stateKey)
6050
+ return;
6051
+ this.mutateStoredConversationState(request.workspace, stored => {
6052
+ const entry = stored.conversations?.[request.stateKey];
6053
+ if (!entry)
6054
+ return stored;
6055
+ const usage = this.normalizedConversationUsage(entry.providerUsage);
6056
+ mutate(usage);
6057
+ entry.providerUsage = usage;
6058
+ entry.updatedAt = new Date(Math.max(Date.now(), (Date.parse(entry.updatedAt || '') || 0) + 1)).toISOString();
6059
+ // A different active conversation can already have a debounced snapshot
6060
+ // of this same store. Update only this entry's usage in that snapshot so
6061
+ // its later flush cannot roll back the late measurement or discard B.
6062
+ const storePath = this.workspaceConversationStorePath(request.workspace);
6063
+ const dirtyEntry = storePath ? this.conversationStateDirty.get(storePath)?.state.conversations?.[request.stateKey] : undefined;
6064
+ if (dirtyEntry)
6065
+ dirtyEntry.providerUsage = structuredClone(usage);
6066
+ const memoryKey = `${request.workspace.isInternal ? 'internal' : 'external'}:${path.resolve(request.workspace.path)}::conversation:${request.conversationId}`;
6067
+ const memory = this.workspaceConversations.get(memoryKey);
6068
+ if (memory) {
6069
+ memory.providerUsage = structuredClone(usage);
6070
+ memory.updatedAt = entry.updatedAt;
6071
+ }
6072
+ return stored;
6073
+ });
6074
+ }
6075
+ beginProviderUsageRequest() {
6076
+ const request = {
6077
+ id: crypto.randomUUID(), conversationId: this.activeConversationId,
6078
+ workspace: this.workspace.current ? { ...this.workspace.current } : null,
6079
+ stateKey: this.workspaceConversationStateKey(),
6080
+ accounting: (0, providerUsageAccounting_1.beginAccountingRequest)((0, providerUsageAccounting_1.createAccounting)()),
5738
6081
  };
5739
- this.lastProviderUsage = usage;
5740
- this.providerUsageTotals.input += usage.input;
5741
- this.providerUsageTotals.output += usage.output;
5742
- this.providerUsageTotals.cacheRead += usage.cacheRead;
5743
- this.providerUsageTotals.cacheWrite += usage.cacheWrite;
6082
+ this.mutateRequestUsage(request, usage => { request.accounting = (0, providerUsageAccounting_1.beginAccountingRequest)(usage.accounting); });
6083
+ return request;
6084
+ }
6085
+ recordRequestContext(request, messages, system, tools, model) {
6086
+ const context = {
6087
+ ...(0, requestContextEstimate_1.estimateSubmittedContext)(messages, system, tools, this.compressionBuildBlockStart(messages)),
6088
+ requestId: request.id, runId: this.currentWorkRunId(), model, at: new Date().toISOString(),
6089
+ };
6090
+ this.mutateRequestUsage(request, usage => { usage.requestContext = context; });
6091
+ }
6092
+ recordProviderUsage(input, request = this.beginProviderUsageRequest()) {
6093
+ this.mutateRequestUsage(request, usage => {
6094
+ (0, providerUsageAccounting_1.applyAccountingUsage)(usage.accounting, usage.totals, request.accounting, input);
6095
+ usage.last = { ...request.accounting.usage };
6096
+ if (usage.requestContext?.requestId === request.id) {
6097
+ if (request.accounting.reported.input)
6098
+ usage.requestContext.inputTokens = request.accounting.usage.input;
6099
+ if (request.accounting.reported.cacheRead)
6100
+ usage.requestContext.cacheReadTokens = request.accounting.usage.cacheRead;
6101
+ }
6102
+ });
6103
+ }
6104
+ async chatWithConversationUsage(provider, ...args) {
6105
+ // Bind before awaiting. The callback runs for successful responses even if
6106
+ // usage is absent; failed HTTP/model-probe requests are not invented spend.
6107
+ const workspace = this.workspace.current ? { ...this.workspace.current } : null;
6108
+ const conversationId = this.activeConversationId;
6109
+ const stateKey = this.workspaceConversationStateKey();
6110
+ let request;
6111
+ const callback = args[7];
6112
+ args[7] = input => {
6113
+ if (!request) {
6114
+ request = { id: crypto.randomUUID(), workspace, conversationId, stateKey, accounting: (0, providerUsageAccounting_1.beginAccountingRequest)((0, providerUsageAccounting_1.createAccounting)()) };
6115
+ this.mutateRequestUsage(request, usage => { request.accounting = (0, providerUsageAccounting_1.beginAccountingRequest)(usage.accounting); });
6116
+ }
6117
+ this.recordProviderUsage(input, request);
6118
+ callback?.(input);
6119
+ };
6120
+ return provider.chat(...args);
5744
6121
  }
5745
6122
  contextWindow(modelName = this.model) {
5746
- const estimatedTokens = this.estimateContextTokens();
6123
+ const activeRequest = this.activeAgentKernelRuntime && this.lastRequestContext?.runId === this.currentWorkRunId()
6124
+ ? this.lastRequestContext : null;
6125
+ const historyEstimate = activeRequest ? null
6126
+ : (0, requestContextEstimate_1.estimateSubmittedContext)(this.history, '', [], this.compressionBuildBlockStart(this.history));
6127
+ const estimatedTokens = activeRequest?.estimatedTokens ?? historyEstimate.estimatedTokens;
5747
6128
  // Display and compression must share one window resolution. Both resolve
5748
6129
  // the auto branch through the active (routed) deployment so the UI ring and
5749
6130
  // the compaction trigger stay on the same maxTokens even after an Auto
@@ -5753,14 +6134,15 @@ class Agent {
5753
6134
  const maxTokens = Math.max(1, Number(model?.max_tokens || 0) || 128000);
5754
6135
  const ratio = estimatedTokens / maxTokens;
5755
6136
  const budget = this.compressionBudget(this.history, modelName);
6137
+ const cache = (0, providerUsageAccounting_1.ratioSummary)(this.providerUsageAccounting);
5756
6138
  return {
5757
6139
  estimatedTokens,
5758
6140
  maxTokens,
5759
6141
  ratio,
5760
6142
  warning: ratio >= 1 ? 'over_limit' : ratio >= 0.85 ? 'near_limit' : 'ok',
5761
6143
  model: modelName,
5762
- buildBlockTokens: budget.buildBlockTokens,
5763
- longHistoryTokens: budget.longHistoryTokens,
6144
+ buildBlockTokens: activeRequest?.buildBlockTokens ?? historyEstimate.buildBlockTokens,
6145
+ longHistoryTokens: activeRequest?.longHistoryTokens ?? historyEstimate.longHistoryTokens,
5764
6146
  buildBlockTriggerTokens: budget.buildBlockTriggerTokens,
5765
6147
  longHistoryTriggerTokens: budget.longHistoryTriggerTokens,
5766
6148
  buildBlockRetentionTokens: budget.buildBlockRetentionTokens,
@@ -5775,9 +6157,20 @@ class Agent {
5775
6157
  providerOutputTokens: this.providerUsageTotals.output,
5776
6158
  providerCacheReadTokens: this.providerUsageTotals.cacheRead,
5777
6159
  providerCacheWriteTokens: this.providerUsageTotals.cacheWrite,
5778
- providerCacheReadRatio: this.providerUsageTotals.input > 0
5779
- ? Math.min(1, this.providerUsageTotals.cacheRead / this.providerUsageTotals.input)
5780
- : 0,
6160
+ providerCacheReadRatio: cache.totalRatio,
6161
+ providerKnownCacheReadRatio: cache.knownRatio,
6162
+ providerCacheEligibleInputTokens: cache.denominator,
6163
+ providerUsageRequests: this.providerUsageAccounting.requests,
6164
+ providerUsageInputReportedRequests: this.providerUsageAccounting.inputReportedRequests,
6165
+ providerUsageOutputReportedRequests: this.providerUsageAccounting.outputReportedRequests,
6166
+ providerUsageCacheReportedRequests: this.providerUsageAccounting.cacheReportedRequests,
6167
+ providerUsageHasLegacyTotals: this.providerUsageAccounting.hasLegacyTotals,
6168
+ providerLastInputTokens: this.providerUsageAccounting.lastInputTokens,
6169
+ requestContext: this.lastRequestContext ? { ...this.lastRequestContext } : null,
6170
+ contextEstimateSource: activeRequest ? 'active_request' : 'history',
6171
+ contextEstimateHasImages: activeRequest?.hasImages ?? historyEstimate.hasImages,
6172
+ systemPromptTokens: activeRequest?.systemPromptTokens ?? 0,
6173
+ toolSchemaTokens: activeRequest?.toolSchemaTokens ?? 0,
5781
6174
  };
5782
6175
  }
5783
6176
  resolveWindowModel(modelName) {
@@ -5827,16 +6220,17 @@ class Agent {
5827
6220
  }
5828
6221
  compressionBuildBlockStart(messages) {
5829
6222
  const activeRunId = this.currentWorkRunId();
6223
+ // No active Build means every retained message is historical, including
6224
+ // legacy assistant/tool tails whose old persistence omitted run metadata.
6225
+ if (!activeRunId)
6226
+ return messages.length;
5830
6227
  if (activeRunId) {
5831
6228
  const index = messages.findIndex(message => String(message.run_id || message.runId || '') === activeRunId);
5832
6229
  if (index >= 0)
5833
6230
  return index;
5834
6231
  }
5835
- // dev-0.5.6: 空闲对话(无活动 run)时不得把全部历史算进当前 Build Block,
5836
- // 否则上下文显示窗口的长期历史恒为 0。回退语义:
5837
- // - 存在带 run_id 的消息:boundary 取最后一个 run 的起点之后(该 run 及其
5838
- // 之前的历史属于长期历史,其后无归属的消息属于当前未命名区块);
5839
- // - 完全没有 run_id:全部历史都是长期历史(不存在当前 Build Block)。
6232
+ // Legacy active transcripts may lack the current run tag. Retain the
6233
+ // historical tagged prefix; ordinary new inputs now carry their run_id.
5840
6234
  let lastRunBoundary = -1;
5841
6235
  for (let index = 0; index < messages.length; index += 1) {
5842
6236
  if (String(messages[index]?.run_id || messages[index]?.runId || ''))
@@ -6189,6 +6583,7 @@ class Agent {
6189
6583
  title: this.titleFromMessages(messages, clean),
6190
6584
  chatMessages: messages,
6191
6585
  history: sourceHistory,
6586
+ providerUsage: memory?.providerUsage,
6192
6587
  plan: memory?.plan,
6193
6588
  linkedPlan: memory?.linkedPlan,
6194
6589
  subagentState: memory?.subagentState,
@@ -6286,6 +6681,7 @@ class Agent {
6286
6681
  title: this.titleFromMessages(messages, clean),
6287
6682
  chatMessages: messages,
6288
6683
  history: sourceHistory,
6684
+ providerUsage: memory?.providerUsage,
6289
6685
  plan: memory?.plan,
6290
6686
  linkedPlan: memory?.linkedPlan,
6291
6687
  subagentState: memory?.subagentState,
@@ -6702,7 +7098,7 @@ class Agent {
6702
7098
  preference: Number.isFinite(Number(routeMetadata.route_preference)) ? Number(routeMetadata.route_preference) : undefined,
6703
7099
  fallbackOnly: !!model.fallback_only,
6704
7100
  };
6705
- }).filter(candidate => !this.isBalanceBlockedDeployment(candidate.deployment));
7101
+ });
6706
7102
  }
6707
7103
  persistRouteDecision(decision) {
6708
7104
  try {
@@ -6749,15 +7145,11 @@ class Agent {
6749
7145
  catch { }
6750
7146
  }
6751
7147
  allModelNames() {
6752
- const names = this.config.allModels().filter(m => {
6753
- const status = String(m.evaluation?.status || 'unvalidated');
6754
- const validationStatus = m.validation?.status;
6755
- return status === 'available' || status === 'unvalidated' || validationStatus === 'verified' || validationStatus === 'degraded';
6756
- }).map(m => {
7148
+ const names = this.config.allModels().filter(m => m.enabled !== false).map(m => {
6757
7149
  const label = m.display || m.name;
6758
7150
  return `${m.provider} / ${label}`;
6759
7151
  });
6760
- return this.config.autoSwitchEnabled() && this.config.allModels().length > 0 ? ['auto', ...names] : names;
7152
+ return this.config.autoSwitchEnabled() && names.length > 0 ? ['auto', ...names] : names;
6761
7153
  }
6762
7154
  async evaluateAndSwitch(task, override = undefined) {
6763
7155
  if (!this.config.autoSwitchEnabled() || this.model !== 'auto')
@@ -6847,39 +7239,22 @@ class Agent {
6847
7239
  return !!this.resolvedDeployment && before !== deploymentIdentity(this.resolvedDeployment);
6848
7240
  }
6849
7241
  shouldExposeToolInterface() {
6850
- // Fixed selections and pre-Standard legacy configurations keep the
6851
- // historical behavior. Auto can safely suppress schemas because its
6852
- // eligible deployments have explicit Standard/Extended evidence.
6853
- if (this.model !== 'auto')
6854
- return true;
6855
- const model = this.activeModelConfig();
6856
- if (!model)
6857
- return false;
6858
- const validation = model.validation;
6859
- if (validation?.level !== 'standard' && validation?.level !== 'extended')
6860
- return true;
6861
- return validation.capabilities?.tool_use === true || validation.capabilities?.tools === true;
7242
+ return true;
6862
7243
  }
6863
7244
  modelIsUnavailable(modelName) {
6864
7245
  const model = modelName === this.model || modelName === 'auto'
6865
- ? this.activeModelConfig()
6866
- : this.config.findModel(modelName);
6867
- if (!model)
6868
- return true;
6869
- const validationStatus = effectiveModelValidationStatus(model);
6870
- // `discovered` means unvalidated, not a failed endpoint. Explicit fixed
6871
- // selections remain usable for backwards compatibility; only an executed
6872
- // Basic/Standard/Extended validation may pre-emptively mark them bad.
6873
- if (model.validation?.level !== 'discovered'
6874
- && (validationStatus === 'unavailable' || validationStatus === 'auth_error' || validationStatus === 'invalid_config'))
6875
- return true;
6876
- const status = validationStatus === 'degraded' ? 'degraded' : String(model?.evaluation?.status || '').toLowerCase();
6877
- return status === 'unavailable' || status.startsWith('error');
7246
+ ? this.activeModelConfig() : (parseDeploymentSelectionValue(modelName) ? this.config.findDeployment(parseDeploymentSelectionValue(modelName)) : this.config.findModel(modelName));
7247
+ // Only configuration/user switches can prevent a request. Response and
7248
+ // validation observations are labels, never permission to disable a model.
7249
+ return !model || model.enabled === false;
6878
7250
  }
7251
+ failedDeploymentsThisRun = new Set();
6879
7252
  switchToFallbackModel(errorText = 'transport failure') {
6880
7253
  const fallbackEnabled = this.config.getBool('models', 'fallback_on_unavailable');
6881
7254
  const observedFailure = (0, autoRouter_1.classifyRouteFailure)(errorText);
6882
7255
  const observedDeployment = this.activeDeployment();
7256
+ if (observedDeployment)
7257
+ this.failedDeploymentsThisRun.add(deploymentIdentity(observedDeployment));
6883
7258
  // Keep balance exhaustion scoped to the deployment that actually failed.
6884
7259
  // Provider adapters normally record this before returning an error, but
6885
7260
  // fallback callers are also a public recovery boundary and must not rely
@@ -6951,7 +7326,7 @@ class Agent {
6951
7326
  || deploymentIdentity(this.deploymentRef(m)) !== deploymentIdentity(currentDeployment));
6952
7327
  if (!all.length)
6953
7328
  return null;
6954
- const usable = all.filter(m => !this.isBalanceBlockedDeployment(this.deploymentRef(m)) && !modelConfigIsUnavailable(m));
7329
+ const usable = all.filter(m => !modelConfigIsUnavailable(m) && !this.failedDeploymentsThisRun.has(deploymentIdentity(this.deploymentRef(m))));
6955
7330
  if (!usable.length)
6956
7331
  return null;
6957
7332
  const pref = this.config.autoSwitchPreference();
@@ -7183,8 +7558,8 @@ class Agent {
7183
7558
  /**
7184
7559
  * Final visual safety net: OCR each submitted image and ask a text-only
7185
7560
  * request to conservatively repair the OCR. This is intentionally callable
7186
- * only after a visual-input refusal and after same-provider vision routing
7187
- * has been exhausted; the original image is never sent again.
7561
+ * only after a real visual-input refusal. Cached capabilities never bypass
7562
+ * the initial image request, and OCR does not disable later image attempts.
7188
7563
  */
7189
7564
  async finalVisualFallback(errorText, signal) {
7190
7565
  if (!/(?:vision|image|multimodal|image_url|input_image).*(?:not supported|unsupported|拒绝|不支持|failed|failure|invalid)|(?:not supported|unsupported|拒绝|不支持).*(?:vision|image|multimodal|image_url|input_image)/i.test(String(errorText || '')))
@@ -7192,11 +7567,6 @@ class Agent {
7192
7567
  const current = this.activeModelConfig();
7193
7568
  if (!current)
7194
7569
  return null;
7195
- const alternateVision = this.config.allModels().some(model => model.enabled !== false && model.provider_id === current.provider_id &&
7196
- model.name !== current.name && !!model.vision && !!model.api_key && !!model.provider_url &&
7197
- !['unavailable', 'auth_error', 'invalid_config'].includes(String(model.evaluation?.status || model.validation?.status || '').toLowerCase()));
7198
- if (alternateVision)
7199
- return null;
7200
7570
  const latest = [...this.history].reverse().find(item => item?.role === 'user');
7201
7571
  const parts = latest?.content && Array.isArray(latest.content) ? latest.content : [];
7202
7572
  const images = parts.map(part => {
@@ -7212,41 +7582,91 @@ class Agent {
7212
7582
  if (result.ok && result.text.trim())
7213
7583
  ocr.push({ index: index + 1, text: result.text.slice(0, 50_000), confidence: result.confidence });
7214
7584
  }
7215
- catch { }
7585
+ catch (error) {
7586
+ if (signal?.aborted)
7587
+ throw error;
7588
+ }
7216
7589
  }
7590
+ if (signal?.aborted)
7591
+ throw signal.reason || new Error('Aborted');
7217
7592
  if (!ocr.length)
7218
7593
  return JSON.stringify({ ok: false, fallback: 'mini_ocr_llm', error: 'Local OCR returned no readable text; no visual content was fabricated.' });
7219
- const task = typeof latest?.content === 'string' ? latest.content : '';
7594
+ const task = typeof latest?.content === 'string' ? latest.content : parts.filter(part => part.type === 'text').map(part => String(part.text || '')).join('\n');
7220
7595
  const evidence = ocr.map(item => `Image ${item.index} (OCR confidence ${item.confidence.toFixed(1)}):\n${item.text}`).join('\n\n');
7221
7596
  const prompt = `The provider rejected image input. Answer the user's task using only this approximate OCR evidence. Correct obvious character, spacing, and line-break errors only when supported by context. Preserve [uncertain] markers for ambiguity and never invent missing visual content.\nUser task:\n${task.slice(0, 12_000)}\nOCR evidence:\n${evidence}`;
7222
7597
  let corrected = '';
7223
7598
  try {
7224
7599
  const provider = this.engineModel();
7225
7600
  if (provider)
7226
- corrected = String(await provider.chat(this.activeModelName(), [{ role: 'user', content: prompt }], 'You are a text-only OCR correction assistant. Be conservative and explicit about uncertainty.', 0.05, 3000, signal) || '').trim();
7601
+ corrected = String(await this.chatWithConversationUsage(provider, this.activeModelName(), [{ role: 'user', content: prompt }], 'You are a text-only OCR correction assistant. Be conservative and explicit about uncertainty.', 0.05, 3000, signal) || '').trim();
7602
+ }
7603
+ catch (error) {
7604
+ if (signal?.aborted)
7605
+ throw error;
7227
7606
  }
7228
- catch { }
7229
7607
  return JSON.stringify({
7230
7608
  ok: !!(corrected || ocr.length),
7231
7609
  fallback: 'mini_ocr_llm',
7232
7610
  approximate: true,
7233
- warning: '视觉输入被拒绝;以下内容来自本地 OCR,并经文本模型保守校正,可能不完整。',
7611
+ warning: corrected ? '视觉输入被拒绝;以下内容来自本地 OCR,并经文本模型保守校正,可能不完整。' : '视觉输入被拒绝,文本校正请求也未成功;以下为未经校正的本地 OCR 结果,可能不完整。',
7234
7612
  raw_ocr: ocr,
7235
7613
  corrected: corrected || ocr.map(item => item.text).join('\n\n'),
7236
7614
  uncertainty: corrected ? 'preserved' : 'raw_ocr_only',
7237
7615
  }, null, 2);
7238
7616
  }
7239
- engineModel() {
7617
+ engineModel(cache) {
7240
7618
  if (this.forcedProvider) {
7241
7619
  const active = this.activeDeployment();
7242
7620
  if (!this.forcedProviderDeployment
7243
- || (active && deploymentIdentity(active) === this.forcedProviderDeployment))
7621
+ || (active && deploymentIdentity(active) === this.forcedProviderDeployment)) {
7622
+ if (cache) {
7623
+ delete cache.key;
7624
+ delete cache.provider;
7625
+ }
7244
7626
  return this.forcedProvider;
7627
+ }
7245
7628
  }
7246
7629
  const m = this.activeModelConfig();
7247
- if (!m)
7630
+ if (!m) {
7631
+ if (cache) {
7632
+ delete cache.key;
7633
+ delete cache.provider;
7634
+ }
7248
7635
  return null;
7249
- return new provider_1.LLMProvider(m.provider, m.provider_url, m.api_key, m.provider_protocol, this.config.openAIApiMode(), this.config.contextFlag('provider_adapters_v2'), undefined, this.modelThinkingTierMaps(m), this.providerProxyConfig());
7636
+ }
7637
+ return this.modelProvider(m, cache);
7638
+ }
7639
+ modelProvider(m, cache) {
7640
+ const apiMode = this.config.openAIApiMode();
7641
+ const adapters = this.config.contextFlag('provider_adapters_v2');
7642
+ const thinkingMaps = this.modelThinkingTierMaps(m);
7643
+ const proxy = this.providerProxyConfig();
7644
+ const create = () => (0, modelResponseHealth_1.observeModelResponses)(new provider_1.LLMProvider(m.provider, m.provider_url, m.api_key, m.provider_protocol, apiMode, adapters, undefined, thinkingMaps, proxy), updates => (0, modelResponseHealth_1.recordModelResponseHealth)(this.config.rootPath, {
7645
+ providerId: m.provider_id, modelId: m.name, endpoint: m.provider_url, protocol: m.provider_protocol, credential: m.api_key,
7646
+ }, updates));
7647
+ if (!cache)
7648
+ return create();
7649
+ // Match the provider's configured-proxy/environment precedence. A changed
7650
+ // effective proxy must not reuse a dispatcher created for its predecessor.
7651
+ const effectiveProxyUrl = proxy.enabled === false ? '' : (proxy.url.trim()
7652
+ || process.env.HTTPS_PROXY || process.env.HTTP_PROXY
7653
+ || process.env.https_proxy || process.env.http_proxy || '');
7654
+ const orderedMaps = Object.keys(thinkingMaps || {}).sort().map(model => [
7655
+ model,
7656
+ Object.keys(thinkingMaps[model]).sort().map(tier => [tier, thinkingMaps[model][tier]]),
7657
+ ]);
7658
+ const key = crypto.createHash('sha256').update(JSON.stringify([
7659
+ m.provider_id, m.name, m.logical_model_group_id || '',
7660
+ m.provider, m.provider_url, m.api_key, m.provider_protocol,
7661
+ apiMode, adapters, orderedMaps,
7662
+ proxy.enabled, proxy.url, proxy.auth, effectiveProxyUrl,
7663
+ ])).digest('hex');
7664
+ if (cache.key === key && cache.provider)
7665
+ return cache.provider;
7666
+ const provider = create();
7667
+ cache.key = key;
7668
+ cache.provider = provider;
7669
+ return provider;
7250
7670
  }
7251
7671
  /**
7252
7672
  * dev-0.4.3 模型原生思考强度档位映射表(模型名 → thinking_tier_map)。
@@ -7540,6 +7960,7 @@ class Agent {
7540
7960
  return [{ type: 'text', text: '[Workspace required] Select or create a workspace before starting a conversation.' }];
7541
7961
  }
7542
7962
  if (this.processDepth === 0) {
7963
+ this.failedDeploymentsThisRun.clear();
7543
7964
  this.processingConversationId = this.activeConversationId || 'default';
7544
7965
  this.activeProcessAbortController = new AbortController();
7545
7966
  this.subagents.resumeScheduling();
@@ -7558,27 +7979,6 @@ class Agent {
7558
7979
  this.fileDiffs = [];
7559
7980
  this.pendingOptions = [];
7560
7981
  try {
7561
- if (this.model === 'auto') {
7562
- // Auto routing re-resolves each turn; drop a stale blocked deployment
7563
- // so the router can pick an unblocked candidate instead of failing here.
7564
- if (this.resolvedDeployment && this.isBalanceBlockedDeployment(this.resolvedDeployment)) {
7565
- this.resolvedDeployment = null;
7566
- this.lastRouteDecision = null;
7567
- this.pendingAutoAttempts = [];
7568
- }
7569
- }
7570
- else {
7571
- const blockedMs = this.balanceBlockedMs();
7572
- if (blockedMs > 0) {
7573
- const waitSeconds = Math.max(1, Math.ceil(blockedMs / 1000));
7574
- const deployment = this.activeDeployment();
7575
- const label = deployment ? `${deployment.providerId}/${deployment.modelId}` : this.model;
7576
- const message = `Provider balance exhausted (HTTP 402) for ${label}. Requests on this deployment are paused for ${waitSeconds}s; switch provider or model to continue immediately.`;
7577
- this.status = 'error';
7578
- this.emitWorkEvent({ type: 'error', content: message });
7579
- throw new Error(message);
7580
- }
7581
- }
7582
7982
  let text = typeof input === 'string' ? input : String(input.text || '');
7583
7983
  const inputEnvelope = typeof input === 'string' ? null : input;
7584
7984
  let hiddenUserInput = inputEnvelope?.hiddenUserInput === true;
@@ -7586,6 +7986,7 @@ class Agent {
7586
7986
  if (!explicitFixedModel)
7587
7987
  this.ensureUsableModelSelection();
7588
7988
  let clientMessageId = String(inputEnvelope?.clientMessageId || '').trim();
7989
+ const userMessageId = String(inputEnvelope?.userMessageId || '').trim();
7589
7990
  const inputRunId = String(inputEnvelope?.runId || this.activeWorkRunId || '').trim();
7590
7991
  let rawImages = typeof input === 'string' ? [] : (Array.isArray(input.images) ? input.images : []);
7591
7992
  // dev-0.4.3 Guide 批量续接:同一 Build block 内连续到达的多个 Guide
@@ -7674,16 +8075,6 @@ class Agent {
7674
8075
  await this.evaluateAndSwitch(`${text}\n[image attachment]`, inputEnvelope?.routePolicy);
7675
8076
  autoRouteEvaluated = true;
7676
8077
  }
7677
- const selectedModel = this.activeModelConfig();
7678
- if (images.length && !selectedModel?.vision) {
7679
- // Give the normal route planner first chance to select another
7680
- // same-provider vision deployment. If none is available, the kernel
7681
- // preflight invokes the final mini-OCR + text-only correction path.
7682
- const hasSameProviderVision = selectedModel && this.config.allModels().some(model => model.enabled !== false && model.provider_id === selectedModel.provider_id &&
7683
- model.name !== selectedModel.name && !!model.vision);
7684
- if (hasSameProviderVision)
7685
- this.switchToFallbackModel('vision input not supported by the selected model');
7686
- }
7687
8078
  const now = this.nowLabel();
7688
8079
  const visibleUserInput = inputEnvelope?.visibleUserInput === undefined
7689
8080
  ? text
@@ -7692,7 +8083,7 @@ class Agent {
7692
8083
  const historyContent = images.length
7693
8084
  ? [{ type: 'text', text }, ...images.map(image => ({ type: 'image_url', image_url: { url: image.dataUrl } }))]
7694
8085
  : text;
7695
- if (clientMessageId) {
8086
+ if (clientMessageId && !userMessageId) {
7696
8087
  this.persistGuideMessage(clientMessageId, displayText, inputRunId, historyContent, attachments, String(inputEnvelope?.guideId || ''));
7697
8088
  if (inputRunId) {
7698
8089
  this.recordGuideReceipt({
@@ -7709,19 +8100,30 @@ class Agent {
7709
8100
  }
7710
8101
  }
7711
8102
  else if (!hiddenUserInput) {
7712
- const messageId = crypto.randomUUID();
7713
- this.chatMessages.push({
8103
+ const messageId = userMessageId || crypto.randomUUID();
8104
+ const persistedUser = userMessageId ? this.chatMessages.find(message => message.role === 'user' && message.messageId === userMessageId) : undefined;
8105
+ const userMessage = {
7714
8106
  messageId,
7715
8107
  branchNodeId: this.currentBranchNodeId(),
7716
8108
  role: 'user',
7717
8109
  content: displayText,
7718
8110
  mode: String(inputEnvelope?.visibleMode || this.modeName()),
7719
8111
  model: this.model,
7720
- timestamp: now,
8112
+ timestamp: persistedUser?.timestamp || now,
7721
8113
  attachments: attachments.length ? attachments : undefined,
7722
8114
  runId: this.currentWorkRunId() || undefined,
7723
- });
7724
- this.history.push({ role: 'user', content: historyContent });
8115
+ };
8116
+ if (persistedUser)
8117
+ Object.assign(persistedUser, userMessage);
8118
+ else
8119
+ this.chatMessages.push(userMessage);
8120
+ const persistedHistory = userMessageId ? this.history.find(message => message.role === 'user' && message.user_message_id === userMessageId) : undefined;
8121
+ if (persistedHistory) {
8122
+ persistedHistory.content = historyContent;
8123
+ persistedHistory.run_id = this.currentWorkRunId() || undefined;
8124
+ }
8125
+ else
8126
+ this.history.push({ role: 'user', content: historyContent, run_id: this.currentWorkRunId() || undefined, ...(userMessageId ? { user_message_id: userMessageId } : {}) });
7725
8127
  }
7726
8128
  else {
7727
8129
  this.history.push({
@@ -7734,10 +8136,16 @@ class Agent {
7734
8136
  }
7735
8137
  if (!hiddenUserInput)
7736
8138
  this.recordWorkRunPrimaryPrompt(displayText);
8139
+ const submittedHistoryMessage = userMessageId
8140
+ ? this.history.find(message => message.role === 'user' && message.user_message_id === userMessageId)
8141
+ : this.history.at(-1);
8142
+ const submittedChatMessage = userMessageId
8143
+ ? this.chatMessages.find(message => message.role === 'user' && message.messageId === userMessageId)
8144
+ : [...this.chatMessages].reverse().find(message => message.role === 'user');
7737
8145
  this.saveWorkspaceConversationState(true);
7738
8146
  let firstResponseGateMessageId = '';
7739
8147
  let firstResponseTitleInput = null;
7740
- if (!hiddenUserInput && !clientMessageId && !this.firstAgentResponseStarted) {
8148
+ if (!hiddenUserInput && (!clientMessageId || !!userMessageId) && !this.firstAgentResponseStarted) {
7741
8149
  const firstInput = this.firstPersistedUserForTitleGate();
7742
8150
  if (!firstInput)
7743
8151
  throw new Error('Conversation title generation failed; the first persisted user input is unavailable.');
@@ -7786,6 +8194,14 @@ class Agent {
7786
8194
  // Agent provider request.
7787
8195
  this.notifyAgentKernelUserMessageStart(text, clientMessageId || undefined);
7788
8196
  this.emitWorkEvent({ type: 'start', content: 'Preparing request.' });
8197
+ // Standalone Agent runs acquire their run ID at start, after the title
8198
+ // gate. Bind the captured input now, without moving or bypassing that gate.
8199
+ const startedRunId = this.currentWorkRunId();
8200
+ if (startedRunId && submittedHistoryMessage?.role === 'user')
8201
+ submittedHistoryMessage.run_id = startedRunId;
8202
+ if (startedRunId && !hiddenUserInput && submittedChatMessage)
8203
+ submittedChatMessage.runId = startedRunId;
8204
+ this.saveWorkspaceConversationState(false);
7789
8205
  // Use external opencode CLI engine
7790
8206
  if (this.engine === 'opencode') {
7791
8207
  if (images.length)
@@ -7996,6 +8412,8 @@ class Agent {
7996
8412
  const accepted = await this.handleSubagentContinueEnvelope(args);
7997
8413
  if (!accepted.ok || !accepted.data?.id)
7998
8414
  return accepted.output;
8415
+ if (!['queued', 'working'].includes(accepted.data.status))
8416
+ return accepted.output;
7999
8417
  const settled = await this.waitForSubagentSettlement(accepted.data.id);
8000
8418
  return `${accepted.output}\n${settled?.result || settled?.error || ''}`.trim();
8001
8419
  }
@@ -8005,22 +8423,36 @@ class Agent {
8005
8423
  async handleSubagentContinueEnvelope(args) {
8006
8424
  try {
8007
8425
  const params = JSON.parse(args);
8008
- const name = params.name || params.id || '';
8009
- const prompt = params.message || params.prompt || '';
8426
+ if (!params || typeof params !== 'object' || Array.isArray(params))
8427
+ throw new Error('Arguments must be an object.');
8428
+ if (params.wakeup !== undefined && typeof params.wakeup !== 'boolean')
8429
+ throw new Error('wakeup must be a boolean.');
8430
+ if (params.kind !== undefined && !['directive', 'question', 'result', 'handoff'].includes(params.kind))
8431
+ throw new Error('Invalid message kind.');
8432
+ const name = String(params.id || params.name || '').trim();
8433
+ const selected = (0, subagentCommunication_1.selectSubagentCommunication)(this.history, params);
8434
+ const wakeup = params.wakeup === true;
8435
+ if (name === 'root' || name === this.subagents.rootAgentId) {
8436
+ const delivery = this.subagents.sendRootMessage(this.runtimeActorId, selected.body, params.kind || 'result', undefined, wakeup);
8437
+ return { ok: delivery.ok, output: delivery.ok
8438
+ ? `[Root mailbox message persisted] ${delivery.message?.id} wakeup=${wakeup}`
8439
+ : `[Subagent] ${delivery.error}`, error: delivery.error,
8440
+ metadata: { kind: 'subagent-send', wakeup, selection: selected.selection } };
8441
+ }
8010
8442
  const sa = this.subagents.get(name);
8011
8443
  if (!sa)
8012
8444
  return { ok: false, output: `[Subagent] Not found: ${name}`, error: `Not found: ${name}` };
8013
- if (!prompt)
8014
- return this.subagents.toToolResult(sa.id, '[Subagent] Prompt required.', false);
8015
- sa.messages.push({ role: 'user', content: String(prompt), hidden_user_input: true });
8016
- const delivery = this.subagents.sendMessage(this.runtimeActorId, sa.id, String(prompt), params.kind || 'directive', {
8445
+ const delivery = this.subagents.sendMessage(this.runtimeActorId, sa.id, selected.body, params.kind || 'directive', {
8017
8446
  correlationId: params.correlation_id,
8018
8447
  replyTo: params.reply_to,
8019
- });
8020
- return this.subagents.toToolResult(sa.id, delivery.ok ? `[Subagent message persisted] ${delivery.message?.id}` : `[Subagent] ${delivery.error}`, delivery.ok);
8448
+ }, wakeup);
8449
+ const result = this.subagents.toToolResult(sa.id, delivery.ok ? `[Subagent message persisted] ${delivery.message?.id} wakeup=${wakeup}` : `[Subagent] ${delivery.error}`, delivery.ok);
8450
+ result.metadata = { ...result.metadata, wakeup, selection: selected.selection };
8451
+ return result;
8021
8452
  }
8022
- catch {
8023
- return { ok: false, output: '[Subagent] Invalid continue arguments.', error: 'Invalid continue arguments.' };
8453
+ catch (error) {
8454
+ const detail = error instanceof Error ? error.message : 'Invalid continue arguments.';
8455
+ return { ok: false, output: `[Subagent] ${detail}`, error: detail };
8024
8456
  }
8025
8457
  }
8026
8458
  handleSubagentResult(args) {
@@ -8038,7 +8470,11 @@ class Agent {
8038
8470
  // assistant/user 消息即足以让主 Agent 判断上下文,完整历史按需走
8039
8471
  // subagent_read 的 max_chars 分页读取。
8040
8472
  const transcript = this.subagents.boundedResultTranscript(sa.id);
8041
- return this.subagents.toToolResult(sa.id, `get.subagent("${sa.name}", id="${sa.id}")\nStatus: ${sa.status}\nModel: ${sa.model}\nMode: ${sa.agentMode}\n\nResult:\n${sa.result || ''}\n\nRecent Conversation (bounded):\n${transcript}`, true);
8473
+ const result = this.subagents.toToolResult(sa.id, `get.subagent("${sa.name}", id="${sa.id}")\nStatus: ${sa.status}\nModel: ${sa.model}\nMode: ${sa.agentMode}\n\nResult:\n${sa.result || ''}\n\nRecent Conversation (bounded):\n${transcript}`, true);
8474
+ const receipt = this.subagentSettlementReceipt(sa.id);
8475
+ if (receipt)
8476
+ result.metadata = { ...result.metadata, settlementReceipt: receipt };
8477
+ return result;
8042
8478
  }
8043
8479
  catch {
8044
8480
  return { ok: false, output: '[Subagent] Invalid result arguments.', error: 'Invalid result arguments.' };
@@ -8053,7 +8489,9 @@ class Agent {
8053
8489
  const read = this.subagents.read(this.runtimeActorId, target, Number(params.max_chars || 16000));
8054
8490
  if (!read.ok)
8055
8491
  return { ok: false, output: `[Subagent] ${read.error}`, error: read.error, metadata: { kind: 'subagent-read' } };
8056
- return { ok: true, output: JSON.stringify(read.snapshot, null, 2), data: read.snapshot, metadata: { kind: 'subagent-read' } };
8492
+ const peer = this.subagents.get(target);
8493
+ const receipt = peer && read.snapshot?.peer.result === peer.result ? this.subagentSettlementReceipt(peer.id) : undefined;
8494
+ return { ok: true, output: JSON.stringify(read.snapshot, null, 2), data: read.snapshot, metadata: { kind: 'subagent-read', ...(receipt ? { settlementReceipt: receipt } : {}) } };
8057
8495
  }
8058
8496
  catch {
8059
8497
  return { ok: false, output: '[Subagent] Invalid read arguments.', error: 'Invalid read arguments.' };
@@ -8065,7 +8503,7 @@ class Agent {
8065
8503
  status = String(JSON.parse(args || '{}').status || '');
8066
8504
  }
8067
8505
  catch { }
8068
- const subagents = this.subagents.listAll().filter(record => !status || record.status === status).map(record => this.subagents.toRecord(record.id));
8506
+ const subagents = this.subagents.listSummaries(status);
8069
8507
  return { ok: true, output: JSON.stringify({ conversationId: this.activeConversationId, subagents }, null, 2), metadata: { kind: 'subagent-list' } };
8070
8508
  }
8071
8509
  handleSubagentClose(args) {
@@ -8082,6 +8520,8 @@ class Agent {
8082
8520
  if (actorId === this.subagents.rootAgentId)
8083
8521
  this.activePeerAgents.get(sa.id)?.abortActiveKernelRun();
8084
8522
  const closed = this.subagents.close(sa.id, actorId);
8523
+ if (closed)
8524
+ this.peerProviderCaches.delete(sa.id);
8085
8525
  return this.subagents.toToolResult(sa.id, closed ? `[Subagent '${sa.name}' closed]` : '[Subagent] Close denied.', closed);
8086
8526
  }
8087
8527
  catch {
@@ -8395,7 +8835,7 @@ class Agent {
8395
8835
  }, null, 2);
8396
8836
  try {
8397
8837
  const cfg = provider.intelligenceConfig(this.intelligence);
8398
- const response = await this.withTimeout(provider.chat(this.activeModelName(), [{ role: 'user', content: prompt }], system, Math.min(cfg.temperature, 0.2), Math.min(cfg.maxTokens, 3000), signal), 120000);
8838
+ const response = await this.withTimeout(this.chatWithConversationUsage(provider, this.activeModelName(), [{ role: 'user', content: prompt }], system, Math.min(cfg.temperature, 0.2), Math.min(cfg.maxTokens, 3000), signal), 120000);
8399
8839
  const parsed = this.extractMemoryLabJson(response);
8400
8840
  if (!parsed)
8401
8841
  return deterministic;
@@ -8443,7 +8883,7 @@ class Agent {
8443
8883
  ].join('\n');
8444
8884
  try {
8445
8885
  const cfg = provider.intelligenceConfig(this.intelligence);
8446
- await this.withTimeout(provider.chat(this.activeModelName(), [{ role: 'user', content: JSON.stringify({ index: read.index }, null, 2) }], system, Math.min(cfg.temperature, 0.2), Math.min(cfg.maxTokens, 1200), signal), 120000);
8886
+ await this.withTimeout(this.chatWithConversationUsage(provider, this.activeModelName(), [{ role: 'user', content: JSON.stringify({ index: read.index }, null, 2) }], system, Math.min(cfg.temperature, 0.2), Math.min(cfg.maxTokens, 1200), signal), 120000);
8447
8887
  }
8448
8888
  catch {
8449
8889
  throwIfAgentAborted(signal); /* deterministic reindex still runs */
@@ -8554,10 +8994,18 @@ class Agent {
8554
8994
  }
8555
8995
  const model = assignedModel?.name || (requestedModel === 'auto' ? this.activeModelName() : requestedModel);
8556
8996
  const activeModel = this.activeModelConfig();
8557
- const activeProvider = this.engineModel();
8558
- const assignedProvider = assignedModel && assignedModel.provider_id !== activeModel?.provider_id
8559
- ? new provider_1.LLMProvider(assignedModel.provider, assignedModel.provider_url, assignedModel.api_key, assignedModel.provider_protocol, this.config.openAIApiMode(), this.config.contextFlag('provider_adapters_v2'), undefined, this.modelThinkingTierMaps(assignedModel), this.providerProxyConfig())
8560
- : activeProvider;
8997
+ const peerProviderCache = this.peerProviderCaches.get(id) || {};
8998
+ this.peerProviderCaches.delete(id);
8999
+ this.peerProviderCaches.set(id, peerProviderCache);
9000
+ for (const retainedId of this.peerProviderCaches.keys()) {
9001
+ if (this.peerProviderCaches.size <= 32)
9002
+ break;
9003
+ if (!this.activePeerAgents.has(retainedId) && retainedId !== id)
9004
+ this.peerProviderCaches.delete(retainedId);
9005
+ }
9006
+ const assignedProvider = assignedModel && (assignedModel.provider_id !== activeModel?.provider_id || assignedModel.name !== activeModel?.name)
9007
+ ? this.modelProvider(assignedModel, peerProviderCache)
9008
+ : this.engineModel(peerProviderCache);
8561
9009
  if (!assignedProvider || !model) {
8562
9010
  throw new Error('No LLM configured. Add provider in Settings > Models.');
8563
9011
  }
@@ -8601,7 +9049,7 @@ class Agent {
8601
9049
  child.config.set('models', 'auto_switch', false);
8602
9050
  child.config.set('skills', 'auto_download', 'disabled');
8603
9051
  child.subagents = this.subagents;
8604
- child.subagentContextPersist = (history, compression) => this.subagents.replaceContext(sa.id, history, compression);
9052
+ child.subagentContextPersist = (history, compression, inputCommitted) => this.subagents.replaceContext(sa.id, history, compression, inputCommitted);
8605
9053
  const requestedFlowName = String(flowName || sa.flowName || '').trim();
8606
9054
  if (sa.agentMode === 'flow' && requestedFlowName) {
8607
9055
  const flowDir = path.join(this.rootPath, 'Flow');
@@ -8617,6 +9065,7 @@ class Agent {
8617
9065
  && message.content.startsWith("Peer agent '")));
8618
9066
  const latestPersisted = persistedMessages.at(-1);
8619
9067
  if (latestPersisted?.role === 'user'
9068
+ && !(reason === 'resume' && prompt === subagent_1.SUBAGENT_RECOVERY_PROMPT)
8620
9069
  && (reason === 'spawn' || reason === 'resume' || prompt.includes(latestPersisted.content))) {
8621
9070
  persistedMessages.pop();
8622
9071
  }
@@ -8644,6 +9093,18 @@ class Agent {
8644
9093
  entry.vision_image_path = message.vision_image_path;
8645
9094
  return entry;
8646
9095
  });
9096
+ const compression = sa.metadata?.contextCompression;
9097
+ if (compression && typeof compression.at === 'string' && typeof compression.summary === 'string') {
9098
+ child.lastCompression = {
9099
+ at: compression.at, summary: compression.summary,
9100
+ originalMessages: Number(compression.originalMessages) || 0,
9101
+ compressedMessages: Number(compression.compressedMessages) || 0,
9102
+ originalChars: Number(compression.originalChars) || 0,
9103
+ compressedChars: Number(compression.compressedChars) || compression.summary.length,
9104
+ compressedTokens: Number(compression.compressedTokens) || 0,
9105
+ model: String(compression.model || model), fallback: !!compression.fallback,
9106
+ };
9107
+ }
8647
9108
  child.subscribeAgentKernelUserMessageStart(content => {
8648
9109
  const match = String(content || '').match(/^\[Peer mailbox id=([0-9a-f-]{36})\b/i);
8649
9110
  if (match)
@@ -8694,6 +9155,8 @@ class Agent {
8694
9155
  if (unrelayPeerEvents)
8695
9156
  unrelayPeerEvents();
8696
9157
  this.activePeerAgents.delete(sa.id);
9158
+ if (this.subagents.get(sa.id)?.status === 'closed')
9159
+ this.peerProviderCaches.delete(sa.id);
8697
9160
  }
8698
9161
  }
8699
9162
  persistPeerTranscript(peerId, child) {
@@ -8710,9 +9173,7 @@ class Agent {
8710
9173
  }
8711
9174
  subagentToolDefinitions(defs) {
8712
9175
  const modelCapabilities = this.activeModelConfig();
8713
- const visionFiltered = modelCapabilities?.vision
8714
- ? defs
8715
- : defs.filter((tool) => tool.function?.name !== 'image_inspect');
9176
+ const visionFiltered = defs;
8716
9177
  const withImageGeneration = modelCapabilities?.image_output
8717
9178
  ? [...visionFiltered, {
8718
9179
  type: 'function',
@@ -8756,8 +9217,6 @@ class Agent {
8756
9217
  }
8757
9218
  }
8758
9219
  async handleImageInspect(args) {
8759
- if (!this.activeModelConfig()?.vision)
8760
- return '[Image inspect unavailable] The selected model has not passed vision validation.';
8761
9220
  let input = {};
8762
9221
  try {
8763
9222
  input = JSON.parse(args);
@@ -9151,7 +9610,7 @@ class Agent {
9151
9610
  const modelName = String(compressionModel || this.activeModelName()).trim();
9152
9611
  if (!modelName)
9153
9612
  return { summary: fallbackSummary, model: 'local-fallback', fallback: true };
9154
- const generated = await this.withTimeout(provider.chat(modelName, [...prefixMessages, { role: 'user', content: prompt }], system, temperature, budget.summaryTokens, signal), 120000);
9613
+ const generated = await this.withTimeout(this.chatWithConversationUsage(provider, modelName, [...prefixMessages, { role: 'user', content: prompt }], system, temperature, budget.summaryTokens, signal), 120000);
9155
9614
  const generatedText = String(generated || '').trim();
9156
9615
  if (!generatedText || /^\[LLM Error(?::|\])/i.test(generatedText) || /^LLM Error:/i.test(generatedText)) {
9157
9616
  return { summary: fallbackSummary, model: 'local-fallback', fallback: true };
@@ -9472,12 +9931,18 @@ class Agent {
9472
9931
  ].join('\n'));
9473
9932
  }
9474
9933
  if (this.intelligence === 'ultra') {
9475
- parts.push([
9934
+ parts.push(this.isSubagentRuntime ? [
9935
+ '[Ultra Intelligence – Specialist Role]',
9936
+ 'Own the delegated task and complete its implementation or investigation yourself. Preserve the assigned file and responsibility boundaries. You may delegate a concrete independent subtask only when it saves time while you continue useful work; never pass your whole assignment to another peer or create a chain of coordinators.',
9937
+ 'Reuse an existing peer for follow-up work. Send concise, addressed mailbox messages only for new evidence, dependencies, blockers, handoffs or actionable corrections; avoid repeated status checks and unchanged progress messages. Include exact references and verification evidence in your result.',
9938
+ ].join('\n') : [
9476
9939
  '[Ultra Intelligence – Orchestrator Role]',
9477
- 'You are the lead orchestrator. Actively decompose complex tasks into parallel sub-tasks. Use the `SubAgent` tool to create specialized SubAgents for each distinct sub-task. Use `task_create` only for the conversation checklist; it never creates a SubAgent. Coordinate the SubAgent team: assign clear responsibilities, merge results, resolve conflicts, and produce a unified final output. SubAgents are your team; delegate aggressively and manage them as a manager, not just a tool caller.',
9478
- 'Do not attempt to do all the work yourself. Use SubAgents for parallel investigation, verification, implementation, and review.',
9940
+ 'You are the lead orchestrator. Decompose complex work into concrete independent sub-tasks and create specialized peers with `SubAgent`. Give each peer a clear objective, file or responsibility boundary, dependencies, deliverable and acceptance check. Retain useful work locally while peers investigate, implement or review; sequence dependent work and prevent overlapping edits. Merge evidence, resolve conflicts and own the final verification.',
9941
+ 'Use task_create only for the conversation checklist; it never creates a SubAgent. Use the available shared concurrency when independent work exists, but do not create peers merely to fill slots. Reuse existing peers for follow-up work. Prefer concise addressed mailbox updates for new evidence, dependencies, blockers and corrections; avoid redundant broadcasts, repeated status checks and unchanged progress messages.',
9479
9942
  ].join('\n'));
9480
9943
  }
9944
+ if (this.shouldExposeToolInterface())
9945
+ parts.push('Peer communication: subagent_send defaults to wakeup=false and the last visible text-history entry. An active receiver processes either wakeup value normally; use wakeup=true when an inactive peer or root must resume work. Choose concise sender-written summaries or explicit ranges of text/tool history, and forward only evidence relevant to the recipient. Use id="root" for the main agent. Do not retransmit unchanged history or rewrite earlier context to deliver a message.');
9481
9946
  parts.push(this.buildModePrompt());
9482
9947
  const value = this.contextV2.orchestrator.assemble({
9483
9948
  // Keep the complete base prompt in one stable section. The linked_plan
@@ -9535,6 +10000,47 @@ class Agent {
9535
10000
  currentUserInput: '',
9536
10001
  });
9537
10002
  }
10003
+ subagentSettlementReceipt(id) {
10004
+ const peer = this.subagents.get(id);
10005
+ return !this.isSubagentRuntime && peer && typeof peer.result === 'string'
10006
+ && Number.isSafeInteger(peer.settlementRevision) && Number(peer.settlementRevision) > 0
10007
+ ? { peerId: peer.id, revision: peer.settlementRevision } : undefined;
10008
+ }
10009
+ /** Peer jobs share a durable request prefix; credentials only enter its hash. */
10010
+ peerRequestCacheIdentity(systemPrompt, catalog) {
10011
+ if (!this.isSubagentRuntime)
10012
+ return undefined;
10013
+ return crypto.createHash('sha256').update(JSON.stringify({
10014
+ systemPrompt, catalog, model: this.activeModelConfig(),
10015
+ apiMode: this.config.openAIApiMode(), adapters: this.config.contextFlag('provider_adapters_v2'),
10016
+ proxy: this.providerProxyConfig(), intelligence: this.intelligence,
10017
+ compression: this.lastCompression?.at || '',
10018
+ })).digest('hex');
10019
+ }
10020
+ readPeerRequestCache(identity) {
10021
+ if (!identity)
10022
+ return undefined;
10023
+ const saved = this.subagents.get(this.runtimeActorId)?.metadata?.requestCache;
10024
+ if (saved?.version !== 1 || saved.identity !== identity || typeof saved.taskFocus !== 'string'
10025
+ || !Array.isArray(saved.initialTools) || !saved.initialTools.every(name => typeof name === 'string')
10026
+ || !Array.isArray(saved.provisionedTools) || !saved.provisionedTools.every(name => typeof name === 'string'))
10027
+ return undefined;
10028
+ return { ...saved, initialTools: saved.initialTools.slice(), provisionedTools: saved.provisionedTools.slice() };
10029
+ }
10030
+ persistPeerRequestCache(cache) {
10031
+ if (!this.isSubagentRuntime)
10032
+ return;
10033
+ const previous = this.subagents.get(this.runtimeActorId)?.metadata?.requestCache;
10034
+ if (JSON.stringify(previous) !== JSON.stringify(cache)) {
10035
+ this.subagents.patchMetadata(this.runtimeActorId, { requestCache: cache });
10036
+ }
10037
+ }
10038
+ checkpointPeerInput() {
10039
+ // Called at the first real provider boundary, after the input has entered
10040
+ // working history. Commit context and job progress in one durable write.
10041
+ if (this.isSubagentRuntime)
10042
+ this.subagentContextPersist?.(this.history, this.lastCompression, true);
10043
+ }
9538
10044
  cachedToolDefinitions() {
9539
10045
  const identity = JSON.stringify({
9540
10046
  mode: this.mode,
@@ -10008,7 +10514,9 @@ function routeProviderFingerprint(provider) {
10008
10514
  });
10009
10515
  }
10010
10516
  function modelConfigurationFingerprint(model) {
10011
- const { validation, evaluation, _previous_name, previous_name, ...configuration } = model;
10517
+ const { validation, evaluation, response_health, enabled, _previous_name, previous_name, ...configuration } = model;
10518
+ void response_health;
10519
+ void enabled;
10012
10520
  void validation;
10013
10521
  void evaluation;
10014
10522
  void _previous_name;
@@ -10050,12 +10558,7 @@ function resetEditedModelValidationEvidence(incomingProviders, existingProviders
10050
10558
  }
10051
10559
  }
10052
10560
  function modelConfigIsUnavailable(model) {
10053
- const validationStatus = effectiveModelValidationStatus(model);
10054
- if (model.validation?.level !== 'discovered'
10055
- && (validationStatus === 'unavailable' || validationStatus === 'auth_error' || validationStatus === 'invalid_config'))
10056
- return true;
10057
- const evaluationStatus = validationStatus === 'degraded' ? 'degraded' : String(model.evaluation?.status || '').toLowerCase();
10058
- return evaluationStatus === 'unavailable' || evaluationStatus.startsWith('error');
10561
+ return model.enabled === false;
10059
10562
  }
10060
10563
  function parseDeploymentSelectionValue(value) {
10061
10564
  const marker = String(value || '').trim();