@alisio/core 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/application.d.ts +225 -0
  4. package/dist/application.js +950 -0
  5. package/dist/config.d.ts +481 -0
  6. package/dist/config.js +592 -0
  7. package/dist/core/compaction.d.ts +136 -0
  8. package/dist/core/compaction.js +404 -0
  9. package/dist/core/contracts.d.ts +147 -0
  10. package/dist/core/contracts.js +1 -0
  11. package/dist/core/registry.d.ts +8 -0
  12. package/dist/core/registry.js +54 -0
  13. package/dist/core/runner.d.ts +160 -0
  14. package/dist/core/runner.js +625 -0
  15. package/dist/extensions/registry.d.ts +28 -0
  16. package/dist/extensions/registry.js +58 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.js +28 -0
  19. package/dist/integrations/herdr.d.ts +34 -0
  20. package/dist/integrations/herdr.js +174 -0
  21. package/dist/mcp/connector.d.ts +65 -0
  22. package/dist/mcp/connector.js +423 -0
  23. package/dist/plugins/host.d.ts +169 -0
  24. package/dist/plugins/host.js +585 -0
  25. package/dist/plugins/install.d.ts +100 -0
  26. package/dist/plugins/install.js +322 -0
  27. package/dist/providers/openai-compatible.d.ts +14 -0
  28. package/dist/providers/openai-compatible.js +228 -0
  29. package/dist/providers/registry.d.ts +35 -0
  30. package/dist/providers/registry.js +74 -0
  31. package/dist/providers/routing.d.ts +11 -0
  32. package/dist/providers/routing.js +41 -0
  33. package/dist/providers/settings.d.ts +30 -0
  34. package/dist/providers/settings.js +80 -0
  35. package/dist/resources/context.d.ts +44 -0
  36. package/dist/resources/context.js +171 -0
  37. package/dist/resources/prompts.d.ts +77 -0
  38. package/dist/resources/prompts.js +182 -0
  39. package/dist/resources/skills.d.ts +82 -0
  40. package/dist/resources/skills.js +214 -0
  41. package/dist/runtime/fs.d.ts +9 -0
  42. package/dist/runtime/fs.js +73 -0
  43. package/dist/runtime/modules.d.ts +16 -0
  44. package/dist/runtime/modules.js +83 -0
  45. package/dist/runtime/paths.d.ts +4 -0
  46. package/dist/runtime/paths.js +42 -0
  47. package/dist/runtime/process.d.ts +25 -0
  48. package/dist/runtime/process.js +122 -0
  49. package/dist/runtime/sqlite.d.ts +3 -0
  50. package/dist/runtime/sqlite.js +85 -0
  51. package/dist/runtime/store.d.ts +38 -0
  52. package/dist/runtime/store.js +259 -0
  53. package/dist/sessions/children.d.ts +55 -0
  54. package/dist/sessions/children.js +233 -0
  55. package/dist/startup/index.d.ts +41 -0
  56. package/dist/startup/index.js +105 -0
  57. package/dist/startup/mascot.d.ts +6 -0
  58. package/dist/startup/mascot.js +30 -0
  59. package/dist/startup/screen.d.ts +18 -0
  60. package/dist/startup/screen.js +117 -0
  61. package/dist/startup/text.d.ts +19 -0
  62. package/dist/startup/text.js +68 -0
  63. package/dist/tools/execute.d.ts +16 -0
  64. package/dist/tools/execute.js +96 -0
  65. package/dist/tools/search.d.ts +69 -0
  66. package/dist/tools/search.js +232 -0
  67. package/dist/tools/standard.d.ts +22 -0
  68. package/dist/tools/standard.js +419 -0
  69. package/dist/tools/webfetch.d.ts +26 -0
  70. package/dist/tools/webfetch.js +119 -0
  71. package/dist/trust.d.ts +34 -0
  72. package/dist/trust.js +113 -0
  73. package/dist/version.d.ts +7 -0
  74. package/dist/version.js +26 -0
  75. package/package.json +62 -0
@@ -0,0 +1,625 @@
1
+ import { textResult, } from "@alisio/sdk";
2
+ import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
+ /**
4
+ * Default cumulative token budget for one run: several context windows (each turn re-sends the
5
+ * context), clamped; 1M tokens when the window is unknown.
6
+ */
7
+ export function defaultTokenBudget(contextWindow) {
8
+ if (!contextWindow || contextWindow <= 0)
9
+ return 1_000_000;
10
+ return Math.min(8_000_000, Math.max(400_000, contextWindow * 8));
11
+ }
12
+ /** Extension output appended to history is capped regardless of what hooks return. */
13
+ const MAX_INJECT_CHARS = 24_000;
14
+ /**
15
+ * Floor for the post-compaction transcript target: retained messages are never reduced below
16
+ * this many characters, so a pathological session (instructions alone crowding out the limit)
17
+ * fails with an actionable error instead of silently losing the whole history.
18
+ */
19
+ const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
20
+ const allowed = (policy, effect) => effect === "read" || effect === "internal" || !!policy[effect];
21
+ export class AgentRunner {
22
+ options;
23
+ /** Sessions with an active run or compaction (one at a time per session). */
24
+ active = new Map();
25
+ /** Messages queued for a session's next turn (e.g. input for a running child). */
26
+ inbox = new Map();
27
+ /** Last provider-reported context size per session and the history length it covered. */
28
+ reported = new Map();
29
+ constructor(options) {
30
+ this.options = options;
31
+ }
32
+ /** Current capability policy (may widen when the user approves an effect for the session). */
33
+ get policy() {
34
+ return this.options.policy;
35
+ }
36
+ /** Whether write/process tools can be approved interactively. */
37
+ get approvals() {
38
+ return !!this.options.approve;
39
+ }
40
+ /**
41
+ * Live-update runnable settings so the next `run`/`compact` call honors them without restarting
42
+ * Alisio. Every patch field is merged over the current options; unrelated settings are kept.
43
+ */
44
+ applySettings(patch) {
45
+ const { compaction, ...rest } = patch;
46
+ this.options = {
47
+ ...this.options,
48
+ ...rest,
49
+ ...(compaction ? { compaction: { ...this.options.compaction, ...compaction } } : {}),
50
+ };
51
+ }
52
+ emitter(sessionId) {
53
+ const runId = crypto.randomUUID();
54
+ let seq = 0;
55
+ return (type, data) => {
56
+ const event = {
57
+ schemaVersion: 1,
58
+ runId,
59
+ sessionId,
60
+ seq: ++seq,
61
+ type,
62
+ timestamp: new Date().toISOString(),
63
+ data,
64
+ };
65
+ if (type !== "text_delta" && type !== "tool_progress" && type !== "reasoning_delta")
66
+ this.options.store.event(sessionId, runId, type, data);
67
+ try {
68
+ this.options.onEvent?.(event);
69
+ }
70
+ catch {
71
+ /* An observer cannot invalidate an executed operation. */
72
+ }
73
+ };
74
+ }
75
+ /** Tools offered to the model under the current policy (and optional run narrowing). */
76
+ availableTools(run = {}) {
77
+ const o = this.options;
78
+ const policy = run.policy ?? o.policy;
79
+ const approvals = !!o.approve && run.approvals !== false;
80
+ return o.registry.list().filter((t) => {
81
+ if (run.toolFilter && !run.toolFilter(t))
82
+ return false;
83
+ const effect = t.effect ?? "external";
84
+ if (allowed(policy, effect))
85
+ return true;
86
+ return approvals && (effect === "write" || effect === "process" || effect === "external");
87
+ });
88
+ }
89
+ /** Abort signal of the session's active run, if any (used to cascade cancellation). */
90
+ signal(sessionId) {
91
+ return this.active.get(sessionId)?.signal;
92
+ }
93
+ isRunning(sessionId) {
94
+ return this.active.has(sessionId);
95
+ }
96
+ /** Aborts the active run (or compaction) of a session. */
97
+ abort(sessionId, reason = new Error("Cancelled")) {
98
+ const controller = this.active.get(sessionId);
99
+ if (!controller)
100
+ return false;
101
+ controller.abort(reason);
102
+ return true;
103
+ }
104
+ /** Queues text as a user message for the session's next turn (or next run when idle). */
105
+ enqueue(sessionId, text) {
106
+ const queue = this.inbox.get(sessionId) ?? [];
107
+ queue.push(text.slice(0, 50_000));
108
+ this.inbox.set(sessionId, queue.slice(-20));
109
+ }
110
+ drain(sessionId) {
111
+ const queue = this.inbox.get(sessionId) ?? [];
112
+ this.inbox.delete(sessionId);
113
+ return queue;
114
+ }
115
+ claim(sessionId) {
116
+ if (this.active.has(sessionId))
117
+ throw new Error("Session is busy; await the current run or cancel it");
118
+ const controller = new AbortController();
119
+ this.active.set(sessionId, controller);
120
+ return controller;
121
+ }
122
+ toolsText(tools) {
123
+ return JSON.stringify(tools.map((t) => ({ name: t.name, schema: t.inputSchema, description: t.description })));
124
+ }
125
+ /** Estimated tokens the next request of a session would send. */
126
+ async estimateContext(sessionId) {
127
+ const instructions = await this.options.context.instructions(sessionId);
128
+ return (estimateTokens(instructions) +
129
+ estimateTokens(this.options.store.messages(sessionId)) +
130
+ estimateTokens(this.toolsText(this.availableTools())));
131
+ }
132
+ /**
133
+ * The effective context budget the TUI context bar displays. The model window is the total
134
+ * when known (and not beyond `MAX_TRUSTED_WINDOW`); otherwise the bar reports an honest
135
+ * unknown (`basis: "unknown"`, no fabricated total). `compactionAt` is the percentage of
136
+ * `total` where auto-compaction triggers, so the bar turns red exactly where the engine
137
+ * compacts. The engine's own auto-compaction guardrail still falls back to the char budget
138
+ * internally (see `shouldCompactContext`) — that fallback is never shown as a fake total.
139
+ */
140
+ contextBudget(model) {
141
+ const window = this.options.contextWindow?.(model);
142
+ if (window !== undefined && window > 0 && window <= MAX_TRUSTED_WINDOW)
143
+ return {
144
+ total: window,
145
+ basis: "window",
146
+ compactionAt: Math.round((this.options.compaction?.threshold ?? 0.85) * 100),
147
+ };
148
+ return { total: undefined, basis: "unknown", compactionAt: 100 };
149
+ }
150
+ /** Change the model used by subsequent turns of a session; recorded in the store. */
151
+ setModel(sessionId, model) {
152
+ const id = model.trim();
153
+ if (!id)
154
+ throw new Error("Model ID must not be empty");
155
+ const previous = this.options.store.get(sessionId).model;
156
+ if (previous === id)
157
+ return;
158
+ this.options.store.setModel(sessionId, id);
159
+ this.reported.delete(sessionId);
160
+ this.emitter(sessionId)("model_changed", { model: id, previous });
161
+ }
162
+ /** Manually compact a session outside a run. */
163
+ async compact(sessionId, request = {}) {
164
+ const controller = this.claim(sessionId);
165
+ const o = this.options;
166
+ let acquired = false;
167
+ const timeout = AbortSignal.timeout(o.timeoutMs ?? 300_000);
168
+ const signal = AbortSignal.any([
169
+ controller.signal,
170
+ timeout,
171
+ ...(request.signal ? [request.signal] : []),
172
+ ]);
173
+ try {
174
+ o.store.acquire(sessionId);
175
+ acquired = true;
176
+ o.store.reconcile(sessionId);
177
+ const session = o.store.get(sessionId);
178
+ return await this.compactLocked(sessionId, this.emitter(sessionId), session.model, signal, "manual", request.focus);
179
+ }
180
+ finally {
181
+ if (acquired)
182
+ o.store.release(sessionId);
183
+ this.active.delete(sessionId);
184
+ }
185
+ }
186
+ async compactLocked(sessionId, emit, model, signal, reason, focus) {
187
+ const o = this.options;
188
+ const messages = o.store.messages(sessionId);
189
+ const plan = planCompaction(messages, { keepTurns: o.compaction?.keepTurns ?? 2 });
190
+ const before = await this.estimateContext(sessionId);
191
+ if (!plan) {
192
+ emit("compaction_skipped", { reason, before, detail: "Not enough history to compact" });
193
+ return undefined;
194
+ }
195
+ emit("compaction_started", { reason, before, messages: plan.summarized.length });
196
+ const reportFailures = (failures) => {
197
+ for (const f of failures)
198
+ emit("plugin_hook_failed", { ...f, continued: true });
199
+ };
200
+ try {
201
+ const ext = o.extensions;
202
+ const hookInput = { sessionId, reason, messages: plan.summarized, focus };
203
+ let contribution = {
204
+ instructions: [],
205
+ fields: {},
206
+ failures: [],
207
+ };
208
+ if (ext)
209
+ try {
210
+ contribution = await ext.beforeCompact(hookInput);
211
+ }
212
+ catch (error) {
213
+ contribution.failures.push({
214
+ source: "host",
215
+ hook: "beforeCompact",
216
+ error: String(error),
217
+ });
218
+ }
219
+ reportFailures(contribution.failures);
220
+ const fields = Object.fromEntries(Object.entries(contribution.fields).map(([name, f]) => [name, f.description]));
221
+ const provider = await (o.providerFor?.(o.store.get(sessionId)) ?? o.provider);
222
+ const { text: raw, truncated: summaryTruncated } = await summarize(provider, {
223
+ instructions: checkpointInstructions(contribution.instructions, fields),
224
+ messages: plan.summarized,
225
+ focus,
226
+ model,
227
+ maxOutputTokens: o.compaction?.maxOutputTokens ?? 4096,
228
+ signal,
229
+ sessionId,
230
+ });
231
+ const output = parseCheckpointOutput(raw, Object.keys(fields));
232
+ let after = {
233
+ inject: [],
234
+ reports: {},
235
+ failures: [],
236
+ };
237
+ if (ext)
238
+ try {
239
+ after = await ext.afterCompact({
240
+ ...hookInput,
241
+ model,
242
+ replaced: plan.cut,
243
+ structured: output.structured,
244
+ ...(output.checkpoint ? { checkpoint: output.checkpoint } : {}),
245
+ checkpointText: output.text,
246
+ extracted: output.extracted,
247
+ });
248
+ }
249
+ catch (error) {
250
+ after.failures.push({ source: "host", hook: "afterCompact", error: String(error) });
251
+ }
252
+ reportFailures(after.failures);
253
+ const body = [output.text, ...after.inject.map((x) => x.slice(0, MAX_INJECT_CHARS))]
254
+ .filter((x) => x.trim())
255
+ .join("\n\n");
256
+ const summary = summaryMessage(body, plan.cut);
257
+ o.store.compact(sessionId, plan.cut, summary);
258
+ this.reported.delete(sessionId);
259
+ const afterTokens = await this.estimateContext(sessionId);
260
+ emit("compaction_completed", {
261
+ reason,
262
+ before,
263
+ after: afterTokens,
264
+ replaced: plan.cut,
265
+ structured: output.structured,
266
+ summarizedTokens: estimateTokens(plan.summarized),
267
+ checkpointTokens: estimateTokens(summary.text),
268
+ plugins: after.reports,
269
+ ...(summaryTruncated ? { partial: true } : {}),
270
+ });
271
+ return { replaced: plan.cut, before, after: afterTokens, summary };
272
+ }
273
+ catch (error) {
274
+ emit("compaction_failed", {
275
+ reason,
276
+ error: error instanceof Error ? error.message : String(error),
277
+ });
278
+ throw error;
279
+ }
280
+ }
281
+ async run(sessionId, prompt, signal, options = {}) {
282
+ const controller = this.claim(sessionId);
283
+ const o = this.options, emit = this.emitter(sessionId);
284
+ const limit = o.maxContextChars ?? 800_000;
285
+ let tokens = 0, lastText = "", acquired = false;
286
+ const usageTotal = { input: 0, output: 0 };
287
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? o.timeoutMs ?? 300_000);
288
+ const combined = AbortSignal.any([controller.signal, timeout, ...(signal ? [signal] : [])]);
289
+ const context = options.context ?? o.context;
290
+ const workspace = options.workspace ?? o.workspace;
291
+ const policy = options.policy ?? o.policy;
292
+ const approvals = !!o.approve && options.approvals !== false;
293
+ const withPersona = async () => {
294
+ const base = await context.instructions(sessionId);
295
+ return options.instructions ? `${base}\n\n${options.instructions}` : base;
296
+ };
297
+ try {
298
+ o.store.acquire(sessionId);
299
+ acquired = true;
300
+ o.store.reconcile(sessionId);
301
+ const session = o.store.get(sessionId);
302
+ const provider = await (o.providerFor?.(session) ?? o.provider);
303
+ if (session.provider !== provider.id || session.workspace !== workspace)
304
+ throw new Error("Session provider/workspace mismatch");
305
+ // The session records the model; switching models is explicit via setModel.
306
+ const model = session.model;
307
+ combined.throwIfAborted();
308
+ if (o.extensions && o.store.messages(sessionId).length === 0) {
309
+ // New session: extensions may inject budgeted context once, persisted with the session.
310
+ try {
311
+ const start = await o.extensions.sessionStart({
312
+ sessionId,
313
+ model,
314
+ workspace,
315
+ });
316
+ for (const f of start.failures)
317
+ emit("plugin_hook_failed", { ...f, continued: true });
318
+ const text = start.inject
319
+ .map((x) => x.text.slice(0, MAX_INJECT_CHARS))
320
+ .filter((x) => x.trim())
321
+ .join("\n\n");
322
+ if (text) {
323
+ o.store.append(sessionId, { role: "user", text, summary: true });
324
+ emit("session_context_injected", {
325
+ tokens: estimateTokens(text),
326
+ sources: start.inject.map((x) => x.source),
327
+ });
328
+ }
329
+ }
330
+ catch (error) {
331
+ emit("plugin_hook_failed", {
332
+ source: "host",
333
+ hook: "sessionStart",
334
+ error: String(error),
335
+ continued: true,
336
+ });
337
+ }
338
+ combined.throwIfAborted();
339
+ }
340
+ for (const queued of this.drain(sessionId))
341
+ o.store.append(sessionId, { role: "user", text: queued });
342
+ o.store.append(sessionId, {
343
+ role: "user",
344
+ text: prompt,
345
+ ...(options.display ? { display: options.display } : {}),
346
+ ...(options.attachments?.length ? { attachments: options.attachments } : {}),
347
+ });
348
+ emit("run_started", { model });
349
+ const budget = options.maxTokens ?? o.maxTokens ?? defaultTokenBudget(o.contextWindow?.(model));
350
+ const compaction = o.compaction ?? {};
351
+ for (let turn = 0; turn < (options.maxTurns ?? o.maxTurns ?? 20); turn++) {
352
+ combined.throwIfAborted();
353
+ // Input queued while the run was working (e.g. a parent's message to a child).
354
+ if (turn > 0)
355
+ for (const queued of this.drain(sessionId))
356
+ o.store.append(sessionId, { role: "user", text: queued });
357
+ let instructions = await withPersona();
358
+ let messages = o.store.messages(sessionId);
359
+ const tools = this.availableTools(options);
360
+ const toolsText = this.toolsText(tools);
361
+ const chars = () => instructions.length + JSON.stringify(messages).length + toolsText.length;
362
+ // REDUCIBLE content only (instructions + transcript). The fixed tool catalog
363
+ // (`toolsText`) is a deployment reality — a huge MCP catalog with dozens of tools is
364
+ // part of every request regardless of history, so it is deliberately excluded from the
365
+ // post-compaction hard cap: counting it would reduce a small transcript to near-nothing
366
+ // and still fake-fail. Oversized catalogs are a `/plugins` decision, not session growth;
367
+ // auto-compaction above still measures the FULL request (`chars()`), because window
368
+ // thresholds protect what the model really sees, tools included.
369
+ const charsConversation = () => instructions.length + JSON.stringify(messages).length;
370
+ const used = () => {
371
+ const last = this.reported.get(sessionId);
372
+ return last && last.count <= messages.length
373
+ ? last.tokens + estimateTokens(messages.slice(last.count))
374
+ : estimateTokens(instructions) + estimateTokens(messages) + estimateTokens(toolsText);
375
+ };
376
+ // One effective budget: a known window triggers at `used >= window * threshold`; an
377
+ // unknown (or absurdly large, see MAX_TRUSTED_WINDOW) window falls back to the char
378
+ // estimate (`chars / 4 >= maxContextChars / 4`). `maxContextChars` below stays as the
379
+ // post-compaction hard limit.
380
+ if (compaction.auto !== false &&
381
+ shouldCompactContext(used(), chars(), o.contextWindow?.(model), limit, compaction.threshold ?? 0.85)) {
382
+ await this.compactLocked(sessionId, emit, model, combined, "auto");
383
+ instructions = await withPersona();
384
+ messages = o.store.messages(sessionId);
385
+ }
386
+ // A compaction that still leaves the session over the hard limit is reduced in place
387
+ // instead of failing outright: retained content is clipped against a TOTAL character
388
+ // target for the transcript (`limit − instructions`), largest items first, tool results
389
+ // before texts, in descending cap rounds. That also covers sessions with many MEDIUM
390
+ // results (e.g. MCP outputs of a few thousand chars each) that individually stay under
391
+ // the per-message caps; roles/callIds/boundaries stay untouched, so the transcript
392
+ // remains valid and replayable. The hard cap measures ONLY reducible content
393
+ // (`charsConversation`): the fixed tool catalog (see above) never triggers the error on
394
+ // its own. The reduction is persisted either way, so the session stays usable for later
395
+ // prompts. Only if even the reduction floor cannot fit (instructions alone crowd out the
396
+ // limit) the run fails with an actionable error.
397
+ if (charsConversation() > limit) {
398
+ const targetChars = Math.max(MIN_MESSAGE_BUDGET_FLOOR, limit - instructions.length);
399
+ const reduction = reduceMessagesToBudget(messages, targetChars);
400
+ if (reduction.truncated > 0) {
401
+ o.store.overwrite(sessionId, reduction.messages);
402
+ messages = reduction.messages;
403
+ emit("context_reduced", { messages: reduction.truncated });
404
+ }
405
+ if (charsConversation() > limit)
406
+ 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.`);
407
+ }
408
+ let completion;
409
+ let truncated = false;
410
+ let usage;
411
+ for await (const e of provider.stream({
412
+ instructions,
413
+ messages,
414
+ tools,
415
+ maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
416
+ signal: combined,
417
+ model,
418
+ sessionId,
419
+ ...(o.nativeTools?.length ? { nativeTools: o.nativeTools } : {}),
420
+ })) {
421
+ combined.throwIfAborted();
422
+ if (e.type === "text_delta")
423
+ emit("text_delta", { delta: e.delta });
424
+ else if (e.type === "reasoning_delta")
425
+ emit("reasoning_delta", { delta: e.delta });
426
+ else {
427
+ if (completion)
428
+ throw new Error("Provider emitted multiple completions");
429
+ completion = e.message;
430
+ truncated = e.message.truncated === true;
431
+ usage = e.usage;
432
+ tokens += (e.usage?.input ?? 0) + (e.usage?.output ?? 0);
433
+ usageTotal.input += e.usage?.input ?? 0;
434
+ usageTotal.output += e.usage?.output ?? 0;
435
+ }
436
+ }
437
+ if (!completion)
438
+ throw new Error("Provider stream ended without a completed response");
439
+ const ids = completion.calls.map((c) => c.id);
440
+ const previousIds = new Set(messages.flatMap((m) => (m.role === "assistant" ? m.calls.map((c) => c.id) : [])));
441
+ if (ids.some((id) => previousIds.has(id)))
442
+ throw new Error("Provider reused a tool call ID across turns");
443
+ if (new Set(ids).size !== ids.length)
444
+ throw new Error("Provider returned duplicate tool call IDs");
445
+ if (completion.calls.length > 64)
446
+ throw new Error("Too many tool calls in one turn");
447
+ o.store.append(sessionId, completion);
448
+ if (usage)
449
+ this.reported.set(sessionId, {
450
+ tokens: usage.input + usage.output,
451
+ count: messages.length + 1,
452
+ });
453
+ lastText = completion.text;
454
+ emit("turn_completed", {
455
+ turn: turn + 1,
456
+ tokens,
457
+ calls: completion.calls.length,
458
+ model,
459
+ ...(usage ? { usage } : {}),
460
+ });
461
+ if (!completion.calls.length) {
462
+ const final = {
463
+ tokens,
464
+ text: lastText,
465
+ };
466
+ if (truncated) {
467
+ // The adapter kept a usable answer, but the token budget cut it off.
468
+ emit("response_truncated", {
469
+ turn: turn + 1,
470
+ maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
471
+ });
472
+ final.truncated = true;
473
+ }
474
+ emit("run_completed", final);
475
+ return { sessionId, text: lastText, status: "completed", usage: usageTotal };
476
+ }
477
+ const prepared = completion.calls.map((call) => {
478
+ try {
479
+ const tool = o.registry.get(call.name), input = o.registry.parse(call.name, call.arguments);
480
+ return { call, tool, input };
481
+ }
482
+ catch (error) {
483
+ return { call, error: String(error) };
484
+ }
485
+ });
486
+ // Resolve scopes before tools execute. New nested instructions are attached to the first
487
+ // read result; state-changing calls return to the model for reconsideration instead.
488
+ let contextUpdate;
489
+ const paths = prepared.flatMap((p) => p.tool?.paths?.(p.input ?? {}) ?? []);
490
+ if (paths.length)
491
+ contextUpdate = await context.beforePaths(paths, sessionId);
492
+ let pendingUpdate = contextUpdate;
493
+ const execute = async (p) => {
494
+ const { call } = p;
495
+ let result;
496
+ const started = Date.now();
497
+ emit("tool_started", { name: call.name, id: call.id, arguments: call.arguments });
498
+ try {
499
+ combined.throwIfAborted();
500
+ if (p.error)
501
+ throw new Error(p.error);
502
+ if (!p.tool || !p.input)
503
+ throw new Error("Invalid tool");
504
+ const effect = p.tool.effect ?? "external";
505
+ if (contextUpdate && effect !== "read")
506
+ throw new Error(`Context changed; reconsider this call before retrying.\n${contextUpdate}`);
507
+ if (tokens >= budget)
508
+ throw new Error("Token budget exhausted; tool was not executed");
509
+ if (options.toolFilter && !options.toolFilter(p.tool))
510
+ throw new Error(`Tool ${call.name} is not available in this session`);
511
+ if (!allowed(policy, effect)) {
512
+ if (!o.approve ||
513
+ !approvals ||
514
+ (effect !== "write" && effect !== "process" && effect !== "external"))
515
+ throw new Error(`Capability denied: ${effect}`);
516
+ emit("approval_requested", {
517
+ id: call.id,
518
+ name: call.name,
519
+ effect,
520
+ ...(options.label ? { label: options.label } : {}),
521
+ });
522
+ const decision = await o.approve({
523
+ call,
524
+ effect,
525
+ input: p.input,
526
+ signal: combined,
527
+ session: sessionId,
528
+ ...(options.label ? { label: options.label } : {}),
529
+ });
530
+ combined.throwIfAborted();
531
+ emit("approval_resolved", { id: call.id, name: call.name, effect, decision });
532
+ if (decision === "deny")
533
+ throw new Error(`Capability ${effect} denied by the user for this call`);
534
+ if (decision === "session")
535
+ policy[effect] = true;
536
+ }
537
+ o.store.beginCall(sessionId, call);
538
+ result = await p.tool.execute(p.input, {
539
+ signal: combined,
540
+ workspace,
541
+ session: sessionId,
542
+ emit: (data) => emit("tool_progress", { id: call.id, data }),
543
+ ...(options.label ? { label: options.label } : {}),
544
+ });
545
+ if (JSON.stringify(result).length > 48_000)
546
+ result = textResult(`${JSON.stringify(result).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
547
+ if (pendingUpdate && effect === "read") {
548
+ result = {
549
+ ...result,
550
+ content: [
551
+ ...result.content,
552
+ { type: "text", text: `\n<instructions>\n${pendingUpdate}\n</instructions>` },
553
+ ],
554
+ };
555
+ pendingUpdate = undefined;
556
+ }
557
+ o.store.endCall(sessionId, call, result);
558
+ }
559
+ catch (error) {
560
+ result = textResult(error instanceof Error ? error.message : String(error), true);
561
+ // A cancellation after execution may have left effects; journal remains pending.
562
+ if (!combined.aborted)
563
+ o.store.endCall(sessionId, call, result);
564
+ }
565
+ emit("tool_completed", {
566
+ id: call.id,
567
+ name: call.name,
568
+ isError: result.isError ?? false,
569
+ durationMs: Date.now() - started,
570
+ preview: result.content
571
+ .map((c) => c.text)
572
+ .join("\n")
573
+ .slice(0, 2_000),
574
+ });
575
+ return result;
576
+ };
577
+ const results = [];
578
+ // Bounded parallel batches for consecutive read or explicitly concurrent tools.
579
+ const parallel = (p) => !!p?.tool && (p.tool.effect === "read" || p.tool.concurrent === true);
580
+ for (let i = 0; i < prepared.length;) {
581
+ const current = prepared[i];
582
+ if (!current)
583
+ break;
584
+ if (parallel(current)) {
585
+ const batch = [];
586
+ while (i < prepared.length &&
587
+ parallel(prepared[i]) &&
588
+ batch.length < (o.readConcurrency ?? 4)) {
589
+ const p = prepared[i++];
590
+ if (p)
591
+ batch.push(p);
592
+ }
593
+ results.push(...(await Promise.all(batch.map(execute))));
594
+ }
595
+ else {
596
+ results.push(await execute(current));
597
+ i++;
598
+ }
599
+ combined.throwIfAborted();
600
+ }
601
+ for (let i = 0; i < completion.calls.length; i++) {
602
+ const c = completion.calls[i], r = results[i];
603
+ if (c && r)
604
+ o.store.append(sessionId, { role: "tool", callId: c.id, result: r });
605
+ }
606
+ if (tokens >= budget)
607
+ throw new Error("Token budget exhausted");
608
+ }
609
+ throw new Error("Maximum turns reached");
610
+ }
611
+ catch (error) {
612
+ // On cancellation report the abort reason, not the transport's secondary error.
613
+ const cause = combined.aborted ? (combined.reason ?? error) : error;
614
+ emit(combined.aborted ? "run_cancelled" : "run_failed", {
615
+ error: cause instanceof Error ? cause.message : String(cause),
616
+ });
617
+ throw error;
618
+ }
619
+ finally {
620
+ if (acquired)
621
+ o.store.release(sessionId);
622
+ this.active.delete(sessionId);
623
+ }
624
+ }
625
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Generic, typed extension registry. Resolution is deterministic and independent of plugin
3
+ * load order: highest priority, then plugin id (lexicographic), then registration order
4
+ * within the plugin. Fallbacks (built-in defaults) only win when nothing else is registered.
5
+ */
6
+ import type { ExtensionPoints } from "@alisio/sdk";
7
+ export interface ExtensionConflict {
8
+ point: string;
9
+ /** `plugin/provider` of the chosen provider. */
10
+ winner: string;
11
+ losers: string[];
12
+ }
13
+ export declare class ExtensionRegistry {
14
+ private entries;
15
+ private seq;
16
+ register<K extends keyof ExtensionPoints>(point: K, provider: ExtensionPoints[K], options: {
17
+ plugin: string;
18
+ priority?: number;
19
+ fallback?: boolean;
20
+ }): () => void;
21
+ private ranked;
22
+ resolve<K extends keyof ExtensionPoints>(point: K): {
23
+ provider: ExtensionPoints[K];
24
+ plugin: string;
25
+ } | undefined;
26
+ /** Equal-priority, non-fallback competitors for each point's winning slot. */
27
+ conflicts(point?: keyof ExtensionPoints): ExtensionConflict[];
28
+ }