@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.
- package/README.md +52 -7
- package/dist/agents/active.d.ts +99 -0
- package/dist/agents/active.js +218 -0
- package/dist/agents/capabilities.d.ts +26 -0
- package/dist/agents/capabilities.js +120 -0
- package/dist/agents/create-agent-skill.d.ts +11 -0
- package/dist/agents/create-agent-skill.js +60 -0
- package/dist/agents/definitions.d.ts +88 -0
- package/dist/agents/definitions.js +404 -0
- package/dist/agents/draft.d.ts +41 -0
- package/dist/agents/draft.js +133 -0
- package/dist/agents/service.d.ts +57 -0
- package/dist/agents/service.js +86 -0
- package/dist/agents/templates.d.ts +8 -0
- package/dist/agents/templates.js +102 -0
- package/dist/analysis/capabilities.d.ts +48 -0
- package/dist/analysis/capabilities.js +90 -0
- package/dist/analysis/data/csv.d.ts +46 -0
- package/dist/analysis/data/csv.js +240 -0
- package/dist/analysis/data/datasets.d.ts +132 -0
- package/dist/analysis/data/datasets.js +441 -0
- package/dist/analysis/data/describe.d.ts +27 -0
- package/dist/analysis/data/describe.js +103 -0
- package/dist/analysis/data/engine-client.d.ts +58 -0
- package/dist/analysis/data/engine-client.js +304 -0
- package/dist/analysis/data/engine-entry.d.ts +1 -0
- package/dist/analysis/data/engine-entry.js +59 -0
- package/dist/analysis/data/engine-source.d.ts +4 -0
- package/dist/analysis/data/engine-source.js +6 -0
- package/dist/analysis/data/engine-types.d.ts +86 -0
- package/dist/analysis/data/engine-types.js +7 -0
- package/dist/analysis/data/infer.d.ts +42 -0
- package/dist/analysis/data/infer.js +118 -0
- package/dist/analysis/data/ingest-worker.d.ts +17 -0
- package/dist/analysis/data/ingest-worker.js +365 -0
- package/dist/analysis/data/json.d.ts +19 -0
- package/dist/analysis/data/json.js +44 -0
- package/dist/analysis/data/query-worker.d.ts +12 -0
- package/dist/analysis/data/query-worker.js +75 -0
- package/dist/analysis/data/rows.d.ts +39 -0
- package/dist/analysis/data/rows.js +120 -0
- package/dist/analysis/data/sql-guard.d.ts +20 -0
- package/dist/analysis/data/sql-guard.js +129 -0
- package/dist/analysis/data/stats.d.ts +18 -0
- package/dist/analysis/data/stats.js +77 -0
- package/dist/analysis/install-hints.d.ts +33 -0
- package/dist/analysis/install-hints.js +144 -0
- package/dist/analysis/janitor.d.ts +60 -0
- package/dist/analysis/janitor.js +264 -0
- package/dist/analysis/jobs.d.ts +94 -0
- package/dist/analysis/jobs.js +165 -0
- package/dist/analysis/oci.d.ts +129 -0
- package/dist/analysis/oci.js +330 -0
- package/dist/analysis/python/sources.d.ts +2 -0
- package/dist/analysis/python/sources.js +10 -0
- package/dist/analysis/requirements.d.ts +32 -0
- package/dist/analysis/requirements.js +73 -0
- package/dist/analysis/rerun.d.ts +40 -0
- package/dist/analysis/rerun.js +136 -0
- package/dist/analysis/runtime-manager.d.ts +146 -0
- package/dist/analysis/runtime-manager.js +472 -0
- package/dist/analysis/status.d.ts +21 -0
- package/dist/analysis/status.js +52 -0
- package/dist/application.d.ts +105 -3
- package/dist/application.js +446 -18
- package/dist/artifacts/kinds.d.ts +35 -0
- package/dist/artifacts/kinds.js +177 -0
- package/dist/artifacts/publisher.d.ts +7 -0
- package/dist/artifacts/publisher.js +29 -0
- package/dist/artifacts/store.d.ts +119 -0
- package/dist/artifacts/store.js +581 -0
- package/dist/background/janitor.d.ts +22 -0
- package/dist/background/janitor.js +62 -0
- package/dist/background/mirror.d.ts +14 -0
- package/dist/background/mirror.js +41 -0
- package/dist/background/notify.d.ts +58 -0
- package/dist/background/notify.js +172 -0
- package/dist/background/output.d.ts +46 -0
- package/dist/background/output.js +179 -0
- package/dist/background/service.d.ts +137 -0
- package/dist/background/service.js +410 -0
- package/dist/background/store.d.ts +103 -0
- package/dist/background/store.js +195 -0
- package/dist/changelog/data.d.ts +6 -0
- package/dist/changelog/data.js +306 -0
- package/dist/changelog/index.d.ts +5 -0
- package/dist/changelog/index.js +7 -0
- package/dist/changelog/parse.d.ts +53 -0
- package/dist/changelog/parse.js +160 -0
- package/dist/commands/builtins.d.ts +10 -0
- package/dist/commands/builtins.js +161 -0
- package/dist/commands/catalog.d.ts +86 -0
- package/dist/commands/catalog.js +304 -0
- package/dist/config.d.ts +206 -6
- package/dist/config.js +426 -20
- package/dist/core/compaction.js +13 -2
- package/dist/core/contracts.d.ts +180 -3
- package/dist/core/opt-in.d.ts +12 -0
- package/dist/core/opt-in.js +12 -0
- package/dist/core/output-limit.d.ts +34 -0
- package/dist/core/output-limit.js +39 -0
- package/dist/core/run-stats.d.ts +16 -0
- package/dist/core/run-stats.js +8 -0
- package/dist/core/runner.d.ts +134 -4
- package/dist/core/runner.js +780 -138
- package/dist/core/timeout.d.ts +15 -0
- package/dist/core/timeout.js +61 -0
- package/dist/core/truncation.d.ts +30 -0
- package/dist/core/truncation.js +83 -0
- package/dist/goal/blockers.d.ts +45 -0
- package/dist/goal/blockers.js +51 -0
- package/dist/goal/budget.d.ts +24 -0
- package/dist/goal/budget.js +53 -0
- package/dist/goal/command.d.ts +36 -0
- package/dist/goal/command.js +98 -0
- package/dist/goal/format.d.ts +19 -0
- package/dist/goal/format.js +102 -0
- package/dist/goal/host.d.ts +29 -0
- package/dist/goal/host.js +42 -0
- package/dist/goal/index.d.ts +9 -0
- package/dist/goal/index.js +9 -0
- package/dist/goal/machine.d.ts +186 -0
- package/dist/goal/machine.js +344 -0
- package/dist/goal/prompts.d.ts +10 -0
- package/dist/goal/prompts.js +75 -0
- package/dist/goal/service.d.ts +113 -0
- package/dist/goal/service.js +273 -0
- package/dist/goal/store.d.ts +59 -0
- package/dist/goal/store.js +184 -0
- package/dist/index.d.ts +50 -5
- package/dist/index.js +49 -4
- package/dist/mcp/connector.js +38 -6
- package/dist/mcp/rich.d.ts +41 -0
- package/dist/mcp/rich.js +362 -0
- package/dist/permissions/modes.d.ts +72 -0
- package/dist/permissions/modes.js +98 -0
- package/dist/plan/exit-plan.d.ts +25 -0
- package/dist/plan/exit-plan.js +130 -0
- package/dist/plan/state.d.ts +102 -0
- package/dist/plan/state.js +165 -0
- package/dist/plugins/host.d.ts +34 -1
- package/dist/plugins/host.js +143 -9
- package/dist/providers/settings.d.ts +15 -0
- package/dist/providers/settings.js +38 -0
- package/dist/reload.d.ts +75 -0
- package/dist/reload.js +204 -0
- package/dist/resources/context.js +6 -1
- package/dist/runtime/access.d.ts +62 -0
- package/dist/runtime/access.js +115 -0
- package/dist/runtime/blobs.d.ts +51 -0
- package/dist/runtime/blobs.js +177 -0
- package/dist/runtime/diff.d.ts +23 -0
- package/dist/runtime/diff.js +154 -0
- package/dist/runtime/ids.d.ts +8 -0
- package/dist/runtime/ids.js +32 -0
- package/dist/runtime/paths.d.ts +11 -0
- package/dist/runtime/paths.js +17 -1
- package/dist/runtime/process.d.ts +22 -0
- package/dist/runtime/process.js +100 -15
- package/dist/runtime/sqlite.d.ts +2 -0
- package/dist/runtime/sqlite.js +18 -0
- package/dist/runtime/store.d.ts +91 -3
- package/dist/runtime/store.js +465 -11
- package/dist/runtime/zip.d.ts +24 -0
- package/dist/runtime/zip.js +126 -0
- package/dist/sessions/children.js +17 -1
- package/dist/sessions/side-questions.d.ts +57 -0
- package/dist/sessions/side-questions.js +161 -0
- package/dist/tools/analysis.d.ts +37 -0
- package/dist/tools/analysis.js +573 -0
- package/dist/tools/artifacts.d.ts +14 -0
- package/dist/tools/artifacts.js +143 -0
- package/dist/tools/background.d.ts +7 -0
- package/dist/tools/background.js +165 -0
- package/dist/tools/data.d.ts +7 -0
- package/dist/tools/data.js +160 -0
- package/dist/tools/execute.d.ts +5 -0
- package/dist/tools/execute.js +15 -3
- package/dist/tools/goal.d.ts +19 -0
- package/dist/tools/goal.js +126 -0
- package/dist/tools/search.d.ts +17 -3
- package/dist/tools/search.js +153 -0
- package/dist/tools/standard.d.ts +4 -1
- package/dist/tools/standard.js +99 -18
- package/dist/version.d.ts +5 -3
- package/dist/version.js +35 -6
- package/package.json +2 -2
package/dist/core/runner.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
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" ||
|
|
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
|
|
66
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
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
|
|
284
|
-
const
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
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 (
|
|
397
|
-
throw new Error(`Context budget exceeded and compaction could not reduce it (approximately ${
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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:
|
|
1114
|
+
maxOutputTokens: outputLimit.value,
|
|
1115
|
+
source: outputLimit.source,
|
|
462
1116
|
});
|
|
463
1117
|
final.truncated = true;
|
|
464
1118
|
}
|
|
465
1119
|
emit("run_completed", final);
|
|
466
|
-
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
if (!
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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 {
|