@alisio/core 0.1.0-alpha.9 → 0.1.1

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 (187) hide show
  1. package/README.md +52 -7
  2. package/dist/agents/active.d.ts +99 -0
  3. package/dist/agents/active.js +218 -0
  4. package/dist/agents/capabilities.d.ts +26 -0
  5. package/dist/agents/capabilities.js +120 -0
  6. package/dist/agents/create-agent-skill.d.ts +11 -0
  7. package/dist/agents/create-agent-skill.js +60 -0
  8. package/dist/agents/definitions.d.ts +88 -0
  9. package/dist/agents/definitions.js +404 -0
  10. package/dist/agents/draft.d.ts +41 -0
  11. package/dist/agents/draft.js +133 -0
  12. package/dist/agents/service.d.ts +57 -0
  13. package/dist/agents/service.js +86 -0
  14. package/dist/agents/templates.d.ts +8 -0
  15. package/dist/agents/templates.js +102 -0
  16. package/dist/analysis/capabilities.d.ts +48 -0
  17. package/dist/analysis/capabilities.js +90 -0
  18. package/dist/analysis/data/csv.d.ts +46 -0
  19. package/dist/analysis/data/csv.js +240 -0
  20. package/dist/analysis/data/datasets.d.ts +132 -0
  21. package/dist/analysis/data/datasets.js +441 -0
  22. package/dist/analysis/data/describe.d.ts +27 -0
  23. package/dist/analysis/data/describe.js +103 -0
  24. package/dist/analysis/data/engine-client.d.ts +58 -0
  25. package/dist/analysis/data/engine-client.js +304 -0
  26. package/dist/analysis/data/engine-entry.d.ts +1 -0
  27. package/dist/analysis/data/engine-entry.js +59 -0
  28. package/dist/analysis/data/engine-source.d.ts +4 -0
  29. package/dist/analysis/data/engine-source.js +6 -0
  30. package/dist/analysis/data/engine-types.d.ts +86 -0
  31. package/dist/analysis/data/engine-types.js +7 -0
  32. package/dist/analysis/data/infer.d.ts +42 -0
  33. package/dist/analysis/data/infer.js +118 -0
  34. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  35. package/dist/analysis/data/ingest-worker.js +365 -0
  36. package/dist/analysis/data/json.d.ts +19 -0
  37. package/dist/analysis/data/json.js +44 -0
  38. package/dist/analysis/data/query-worker.d.ts +12 -0
  39. package/dist/analysis/data/query-worker.js +75 -0
  40. package/dist/analysis/data/rows.d.ts +39 -0
  41. package/dist/analysis/data/rows.js +120 -0
  42. package/dist/analysis/data/sql-guard.d.ts +20 -0
  43. package/dist/analysis/data/sql-guard.js +129 -0
  44. package/dist/analysis/data/stats.d.ts +18 -0
  45. package/dist/analysis/data/stats.js +77 -0
  46. package/dist/analysis/install-hints.d.ts +33 -0
  47. package/dist/analysis/install-hints.js +144 -0
  48. package/dist/analysis/janitor.d.ts +60 -0
  49. package/dist/analysis/janitor.js +264 -0
  50. package/dist/analysis/jobs.d.ts +94 -0
  51. package/dist/analysis/jobs.js +165 -0
  52. package/dist/analysis/oci.d.ts +129 -0
  53. package/dist/analysis/oci.js +330 -0
  54. package/dist/analysis/python/sources.d.ts +2 -0
  55. package/dist/analysis/python/sources.js +10 -0
  56. package/dist/analysis/requirements.d.ts +32 -0
  57. package/dist/analysis/requirements.js +73 -0
  58. package/dist/analysis/rerun.d.ts +40 -0
  59. package/dist/analysis/rerun.js +136 -0
  60. package/dist/analysis/runtime-manager.d.ts +146 -0
  61. package/dist/analysis/runtime-manager.js +472 -0
  62. package/dist/analysis/status.d.ts +21 -0
  63. package/dist/analysis/status.js +52 -0
  64. package/dist/application.d.ts +105 -3
  65. package/dist/application.js +446 -18
  66. package/dist/artifacts/kinds.d.ts +35 -0
  67. package/dist/artifacts/kinds.js +177 -0
  68. package/dist/artifacts/publisher.d.ts +7 -0
  69. package/dist/artifacts/publisher.js +29 -0
  70. package/dist/artifacts/store.d.ts +119 -0
  71. package/dist/artifacts/store.js +581 -0
  72. package/dist/background/janitor.d.ts +22 -0
  73. package/dist/background/janitor.js +62 -0
  74. package/dist/background/mirror.d.ts +14 -0
  75. package/dist/background/mirror.js +41 -0
  76. package/dist/background/notify.d.ts +58 -0
  77. package/dist/background/notify.js +172 -0
  78. package/dist/background/output.d.ts +46 -0
  79. package/dist/background/output.js +179 -0
  80. package/dist/background/service.d.ts +137 -0
  81. package/dist/background/service.js +410 -0
  82. package/dist/background/store.d.ts +103 -0
  83. package/dist/background/store.js +195 -0
  84. package/dist/changelog/data.d.ts +6 -0
  85. package/dist/changelog/data.js +306 -0
  86. package/dist/changelog/index.d.ts +5 -0
  87. package/dist/changelog/index.js +7 -0
  88. package/dist/changelog/parse.d.ts +53 -0
  89. package/dist/changelog/parse.js +160 -0
  90. package/dist/commands/builtins.d.ts +10 -0
  91. package/dist/commands/builtins.js +161 -0
  92. package/dist/commands/catalog.d.ts +86 -0
  93. package/dist/commands/catalog.js +304 -0
  94. package/dist/config.d.ts +206 -6
  95. package/dist/config.js +426 -20
  96. package/dist/core/compaction.js +13 -2
  97. package/dist/core/contracts.d.ts +180 -3
  98. package/dist/core/opt-in.d.ts +12 -0
  99. package/dist/core/opt-in.js +12 -0
  100. package/dist/core/output-limit.d.ts +34 -0
  101. package/dist/core/output-limit.js +39 -0
  102. package/dist/core/run-stats.d.ts +16 -0
  103. package/dist/core/run-stats.js +8 -0
  104. package/dist/core/runner.d.ts +134 -4
  105. package/dist/core/runner.js +780 -138
  106. package/dist/core/timeout.d.ts +15 -0
  107. package/dist/core/timeout.js +61 -0
  108. package/dist/core/truncation.d.ts +30 -0
  109. package/dist/core/truncation.js +83 -0
  110. package/dist/goal/blockers.d.ts +45 -0
  111. package/dist/goal/blockers.js +51 -0
  112. package/dist/goal/budget.d.ts +24 -0
  113. package/dist/goal/budget.js +53 -0
  114. package/dist/goal/command.d.ts +36 -0
  115. package/dist/goal/command.js +98 -0
  116. package/dist/goal/format.d.ts +19 -0
  117. package/dist/goal/format.js +102 -0
  118. package/dist/goal/host.d.ts +29 -0
  119. package/dist/goal/host.js +42 -0
  120. package/dist/goal/index.d.ts +9 -0
  121. package/dist/goal/index.js +9 -0
  122. package/dist/goal/machine.d.ts +186 -0
  123. package/dist/goal/machine.js +344 -0
  124. package/dist/goal/prompts.d.ts +10 -0
  125. package/dist/goal/prompts.js +75 -0
  126. package/dist/goal/service.d.ts +113 -0
  127. package/dist/goal/service.js +273 -0
  128. package/dist/goal/store.d.ts +59 -0
  129. package/dist/goal/store.js +184 -0
  130. package/dist/index.d.ts +50 -5
  131. package/dist/index.js +49 -4
  132. package/dist/mcp/connector.js +38 -6
  133. package/dist/mcp/rich.d.ts +41 -0
  134. package/dist/mcp/rich.js +362 -0
  135. package/dist/permissions/modes.d.ts +72 -0
  136. package/dist/permissions/modes.js +98 -0
  137. package/dist/plan/exit-plan.d.ts +25 -0
  138. package/dist/plan/exit-plan.js +130 -0
  139. package/dist/plan/state.d.ts +102 -0
  140. package/dist/plan/state.js +165 -0
  141. package/dist/plugins/host.d.ts +34 -1
  142. package/dist/plugins/host.js +143 -9
  143. package/dist/providers/settings.d.ts +15 -0
  144. package/dist/providers/settings.js +38 -0
  145. package/dist/reload.d.ts +75 -0
  146. package/dist/reload.js +204 -0
  147. package/dist/resources/context.js +6 -1
  148. package/dist/runtime/access.d.ts +62 -0
  149. package/dist/runtime/access.js +115 -0
  150. package/dist/runtime/blobs.d.ts +51 -0
  151. package/dist/runtime/blobs.js +177 -0
  152. package/dist/runtime/diff.d.ts +23 -0
  153. package/dist/runtime/diff.js +154 -0
  154. package/dist/runtime/ids.d.ts +8 -0
  155. package/dist/runtime/ids.js +32 -0
  156. package/dist/runtime/paths.d.ts +11 -0
  157. package/dist/runtime/paths.js +17 -1
  158. package/dist/runtime/process.d.ts +22 -0
  159. package/dist/runtime/process.js +100 -15
  160. package/dist/runtime/sqlite.d.ts +2 -0
  161. package/dist/runtime/sqlite.js +18 -0
  162. package/dist/runtime/store.d.ts +91 -3
  163. package/dist/runtime/store.js +465 -11
  164. package/dist/runtime/zip.d.ts +24 -0
  165. package/dist/runtime/zip.js +126 -0
  166. package/dist/sessions/children.js +17 -1
  167. package/dist/sessions/side-questions.d.ts +57 -0
  168. package/dist/sessions/side-questions.js +161 -0
  169. package/dist/tools/analysis.d.ts +37 -0
  170. package/dist/tools/analysis.js +573 -0
  171. package/dist/tools/artifacts.d.ts +14 -0
  172. package/dist/tools/artifacts.js +143 -0
  173. package/dist/tools/background.d.ts +7 -0
  174. package/dist/tools/background.js +165 -0
  175. package/dist/tools/data.d.ts +7 -0
  176. package/dist/tools/data.js +160 -0
  177. package/dist/tools/execute.d.ts +5 -0
  178. package/dist/tools/execute.js +15 -3
  179. package/dist/tools/goal.d.ts +19 -0
  180. package/dist/tools/goal.js +126 -0
  181. package/dist/tools/search.d.ts +17 -3
  182. package/dist/tools/search.js +153 -0
  183. package/dist/tools/standard.d.ts +4 -1
  184. package/dist/tools/standard.js +99 -18
  185. package/dist/version.d.ts +5 -3
  186. package/dist/version.js +35 -6
  187. package/package.json +2 -2
@@ -1,5 +1,27 @@
1
- import { textResult, } from "@alisio/sdk";
1
+ import { isEphemeralRunEventType, textProjection, textResult, } from "@alisio/sdk";
2
2
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
+ import { EXIT_PLAN_TOOL, optInAllows } from "./opt-in.js";
4
+ import { resolveMaxOutputTokens } from "./output-limit.js";
5
+ import { attachRunStats } from "./run-stats.js";
6
+ import { isTimeoutReason, providerLabel, RunTimeoutError } from "./timeout.js";
7
+ import { incompleteCalls, isOutputTruncationError, lowerEffort, RunTruncationError, TRUNCATION_NOTICE, } from "./truncation.js";
8
+ /** Pause before a silent request is sent again; short, and cut off by a stop or the run limit. */
9
+ const FIRST_TOKEN_RETRY_PAUSE_MS = 250;
10
+ /** Pause before a cut-off turn is requested again; short, and cut off by a stop or the run limit. */
11
+ const TRUNCATION_RETRY_PAUSE_MS = 100;
12
+ function abortableDelay(ms, signal) {
13
+ return new Promise((resolve) => {
14
+ if (signal.aborted)
15
+ return resolve();
16
+ const done = () => {
17
+ clearTimeout(timer);
18
+ signal.removeEventListener("abort", done);
19
+ resolve();
20
+ };
21
+ const timer = setTimeout(done, ms);
22
+ signal.addEventListener("abort", done, { once: true });
23
+ });
24
+ }
3
25
  /**
4
26
  * Default cumulative token budget for one run: several context windows (each turn re-sends the
5
27
  * context), clamped; 1M tokens when the window is unknown.
@@ -13,11 +35,23 @@ export function defaultTokenBudget(contextWindow) {
13
35
  const MAX_INJECT_CHARS = 24_000;
14
36
  /**
15
37
  * Floor for the post-compaction transcript target: retained messages are never reduced below
16
- * this many characters, so a pathological session (instructions + tools alone crowding out the
17
- * limit) fails with an actionable error instead of silently losing the whole history.
38
+ * this many characters, so a pathological session (instructions alone crowding out the limit)
39
+ * fails with an actionable error instead of silently losing the whole history.
18
40
  */
19
41
  const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
20
- const allowed = (policy, effect) => effect === "read" || effect === "internal" || !!policy[effect];
42
+ const allowed = (policy, effect) => effect === "read" ||
43
+ effect === "internal" ||
44
+ ((effect === "write" || effect === "process" || effect === "external") && !!policy[effect]);
45
+ /** A tool's capability is pre-allowed by the policy alone (`--allow-analysis`). */
46
+ const capabilityAllowed = (policy, tool) => tool.capability === "analysis.run" && !!policy.analysis;
47
+ /** First 40 lines of a script input, for capability approvals. */
48
+ const scriptPreview = (input) => typeof input.code === "string" ? input.code.split(/\r?\n/).slice(0, 40).join("\n") : undefined;
49
+ /** A short id for a tool call the user started from a UI (alphanumeric, 9 characters). */
50
+ const uiCallId = () => {
51
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
52
+ const bytes = crypto.getRandomValues(new Uint8Array(9));
53
+ return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
54
+ };
21
55
  export class AgentRunner {
22
56
  options;
23
57
  /** Sessions with an active run or compaction (one at a time per session). */
@@ -37,6 +71,20 @@ export class AgentRunner {
37
71
  get approvals() {
38
72
  return !!this.options.approve;
39
73
  }
74
+ /**
75
+ * Sets which effects run without asking (a permission mode). The policy object is mutated in
76
+ * place because the application and the runner share it; `analysis` is never touched. Takes
77
+ * effect from the next tool call, so it also drops "allow for this session" widenings.
78
+ */
79
+ setPolicy(next) {
80
+ const policy = this.options.policy;
81
+ if (next.write !== undefined)
82
+ policy.write = next.write;
83
+ if (next.process !== undefined)
84
+ policy.process = next.process;
85
+ if (next.external !== undefined)
86
+ policy.external = next.external;
87
+ }
40
88
  /**
41
89
  * Live-update runnable settings so the next `run`/`compact` call honors them without restarting
42
90
  * Alisio. Every patch field is merged over the current options; unrelated settings are kept.
@@ -49,8 +97,9 @@ export class AgentRunner {
49
97
  ...(compaction ? { compaction: { ...this.options.compaction, ...compaction } } : {}),
50
98
  };
51
99
  }
52
- emitter(sessionId) {
53
- const runId = crypto.randomUUID();
100
+ emitter(sessionId, run = {}) {
101
+ const runId = run.runId ?? crypto.randomUUID();
102
+ const correlationId = run.correlationId;
54
103
  let seq = 0;
55
104
  return (type, data) => {
56
105
  const event = {
@@ -62,8 +111,15 @@ export class AgentRunner {
62
111
  timestamp: new Date().toISOString(),
63
112
  data,
64
113
  };
65
- if (type !== "text_delta" && type !== "tool_progress" && type !== "reasoning_delta")
66
- this.options.store.event(sessionId, runId, type, data);
114
+ if (!isEphemeralRunEventType(type)) {
115
+ const persisted = correlationId !== undefined
116
+ ? this.options.store.event(sessionId, runId, type, data, { correlationId })
117
+ : this.options.store.event(sessionId, runId, type, data);
118
+ if (typeof persisted === "number")
119
+ event.eventId = String(persisted);
120
+ }
121
+ if (correlationId !== undefined)
122
+ event.correlationId = correlationId;
67
123
  try {
68
124
  this.options.onEvent?.(event);
69
125
  }
@@ -72,17 +128,28 @@ export class AgentRunner {
72
128
  }
73
129
  };
74
130
  }
75
- /** Tools offered to the model under the current policy (and optional run narrowing). */
76
- availableTools(run = {}) {
131
+ /**
132
+ * Tools offered to the model under the current policy (and optional run narrowing). With a
133
+ * session, a capability tool is also offered when that session's root holds a persisted grant.
134
+ */
135
+ availableTools(run = {}, sessionId) {
77
136
  const o = this.options;
78
137
  const policy = run.policy ?? o.policy;
79
138
  const approvals = !!o.approve && run.approvals !== false;
80
139
  return o.registry.list().filter((t) => {
81
140
  if (run.toolFilter && !run.toolFilter(t))
82
141
  return false;
142
+ if (!optInAllows(t.name, run.optInTools))
143
+ return false;
83
144
  const effect = t.effect ?? "external";
84
145
  if (allowed(policy, effect))
85
146
  return true;
147
+ if (t.capability) {
148
+ if (capabilityAllowed(policy, t))
149
+ return true;
150
+ if (sessionId && o.capabilities?.granted(t.capability, sessionId))
151
+ return true;
152
+ }
86
153
  return approvals && (effect === "write" || effect === "process" || effect === "external");
87
154
  });
88
155
  }
@@ -147,6 +214,40 @@ export class AgentRunner {
147
214
  };
148
215
  return { total: undefined, basis: "unknown", compactionAt: 100 };
149
216
  }
217
+ /**
218
+ * What a tool-less side call about a session needs (for example a `/btw` side question): the
219
+ * session's provider and model, its system instructions, its ACTIVE history and the current
220
+ * limits. Claims no lock and writes nothing, so it is safe while a run is active.
221
+ */
222
+ async sideContext(sessionId) {
223
+ const o = this.options;
224
+ const session = o.store.get(sessionId);
225
+ const provider = await (o.providerFor?.(session) ?? o.provider);
226
+ const window = o.contextWindow?.(session.model);
227
+ return {
228
+ provider,
229
+ model: session.model,
230
+ ...(window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW
231
+ ? { contextWindow: window }
232
+ : {}),
233
+ instructions: await o.context.instructions(sessionId),
234
+ messages: o.store.messages(sessionId),
235
+ maxContextChars: o.maxContextChars ?? 800_000,
236
+ maxOutputTokens: (await this.outputLimit(session.model)).value,
237
+ timeoutMs: o.timeoutMs ?? 300_000,
238
+ ...(o.reasoningEffort ? { reasoningEffort: o.reasoningEffort } : {}),
239
+ };
240
+ }
241
+ /** The output-token budget for `model`: user value, else catalog maximum, else fallback. */
242
+ async outputLimit(model, perRun) {
243
+ const o = this.options;
244
+ const declared = await Promise.resolve(o.modelOutputLimit?.(model)).catch(() => undefined);
245
+ return resolveMaxOutputTokens({
246
+ explicit: perRun ?? o.maxOutputTokens,
247
+ declared,
248
+ fallback: o.fallbackMaxOutputTokens ?? 4096,
249
+ });
250
+ }
150
251
  /** Change the model used by subsequent turns of a session; recorded in the store. */
151
252
  setModel(sessionId, model) {
152
253
  const id = model.trim();
@@ -278,16 +379,402 @@ export class AgentRunner {
278
379
  throw error;
279
380
  }
280
381
  }
382
+ /**
383
+ * Gates and executes one tool call: external-directory paths, the capability/effect gate with
384
+ * its approval, the call journal, artifact publishing and the result bound. Shared by model
385
+ * runs and `runToolCall`, so a call started from a UI passes exactly the same gates.
386
+ */
387
+ async executeCall(scope, p) {
388
+ const o = this.options;
389
+ const { sessionId, runId, emit, workspace, policy, approvals, options } = scope;
390
+ const combined = scope.signal;
391
+ const pathAccess = o.pathAccess;
392
+ const { call } = p;
393
+ let result;
394
+ const started = Date.now();
395
+ // `effect` rides along so the TUI can group consecutive calls by capability kind
396
+ // (read batches, write batches, commands…) without a name heuristic. Additive field.
397
+ emit("tool_started", {
398
+ name: call.name,
399
+ id: call.id,
400
+ arguments: call.arguments,
401
+ effect: p.tool?.effect ?? "external",
402
+ });
403
+ try {
404
+ combined.throwIfAborted();
405
+ if (p.error)
406
+ throw new Error(p.error);
407
+ if (!p.tool || !p.input)
408
+ throw new Error("Invalid tool");
409
+ const effect = p.tool.effect ?? "external";
410
+ // External-directory prerequisite: declared paths are resolved (and, outside every
411
+ // allowed root, approved for the containing directory) BEFORE the effect gate, so a
412
+ // write still needs its own write policy and a read never hard-throws first. The
413
+ // resolved map keeps "allow once" scoped to this one call, with no shared mutable
414
+ // state that parallel read batches could race on.
415
+ const resolved = new Map();
416
+ if (pathAccess)
417
+ for (const declared of p.tool.paths?.(p.input) ?? [])
418
+ if (!resolved.has(declared)) {
419
+ const target = await pathAccess.resolve(declared, {
420
+ workspace,
421
+ session: sessionId,
422
+ ...(options.label ? { label: options.label } : {}),
423
+ signal: combined,
424
+ });
425
+ // Key both the declared input and its resolved form: a write resolves the
426
+ // input once, then re-checks the already-absolute path, and neither lookup may
427
+ // ask a second time for an "allow once" decision.
428
+ resolved.set(declared, target);
429
+ resolved.set(target, target);
430
+ }
431
+ scope.guard?.(effect);
432
+ if ((options.toolFilter && !options.toolFilter(p.tool)) ||
433
+ !optInAllows(p.tool.name, options.optInTools))
434
+ throw new Error(`Tool ${call.name} is not available in this session`);
435
+ const capability = p.tool.capability;
436
+ const gate = o.capabilities;
437
+ if (capability && (allowed(policy, effect) || capabilityAllowed(policy, p.tool))) {
438
+ // A broad process grant or --allow-analysis: allowed, audited as a flag decision.
439
+ gate?.record({
440
+ capability,
441
+ sessionId,
442
+ scope: "once",
443
+ decision: "allow",
444
+ source: "flag",
445
+ callId: call.id,
446
+ runId,
447
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
448
+ });
449
+ }
450
+ else if (capability && gate?.granted(capability, sessionId)) {
451
+ // A persisted "allow for this session" grant of the root session.
452
+ }
453
+ else if (!allowed(policy, effect)) {
454
+ if (!o.approve ||
455
+ !approvals ||
456
+ (effect !== "write" && effect !== "process" && effect !== "external"))
457
+ throw new Error(`Capability denied: ${capability ?? effect}`);
458
+ const preview = capability
459
+ ? (scriptPreview(p.input) ?? (await gate?.preview?.(p.input, sessionId)))
460
+ : undefined;
461
+ emit("approval_requested", {
462
+ id: call.id,
463
+ name: call.name,
464
+ effect,
465
+ ...(options.label ? { label: options.label } : {}),
466
+ ...(capability ? { capability } : {}),
467
+ });
468
+ const decision = await o.approve({
469
+ call,
470
+ effect,
471
+ input: p.input,
472
+ signal: combined,
473
+ session: sessionId,
474
+ ...(options.label ? { label: options.label } : {}),
475
+ ...(capability ? { capability } : {}),
476
+ ...(preview !== undefined ? { preview } : {}),
477
+ ...(capability && gate?.runtime ? { runtime: gate.runtime } : {}),
478
+ });
479
+ combined.throwIfAborted();
480
+ const persisted = !!capability && !!gate && decision === "session";
481
+ if (capability && gate)
482
+ gate.record({
483
+ capability,
484
+ sessionId,
485
+ scope: decision === "session" ? "session" : "once",
486
+ decision: decision === "deny" ? "deny" : "allow",
487
+ source: gate.source ?? "tui",
488
+ callId: call.id,
489
+ runId,
490
+ ...(options.correlationId !== undefined
491
+ ? { correlationId: options.correlationId }
492
+ : {}),
493
+ });
494
+ emit("approval_resolved", {
495
+ id: call.id,
496
+ name: call.name,
497
+ effect,
498
+ decision,
499
+ ...(capability ? { capability } : {}),
500
+ ...(persisted ? { persisted: true } : {}),
501
+ });
502
+ if (decision === "deny")
503
+ throw new Error(`Capability ${capability ?? effect} denied by the user for this call`);
504
+ // A capability grant never widens the effect: only effect approvals do.
505
+ if (decision === "session" && !capability)
506
+ policy[effect] = true;
507
+ }
508
+ o.store.beginCall(sessionId, call, { runId, effect });
509
+ const publisher = o.artifacts?.({ sessionId, runId, callId: call.id }, (published) => emit("artifact_published", {
510
+ artifact: published.artifact,
511
+ path: published.path,
512
+ callId: call.id,
513
+ ...(published.executionId ? { executionId: published.executionId } : {}),
514
+ }));
515
+ /**
516
+ * `analysis.install`: always asks, `once` only, and no flag or earlier grant covers it. Where
517
+ * nobody can be asked (headless, no approval handler) the answer is `deny`.
518
+ */
519
+ const approveInstall = async (install) => {
520
+ if (!o.approve || !approvals)
521
+ return "deny";
522
+ const installCall = { ...call, id: `${call.id}:install` };
523
+ emit("approval_requested", {
524
+ id: installCall.id,
525
+ name: call.name,
526
+ effect: "process",
527
+ ...(options.label ? { label: options.label } : {}),
528
+ capability: "analysis.install",
529
+ install,
530
+ });
531
+ let decision = "deny";
532
+ try {
533
+ decision = await o.approve({
534
+ call: installCall,
535
+ effect: "process",
536
+ input: { extras: install.extras },
537
+ signal: combined,
538
+ session: sessionId,
539
+ ...(options.label ? { label: options.label } : {}),
540
+ capability: "analysis.install",
541
+ install,
542
+ });
543
+ }
544
+ finally {
545
+ combined.throwIfAborted();
546
+ }
547
+ // A `session` answer is treated as `once`: an installation permission is never stored.
548
+ const allowedNow = decision !== "deny";
549
+ gate?.record({
550
+ capability: "analysis.install",
551
+ sessionId,
552
+ scope: "once",
553
+ decision: allowedNow ? "allow" : "deny",
554
+ source: gate.source ?? "tui",
555
+ callId: call.id,
556
+ runId,
557
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
558
+ });
559
+ emit("approval_resolved", {
560
+ id: installCall.id,
561
+ name: call.name,
562
+ effect: "process",
563
+ decision: allowedNow ? "once" : "deny",
564
+ capability: "analysis.install",
565
+ });
566
+ return allowedNow ? "once" : "deny";
567
+ };
568
+ result = await p.tool.execute(p.input, {
569
+ signal: combined,
570
+ workspace,
571
+ session: sessionId,
572
+ runId,
573
+ callId: call.id,
574
+ ...(publisher ? { artifacts: publisher } : {}),
575
+ approveInstall,
576
+ emit: (data) => emit("tool_progress", { id: call.id, data }),
577
+ // Only the built-in plan tool reports its durable events (never a plugin tool).
578
+ ...(p.tool.name === EXIT_PLAN_TOOL
579
+ ? {
580
+ emitEvent: (type, data) => emit(type, data),
581
+ }
582
+ : {}),
583
+ ...(options.label ? { label: options.label } : {}),
584
+ ...(pathAccess
585
+ ? {
586
+ resolvePath: (path) => {
587
+ const known = resolved.get(path);
588
+ return known !== undefined
589
+ ? Promise.resolve(known)
590
+ : pathAccess.resolve(path, {
591
+ workspace,
592
+ session: sessionId,
593
+ ...(options.label ? { label: options.label } : {}),
594
+ signal: combined,
595
+ });
596
+ },
597
+ }
598
+ : {}),
599
+ });
600
+ // The bound applies to what the model sees (the text projection): display-only
601
+ // ui/image parts are bounded by their producers and must not push text over it.
602
+ const projected = textProjection(result);
603
+ if (JSON.stringify(projected).length > 48_000)
604
+ result = textResult(`${JSON.stringify(projected).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
605
+ result = scope.amend?.(result, effect) ?? result;
606
+ o.store.endCall(sessionId, call, result);
607
+ }
608
+ catch (error) {
609
+ result = textResult(error instanceof Error ? error.message : String(error), true);
610
+ // A cancellation after execution may have left effects; journal remains pending.
611
+ if (!combined.aborted)
612
+ o.store.endCall(sessionId, call, result);
613
+ }
614
+ emit("tool_completed", {
615
+ id: call.id,
616
+ name: call.name,
617
+ isError: result.isError ?? false,
618
+ durationMs: Date.now() - started,
619
+ preview: result.content
620
+ .filter((part) => part.type === "text")
621
+ .map((c) => c.text)
622
+ .join("\n")
623
+ .slice(0, 2_000),
624
+ });
625
+ return result;
626
+ }
627
+ /**
628
+ * Runs ONE tool call without a model (spec §20 `runToolCall`): an action the user started from
629
+ * a UI, such as copying an artifact into the workspace. It is a real run of the session
630
+ * (`run_started` → `tool_*` → `run_completed`, journaled) and the call passes exactly the gates
631
+ * of a model call (`write` approval, capabilities, paths). The transcript stays valid for every
632
+ * provider: a user note (shown as `display`), an assistant message with the call, the tool
633
+ * result and a short assistant summary.
634
+ */
635
+ async runToolCall(sessionId, name, input, options = {}) {
636
+ const controller = this.claim(sessionId);
637
+ const runId = options.runId ?? crypto.randomUUID();
638
+ const o = this.options, emit = this.emitter(sessionId, { runId, correlationId: options.correlationId });
639
+ const finish = (status, error) => {
640
+ try {
641
+ o.store.endRun?.(runId, {
642
+ status,
643
+ usage: { input: 0, output: 0 },
644
+ ...(error ? { error } : {}),
645
+ });
646
+ }
647
+ catch {
648
+ /* A journal write failure cannot invalidate an executed run. */
649
+ }
650
+ };
651
+ const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
652
+ const timeout = AbortSignal.timeout(runTimeoutMs);
653
+ const combined = AbortSignal.any([
654
+ controller.signal,
655
+ timeout,
656
+ ...(options.signal ? [options.signal] : []),
657
+ ]);
658
+ const workspace = options.workspace ?? o.workspace;
659
+ let acquired = false;
660
+ try {
661
+ o.store.beginRun?.({
662
+ id: runId,
663
+ session: sessionId,
664
+ status: "running",
665
+ model: o.store.get(sessionId).model,
666
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
667
+ });
668
+ o.store.acquire(sessionId);
669
+ acquired = true;
670
+ o.store.reconcile(sessionId);
671
+ const session = o.store.get(sessionId);
672
+ if (session.workspace !== workspace)
673
+ throw new Error("Session workspace mismatch");
674
+ combined.throwIfAborted();
675
+ const call = { id: uiCallId(), name, arguments: JSON.stringify(input) };
676
+ let prepared;
677
+ try {
678
+ prepared = {
679
+ call,
680
+ tool: o.registry.get(name),
681
+ input: o.registry.parse(name, call.arguments),
682
+ };
683
+ }
684
+ catch (error) {
685
+ prepared = { call, error: String(error) };
686
+ }
687
+ const note = `The user ran the ${name} tool from the interface.`;
688
+ o.store.append(sessionId, { role: "user", text: note, display: options.display ?? note });
689
+ emit("run_started", { model: session.model });
690
+ o.store.append(sessionId, { role: "assistant", text: "", calls: [call] });
691
+ const result = await this.executeCall({
692
+ sessionId,
693
+ runId,
694
+ emit,
695
+ signal: combined,
696
+ workspace,
697
+ policy: options.policy ?? o.policy,
698
+ approvals: !!o.approve && options.approvals !== false,
699
+ options,
700
+ }, prepared);
701
+ o.store.append(sessionId, { role: "tool", callId: call.id, result });
702
+ const first = textProjection(result)
703
+ .content.map((part) => (part.type === "text" ? part.text : ""))
704
+ .join(" ")
705
+ .replace(/\s*\n\s*/g, " ")
706
+ .trim()
707
+ .slice(0, 300);
708
+ const text = `${name} ${result.isError ? "failed" : "finished"}${first ? `: ${first}` : ""}`;
709
+ o.store.append(sessionId, { role: "assistant", text, calls: [] });
710
+ emit("run_completed", { tokens: 0, text });
711
+ finish("completed");
712
+ return { runId, status: "completed", result };
713
+ }
714
+ catch (error) {
715
+ if (combined.aborted && isTimeoutReason(combined.reason)) {
716
+ const failure = new RunTimeoutError({
717
+ kind: "run",
718
+ ms: runTimeoutMs,
719
+ stage: "tool",
720
+ tool: name,
721
+ });
722
+ emit("run_failed", { error: failure.message, code: "timeout", timeout: failure.info });
723
+ finish("failed", failure.message);
724
+ throw failure;
725
+ }
726
+ const cause = combined.aborted ? (combined.reason ?? error) : error;
727
+ const message = cause instanceof Error ? cause.message : String(cause);
728
+ emit(combined.aborted ? "run_cancelled" : "run_failed", { error: message });
729
+ finish(combined.aborted ? "cancelled" : "failed", message);
730
+ throw error;
731
+ }
732
+ finally {
733
+ if (acquired)
734
+ o.store.release(sessionId);
735
+ this.active.delete(sessionId);
736
+ }
737
+ }
281
738
  async run(sessionId, prompt, signal, options = {}) {
282
739
  const controller = this.claim(sessionId);
283
- const o = this.options, emit = this.emitter(sessionId);
284
- const limit = o.maxContextChars ?? 160_000;
285
- let tokens = 0, lastText = "", acquired = false;
740
+ const runId = options.runId ?? crypto.randomUUID();
741
+ const o = this.options, emit = this.emitter(sessionId, { runId, correlationId: options.correlationId });
742
+ /** Run journal (v4 stores only): an auxiliary record that never changes the run outcome. */
743
+ const finish = (status, error) => {
744
+ try {
745
+ o.store.endRun?.(runId, { status, usage: usageTotal, ...(error ? { error } : {}) });
746
+ }
747
+ catch {
748
+ /* A journal write failure cannot invalidate an executed run. */
749
+ }
750
+ };
751
+ const limit = o.maxContextChars ?? 800_000;
752
+ let tokens = 0, toolCalls = 0, lastText = "", acquired = false;
286
753
  const usageTotal = { input: 0, output: 0 };
287
- const timeout = AbortSignal.timeout(options.timeoutMs ?? o.timeoutMs ?? 300_000);
754
+ const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
755
+ const firstTokenMs = options.firstTokenTimeoutMs ?? o.firstTokenTimeoutMs ?? 0;
756
+ const firstTokenRetries = firstTokenMs > 0 ? Math.max(0, options.firstTokenRetries ?? o.firstTokenRetries ?? 0) : 0;
757
+ const truncationRecoveries = Math.max(0, options.truncationRecoveries ?? o.truncationRecoveries ?? 0);
758
+ /** Responses of this run cut off by the output limit (the first plus every recovery). */
759
+ let truncations = 0;
760
+ /** Continuation notice and lowered effort for the request that follows a cut-off response. */
761
+ let recoveryNotice;
762
+ let recoveryEffort;
763
+ const runStartedAt = Date.now();
764
+ const timeout = AbortSignal.timeout(runTimeoutMs);
288
765
  const combined = AbortSignal.any([controller.signal, timeout, ...(signal ? [signal] : [])]);
766
+ // What the run was doing, so a timeout can say where it was stuck (see `RunTimeoutError`).
767
+ let stage = "other";
768
+ let stageTool;
769
+ let modelId;
770
+ let providerName;
771
+ let requests = 0;
772
+ let silentTimedOut = false;
773
+ /** Sends of the request that is (or was last) waiting for its first token. */
774
+ let silentAttempts = 0;
289
775
  const context = options.context ?? o.context;
290
776
  const workspace = options.workspace ?? o.workspace;
777
+ const pathAccess = o.pathAccess;
291
778
  const policy = options.policy ?? o.policy;
292
779
  const approvals = !!o.approve && options.approvals !== false;
293
780
  const withPersona = async () => {
@@ -295,6 +782,13 @@ export class AgentRunner {
295
782
  return options.instructions ? `${base}\n\n${options.instructions}` : base;
296
783
  };
297
784
  try {
785
+ o.store.beginRun?.({
786
+ id: runId,
787
+ session: sessionId,
788
+ status: "running",
789
+ model: o.store.get(sessionId).model,
790
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
791
+ });
298
792
  o.store.acquire(sessionId);
299
793
  acquired = true;
300
794
  o.store.reconcile(sessionId);
@@ -304,6 +798,8 @@ export class AgentRunner {
304
798
  throw new Error("Session provider/workspace mismatch");
305
799
  // The session records the model; switching models is explicit via setModel.
306
800
  const model = session.model;
801
+ modelId = model;
802
+ providerName = providerLabel(provider.id);
307
803
  combined.throwIfAborted();
308
804
  if (o.extensions && o.store.messages(sessionId).length === 0) {
309
805
  // New session: extensions may inject budgeted context once, persisted with the session.
@@ -344,11 +840,13 @@ export class AgentRunner {
344
840
  text: prompt,
345
841
  ...(options.display ? { display: options.display } : {}),
346
842
  ...(options.attachments?.length ? { attachments: options.attachments } : {}),
843
+ ...(options.datasets?.length ? { datasets: options.datasets } : {}),
347
844
  });
348
845
  emit("run_started", { model });
349
846
  const budget = options.maxTokens ?? o.maxTokens ?? defaultTokenBudget(o.contextWindow?.(model));
350
847
  const compaction = o.compaction ?? {};
351
- for (let turn = 0; turn < (options.maxTurns ?? o.maxTurns ?? 20); turn++) {
848
+ const maxTurns = options.maxTurns ?? o.maxTurns ?? 100;
849
+ for (let turn = 0; turn < maxTurns; turn++) {
352
850
  combined.throwIfAborted();
353
851
  // Input queued while the run was working (e.g. a parent's message to a child).
354
852
  if (turn > 0)
@@ -356,9 +854,17 @@ export class AgentRunner {
356
854
  o.store.append(sessionId, { role: "user", text: queued });
357
855
  let instructions = await withPersona();
358
856
  let messages = o.store.messages(sessionId);
359
- const tools = this.availableTools(options);
857
+ const tools = this.availableTools(options, sessionId);
360
858
  const toolsText = this.toolsText(tools);
361
859
  const chars = () => instructions.length + JSON.stringify(messages).length + toolsText.length;
860
+ // REDUCIBLE content only (instructions + transcript). The fixed tool catalog
861
+ // (`toolsText`) is a deployment reality — a huge MCP catalog with dozens of tools is
862
+ // part of every request regardless of history, so it is deliberately excluded from the
863
+ // post-compaction hard cap: counting it would reduce a small transcript to near-nothing
864
+ // and still fake-fail. Oversized catalogs are a `/plugins` decision, not session growth;
865
+ // auto-compaction above still measures the FULL request (`chars()`), because window
866
+ // thresholds protect what the model really sees, tools included.
867
+ const charsConversation = () => instructions.length + JSON.stringify(messages).length;
362
868
  const used = () => {
363
869
  const last = this.reported.get(sessionId);
364
870
  return last && last.count <= messages.length
@@ -375,58 +881,203 @@ export class AgentRunner {
375
881
  instructions = await withPersona();
376
882
  messages = o.store.messages(sessionId);
377
883
  }
378
- // A compaction that still leaves the session over the hard limit (typically a kept tail
379
- // holding huge tool outputs) is reduced in place instead of failing outright: retained
380
- // content is clipped against a TOTAL character target for the transcript
381
- // (`limit − instructions − tools`), largest items first, tool results before texts, in
382
- // descending cap rounds. That also covers sessions with many MEDIUM results (e.g. MCP
383
- // outputs of a few thousand chars each) that individually stay under the per-message
384
- // caps; roles/callIds/boundaries stay untouched, so the transcript remains valid and
385
- // replayable. Only if even the reduction floor cannot fit (instructions + tools alone
386
- // crowd out the limit) the run fails with an actionable error; the reduction is
387
- // persisted either way, so the session stays usable for later prompts.
388
- if (chars() > limit) {
389
- const targetChars = Math.max(MIN_MESSAGE_BUDGET_FLOOR, limit - instructions.length - toolsText.length);
884
+ // A compaction that still leaves the session over the hard limit is reduced in place
885
+ // instead of failing outright: retained content is clipped against a TOTAL character
886
+ // target for the transcript (`limit − instructions`), largest items first, tool results
887
+ // before texts, in descending cap rounds. That also covers sessions with many MEDIUM
888
+ // results (e.g. MCP outputs of a few thousand chars each) that individually stay under
889
+ // the per-message caps; roles/callIds/boundaries stay untouched, so the transcript
890
+ // remains valid and replayable. The hard cap measures ONLY reducible content
891
+ // (`charsConversation`): the fixed tool catalog (see above) never triggers the error on
892
+ // its own. The reduction is persisted either way, so the session stays usable for later
893
+ // prompts. Only if even the reduction floor cannot fit (instructions alone crowd out the
894
+ // limit) the run fails with an actionable error.
895
+ if (charsConversation() > limit) {
896
+ const targetChars = Math.max(MIN_MESSAGE_BUDGET_FLOOR, limit - instructions.length);
390
897
  const reduction = reduceMessagesToBudget(messages, targetChars);
391
898
  if (reduction.truncated > 0) {
392
899
  o.store.overwrite(sessionId, reduction.messages);
393
900
  messages = reduction.messages;
394
901
  emit("context_reduced", { messages: reduction.truncated });
395
902
  }
396
- if (chars() > limit)
397
- throw new Error(`Context budget exceeded and compaction could not reduce it (approximately ${chars()} characters; limit ${limit}). Run /compact, trim large tool outputs, or start a new session.`);
903
+ if (charsConversation() > limit)
904
+ throw new Error(`Context budget exceeded and compaction could not reduce it (approximately ${charsConversation()} characters of conversation; limit ${limit}). Run /compact, trim large tool outputs, start a new session, or disable unneeded MCP servers with /plugins.`);
398
905
  }
399
906
  let completion;
400
907
  let truncated = false;
401
908
  let usage;
402
- for await (const e of provider.stream({
403
- instructions,
404
- messages,
405
- tools,
406
- maxOutputTokens: o.maxOutputTokens ?? 4096,
407
- signal: combined,
408
- model,
409
- sessionId,
410
- ...(o.nativeTools?.length ? { nativeTools: o.nativeTools } : {}),
411
- })) {
909
+ // Providers only ever see the text projection of tool results: ui/image parts are a
910
+ // display-only extension of the persisted transcript and never reach a model prompt
911
+ // (raw image bytes included). The store keeps the rich parts for TUI replay.
912
+ const providerMessages = messages.map((m) => m.role === "tool" ? { ...m, result: textProjection(m.result) } : m);
913
+ // After a cut-off response the request carries a transient continuation notice (never
914
+ // persisted: the stored transcript only ever holds what the model really answered).
915
+ if (recoveryNotice)
916
+ providerMessages.push({ role: "user", text: recoveryNotice });
917
+ const requestEffort = recoveryEffort ?? options.reasoningEffort ?? o.reasoningEffort;
918
+ recoveryNotice = undefined;
919
+ recoveryEffort = undefined;
920
+ // Resolved for the model of THIS request (a catalog may still be loading on the first).
921
+ const outputLimit = await this.outputLimit(model, options.maxOutputTokens);
922
+ combined.throwIfAborted();
923
+ /** The provider threw the "cut off by the output limit" signal instead of completing. */
924
+ let cutByError = false;
925
+ let requested = Date.now();
926
+ let firstDelta;
927
+ stage = "waiting_model";
928
+ requests++;
929
+ silentAttempts = 0;
930
+ // The same request is sent again only while the provider stayed completely silent until
931
+ // `firstTokenTimeoutMs`. Nothing was received, appended or accounted for the aborted
932
+ // attempt, so the retry reuses the exact same inputs inside this turn.
933
+ for (;;) {
934
+ silentAttempts++;
935
+ silentTimedOut = false;
936
+ requested = Date.now();
937
+ // A request that stays completely silent can be stopped early (`firstTokenTimeoutMs`).
938
+ const silent = new AbortController();
939
+ let silentTimer = firstTokenMs > 0
940
+ ? setTimeout(() => {
941
+ silentTimedOut = true;
942
+ silent.abort();
943
+ }, firstTokenMs)
944
+ : undefined;
945
+ const stopSilentTimer = () => {
946
+ if (silentTimer)
947
+ clearTimeout(silentTimer);
948
+ silentTimer = undefined;
949
+ };
950
+ let failure;
951
+ try {
952
+ for await (const e of provider.stream({
953
+ instructions,
954
+ messages: providerMessages,
955
+ tools,
956
+ maxOutputTokens: outputLimit.value,
957
+ signal: firstTokenMs > 0 ? AbortSignal.any([combined, silent.signal]) : combined,
958
+ model,
959
+ sessionId,
960
+ ...(o.nativeTools?.length ? { nativeTools: o.nativeTools } : {}),
961
+ ...(requestEffort ? { reasoningEffort: requestEffort } : {}),
962
+ })) {
963
+ combined.throwIfAborted();
964
+ stopSilentTimer();
965
+ if (stage === "waiting_model")
966
+ stage = "streaming";
967
+ if (e.type !== "completed")
968
+ firstDelta ??= Date.now();
969
+ if (e.type === "text_delta")
970
+ emit("text_delta", { delta: e.delta });
971
+ else if (e.type === "reasoning_delta")
972
+ emit("reasoning_delta", { delta: e.delta });
973
+ else {
974
+ if (completion)
975
+ throw new Error("Provider emitted multiple completions");
976
+ completion = e.message;
977
+ truncated = e.message.truncated === true;
978
+ usage = e.usage;
979
+ tokens += (e.usage?.input ?? 0) + (e.usage?.output ?? 0);
980
+ usageTotal.input += e.usage?.input ?? 0;
981
+ usageTotal.output += e.usage?.output ?? 0;
982
+ }
983
+ }
984
+ }
985
+ catch (error) {
986
+ failure = error;
987
+ }
988
+ finally {
989
+ stopSilentTimer();
990
+ }
991
+ const silentFailure = silentTimedOut &&
992
+ firstDelta === undefined &&
993
+ !completion &&
994
+ !combined.aborted &&
995
+ silentAttempts <= firstTokenRetries;
996
+ if (!silentFailure) {
997
+ if (failure !== undefined) {
998
+ // A response cut off before anything usable is recovered like a truncated one.
999
+ if (!completion && !combined.aborted && isOutputTruncationError(failure)) {
1000
+ cutByError = true;
1001
+ break;
1002
+ }
1003
+ throw failure;
1004
+ }
1005
+ break;
1006
+ }
1007
+ // Do not start an attempt the whole-run limit could not let finish.
1008
+ const retryPauseMs = FIRST_TOKEN_RETRY_PAUSE_MS;
1009
+ if (runTimeoutMs - (Date.now() - runStartedAt) < retryPauseMs + firstTokenMs) {
1010
+ if (failure !== undefined)
1011
+ throw failure;
1012
+ break;
1013
+ }
1014
+ // From here the silence is handled: a later stop or run timeout is judged on its own.
1015
+ silentTimedOut = false;
1016
+ emit("request_retry", {
1017
+ attempt: silentAttempts,
1018
+ of: firstTokenRetries,
1019
+ reason: "first_token_timeout",
1020
+ afterMs: Date.now() - requested,
1021
+ });
1022
+ await abortableDelay(retryPauseMs, combined);
412
1023
  combined.throwIfAborted();
413
- if (e.type === "text_delta")
414
- emit("text_delta", { delta: e.delta });
415
- else if (e.type === "reasoning_delta")
416
- emit("reasoning_delta", { delta: e.delta });
417
- else {
418
- if (completion)
419
- throw new Error("Provider emitted multiple completions");
420
- completion = e.message;
421
- truncated = e.message.truncated === true;
422
- usage = e.usage;
423
- tokens += (e.usage?.input ?? 0) + (e.usage?.output ?? 0);
424
- usageTotal.input += e.usage?.input ?? 0;
425
- usageTotal.output += e.usage?.output ?? 0;
1024
+ stage = "waiting_model";
1025
+ }
1026
+ stage = "other";
1027
+ // A response cut off by the output limit that cannot be used as it is: no visible text
1028
+ // and no tool call, or a tool call whose arguments are incomplete. Complete calls of a
1029
+ // cut response, and a cut response with usable text, keep their normal handling below.
1030
+ let cut;
1031
+ if (cutByError)
1032
+ cut = "empty_response";
1033
+ else if (!completion)
1034
+ throw new Error("Provider stream ended without a completed response");
1035
+ else if (truncated) {
1036
+ if (incompleteCalls(completion.calls))
1037
+ cut = "tool_call_cut";
1038
+ else if (!completion.text.trim() && !completion.calls.length)
1039
+ cut = "empty_response";
1040
+ }
1041
+ if (cut) {
1042
+ const maxOutputTokens = outputLimit.value;
1043
+ truncations++;
1044
+ // Visible text is kept as a normal assistant message; the cut calls are discarded:
1045
+ // never executed, never persisted (they would have no result), no provider data.
1046
+ const keep = completion?.text ?? "";
1047
+ if (keep.trim()) {
1048
+ o.store.append(sessionId, { role: "assistant", text: keep, calls: [] });
1049
+ lastText = keep;
426
1050
  }
1051
+ if (truncations > truncationRecoveries)
1052
+ throw new RunTruncationError({
1053
+ attempts: truncations,
1054
+ maxOutputTokens,
1055
+ source: outputLimit.source,
1056
+ ...(modelId ? { model: modelId } : {}),
1057
+ });
1058
+ // Reasoning exhausted the budget (nothing at all came out): ask for less of it once.
1059
+ const levels = o.effortLevels?.(model);
1060
+ const lowered = truncations === 1 && cut === "empty_response"
1061
+ ? lowerEffort(requestEffort ?? levels?.defaultLevel, levels?.supportedLevels)
1062
+ : undefined;
1063
+ emit("truncation_recovery", {
1064
+ attempt: truncations,
1065
+ of: truncationRecoveries,
1066
+ reason: cut,
1067
+ maxOutputTokens,
1068
+ ...(lowered ? { effort: lowered } : {}),
1069
+ });
1070
+ recoveryNotice = TRUNCATION_NOTICE;
1071
+ recoveryEffort = lowered;
1072
+ await abortableDelay(TRUNCATION_RETRY_PAUSE_MS, combined);
1073
+ combined.throwIfAborted();
1074
+ // The recovery is not a turn.
1075
+ turn--;
1076
+ continue;
427
1077
  }
428
1078
  if (!completion)
429
1079
  throw new Error("Provider stream ended without a completed response");
1080
+ const durationMs = Date.now() - requested;
430
1081
  const ids = completion.calls.map((c) => c.id);
431
1082
  const previousIds = new Set(messages.flatMap((m) => (m.role === "assistant" ? m.calls.map((c) => c.id) : [])));
432
1083
  if (ids.some((id) => previousIds.has(id)))
@@ -448,6 +1099,8 @@ export class AgentRunner {
448
1099
  calls: completion.calls.length,
449
1100
  model,
450
1101
  ...(usage ? { usage } : {}),
1102
+ durationMs,
1103
+ ...(firstDelta !== undefined ? { ttftMs: firstDelta - requested } : {}),
451
1104
  });
452
1105
  if (!completion.calls.length) {
453
1106
  const final = {
@@ -458,13 +1111,16 @@ export class AgentRunner {
458
1111
  // The adapter kept a usable answer, but the token budget cut it off.
459
1112
  emit("response_truncated", {
460
1113
  turn: turn + 1,
461
- maxOutputTokens: o.maxOutputTokens ?? 4096,
1114
+ maxOutputTokens: outputLimit.value,
1115
+ source: outputLimit.source,
462
1116
  });
463
1117
  final.truncated = true;
464
1118
  }
465
1119
  emit("run_completed", final);
466
- return { sessionId, text: lastText, status: "completed", usage: usageTotal };
1120
+ finish("completed");
1121
+ return { sessionId, text: lastText, status: "completed", usage: usageTotal, toolCalls };
467
1122
  }
1123
+ toolCalls += completion.calls.length;
468
1124
  const prepared = completion.calls.map((call) => {
469
1125
  try {
470
1126
  const tool = o.registry.get(call.name), input = o.registry.parse(call.name, call.arguments);
@@ -481,90 +1137,38 @@ export class AgentRunner {
481
1137
  if (paths.length)
482
1138
  contextUpdate = await context.beforePaths(paths, sessionId);
483
1139
  let pendingUpdate = contextUpdate;
484
- const execute = async (p) => {
485
- const { call } = p;
486
- let result;
487
- const started = Date.now();
488
- emit("tool_started", { name: call.name, id: call.id, arguments: call.arguments });
489
- try {
490
- combined.throwIfAborted();
491
- if (p.error)
492
- throw new Error(p.error);
493
- if (!p.tool || !p.input)
494
- throw new Error("Invalid tool");
495
- const effect = p.tool.effect ?? "external";
1140
+ const scope = {
1141
+ sessionId,
1142
+ runId,
1143
+ emit,
1144
+ signal: combined,
1145
+ workspace,
1146
+ policy,
1147
+ approvals,
1148
+ options,
1149
+ guard: (effect) => {
496
1150
  if (contextUpdate && effect !== "read")
497
1151
  throw new Error(`Context changed; reconsider this call before retrying.\n${contextUpdate}`);
498
1152
  if (tokens >= budget)
499
1153
  throw new Error("Token budget exhausted; tool was not executed");
500
- if (options.toolFilter && !options.toolFilter(p.tool))
501
- throw new Error(`Tool ${call.name} is not available in this session`);
502
- if (!allowed(policy, effect)) {
503
- if (!o.approve ||
504
- !approvals ||
505
- (effect !== "write" && effect !== "process" && effect !== "external"))
506
- throw new Error(`Capability denied: ${effect}`);
507
- emit("approval_requested", {
508
- id: call.id,
509
- name: call.name,
510
- effect,
511
- ...(options.label ? { label: options.label } : {}),
512
- });
513
- const decision = await o.approve({
514
- call,
515
- effect,
516
- input: p.input,
517
- signal: combined,
518
- session: sessionId,
519
- ...(options.label ? { label: options.label } : {}),
520
- });
521
- combined.throwIfAborted();
522
- emit("approval_resolved", { id: call.id, name: call.name, effect, decision });
523
- if (decision === "deny")
524
- throw new Error(`Capability ${effect} denied by the user for this call`);
525
- if (decision === "session")
526
- policy[effect] = true;
527
- }
528
- o.store.beginCall(sessionId, call);
529
- result = await p.tool.execute(p.input, {
530
- signal: combined,
531
- workspace,
532
- session: sessionId,
533
- emit: (data) => emit("tool_progress", { id: call.id, data }),
534
- ...(options.label ? { label: options.label } : {}),
535
- });
536
- if (JSON.stringify(result).length > 48_000)
537
- result = textResult(`${JSON.stringify(result).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
538
- if (pendingUpdate && effect === "read") {
539
- result = {
540
- ...result,
541
- content: [
542
- ...result.content,
543
- { type: "text", text: `\n<instructions>\n${pendingUpdate}\n</instructions>` },
544
- ],
545
- };
546
- pendingUpdate = undefined;
547
- }
548
- o.store.endCall(sessionId, call, result);
549
- }
550
- catch (error) {
551
- result = textResult(error instanceof Error ? error.message : String(error), true);
552
- // A cancellation after execution may have left effects; journal remains pending.
553
- if (!combined.aborted)
554
- o.store.endCall(sessionId, call, result);
555
- }
556
- emit("tool_completed", {
557
- id: call.id,
558
- name: call.name,
559
- isError: result.isError ?? false,
560
- durationMs: Date.now() - started,
561
- preview: result.content
562
- .map((c) => c.text)
563
- .join("\n")
564
- .slice(0, 2_000),
565
- });
566
- return result;
1154
+ },
1155
+ amend: (result, effect) => {
1156
+ if (!pendingUpdate || effect !== "read")
1157
+ return result;
1158
+ const update = pendingUpdate;
1159
+ pendingUpdate = undefined;
1160
+ return {
1161
+ ...result,
1162
+ content: [
1163
+ ...result.content,
1164
+ { type: "text", text: `\n<instructions>\n${update}\n</instructions>` },
1165
+ ],
1166
+ };
1167
+ },
567
1168
  };
1169
+ stage = "tool";
1170
+ stageTool = prepared[0]?.call.name;
1171
+ const execute = (p) => this.executeCall(scope, p);
568
1172
  const results = [];
569
1173
  // Bounded parallel batches for consecutive read or explicitly concurrent tools.
570
1174
  const parallel = (p) => !!p?.tool && (p.tool.effect === "read" || p.tool.concurrent === true);
@@ -594,17 +1198,55 @@ export class AgentRunner {
594
1198
  if (c && r)
595
1199
  o.store.append(sessionId, { role: "tool", callId: c.id, result: r });
596
1200
  }
1201
+ stage = "other";
1202
+ stageTool = undefined;
597
1203
  if (tokens >= budget)
598
1204
  throw new Error("Token budget exhausted");
599
1205
  }
600
- throw new Error("Maximum turns reached");
1206
+ // Turn cap reached. This is NOT a failure: everything produced up to this point stays in
1207
+ // the transcript and the run returns a successful-but-marked partial result, so the user
1208
+ // can simply prompt again to continue in the same session. The real hard stops remain the
1209
+ // token budget (`maxTokens`) and the timeout; the turn count is a safety rail.
1210
+ emit("run_turns_exceeded", { turns: maxTurns, maxTurns });
1211
+ finish("turns_exceeded");
1212
+ return { sessionId, text: lastText, status: "turns-exceeded", usage: usageTotal, toolCalls };
601
1213
  }
602
1214
  catch (error) {
1215
+ const timedOut = silentTimedOut
1216
+ ? { kind: "first_token", ms: firstTokenMs, attempts: Math.max(1, silentAttempts) }
1217
+ : combined.aborted && isTimeoutReason(combined.reason)
1218
+ ? { kind: "run", ms: runTimeoutMs }
1219
+ : undefined;
1220
+ if (timedOut && !(combined.aborted && !isTimeoutReason(combined.reason))) {
1221
+ const failure = new RunTimeoutError({
1222
+ ...timedOut,
1223
+ ...(modelId ? { model: modelId } : {}),
1224
+ ...(providerName ? { provider: providerName } : {}),
1225
+ stage,
1226
+ ...(stage === "tool" && stageTool ? { tool: stageTool } : {}),
1227
+ ...(requests <= 1 ? { firstRequest: true } : {}),
1228
+ });
1229
+ emit("run_failed", { error: failure.message, code: "timeout", timeout: failure.info });
1230
+ finish("failed", failure.message);
1231
+ attachRunStats(failure, { usage: { ...usageTotal }, toolCalls });
1232
+ throw failure;
1233
+ }
1234
+ if (error instanceof RunTruncationError) {
1235
+ emit("run_failed", {
1236
+ error: error.message,
1237
+ code: "output_truncated",
1238
+ truncation: error.info,
1239
+ });
1240
+ finish("failed", error.message);
1241
+ attachRunStats(error, { usage: { ...usageTotal }, toolCalls });
1242
+ throw error;
1243
+ }
603
1244
  // On cancellation report the abort reason, not the transport's secondary error.
604
1245
  const cause = combined.aborted ? (combined.reason ?? error) : error;
605
- emit(combined.aborted ? "run_cancelled" : "run_failed", {
606
- error: cause instanceof Error ? cause.message : String(cause),
607
- });
1246
+ const message = cause instanceof Error ? cause.message : String(cause);
1247
+ emit(combined.aborted ? "run_cancelled" : "run_failed", { error: message });
1248
+ finish(combined.aborted ? "cancelled" : "failed", message);
1249
+ attachRunStats(error, { usage: { ...usageTotal }, toolCalls });
608
1250
  throw error;
609
1251
  }
610
1252
  finally {