@alisio/core 0.1.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +118 -3
- package/dist/application.js +510 -19
- 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 +293 -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 +252 -5
- package/dist/config.js +514 -13
- 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 +152 -4
- package/dist/core/runner.js +792 -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 +51 -6
- 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 +39 -1
- package/dist/plugins/host.js +150 -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.d.ts +5 -0
- package/dist/resources/context.js +13 -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/startup/screen.js +6 -5
- 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,8 +71,35 @@ export class AgentRunner {
|
|
|
37
71
|
get approvals() {
|
|
38
72
|
return !!this.options.approve;
|
|
39
73
|
}
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Live-update runnable settings so the next `run`/`compact` call honors them without restarting
|
|
90
|
+
* Alisio. Every patch field is merged over the current options; unrelated settings are kept.
|
|
91
|
+
*/
|
|
92
|
+
applySettings(patch) {
|
|
93
|
+
const { compaction, ...rest } = patch;
|
|
94
|
+
this.options = {
|
|
95
|
+
...this.options,
|
|
96
|
+
...rest,
|
|
97
|
+
...(compaction ? { compaction: { ...this.options.compaction, ...compaction } } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
emitter(sessionId, run = {}) {
|
|
101
|
+
const runId = run.runId ?? crypto.randomUUID();
|
|
102
|
+
const correlationId = run.correlationId;
|
|
42
103
|
let seq = 0;
|
|
43
104
|
return (type, data) => {
|
|
44
105
|
const event = {
|
|
@@ -50,8 +111,15 @@ export class AgentRunner {
|
|
|
50
111
|
timestamp: new Date().toISOString(),
|
|
51
112
|
data,
|
|
52
113
|
};
|
|
53
|
-
if (type
|
|
54
|
-
|
|
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;
|
|
55
123
|
try {
|
|
56
124
|
this.options.onEvent?.(event);
|
|
57
125
|
}
|
|
@@ -60,17 +128,28 @@ export class AgentRunner {
|
|
|
60
128
|
}
|
|
61
129
|
};
|
|
62
130
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
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) {
|
|
65
136
|
const o = this.options;
|
|
66
137
|
const policy = run.policy ?? o.policy;
|
|
67
138
|
const approvals = !!o.approve && run.approvals !== false;
|
|
68
139
|
return o.registry.list().filter((t) => {
|
|
69
140
|
if (run.toolFilter && !run.toolFilter(t))
|
|
70
141
|
return false;
|
|
142
|
+
if (!optInAllows(t.name, run.optInTools))
|
|
143
|
+
return false;
|
|
71
144
|
const effect = t.effect ?? "external";
|
|
72
145
|
if (allowed(policy, effect))
|
|
73
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
|
+
}
|
|
74
153
|
return approvals && (effect === "write" || effect === "process" || effect === "external");
|
|
75
154
|
});
|
|
76
155
|
}
|
|
@@ -135,6 +214,40 @@ export class AgentRunner {
|
|
|
135
214
|
};
|
|
136
215
|
return { total: undefined, basis: "unknown", compactionAt: 100 };
|
|
137
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
|
+
}
|
|
138
251
|
/** Change the model used by subsequent turns of a session; recorded in the store. */
|
|
139
252
|
setModel(sessionId, model) {
|
|
140
253
|
const id = model.trim();
|
|
@@ -266,16 +379,402 @@ export class AgentRunner {
|
|
|
266
379
|
throw error;
|
|
267
380
|
}
|
|
268
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
|
+
}
|
|
269
738
|
async run(sessionId, prompt, signal, options = {}) {
|
|
270
739
|
const controller = this.claim(sessionId);
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
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;
|
|
274
753
|
const usageTotal = { input: 0, output: 0 };
|
|
275
|
-
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);
|
|
276
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;
|
|
277
775
|
const context = options.context ?? o.context;
|
|
278
776
|
const workspace = options.workspace ?? o.workspace;
|
|
777
|
+
const pathAccess = o.pathAccess;
|
|
279
778
|
const policy = options.policy ?? o.policy;
|
|
280
779
|
const approvals = !!o.approve && options.approvals !== false;
|
|
281
780
|
const withPersona = async () => {
|
|
@@ -283,6 +782,13 @@ export class AgentRunner {
|
|
|
283
782
|
return options.instructions ? `${base}\n\n${options.instructions}` : base;
|
|
284
783
|
};
|
|
285
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
|
+
});
|
|
286
792
|
o.store.acquire(sessionId);
|
|
287
793
|
acquired = true;
|
|
288
794
|
o.store.reconcile(sessionId);
|
|
@@ -292,6 +798,8 @@ export class AgentRunner {
|
|
|
292
798
|
throw new Error("Session provider/workspace mismatch");
|
|
293
799
|
// The session records the model; switching models is explicit via setModel.
|
|
294
800
|
const model = session.model;
|
|
801
|
+
modelId = model;
|
|
802
|
+
providerName = providerLabel(provider.id);
|
|
295
803
|
combined.throwIfAborted();
|
|
296
804
|
if (o.extensions && o.store.messages(sessionId).length === 0) {
|
|
297
805
|
// New session: extensions may inject budgeted context once, persisted with the session.
|
|
@@ -332,11 +840,13 @@ export class AgentRunner {
|
|
|
332
840
|
text: prompt,
|
|
333
841
|
...(options.display ? { display: options.display } : {}),
|
|
334
842
|
...(options.attachments?.length ? { attachments: options.attachments } : {}),
|
|
843
|
+
...(options.datasets?.length ? { datasets: options.datasets } : {}),
|
|
335
844
|
});
|
|
336
845
|
emit("run_started", { model });
|
|
337
846
|
const budget = options.maxTokens ?? o.maxTokens ?? defaultTokenBudget(o.contextWindow?.(model));
|
|
338
847
|
const compaction = o.compaction ?? {};
|
|
339
|
-
|
|
848
|
+
const maxTurns = options.maxTurns ?? o.maxTurns ?? 100;
|
|
849
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
340
850
|
combined.throwIfAborted();
|
|
341
851
|
// Input queued while the run was working (e.g. a parent's message to a child).
|
|
342
852
|
if (turn > 0)
|
|
@@ -344,9 +854,17 @@ export class AgentRunner {
|
|
|
344
854
|
o.store.append(sessionId, { role: "user", text: queued });
|
|
345
855
|
let instructions = await withPersona();
|
|
346
856
|
let messages = o.store.messages(sessionId);
|
|
347
|
-
const tools = this.availableTools(options);
|
|
857
|
+
const tools = this.availableTools(options, sessionId);
|
|
348
858
|
const toolsText = this.toolsText(tools);
|
|
349
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;
|
|
350
868
|
const used = () => {
|
|
351
869
|
const last = this.reported.get(sessionId);
|
|
352
870
|
return last && last.count <= messages.length
|
|
@@ -363,58 +881,203 @@ export class AgentRunner {
|
|
|
363
881
|
instructions = await withPersona();
|
|
364
882
|
messages = o.store.messages(sessionId);
|
|
365
883
|
}
|
|
366
|
-
// A compaction that still leaves the session over the hard limit
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
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);
|
|
378
897
|
const reduction = reduceMessagesToBudget(messages, targetChars);
|
|
379
898
|
if (reduction.truncated > 0) {
|
|
380
899
|
o.store.overwrite(sessionId, reduction.messages);
|
|
381
900
|
messages = reduction.messages;
|
|
382
901
|
emit("context_reduced", { messages: reduction.truncated });
|
|
383
902
|
}
|
|
384
|
-
if (
|
|
385
|
-
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.`);
|
|
386
905
|
}
|
|
387
906
|
let completion;
|
|
388
907
|
let truncated = false;
|
|
389
908
|
let usage;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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);
|
|
400
1023
|
combined.throwIfAborted();
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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;
|
|
414
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;
|
|
415
1077
|
}
|
|
416
1078
|
if (!completion)
|
|
417
1079
|
throw new Error("Provider stream ended without a completed response");
|
|
1080
|
+
const durationMs = Date.now() - requested;
|
|
418
1081
|
const ids = completion.calls.map((c) => c.id);
|
|
419
1082
|
const previousIds = new Set(messages.flatMap((m) => (m.role === "assistant" ? m.calls.map((c) => c.id) : [])));
|
|
420
1083
|
if (ids.some((id) => previousIds.has(id)))
|
|
@@ -436,6 +1099,8 @@ export class AgentRunner {
|
|
|
436
1099
|
calls: completion.calls.length,
|
|
437
1100
|
model,
|
|
438
1101
|
...(usage ? { usage } : {}),
|
|
1102
|
+
durationMs,
|
|
1103
|
+
...(firstDelta !== undefined ? { ttftMs: firstDelta - requested } : {}),
|
|
439
1104
|
});
|
|
440
1105
|
if (!completion.calls.length) {
|
|
441
1106
|
const final = {
|
|
@@ -446,13 +1111,16 @@ export class AgentRunner {
|
|
|
446
1111
|
// The adapter kept a usable answer, but the token budget cut it off.
|
|
447
1112
|
emit("response_truncated", {
|
|
448
1113
|
turn: turn + 1,
|
|
449
|
-
maxOutputTokens:
|
|
1114
|
+
maxOutputTokens: outputLimit.value,
|
|
1115
|
+
source: outputLimit.source,
|
|
450
1116
|
});
|
|
451
1117
|
final.truncated = true;
|
|
452
1118
|
}
|
|
453
1119
|
emit("run_completed", final);
|
|
454
|
-
|
|
1120
|
+
finish("completed");
|
|
1121
|
+
return { sessionId, text: lastText, status: "completed", usage: usageTotal, toolCalls };
|
|
455
1122
|
}
|
|
1123
|
+
toolCalls += completion.calls.length;
|
|
456
1124
|
const prepared = completion.calls.map((call) => {
|
|
457
1125
|
try {
|
|
458
1126
|
const tool = o.registry.get(call.name), input = o.registry.parse(call.name, call.arguments);
|
|
@@ -469,90 +1137,38 @@ export class AgentRunner {
|
|
|
469
1137
|
if (paths.length)
|
|
470
1138
|
contextUpdate = await context.beforePaths(paths, sessionId);
|
|
471
1139
|
let pendingUpdate = contextUpdate;
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
throw new Error("Invalid tool");
|
|
483
|
-
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) => {
|
|
484
1150
|
if (contextUpdate && effect !== "read")
|
|
485
1151
|
throw new Error(`Context changed; reconsider this call before retrying.\n${contextUpdate}`);
|
|
486
1152
|
if (tokens >= budget)
|
|
487
1153
|
throw new Error("Token budget exhausted; tool was not executed");
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (!
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
call,
|
|
503
|
-
effect,
|
|
504
|
-
input: p.input,
|
|
505
|
-
signal: combined,
|
|
506
|
-
session: sessionId,
|
|
507
|
-
...(options.label ? { label: options.label } : {}),
|
|
508
|
-
});
|
|
509
|
-
combined.throwIfAborted();
|
|
510
|
-
emit("approval_resolved", { id: call.id, name: call.name, effect, decision });
|
|
511
|
-
if (decision === "deny")
|
|
512
|
-
throw new Error(`Capability ${effect} denied by the user for this call`);
|
|
513
|
-
if (decision === "session")
|
|
514
|
-
policy[effect] = true;
|
|
515
|
-
}
|
|
516
|
-
o.store.beginCall(sessionId, call);
|
|
517
|
-
result = await p.tool.execute(p.input, {
|
|
518
|
-
signal: combined,
|
|
519
|
-
workspace,
|
|
520
|
-
session: sessionId,
|
|
521
|
-
emit: (data) => emit("tool_progress", { id: call.id, data }),
|
|
522
|
-
...(options.label ? { label: options.label } : {}),
|
|
523
|
-
});
|
|
524
|
-
if (JSON.stringify(result).length > 48_000)
|
|
525
|
-
result = textResult(`${JSON.stringify(result).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
|
|
526
|
-
if (pendingUpdate && effect === "read") {
|
|
527
|
-
result = {
|
|
528
|
-
...result,
|
|
529
|
-
content: [
|
|
530
|
-
...result.content,
|
|
531
|
-
{ type: "text", text: `\n<instructions>\n${pendingUpdate}\n</instructions>` },
|
|
532
|
-
],
|
|
533
|
-
};
|
|
534
|
-
pendingUpdate = undefined;
|
|
535
|
-
}
|
|
536
|
-
o.store.endCall(sessionId, call, result);
|
|
537
|
-
}
|
|
538
|
-
catch (error) {
|
|
539
|
-
result = textResult(error instanceof Error ? error.message : String(error), true);
|
|
540
|
-
// A cancellation after execution may have left effects; journal remains pending.
|
|
541
|
-
if (!combined.aborted)
|
|
542
|
-
o.store.endCall(sessionId, call, result);
|
|
543
|
-
}
|
|
544
|
-
emit("tool_completed", {
|
|
545
|
-
id: call.id,
|
|
546
|
-
name: call.name,
|
|
547
|
-
isError: result.isError ?? false,
|
|
548
|
-
durationMs: Date.now() - started,
|
|
549
|
-
preview: result.content
|
|
550
|
-
.map((c) => c.text)
|
|
551
|
-
.join("\n")
|
|
552
|
-
.slice(0, 2_000),
|
|
553
|
-
});
|
|
554
|
-
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
|
+
},
|
|
555
1168
|
};
|
|
1169
|
+
stage = "tool";
|
|
1170
|
+
stageTool = prepared[0]?.call.name;
|
|
1171
|
+
const execute = (p) => this.executeCall(scope, p);
|
|
556
1172
|
const results = [];
|
|
557
1173
|
// Bounded parallel batches for consecutive read or explicitly concurrent tools.
|
|
558
1174
|
const parallel = (p) => !!p?.tool && (p.tool.effect === "read" || p.tool.concurrent === true);
|
|
@@ -582,17 +1198,55 @@ export class AgentRunner {
|
|
|
582
1198
|
if (c && r)
|
|
583
1199
|
o.store.append(sessionId, { role: "tool", callId: c.id, result: r });
|
|
584
1200
|
}
|
|
1201
|
+
stage = "other";
|
|
1202
|
+
stageTool = undefined;
|
|
585
1203
|
if (tokens >= budget)
|
|
586
1204
|
throw new Error("Token budget exhausted");
|
|
587
1205
|
}
|
|
588
|
-
|
|
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 };
|
|
589
1213
|
}
|
|
590
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
|
+
}
|
|
591
1244
|
// On cancellation report the abort reason, not the transport's secondary error.
|
|
592
1245
|
const cause = combined.aborted ? (combined.reason ?? error) : error;
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 });
|
|
596
1250
|
throw error;
|
|
597
1251
|
}
|
|
598
1252
|
finally {
|