@alisio/core 0.1.0-alpha.21 → 0.1.0-alpha.23

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 (91) hide show
  1. package/dist/analysis/capabilities.d.ts +48 -0
  2. package/dist/analysis/capabilities.js +90 -0
  3. package/dist/analysis/data/csv.d.ts +46 -0
  4. package/dist/analysis/data/csv.js +240 -0
  5. package/dist/analysis/data/datasets.d.ts +132 -0
  6. package/dist/analysis/data/datasets.js +441 -0
  7. package/dist/analysis/data/describe.d.ts +27 -0
  8. package/dist/analysis/data/describe.js +103 -0
  9. package/dist/analysis/data/engine-client.d.ts +58 -0
  10. package/dist/analysis/data/engine-client.js +304 -0
  11. package/dist/analysis/data/engine-entry.d.ts +1 -0
  12. package/dist/analysis/data/engine-entry.js +59 -0
  13. package/dist/analysis/data/engine-source.d.ts +4 -0
  14. package/dist/analysis/data/engine-source.js +6 -0
  15. package/dist/analysis/data/engine-types.d.ts +86 -0
  16. package/dist/analysis/data/engine-types.js +7 -0
  17. package/dist/analysis/data/infer.d.ts +42 -0
  18. package/dist/analysis/data/infer.js +118 -0
  19. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  20. package/dist/analysis/data/ingest-worker.js +365 -0
  21. package/dist/analysis/data/json.d.ts +19 -0
  22. package/dist/analysis/data/json.js +44 -0
  23. package/dist/analysis/data/query-worker.d.ts +12 -0
  24. package/dist/analysis/data/query-worker.js +75 -0
  25. package/dist/analysis/data/rows.d.ts +39 -0
  26. package/dist/analysis/data/rows.js +120 -0
  27. package/dist/analysis/data/sql-guard.d.ts +20 -0
  28. package/dist/analysis/data/sql-guard.js +129 -0
  29. package/dist/analysis/data/stats.d.ts +18 -0
  30. package/dist/analysis/data/stats.js +77 -0
  31. package/dist/analysis/install-hints.d.ts +33 -0
  32. package/dist/analysis/install-hints.js +144 -0
  33. package/dist/analysis/janitor.d.ts +60 -0
  34. package/dist/analysis/janitor.js +264 -0
  35. package/dist/analysis/jobs.d.ts +94 -0
  36. package/dist/analysis/jobs.js +165 -0
  37. package/dist/analysis/oci.d.ts +129 -0
  38. package/dist/analysis/oci.js +330 -0
  39. package/dist/analysis/python/sources.d.ts +2 -0
  40. package/dist/analysis/python/sources.js +10 -0
  41. package/dist/analysis/requirements.d.ts +32 -0
  42. package/dist/analysis/requirements.js +73 -0
  43. package/dist/analysis/rerun.d.ts +40 -0
  44. package/dist/analysis/rerun.js +136 -0
  45. package/dist/analysis/runtime-manager.d.ts +146 -0
  46. package/dist/analysis/runtime-manager.js +472 -0
  47. package/dist/analysis/status.d.ts +21 -0
  48. package/dist/analysis/status.js +52 -0
  49. package/dist/application.d.ts +41 -0
  50. package/dist/application.js +197 -0
  51. package/dist/artifacts/kinds.d.ts +35 -0
  52. package/dist/artifacts/kinds.js +177 -0
  53. package/dist/artifacts/publisher.d.ts +7 -0
  54. package/dist/artifacts/publisher.js +29 -0
  55. package/dist/artifacts/store.d.ts +119 -0
  56. package/dist/artifacts/store.js +581 -0
  57. package/dist/commands/builtins.js +13 -0
  58. package/dist/config.d.ts +110 -0
  59. package/dist/config.js +186 -8
  60. package/dist/core/contracts.d.ts +69 -1
  61. package/dist/core/runner.d.ts +53 -4
  62. package/dist/core/runner.js +549 -161
  63. package/dist/core/timeout.d.ts +15 -0
  64. package/dist/core/timeout.js +61 -0
  65. package/dist/index.d.ts +23 -4
  66. package/dist/index.js +22 -3
  67. package/dist/mcp/rich.js +4 -0
  68. package/dist/plugins/host.js +14 -6
  69. package/dist/runtime/blobs.d.ts +22 -0
  70. package/dist/runtime/blobs.js +84 -1
  71. package/dist/runtime/ids.d.ts +8 -0
  72. package/dist/runtime/ids.js +32 -0
  73. package/dist/runtime/paths.d.ts +5 -0
  74. package/dist/runtime/paths.js +6 -0
  75. package/dist/runtime/process.d.ts +12 -0
  76. package/dist/runtime/process.js +64 -4
  77. package/dist/runtime/sqlite.d.ts +2 -0
  78. package/dist/runtime/sqlite.js +18 -0
  79. package/dist/runtime/store.d.ts +5 -0
  80. package/dist/runtime/store.js +75 -0
  81. package/dist/runtime/zip.d.ts +24 -0
  82. package/dist/runtime/zip.js +126 -0
  83. package/dist/sessions/children.js +4 -0
  84. package/dist/tools/analysis.d.ts +37 -0
  85. package/dist/tools/analysis.js +572 -0
  86. package/dist/tools/artifacts.d.ts +14 -0
  87. package/dist/tools/artifacts.js +143 -0
  88. package/dist/tools/data.d.ts +7 -0
  89. package/dist/tools/data.js +160 -0
  90. package/dist/tools/execute.js +6 -2
  91. package/package.json +2 -2
@@ -1,5 +1,21 @@
1
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 { isTimeoutReason, providerLabel, RunTimeoutError } from "./timeout.js";
4
+ /** Pause before a silent request is sent again; short, and cut off by a stop or the run limit. */
5
+ const FIRST_TOKEN_RETRY_PAUSE_MS = 250;
6
+ function abortableDelay(ms, signal) {
7
+ return new Promise((resolve) => {
8
+ if (signal.aborted)
9
+ return resolve();
10
+ const done = () => {
11
+ clearTimeout(timer);
12
+ signal.removeEventListener("abort", done);
13
+ resolve();
14
+ };
15
+ const timer = setTimeout(done, ms);
16
+ signal.addEventListener("abort", done, { once: true });
17
+ });
18
+ }
3
19
  /**
4
20
  * Default cumulative token budget for one run: several context windows (each turn re-sends the
5
21
  * context), clamped; 1M tokens when the window is unknown.
@@ -17,7 +33,19 @@ const MAX_INJECT_CHARS = 24_000;
17
33
  * fails with an actionable error instead of silently losing the whole history.
18
34
  */
19
35
  const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
20
- const allowed = (policy, effect) => effect === "read" || effect === "internal" || !!policy[effect];
36
+ const allowed = (policy, effect) => effect === "read" ||
37
+ effect === "internal" ||
38
+ ((effect === "write" || effect === "process" || effect === "external") && !!policy[effect]);
39
+ /** A tool's capability is pre-allowed by the policy alone (`--allow-analysis`). */
40
+ const capabilityAllowed = (policy, tool) => tool.capability === "analysis.run" && !!policy.analysis;
41
+ /** First 40 lines of a script input, for capability approvals. */
42
+ const scriptPreview = (input) => typeof input.code === "string" ? input.code.split(/\r?\n/).slice(0, 40).join("\n") : undefined;
43
+ /** A short id for a tool call the user started from a UI (alphanumeric, 9 characters). */
44
+ const uiCallId = () => {
45
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
46
+ const bytes = crypto.getRandomValues(new Uint8Array(9));
47
+ return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
48
+ };
21
49
  export class AgentRunner {
22
50
  options;
23
51
  /** Sessions with an active run or compaction (one at a time per session). */
@@ -80,8 +108,11 @@ export class AgentRunner {
80
108
  }
81
109
  };
82
110
  }
83
- /** Tools offered to the model under the current policy (and optional run narrowing). */
84
- availableTools(run = {}) {
111
+ /**
112
+ * Tools offered to the model under the current policy (and optional run narrowing). With a
113
+ * session, a capability tool is also offered when that session's root holds a persisted grant.
114
+ */
115
+ availableTools(run = {}, sessionId) {
85
116
  const o = this.options;
86
117
  const policy = run.policy ?? o.policy;
87
118
  const approvals = !!o.approve && run.approvals !== false;
@@ -91,6 +122,12 @@ export class AgentRunner {
91
122
  const effect = t.effect ?? "external";
92
123
  if (allowed(policy, effect))
93
124
  return true;
125
+ if (t.capability) {
126
+ if (capabilityAllowed(policy, t))
127
+ return true;
128
+ if (sessionId && o.capabilities?.granted(t.capability, sessionId))
129
+ return true;
130
+ }
94
131
  return approvals && (effect === "write" || effect === "process" || effect === "external");
95
132
  });
96
133
  }
@@ -310,6 +347,355 @@ export class AgentRunner {
310
347
  throw error;
311
348
  }
312
349
  }
350
+ /**
351
+ * Gates and executes one tool call: external-directory paths, the capability/effect gate with
352
+ * its approval, the call journal, artifact publishing and the result bound. Shared by model
353
+ * runs and `runToolCall`, so a call started from a UI passes exactly the same gates.
354
+ */
355
+ async executeCall(scope, p) {
356
+ const o = this.options;
357
+ const { sessionId, runId, emit, workspace, policy, approvals, options } = scope;
358
+ const combined = scope.signal;
359
+ const pathAccess = o.pathAccess;
360
+ const { call } = p;
361
+ let result;
362
+ const started = Date.now();
363
+ // `effect` rides along so the TUI can group consecutive calls by capability kind
364
+ // (read batches, write batches, commands…) without a name heuristic. Additive field.
365
+ emit("tool_started", {
366
+ name: call.name,
367
+ id: call.id,
368
+ arguments: call.arguments,
369
+ effect: p.tool?.effect ?? "external",
370
+ });
371
+ try {
372
+ combined.throwIfAborted();
373
+ if (p.error)
374
+ throw new Error(p.error);
375
+ if (!p.tool || !p.input)
376
+ throw new Error("Invalid tool");
377
+ const effect = p.tool.effect ?? "external";
378
+ // External-directory prerequisite: declared paths are resolved (and, outside every
379
+ // allowed root, approved for the containing directory) BEFORE the effect gate, so a
380
+ // write still needs its own write policy and a read never hard-throws first. The
381
+ // resolved map keeps "allow once" scoped to this one call, with no shared mutable
382
+ // state that parallel read batches could race on.
383
+ const resolved = new Map();
384
+ if (pathAccess)
385
+ for (const declared of p.tool.paths?.(p.input) ?? [])
386
+ if (!resolved.has(declared)) {
387
+ const target = await pathAccess.resolve(declared, {
388
+ workspace,
389
+ session: sessionId,
390
+ ...(options.label ? { label: options.label } : {}),
391
+ signal: combined,
392
+ });
393
+ // Key both the declared input and its resolved form: a write resolves the
394
+ // input once, then re-checks the already-absolute path, and neither lookup may
395
+ // ask a second time for an "allow once" decision.
396
+ resolved.set(declared, target);
397
+ resolved.set(target, target);
398
+ }
399
+ scope.guard?.(effect);
400
+ if (options.toolFilter && !options.toolFilter(p.tool))
401
+ throw new Error(`Tool ${call.name} is not available in this session`);
402
+ const capability = p.tool.capability;
403
+ const gate = o.capabilities;
404
+ if (capability && (allowed(policy, effect) || capabilityAllowed(policy, p.tool))) {
405
+ // A broad process grant or --allow-analysis: allowed, audited as a flag decision.
406
+ gate?.record({
407
+ capability,
408
+ sessionId,
409
+ scope: "once",
410
+ decision: "allow",
411
+ source: "flag",
412
+ callId: call.id,
413
+ runId,
414
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
415
+ });
416
+ }
417
+ else if (capability && gate?.granted(capability, sessionId)) {
418
+ // A persisted "allow for this session" grant of the root session.
419
+ }
420
+ else if (!allowed(policy, effect)) {
421
+ if (!o.approve ||
422
+ !approvals ||
423
+ (effect !== "write" && effect !== "process" && effect !== "external"))
424
+ throw new Error(`Capability denied: ${capability ?? effect}`);
425
+ const preview = capability
426
+ ? (scriptPreview(p.input) ?? (await gate?.preview?.(p.input, sessionId)))
427
+ : undefined;
428
+ emit("approval_requested", {
429
+ id: call.id,
430
+ name: call.name,
431
+ effect,
432
+ ...(options.label ? { label: options.label } : {}),
433
+ ...(capability ? { capability } : {}),
434
+ });
435
+ const decision = await o.approve({
436
+ call,
437
+ effect,
438
+ input: p.input,
439
+ signal: combined,
440
+ session: sessionId,
441
+ ...(options.label ? { label: options.label } : {}),
442
+ ...(capability ? { capability } : {}),
443
+ ...(preview !== undefined ? { preview } : {}),
444
+ ...(capability && gate?.runtime ? { runtime: gate.runtime } : {}),
445
+ });
446
+ combined.throwIfAborted();
447
+ const persisted = !!capability && !!gate && decision === "session";
448
+ if (capability && gate)
449
+ gate.record({
450
+ capability,
451
+ sessionId,
452
+ scope: decision === "session" ? "session" : "once",
453
+ decision: decision === "deny" ? "deny" : "allow",
454
+ source: gate.source ?? "tui",
455
+ callId: call.id,
456
+ runId,
457
+ ...(options.correlationId !== undefined
458
+ ? { correlationId: options.correlationId }
459
+ : {}),
460
+ });
461
+ emit("approval_resolved", {
462
+ id: call.id,
463
+ name: call.name,
464
+ effect,
465
+ decision,
466
+ ...(capability ? { capability } : {}),
467
+ ...(persisted ? { persisted: true } : {}),
468
+ });
469
+ if (decision === "deny")
470
+ throw new Error(`Capability ${capability ?? effect} denied by the user for this call`);
471
+ // A capability grant never widens the effect: only effect approvals do.
472
+ if (decision === "session" && !capability)
473
+ policy[effect] = true;
474
+ }
475
+ o.store.beginCall(sessionId, call, { runId, effect });
476
+ const publisher = o.artifacts?.({ sessionId, runId, callId: call.id }, (published) => emit("artifact_published", {
477
+ artifact: published.artifact,
478
+ path: published.path,
479
+ callId: call.id,
480
+ ...(published.executionId ? { executionId: published.executionId } : {}),
481
+ }));
482
+ /**
483
+ * `analysis.install`: always asks, `once` only, and no flag or earlier grant covers it. Where
484
+ * nobody can be asked (headless, no approval handler) the answer is `deny`.
485
+ */
486
+ const approveInstall = async (install) => {
487
+ if (!o.approve || !approvals)
488
+ return "deny";
489
+ const installCall = { ...call, id: `${call.id}:install` };
490
+ emit("approval_requested", {
491
+ id: installCall.id,
492
+ name: call.name,
493
+ effect: "process",
494
+ ...(options.label ? { label: options.label } : {}),
495
+ capability: "analysis.install",
496
+ install,
497
+ });
498
+ let decision = "deny";
499
+ try {
500
+ decision = await o.approve({
501
+ call: installCall,
502
+ effect: "process",
503
+ input: { extras: install.extras },
504
+ signal: combined,
505
+ session: sessionId,
506
+ ...(options.label ? { label: options.label } : {}),
507
+ capability: "analysis.install",
508
+ install,
509
+ });
510
+ }
511
+ finally {
512
+ combined.throwIfAborted();
513
+ }
514
+ // A `session` answer is treated as `once`: an installation permission is never stored.
515
+ const allowedNow = decision !== "deny";
516
+ gate?.record({
517
+ capability: "analysis.install",
518
+ sessionId,
519
+ scope: "once",
520
+ decision: allowedNow ? "allow" : "deny",
521
+ source: gate.source ?? "tui",
522
+ callId: call.id,
523
+ runId,
524
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
525
+ });
526
+ emit("approval_resolved", {
527
+ id: installCall.id,
528
+ name: call.name,
529
+ effect: "process",
530
+ decision: allowedNow ? "once" : "deny",
531
+ capability: "analysis.install",
532
+ });
533
+ return allowedNow ? "once" : "deny";
534
+ };
535
+ result = await p.tool.execute(p.input, {
536
+ signal: combined,
537
+ workspace,
538
+ session: sessionId,
539
+ runId,
540
+ callId: call.id,
541
+ ...(publisher ? { artifacts: publisher } : {}),
542
+ approveInstall,
543
+ emit: (data) => emit("tool_progress", { id: call.id, data }),
544
+ ...(options.label ? { label: options.label } : {}),
545
+ ...(pathAccess
546
+ ? {
547
+ resolvePath: (path) => {
548
+ const known = resolved.get(path);
549
+ return known !== undefined
550
+ ? Promise.resolve(known)
551
+ : pathAccess.resolve(path, {
552
+ workspace,
553
+ session: sessionId,
554
+ ...(options.label ? { label: options.label } : {}),
555
+ signal: combined,
556
+ });
557
+ },
558
+ }
559
+ : {}),
560
+ });
561
+ // The bound applies to what the model sees (the text projection): display-only
562
+ // ui/image parts are bounded by their producers and must not push text over it.
563
+ const projected = textProjection(result);
564
+ if (JSON.stringify(projected).length > 48_000)
565
+ result = textResult(`${JSON.stringify(projected).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
566
+ result = scope.amend?.(result, effect) ?? result;
567
+ o.store.endCall(sessionId, call, result);
568
+ }
569
+ catch (error) {
570
+ result = textResult(error instanceof Error ? error.message : String(error), true);
571
+ // A cancellation after execution may have left effects; journal remains pending.
572
+ if (!combined.aborted)
573
+ o.store.endCall(sessionId, call, result);
574
+ }
575
+ emit("tool_completed", {
576
+ id: call.id,
577
+ name: call.name,
578
+ isError: result.isError ?? false,
579
+ durationMs: Date.now() - started,
580
+ preview: result.content
581
+ .filter((part) => part.type === "text")
582
+ .map((c) => c.text)
583
+ .join("\n")
584
+ .slice(0, 2_000),
585
+ });
586
+ return result;
587
+ }
588
+ /**
589
+ * Runs ONE tool call without a model (spec §20 `runToolCall`): an action the user started from
590
+ * a UI, such as copying an artifact into the workspace. It is a real run of the session
591
+ * (`run_started` → `tool_*` → `run_completed`, journaled) and the call passes exactly the gates
592
+ * of a model call (`write` approval, capabilities, paths). The transcript stays valid for every
593
+ * provider: a user note (shown as `display`), an assistant message with the call, the tool
594
+ * result and a short assistant summary.
595
+ */
596
+ async runToolCall(sessionId, name, input, options = {}) {
597
+ const controller = this.claim(sessionId);
598
+ const runId = options.runId ?? crypto.randomUUID();
599
+ const o = this.options, emit = this.emitter(sessionId, { runId, correlationId: options.correlationId });
600
+ const finish = (status, error) => {
601
+ try {
602
+ o.store.endRun?.(runId, {
603
+ status,
604
+ usage: { input: 0, output: 0 },
605
+ ...(error ? { error } : {}),
606
+ });
607
+ }
608
+ catch {
609
+ /* A journal write failure cannot invalidate an executed run. */
610
+ }
611
+ };
612
+ const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
613
+ const timeout = AbortSignal.timeout(runTimeoutMs);
614
+ const combined = AbortSignal.any([
615
+ controller.signal,
616
+ timeout,
617
+ ...(options.signal ? [options.signal] : []),
618
+ ]);
619
+ const workspace = options.workspace ?? o.workspace;
620
+ let acquired = false;
621
+ try {
622
+ o.store.beginRun?.({
623
+ id: runId,
624
+ session: sessionId,
625
+ status: "running",
626
+ model: o.store.get(sessionId).model,
627
+ ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
628
+ });
629
+ o.store.acquire(sessionId);
630
+ acquired = true;
631
+ o.store.reconcile(sessionId);
632
+ const session = o.store.get(sessionId);
633
+ if (session.workspace !== workspace)
634
+ throw new Error("Session workspace mismatch");
635
+ combined.throwIfAborted();
636
+ const call = { id: uiCallId(), name, arguments: JSON.stringify(input) };
637
+ let prepared;
638
+ try {
639
+ prepared = {
640
+ call,
641
+ tool: o.registry.get(name),
642
+ input: o.registry.parse(name, call.arguments),
643
+ };
644
+ }
645
+ catch (error) {
646
+ prepared = { call, error: String(error) };
647
+ }
648
+ const note = `The user ran the ${name} tool from the interface.`;
649
+ o.store.append(sessionId, { role: "user", text: note, display: options.display ?? note });
650
+ emit("run_started", { model: session.model });
651
+ o.store.append(sessionId, { role: "assistant", text: "", calls: [call] });
652
+ const result = await this.executeCall({
653
+ sessionId,
654
+ runId,
655
+ emit,
656
+ signal: combined,
657
+ workspace,
658
+ policy: options.policy ?? o.policy,
659
+ approvals: !!o.approve && options.approvals !== false,
660
+ options,
661
+ }, prepared);
662
+ o.store.append(sessionId, { role: "tool", callId: call.id, result });
663
+ const first = textProjection(result)
664
+ .content.map((part) => (part.type === "text" ? part.text : ""))
665
+ .join(" ")
666
+ .replace(/\s*\n\s*/g, " ")
667
+ .trim()
668
+ .slice(0, 300);
669
+ const text = `${name} ${result.isError ? "failed" : "finished"}${first ? `: ${first}` : ""}`;
670
+ o.store.append(sessionId, { role: "assistant", text, calls: [] });
671
+ emit("run_completed", { tokens: 0, text });
672
+ finish("completed");
673
+ return { runId, status: "completed", result };
674
+ }
675
+ catch (error) {
676
+ if (combined.aborted && isTimeoutReason(combined.reason)) {
677
+ const failure = new RunTimeoutError({
678
+ kind: "run",
679
+ ms: runTimeoutMs,
680
+ stage: "tool",
681
+ tool: name,
682
+ });
683
+ emit("run_failed", { error: failure.message, code: "timeout", timeout: failure.info });
684
+ finish("failed", failure.message);
685
+ throw failure;
686
+ }
687
+ const cause = combined.aborted ? (combined.reason ?? error) : error;
688
+ const message = cause instanceof Error ? cause.message : String(cause);
689
+ emit(combined.aborted ? "run_cancelled" : "run_failed", { error: message });
690
+ finish(combined.aborted ? "cancelled" : "failed", message);
691
+ throw error;
692
+ }
693
+ finally {
694
+ if (acquired)
695
+ o.store.release(sessionId);
696
+ this.active.delete(sessionId);
697
+ }
698
+ }
313
699
  async run(sessionId, prompt, signal, options = {}) {
314
700
  const controller = this.claim(sessionId);
315
701
  const runId = options.runId ?? crypto.randomUUID();
@@ -326,8 +712,21 @@ export class AgentRunner {
326
712
  const limit = o.maxContextChars ?? 800_000;
327
713
  let tokens = 0, lastText = "", acquired = false;
328
714
  const usageTotal = { input: 0, output: 0 };
329
- const timeout = AbortSignal.timeout(options.timeoutMs ?? o.timeoutMs ?? 300_000);
715
+ const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
716
+ const firstTokenMs = options.firstTokenTimeoutMs ?? o.firstTokenTimeoutMs ?? 0;
717
+ const firstTokenRetries = firstTokenMs > 0 ? Math.max(0, options.firstTokenRetries ?? o.firstTokenRetries ?? 0) : 0;
718
+ const runStartedAt = Date.now();
719
+ const timeout = AbortSignal.timeout(runTimeoutMs);
330
720
  const combined = AbortSignal.any([controller.signal, timeout, ...(signal ? [signal] : [])]);
721
+ // What the run was doing, so a timeout can say where it was stuck (see `RunTimeoutError`).
722
+ let stage = "other";
723
+ let stageTool;
724
+ let modelId;
725
+ let providerName;
726
+ let requests = 0;
727
+ let silentTimedOut = false;
728
+ /** Sends of the request that is (or was last) waiting for its first token. */
729
+ let silentAttempts = 0;
331
730
  const context = options.context ?? o.context;
332
731
  const workspace = options.workspace ?? o.workspace;
333
732
  const pathAccess = o.pathAccess;
@@ -354,6 +753,8 @@ export class AgentRunner {
354
753
  throw new Error("Session provider/workspace mismatch");
355
754
  // The session records the model; switching models is explicit via setModel.
356
755
  const model = session.model;
756
+ modelId = model;
757
+ providerName = providerLabel(provider.id);
357
758
  combined.throwIfAborted();
358
759
  if (o.extensions && o.store.messages(sessionId).length === 0) {
359
760
  // New session: extensions may inject budgeted context once, persisted with the session.
@@ -394,6 +795,7 @@ export class AgentRunner {
394
795
  text: prompt,
395
796
  ...(options.display ? { display: options.display } : {}),
396
797
  ...(options.attachments?.length ? { attachments: options.attachments } : {}),
798
+ ...(options.datasets?.length ? { datasets: options.datasets } : {}),
397
799
  });
398
800
  emit("run_started", { model });
399
801
  const budget = options.maxTokens ?? o.maxTokens ?? defaultTokenBudget(o.contextWindow?.(model));
@@ -407,7 +809,7 @@ export class AgentRunner {
407
809
  o.store.append(sessionId, { role: "user", text: queued });
408
810
  let instructions = await withPersona();
409
811
  let messages = o.store.messages(sessionId);
410
- const tools = this.availableTools(options);
812
+ const tools = this.availableTools(options, sessionId);
411
813
  const toolsText = this.toolsText(tools);
412
814
  const chars = () => instructions.length + JSON.stringify(messages).length + toolsText.length;
413
815
  // REDUCIBLE content only (instructions + transcript). The fixed tool catalog
@@ -463,39 +865,104 @@ export class AgentRunner {
463
865
  // display-only extension of the persisted transcript and never reach a model prompt
464
866
  // (raw image bytes included). The store keeps the rich parts for TUI replay.
465
867
  const providerMessages = messages.map((m) => m.role === "tool" ? { ...m, result: textProjection(m.result) } : m);
466
- const requested = Date.now();
868
+ let requested = Date.now();
467
869
  let firstDelta;
468
- for await (const e of provider.stream({
469
- instructions,
470
- messages: providerMessages,
471
- tools,
472
- maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
473
- signal: combined,
474
- model,
475
- sessionId,
476
- ...(o.nativeTools?.length ? { nativeTools: o.nativeTools } : {}),
477
- ...((options.reasoningEffort ?? o.reasoningEffort)
478
- ? { reasoningEffort: options.reasoningEffort ?? o.reasoningEffort }
479
- : {}),
480
- })) {
481
- combined.throwIfAborted();
482
- if (e.type !== "completed")
483
- firstDelta ??= Date.now();
484
- if (e.type === "text_delta")
485
- emit("text_delta", { delta: e.delta });
486
- else if (e.type === "reasoning_delta")
487
- emit("reasoning_delta", { delta: e.delta });
488
- else {
489
- if (completion)
490
- throw new Error("Provider emitted multiple completions");
491
- completion = e.message;
492
- truncated = e.message.truncated === true;
493
- usage = e.usage;
494
- tokens += (e.usage?.input ?? 0) + (e.usage?.output ?? 0);
495
- usageTotal.input += e.usage?.input ?? 0;
496
- usageTotal.output += e.usage?.output ?? 0;
870
+ stage = "waiting_model";
871
+ requests++;
872
+ silentAttempts = 0;
873
+ // The same request is sent again only while the provider stayed completely silent until
874
+ // `firstTokenTimeoutMs`. Nothing was received, appended or accounted for the aborted
875
+ // attempt, so the retry reuses the exact same inputs inside this turn.
876
+ for (;;) {
877
+ silentAttempts++;
878
+ silentTimedOut = false;
879
+ requested = Date.now();
880
+ // A request that stays completely silent can be stopped early (`firstTokenTimeoutMs`).
881
+ const silent = new AbortController();
882
+ let silentTimer = firstTokenMs > 0
883
+ ? setTimeout(() => {
884
+ silentTimedOut = true;
885
+ silent.abort();
886
+ }, firstTokenMs)
887
+ : undefined;
888
+ const stopSilentTimer = () => {
889
+ if (silentTimer)
890
+ clearTimeout(silentTimer);
891
+ silentTimer = undefined;
892
+ };
893
+ let failure;
894
+ try {
895
+ for await (const e of provider.stream({
896
+ instructions,
897
+ messages: providerMessages,
898
+ tools,
899
+ maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
900
+ signal: firstTokenMs > 0 ? AbortSignal.any([combined, silent.signal]) : combined,
901
+ model,
902
+ sessionId,
903
+ ...(o.nativeTools?.length ? { nativeTools: o.nativeTools } : {}),
904
+ ...((options.reasoningEffort ?? o.reasoningEffort)
905
+ ? { reasoningEffort: options.reasoningEffort ?? o.reasoningEffort }
906
+ : {}),
907
+ })) {
908
+ combined.throwIfAborted();
909
+ stopSilentTimer();
910
+ if (stage === "waiting_model")
911
+ stage = "streaming";
912
+ if (e.type !== "completed")
913
+ firstDelta ??= Date.now();
914
+ if (e.type === "text_delta")
915
+ emit("text_delta", { delta: e.delta });
916
+ else if (e.type === "reasoning_delta")
917
+ emit("reasoning_delta", { delta: e.delta });
918
+ else {
919
+ if (completion)
920
+ throw new Error("Provider emitted multiple completions");
921
+ completion = e.message;
922
+ truncated = e.message.truncated === true;
923
+ usage = e.usage;
924
+ tokens += (e.usage?.input ?? 0) + (e.usage?.output ?? 0);
925
+ usageTotal.input += e.usage?.input ?? 0;
926
+ usageTotal.output += e.usage?.output ?? 0;
927
+ }
928
+ }
929
+ }
930
+ catch (error) {
931
+ failure = error;
497
932
  }
933
+ finally {
934
+ stopSilentTimer();
935
+ }
936
+ const silentFailure = silentTimedOut &&
937
+ firstDelta === undefined &&
938
+ !completion &&
939
+ !combined.aborted &&
940
+ silentAttempts <= firstTokenRetries;
941
+ if (!silentFailure) {
942
+ if (failure !== undefined)
943
+ throw failure;
944
+ break;
945
+ }
946
+ // Do not start an attempt the whole-run limit could not let finish.
947
+ const retryPauseMs = FIRST_TOKEN_RETRY_PAUSE_MS;
948
+ if (runTimeoutMs - (Date.now() - runStartedAt) < retryPauseMs + firstTokenMs) {
949
+ if (failure !== undefined)
950
+ throw failure;
951
+ break;
952
+ }
953
+ // From here the silence is handled: a later stop or run timeout is judged on its own.
954
+ silentTimedOut = false;
955
+ emit("request_retry", {
956
+ attempt: silentAttempts,
957
+ of: firstTokenRetries,
958
+ reason: "first_token_timeout",
959
+ afterMs: Date.now() - requested,
960
+ });
961
+ await abortableDelay(retryPauseMs, combined);
962
+ combined.throwIfAborted();
963
+ stage = "waiting_model";
498
964
  }
965
+ stage = "other";
499
966
  if (!completion)
500
967
  throw new Error("Provider stream ended without a completed response");
501
968
  const durationMs = Date.now() - requested;
@@ -556,137 +1023,38 @@ export class AgentRunner {
556
1023
  if (paths.length)
557
1024
  contextUpdate = await context.beforePaths(paths, sessionId);
558
1025
  let pendingUpdate = contextUpdate;
559
- const execute = async (p) => {
560
- const { call } = p;
561
- let result;
562
- const started = Date.now();
563
- // `effect` rides along so the TUI can group consecutive calls by capability kind
564
- // (read batches, write batches, commands…) without a name heuristic. Additive field.
565
- emit("tool_started", {
566
- name: call.name,
567
- id: call.id,
568
- arguments: call.arguments,
569
- effect: p.tool?.effect ?? "external",
570
- });
571
- try {
572
- combined.throwIfAborted();
573
- if (p.error)
574
- throw new Error(p.error);
575
- if (!p.tool || !p.input)
576
- throw new Error("Invalid tool");
577
- const effect = p.tool.effect ?? "external";
578
- // External-directory prerequisite: declared paths are resolved (and, outside every
579
- // allowed root, approved for the containing directory) BEFORE the effect gate, so a
580
- // write still needs its own write policy and a read never hard-throws first. The
581
- // resolved map keeps "allow once" scoped to this one call, with no shared mutable
582
- // state that parallel read batches could race on.
583
- const resolved = new Map();
584
- if (pathAccess)
585
- for (const declared of p.tool.paths?.(p.input) ?? [])
586
- if (!resolved.has(declared)) {
587
- const target = await pathAccess.resolve(declared, {
588
- workspace,
589
- session: sessionId,
590
- ...(options.label ? { label: options.label } : {}),
591
- signal: combined,
592
- });
593
- // Key both the declared input and its resolved form: a write resolves the
594
- // input once, then re-checks the already-absolute path, and neither lookup may
595
- // ask a second time for an "allow once" decision.
596
- resolved.set(declared, target);
597
- resolved.set(target, target);
598
- }
1026
+ const scope = {
1027
+ sessionId,
1028
+ runId,
1029
+ emit,
1030
+ signal: combined,
1031
+ workspace,
1032
+ policy,
1033
+ approvals,
1034
+ options,
1035
+ guard: (effect) => {
599
1036
  if (contextUpdate && effect !== "read")
600
1037
  throw new Error(`Context changed; reconsider this call before retrying.\n${contextUpdate}`);
601
1038
  if (tokens >= budget)
602
1039
  throw new Error("Token budget exhausted; tool was not executed");
603
- if (options.toolFilter && !options.toolFilter(p.tool))
604
- throw new Error(`Tool ${call.name} is not available in this session`);
605
- if (!allowed(policy, effect)) {
606
- if (!o.approve ||
607
- !approvals ||
608
- (effect !== "write" && effect !== "process" && effect !== "external"))
609
- throw new Error(`Capability denied: ${effect}`);
610
- emit("approval_requested", {
611
- id: call.id,
612
- name: call.name,
613
- effect,
614
- ...(options.label ? { label: options.label } : {}),
615
- });
616
- const decision = await o.approve({
617
- call,
618
- effect,
619
- input: p.input,
620
- signal: combined,
621
- session: sessionId,
622
- ...(options.label ? { label: options.label } : {}),
623
- });
624
- combined.throwIfAborted();
625
- emit("approval_resolved", { id: call.id, name: call.name, effect, decision });
626
- if (decision === "deny")
627
- throw new Error(`Capability ${effect} denied by the user for this call`);
628
- if (decision === "session")
629
- policy[effect] = true;
630
- }
631
- o.store.beginCall(sessionId, call, { runId, effect });
632
- result = await p.tool.execute(p.input, {
633
- signal: combined,
634
- workspace,
635
- session: sessionId,
636
- emit: (data) => emit("tool_progress", { id: call.id, data }),
637
- ...(options.label ? { label: options.label } : {}),
638
- ...(pathAccess
639
- ? {
640
- resolvePath: (path) => {
641
- const known = resolved.get(path);
642
- return known !== undefined
643
- ? Promise.resolve(known)
644
- : pathAccess.resolve(path, {
645
- workspace,
646
- session: sessionId,
647
- ...(options.label ? { label: options.label } : {}),
648
- signal: combined,
649
- });
650
- },
651
- }
652
- : {}),
653
- });
654
- // The bound applies to what the model sees (the text projection): display-only
655
- // ui/image parts are bounded by their producers and must not push text over it.
656
- const projected = textProjection(result);
657
- if (JSON.stringify(projected).length > 48_000)
658
- result = textResult(`${JSON.stringify(projected).slice(0, 40_000)}\n[tool output truncated]`, result.isError);
659
- if (pendingUpdate && effect === "read") {
660
- result = {
661
- ...result,
662
- content: [
663
- ...result.content,
664
- { type: "text", text: `\n<instructions>\n${pendingUpdate}\n</instructions>` },
665
- ],
666
- };
667
- pendingUpdate = undefined;
668
- }
669
- o.store.endCall(sessionId, call, result);
670
- }
671
- catch (error) {
672
- result = textResult(error instanceof Error ? error.message : String(error), true);
673
- // A cancellation after execution may have left effects; journal remains pending.
674
- if (!combined.aborted)
675
- o.store.endCall(sessionId, call, result);
676
- }
677
- emit("tool_completed", {
678
- id: call.id,
679
- name: call.name,
680
- isError: result.isError ?? false,
681
- durationMs: Date.now() - started,
682
- preview: result.content
683
- .filter((part) => part.type === "text")
684
- .map((c) => c.text)
685
- .join("\n")
686
- .slice(0, 2_000),
687
- });
688
- return result;
1040
+ },
1041
+ amend: (result, effect) => {
1042
+ if (!pendingUpdate || effect !== "read")
1043
+ return result;
1044
+ const update = pendingUpdate;
1045
+ pendingUpdate = undefined;
1046
+ return {
1047
+ ...result,
1048
+ content: [
1049
+ ...result.content,
1050
+ { type: "text", text: `\n<instructions>\n${update}\n</instructions>` },
1051
+ ],
1052
+ };
1053
+ },
689
1054
  };
1055
+ stage = "tool";
1056
+ stageTool = prepared[0]?.call.name;
1057
+ const execute = (p) => this.executeCall(scope, p);
690
1058
  const results = [];
691
1059
  // Bounded parallel batches for consecutive read or explicitly concurrent tools.
692
1060
  const parallel = (p) => !!p?.tool && (p.tool.effect === "read" || p.tool.concurrent === true);
@@ -716,6 +1084,8 @@ export class AgentRunner {
716
1084
  if (c && r)
717
1085
  o.store.append(sessionId, { role: "tool", callId: c.id, result: r });
718
1086
  }
1087
+ stage = "other";
1088
+ stageTool = undefined;
719
1089
  if (tokens >= budget)
720
1090
  throw new Error("Token budget exhausted");
721
1091
  }
@@ -728,6 +1098,24 @@ export class AgentRunner {
728
1098
  return { sessionId, text: lastText, status: "turns-exceeded", usage: usageTotal };
729
1099
  }
730
1100
  catch (error) {
1101
+ const timedOut = silentTimedOut
1102
+ ? { kind: "first_token", ms: firstTokenMs, attempts: Math.max(1, silentAttempts) }
1103
+ : combined.aborted && isTimeoutReason(combined.reason)
1104
+ ? { kind: "run", ms: runTimeoutMs }
1105
+ : undefined;
1106
+ if (timedOut && !(combined.aborted && !isTimeoutReason(combined.reason))) {
1107
+ const failure = new RunTimeoutError({
1108
+ ...timedOut,
1109
+ ...(modelId ? { model: modelId } : {}),
1110
+ ...(providerName ? { provider: providerName } : {}),
1111
+ stage,
1112
+ ...(stage === "tool" && stageTool ? { tool: stageTool } : {}),
1113
+ ...(requests <= 1 ? { firstRequest: true } : {}),
1114
+ });
1115
+ emit("run_failed", { error: failure.message, code: "timeout", timeout: failure.info });
1116
+ finish("failed", failure.message);
1117
+ throw failure;
1118
+ }
731
1119
  // On cancellation report the abort reason, not the transport's secondary error.
732
1120
  const cause = combined.aborted ? (combined.reason ?? error) : error;
733
1121
  const message = cause instanceof Error ? cause.message : String(cause);