@alisio/core 0.2.0 → 0.3.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.
Files changed (40) hide show
  1. package/dist/application.d.ts +7 -0
  2. package/dist/application.js +90 -6
  3. package/dist/changelog/data.js +36 -0
  4. package/dist/commands/builtins.js +6 -0
  5. package/dist/commands/catalog.d.ts +8 -1
  6. package/dist/commands/catalog.js +62 -0
  7. package/dist/config.d.ts +37 -3
  8. package/dist/config.js +109 -7
  9. package/dist/core/contracts.d.ts +10 -1
  10. package/dist/core/human-wait.d.ts +25 -0
  11. package/dist/core/human-wait.js +54 -0
  12. package/dist/core/run-clock.d.ts +47 -0
  13. package/dist/core/run-clock.js +111 -0
  14. package/dist/core/run-stats.d.ts +2 -0
  15. package/dist/core/runner.d.ts +15 -1
  16. package/dist/core/runner.js +70 -18
  17. package/dist/core/timeout.js +2 -2
  18. package/dist/decisions/bound.d.ts +23 -0
  19. package/dist/decisions/bound.js +48 -0
  20. package/dist/decisions/breaker.d.ts +35 -0
  21. package/dist/decisions/breaker.js +61 -0
  22. package/dist/decisions/format.d.ts +15 -0
  23. package/dist/decisions/format.js +89 -0
  24. package/dist/decisions/index.d.ts +7 -0
  25. package/dist/decisions/index.js +7 -0
  26. package/dist/decisions/metrics.d.ts +45 -0
  27. package/dist/decisions/metrics.js +79 -0
  28. package/dist/decisions/registry.d.ts +22 -0
  29. package/dist/decisions/registry.js +64 -0
  30. package/dist/decisions/service.d.ts +137 -0
  31. package/dist/decisions/service.js +377 -0
  32. package/dist/decisions/validate.d.ts +26 -0
  33. package/dist/decisions/validate.js +182 -0
  34. package/dist/goal/host.d.ts +2 -0
  35. package/dist/goal/host.js +2 -1
  36. package/dist/index.d.ts +5 -2
  37. package/dist/index.js +3 -0
  38. package/dist/plugins/host.d.ts +43 -2
  39. package/dist/plugins/host.js +111 -16
  40. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import type { AnalysisCapability, ArtifactPublisher, ArtifactPublishInput, ArtifactRef, CompactionCheckpoint, InstallPreview, Message, ToolCall, ToolResult } from "@alisio/sdk";
1
+ import type { AnalysisCapability, ArtifactPublisher, ArtifactPublishInput, ArtifactRef, CompactionCheckpoint, InstallPreview, Message, RunEventDataMap, ToolCall, ToolDecisions, ToolResult } from "@alisio/sdk";
2
2
  export interface Session {
3
3
  id: string;
4
4
  workspace: string;
@@ -275,6 +275,15 @@ export type ArtifactPublisherFactory = (call: {
275
275
  runId: string;
276
276
  callId: string;
277
277
  }, announce: (published: PublishedArtifactInfo) => void) => CoreArtifactPublisher | undefined;
278
+ /**
279
+ * Creates the `ToolContext.decisions` of one tool call; `announce` emits the decision event of
280
+ * the run (persisted and fanned out like any other). `undefined` leaves the context without it.
281
+ */
282
+ export type DecisionsFactory = (call: {
283
+ sessionId: string;
284
+ runId: string;
285
+ callId: string;
286
+ }, announce: <K extends "decision_completed" | "decision_fallback">(type: K, data: RunEventDataMap[K]) => void) => ToolDecisions | undefined;
278
287
  /** Optional interactive approval for write/process/external tools not pre-allowed by the Policy. */
279
288
  export type ApprovalHandler = (request: ApprovalRequest) => Promise<ApprovalDecision>;
280
289
  export interface HookFailure {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Human waits: the moments a run is blocked on a PERSON (a tool approval, an external-directory
3
+ * approval, `ask_user_question`, the plan review) and not on work. The run's time limit does not
4
+ * count them (see `RunClock`).
5
+ *
6
+ * One registry per application. Each active run registers its clock under its session; anything
7
+ * that asks a person goes through `track`, which pauses the clocks involved until the answer
8
+ * arrives. A child session (a subagent) pauses its own clock AND its ancestors': a parent that
9
+ * waits for a child is doing real work, but when that child waits for a person nobody works.
10
+ */
11
+ import type { RunClock } from "./run-clock.ts";
12
+ export declare class HumanWaits {
13
+ private readonly parentOf;
14
+ private clocks;
15
+ constructor(parentOf?: (session: string) => string | undefined);
16
+ /** A run began: its clock follows the waits of its session. Returns the unregister function. */
17
+ register(session: string, clock: RunClock): () => void;
18
+ /**
19
+ * Opens a wait for `session` (the session asking; its ancestors are paused too). Without a
20
+ * session every active run is paused: somebody is being asked and nobody can say who for.
21
+ */
22
+ begin(session?: string): () => void;
23
+ /** Runs `wait` as a human wait: the clocks are paused until it settles, however it settles. */
24
+ track<T>(session: string | undefined, wait: () => Promise<T>): Promise<T>;
25
+ }
@@ -0,0 +1,54 @@
1
+ export class HumanWaits {
2
+ parentOf;
3
+ clocks = new Map();
4
+ constructor(parentOf = () => undefined) {
5
+ this.parentOf = parentOf;
6
+ }
7
+ /** A run began: its clock follows the waits of its session. Returns the unregister function. */
8
+ register(session, clock) {
9
+ this.clocks.set(session, clock);
10
+ return () => {
11
+ if (this.clocks.get(session) === clock)
12
+ this.clocks.delete(session);
13
+ };
14
+ }
15
+ /**
16
+ * Opens a wait for `session` (the session asking; its ancestors are paused too). Without a
17
+ * session every active run is paused: somebody is being asked and nobody can say who for.
18
+ */
19
+ begin(session) {
20
+ const releases = [];
21
+ if (session === undefined)
22
+ for (const clock of this.clocks.values())
23
+ releases.push(clock.pause());
24
+ else {
25
+ const seen = new Set();
26
+ for (let id = session; id !== undefined && !seen.has(id);) {
27
+ seen.add(id);
28
+ const clock = this.clocks.get(id);
29
+ if (clock)
30
+ releases.push(clock.pause());
31
+ try {
32
+ id = this.parentOf(id);
33
+ }
34
+ catch {
35
+ id = undefined;
36
+ }
37
+ }
38
+ }
39
+ return () => {
40
+ for (const release of releases)
41
+ release();
42
+ };
43
+ }
44
+ /** Runs `wait` as a human wait: the clocks are paused until it settles, however it settles. */
45
+ async track(session, wait) {
46
+ const end = this.begin(session);
47
+ try {
48
+ return await wait();
49
+ }
50
+ finally {
51
+ end();
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The run's time limit (`limits.timeoutMs`) as a PAUSABLE clock.
3
+ *
4
+ * The limit exists to stop runaway work (a stuck model, a looping tool), not to cap how long a
5
+ * person takes to read a plan or answer a question. So the clock counts ACTIVE time only: while at
6
+ * least one human wait is open (an approval, a question, a plan review) it is paused, and it
7
+ * resumes with the remaining time when the last wait ends.
8
+ *
9
+ * Waits nest and overlap (several tools can wait for approvals in parallel), so pausing is
10
+ * counted: the clock runs again only when every pause was released. A pause is released exactly
11
+ * once (the function `pause()` returns is idempotent), and a released or disposed clock ignores
12
+ * late calls, so an aborted run can never leave a timer behind.
13
+ */
14
+ /** Time source and timers, replaceable in tests. */
15
+ export interface ClockSource {
16
+ now(): number;
17
+ setTimeout(fn: () => void, ms: number): unknown;
18
+ clearTimeout(handle: unknown): void;
19
+ }
20
+ export declare class RunClock {
21
+ readonly limitMs: number;
22
+ private readonly controller;
23
+ private readonly source;
24
+ private consumed;
25
+ /** Start of the current running stretch (undefined while paused or finished). */
26
+ private since;
27
+ private timer;
28
+ private pauses;
29
+ private done;
30
+ constructor(limitMs: number, source?: ClockSource);
31
+ /** Aborts with a `TimeoutError` (the same reason `AbortSignal.timeout` uses) at the limit. */
32
+ get signal(): AbortSignal;
33
+ /** Whether at least one human wait is open. */
34
+ get paused(): boolean;
35
+ /** Time the run was really working: elapsed time minus every paused stretch. */
36
+ activeMs(): number;
37
+ remainingMs(): number;
38
+ /**
39
+ * Opens a human wait. Returns the function that closes it (idempotent). Pausing a clock that
40
+ * already ended (limit reached or disposed) does nothing.
41
+ */
42
+ pause(): () => void;
43
+ /** Stops the clock for good and frees its timer (call when the run ends, whatever the reason). */
44
+ dispose(): void;
45
+ private arm;
46
+ private disarm;
47
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The run's time limit (`limits.timeoutMs`) as a PAUSABLE clock.
3
+ *
4
+ * The limit exists to stop runaway work (a stuck model, a looping tool), not to cap how long a
5
+ * person takes to read a plan or answer a question. So the clock counts ACTIVE time only: while at
6
+ * least one human wait is open (an approval, a question, a plan review) it is paused, and it
7
+ * resumes with the remaining time when the last wait ends.
8
+ *
9
+ * Waits nest and overlap (several tools can wait for approvals in parallel), so pausing is
10
+ * counted: the clock runs again only when every pause was released. A pause is released exactly
11
+ * once (the function `pause()` returns is idempotent), and a released or disposed clock ignores
12
+ * late calls, so an aborted run can never leave a timer behind.
13
+ */
14
+ /** Longest delay a timer supports; a longer limit re-arms itself. */
15
+ const MAX_TIMER_MS = 2_147_483_647;
16
+ const realSource = {
17
+ now: () => Date.now(),
18
+ setTimeout: (fn, ms) => {
19
+ const timer = setTimeout(fn, ms);
20
+ // Like `AbortSignal.timeout`: the limit alone never keeps the process alive.
21
+ timer.unref?.();
22
+ return timer;
23
+ },
24
+ clearTimeout: (handle) => clearTimeout(handle),
25
+ };
26
+ export class RunClock {
27
+ limitMs;
28
+ controller = new AbortController();
29
+ source;
30
+ consumed = 0;
31
+ /** Start of the current running stretch (undefined while paused or finished). */
32
+ since;
33
+ timer;
34
+ pauses = 0;
35
+ done = false;
36
+ constructor(limitMs, source = realSource) {
37
+ this.limitMs = limitMs;
38
+ this.source = source;
39
+ this.since = source.now();
40
+ this.arm();
41
+ }
42
+ /** Aborts with a `TimeoutError` (the same reason `AbortSignal.timeout` uses) at the limit. */
43
+ get signal() {
44
+ return this.controller.signal;
45
+ }
46
+ /** Whether at least one human wait is open. */
47
+ get paused() {
48
+ return this.pauses > 0;
49
+ }
50
+ /** Time the run was really working: elapsed time minus every paused stretch. */
51
+ activeMs() {
52
+ return (this.consumed + (this.since === undefined ? 0 : Math.max(0, this.source.now() - this.since)));
53
+ }
54
+ remainingMs() {
55
+ return Math.max(0, this.limitMs - this.activeMs());
56
+ }
57
+ /**
58
+ * Opens a human wait. Returns the function that closes it (idempotent). Pausing a clock that
59
+ * already ended (limit reached or disposed) does nothing.
60
+ */
61
+ pause() {
62
+ if (this.done)
63
+ return () => { };
64
+ if (this.pauses++ === 0) {
65
+ if (this.since !== undefined)
66
+ this.consumed += Math.max(0, this.source.now() - this.since);
67
+ this.since = undefined;
68
+ this.disarm();
69
+ }
70
+ let released = false;
71
+ return () => {
72
+ if (released)
73
+ return;
74
+ released = true;
75
+ if (this.done)
76
+ return;
77
+ if (--this.pauses === 0) {
78
+ this.since = this.source.now();
79
+ this.arm();
80
+ }
81
+ };
82
+ }
83
+ /** Stops the clock for good and frees its timer (call when the run ends, whatever the reason). */
84
+ dispose() {
85
+ if (this.done)
86
+ return;
87
+ this.consumed = this.activeMs();
88
+ this.done = true;
89
+ this.since = undefined;
90
+ this.disarm();
91
+ }
92
+ arm() {
93
+ this.disarm();
94
+ this.timer = this.source.setTimeout(() => {
95
+ this.timer = undefined;
96
+ if (this.done || this.pauses > 0)
97
+ return;
98
+ if (this.remainingMs() > 0)
99
+ return this.arm();
100
+ this.consumed = this.activeMs();
101
+ this.done = true;
102
+ this.since = undefined;
103
+ this.controller.abort(new DOMException("The operation timed out.", "TimeoutError"));
104
+ }, Math.min(this.remainingMs(), MAX_TIMER_MS));
105
+ }
106
+ disarm() {
107
+ if (this.timer !== undefined)
108
+ this.source.clearTimeout(this.timer);
109
+ this.timer = undefined;
110
+ }
111
+ }
@@ -11,6 +11,8 @@ export interface RunStats {
11
11
  output: number;
12
12
  };
13
13
  toolCalls: number;
14
+ /** Active time of the run (waits for a person excluded). */
15
+ activeMs?: number;
14
16
  }
15
17
  export declare function attachRunStats(error: unknown, stats: RunStats): void;
16
18
  export declare function runStatsOf(error: unknown): RunStats | undefined;
@@ -1,6 +1,7 @@
1
1
  import { type Attachment, type DatasetRef, type Message, type ModelProvider, type RunEvent, type ToolDefinition, type ToolResult } from "@alisio/sdk";
2
2
  import type { PathAccess } from "../runtime/access.ts";
3
- import type { ApprovalHandler, ArtifactPublisherFactory, CapabilityGate, ContextSource, Policy, RunnerExtensions, SessionStore } from "./contracts.ts";
3
+ import type { ApprovalHandler, ArtifactPublisherFactory, CapabilityGate, ContextSource, DecisionsFactory, Policy, RunnerExtensions, SessionStore } from "./contracts.ts";
4
+ import { HumanWaits } from "./human-wait.ts";
4
5
  import type { ToolRegistry } from "./registry.ts";
5
6
  export interface CompactionSettings {
6
7
  /** Compact automatically when context usage crosses `threshold` of a known window. */
@@ -62,6 +63,13 @@ export interface RunnerOptions {
62
63
  compaction?: CompactionSettings;
63
64
  /** When set, write/process tools not allowed by the policy are offered and ask first. */
64
65
  approve?: ApprovalHandler;
66
+ /**
67
+ * Registry of the waits for a person (approvals, questions, the plan review): the run's time
68
+ * limit does not count them. Share one with the plugin host and every other asking surface so a
69
+ * wait outside the runner (for example `ask_user_question`) pauses the same clocks. Without it
70
+ * the runner uses a private one, which still covers every approval it asks itself.
71
+ */
72
+ humanWaits?: HumanWaits;
65
73
  /**
66
74
  * Mediated path policy (workspace plus declared extra directories and external approval).
67
75
  * When set, declared tool paths outside the allowed roots ask before the effect gate runs.
@@ -73,6 +81,8 @@ export interface RunnerOptions {
73
81
  capabilities?: CapabilityGate;
74
82
  /** Artifact publisher of each tool call (`ToolContext.artifacts`). */
75
83
  artifacts?: ArtifactPublisherFactory;
84
+ /** Decision Intelligence bound to each tool call (`ToolContext.decisions`). */
85
+ decisions?: DecisionsFactory;
76
86
  /**
77
87
  * Provider-native tool definitions (for example a hosted `web_search` tool) appended to every
78
88
  * request's `tools` alongside the registry's function tools. Opt-in, set once for the whole
@@ -179,6 +189,8 @@ export declare class AgentRunner {
179
189
  private inbox;
180
190
  /** Last provider-reported context size per session and the history length it covered. */
181
191
  private reported;
192
+ /** Where human waits are opened; the clocks of the active runs listen to it. */
193
+ readonly humanWaits: HumanWaits;
182
194
  constructor(options: RunnerOptions);
183
195
  /** Current capability policy (may widen when the user approves an effect for the session). */
184
196
  get policy(): Readonly<Policy>;
@@ -284,5 +296,7 @@ export declare class AgentRunner {
284
296
  };
285
297
  /** Tool calls the model made during the run (the goal controller's no-progress breaker). */
286
298
  toolCalls: number;
299
+ /** Time the run was really working: wall time minus the waits for a person. */
300
+ activeMs: number;
287
301
  }>;
288
302
  }
@@ -1,7 +1,9 @@
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 { HumanWaits } from "./human-wait.js";
3
4
  import { EXIT_PLAN_TOOL, optInAllows } from "./opt-in.js";
4
5
  import { resolveMaxOutputTokens } from "./output-limit.js";
6
+ import { RunClock } from "./run-clock.js";
5
7
  import { attachRunStats } from "./run-stats.js";
6
8
  import { isTimeoutReason, providerLabel, RunTimeoutError } from "./timeout.js";
7
9
  import { incompleteCalls, isOutputTruncationError, lowerEffort, RunTruncationError, TRUNCATION_NOTICE, } from "./truncation.js";
@@ -60,8 +62,20 @@ export class AgentRunner {
60
62
  inbox = new Map();
61
63
  /** Last provider-reported context size per session and the history length it covered. */
62
64
  reported = new Map();
65
+ /** Where human waits are opened; the clocks of the active runs listen to it. */
66
+ humanWaits;
63
67
  constructor(options) {
64
68
  this.options = options;
69
+ this.humanWaits =
70
+ options.humanWaits ??
71
+ new HumanWaits((session) => {
72
+ try {
73
+ return options.store.get(session).parentId ?? undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ });
65
79
  }
66
80
  /** Current capability policy (may widen when the user approves an effect for the session). */
67
81
  get policy() {
@@ -465,17 +479,20 @@ export class AgentRunner {
465
479
  ...(options.label ? { label: options.label } : {}),
466
480
  ...(capability ? { capability } : {}),
467
481
  });
468
- const decision = await o.approve({
482
+ const approve = o.approve;
483
+ const callInput = p.input;
484
+ // Waiting for the person is not work: the run's time limit does not count it.
485
+ const decision = await this.humanWaits.track(sessionId, () => approve({
469
486
  call,
470
487
  effect,
471
- input: p.input,
488
+ input: callInput,
472
489
  signal: combined,
473
490
  session: sessionId,
474
491
  ...(options.label ? { label: options.label } : {}),
475
492
  ...(capability ? { capability } : {}),
476
493
  ...(preview !== undefined ? { preview } : {}),
477
494
  ...(capability && gate?.runtime ? { runtime: gate.runtime } : {}),
478
- });
495
+ }));
479
496
  combined.throwIfAborted();
480
497
  const persisted = !!capability && !!gate && decision === "session";
481
498
  if (capability && gate)
@@ -512,6 +529,7 @@ export class AgentRunner {
512
529
  callId: call.id,
513
530
  ...(published.executionId ? { executionId: published.executionId } : {}),
514
531
  }));
532
+ const decisions = o.decisions?.({ sessionId, runId, callId: call.id }, (type, data) => emit(type, data));
515
533
  /**
516
534
  * `analysis.install`: always asks, `once` only, and no flag or earlier grant covers it. Where
517
535
  * nobody can be asked (headless, no approval handler) the answer is `deny`.
@@ -530,7 +548,8 @@ export class AgentRunner {
530
548
  });
531
549
  let decision = "deny";
532
550
  try {
533
- decision = await o.approve({
551
+ const approve = o.approve;
552
+ decision = await this.humanWaits.track(sessionId, () => approve({
534
553
  call: installCall,
535
554
  effect: "process",
536
555
  input: { extras: install.extras },
@@ -539,7 +558,7 @@ export class AgentRunner {
539
558
  ...(options.label ? { label: options.label } : {}),
540
559
  capability: "analysis.install",
541
560
  install,
542
- });
561
+ }));
543
562
  }
544
563
  finally {
545
564
  combined.throwIfAborted();
@@ -572,6 +591,7 @@ export class AgentRunner {
572
591
  runId,
573
592
  callId: call.id,
574
593
  ...(publisher ? { artifacts: publisher } : {}),
594
+ ...(decisions ? { decisions } : {}),
575
595
  approveInstall,
576
596
  emit: (data) => emit("tool_progress", { id: call.id, data }),
577
597
  // Only the built-in plan tool reports its durable events (never a plugin tool).
@@ -607,8 +627,12 @@ export class AgentRunner {
607
627
  }
608
628
  catch (error) {
609
629
  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)
630
+ // A cancellation after execution may have left effects, so the journal stays pending for
631
+ // the user to inspect. A `read` call cannot have left any (and `exit_plan`, which is one,
632
+ // is exactly what a stop or the run limit interrupts while a person decides): it is closed
633
+ // with the cancellation, so the next message continues normally instead of asking to
634
+ // "recover" a call nobody needs to inspect.
635
+ if (!combined.aborted || p.tool?.effect === "read")
612
636
  o.store.endCall(sessionId, call, result);
613
637
  }
614
638
  emit("tool_completed", {
@@ -649,10 +673,11 @@ export class AgentRunner {
649
673
  }
650
674
  };
651
675
  const runTimeoutMs = options.timeoutMs ?? o.timeoutMs ?? 300_000;
652
- const timeout = AbortSignal.timeout(runTimeoutMs);
676
+ const clock = new RunClock(runTimeoutMs);
677
+ const unregisterClock = this.humanWaits.register(sessionId, clock);
653
678
  const combined = AbortSignal.any([
654
679
  controller.signal,
655
- timeout,
680
+ clock.signal,
656
681
  ...(options.signal ? [options.signal] : []),
657
682
  ]);
658
683
  const workspace = options.workspace ?? o.workspace;
@@ -730,6 +755,8 @@ export class AgentRunner {
730
755
  throw error;
731
756
  }
732
757
  finally {
758
+ unregisterClock();
759
+ clock.dispose();
733
760
  if (acquired)
734
761
  o.store.release(sessionId);
735
762
  this.active.delete(sessionId);
@@ -760,9 +787,14 @@ export class AgentRunner {
760
787
  /** Continuation notice and lowered effort for the request that follows a cut-off response. */
761
788
  let recoveryNotice;
762
789
  let recoveryEffort;
763
- const runStartedAt = Date.now();
764
- const timeout = AbortSignal.timeout(runTimeoutMs);
765
- const combined = AbortSignal.any([controller.signal, timeout, ...(signal ? [signal] : [])]);
790
+ // Active time only: waits for a person pause it (see `RunClock`).
791
+ const clock = new RunClock(runTimeoutMs);
792
+ const unregisterClock = this.humanWaits.register(sessionId, clock);
793
+ const combined = AbortSignal.any([
794
+ controller.signal,
795
+ clock.signal,
796
+ ...(signal ? [signal] : []),
797
+ ]);
766
798
  // What the run was doing, so a timeout can say where it was stuck (see `RunTimeoutError`).
767
799
  let stage = "other";
768
800
  let stageTool;
@@ -1006,7 +1038,7 @@ export class AgentRunner {
1006
1038
  }
1007
1039
  // Do not start an attempt the whole-run limit could not let finish.
1008
1040
  const retryPauseMs = FIRST_TOKEN_RETRY_PAUSE_MS;
1009
- if (runTimeoutMs - (Date.now() - runStartedAt) < retryPauseMs + firstTokenMs) {
1041
+ if (clock.remainingMs() < retryPauseMs + firstTokenMs) {
1010
1042
  if (failure !== undefined)
1011
1043
  throw failure;
1012
1044
  break;
@@ -1118,7 +1150,14 @@ export class AgentRunner {
1118
1150
  }
1119
1151
  emit("run_completed", final);
1120
1152
  finish("completed");
1121
- return { sessionId, text: lastText, status: "completed", usage: usageTotal, toolCalls };
1153
+ return {
1154
+ sessionId,
1155
+ text: lastText,
1156
+ status: "completed",
1157
+ usage: usageTotal,
1158
+ toolCalls,
1159
+ activeMs: clock.activeMs(),
1160
+ };
1122
1161
  }
1123
1162
  toolCalls += completion.calls.length;
1124
1163
  const prepared = completion.calls.map((call) => {
@@ -1209,7 +1248,14 @@ export class AgentRunner {
1209
1248
  // token budget (`maxTokens`) and the timeout; the turn count is a safety rail.
1210
1249
  emit("run_turns_exceeded", { turns: maxTurns, maxTurns });
1211
1250
  finish("turns_exceeded");
1212
- return { sessionId, text: lastText, status: "turns-exceeded", usage: usageTotal, toolCalls };
1251
+ return {
1252
+ sessionId,
1253
+ text: lastText,
1254
+ status: "turns-exceeded",
1255
+ usage: usageTotal,
1256
+ toolCalls,
1257
+ activeMs: clock.activeMs(),
1258
+ };
1213
1259
  }
1214
1260
  catch (error) {
1215
1261
  const timedOut = silentTimedOut
@@ -1228,7 +1274,11 @@ export class AgentRunner {
1228
1274
  });
1229
1275
  emit("run_failed", { error: failure.message, code: "timeout", timeout: failure.info });
1230
1276
  finish("failed", failure.message);
1231
- attachRunStats(failure, { usage: { ...usageTotal }, toolCalls });
1277
+ attachRunStats(failure, {
1278
+ usage: { ...usageTotal },
1279
+ toolCalls,
1280
+ activeMs: clock.activeMs(),
1281
+ });
1232
1282
  throw failure;
1233
1283
  }
1234
1284
  if (error instanceof RunTruncationError) {
@@ -1238,7 +1288,7 @@ export class AgentRunner {
1238
1288
  truncation: error.info,
1239
1289
  });
1240
1290
  finish("failed", error.message);
1241
- attachRunStats(error, { usage: { ...usageTotal }, toolCalls });
1291
+ attachRunStats(error, { usage: { ...usageTotal }, toolCalls, activeMs: clock.activeMs() });
1242
1292
  throw error;
1243
1293
  }
1244
1294
  // On cancellation report the abort reason, not the transport's secondary error.
@@ -1246,10 +1296,12 @@ export class AgentRunner {
1246
1296
  const message = cause instanceof Error ? cause.message : String(cause);
1247
1297
  emit(combined.aborted ? "run_cancelled" : "run_failed", { error: message });
1248
1298
  finish(combined.aborted ? "cancelled" : "failed", message);
1249
- attachRunStats(error, { usage: { ...usageTotal }, toolCalls });
1299
+ attachRunStats(error, { usage: { ...usageTotal }, toolCalls, activeMs: clock.activeMs() });
1250
1300
  throw error;
1251
1301
  }
1252
1302
  finally {
1303
+ unregisterClock();
1304
+ clock.dispose();
1253
1305
  if (acquired)
1254
1306
  o.store.release(sessionId);
1255
1307
  this.active.delete(sessionId);
@@ -48,7 +48,7 @@ export function describeTimeout(info) {
48
48
  return info.firstRequest
49
49
  ? `${who} did not respond within ${limit}: no tokens were received. ` +
50
50
  "Retry, switch to another model, or raise limits.timeoutMs."
51
- : `The run reached its ${limit} limit while waiting for ${info.model ? `the model ${info.model}` : "the model"}` +
51
+ : `The run reached its limit of ${limit} of active time (time spent waiting for you is not counted) while waiting for ${info.model ? `the model ${info.model}` : "the model"}` +
52
52
  `${info.provider ? ` (${info.provider})` : ""}. ` +
53
53
  "Everything produced so far is kept: prompt again to continue, switch model, or raise limits.timeoutMs.";
54
54
  const doing = info.stage === "tool" && info.tool
@@ -56,6 +56,6 @@ export function describeTimeout(info) {
56
56
  : info.stage === "streaming"
57
57
  ? "while the model was answering"
58
58
  : "";
59
- return (`The run reached its ${limit} limit${doing ? ` ${doing}` : ""}. ` +
59
+ return (`The run reached its limit of ${limit} of active time (time spent waiting for you is not counted)${doing ? ` ${doing}` : ""}. ` +
60
60
  "Everything produced so far is kept: prompt again to continue, or raise limits.timeoutMs.");
61
61
  }
@@ -0,0 +1,23 @@
1
+ import type { RunEventDataMap, ToolDecisions } from "@alisio/sdk";
2
+ import type { DecisionOutcome, DecisionService } from "./service.ts";
3
+ /** Reports one decision event of the run that owns the call (never persisted by the caller). */
4
+ export type DecisionAnnouncer = <K extends "decision_completed" | "decision_fallback">(type: K, data: RunEventDataMap[K]) => void;
5
+ /**
6
+ * The event of one outcome. Fields are copied one by one on purpose (ADR-7): the payload is
7
+ * metadata only, so nothing that rides along on the outcome can reach a persisted row.
8
+ */
9
+ export declare function outcomeEvent(outcome: DecisionOutcome): {
10
+ type: "decision_completed";
11
+ data: RunEventDataMap["decision_completed"];
12
+ } | {
13
+ type: "decision_fallback";
14
+ data: RunEventDataMap["decision_fallback"];
15
+ };
16
+ /**
17
+ * `ToolContext.decisions` of one tool call (ADR-8): the consumer surface of the service, bound to
18
+ * the run. Metrics are attributed to the session; the event is announced only while
19
+ * `decisions.telemetry` is on (read per call, so a live change applies at once).
20
+ */
21
+ export declare function bindDecisions(service: DecisionService, telemetry: () => boolean, call: {
22
+ sessionId: string;
23
+ }, announce: DecisionAnnouncer): ToolDecisions;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The event of one outcome. Fields are copied one by one on purpose (ADR-7): the payload is
3
+ * metadata only, so nothing that rides along on the outcome can reach a persisted row.
4
+ */
5
+ export function outcomeEvent(outcome) {
6
+ const identity = {
7
+ decisionId: outcome.decisionId,
8
+ ...(outcome.pack ? { pack: { id: outcome.pack.id, version: outcome.pack.version } } : {}),
9
+ provider: outcome.provider,
10
+ latencyMs: outcome.latencyMs,
11
+ };
12
+ if (outcome.status === "completed")
13
+ return {
14
+ type: "decision_completed",
15
+ data: {
16
+ ...identity,
17
+ decisionCount: outcome.decisionCount,
18
+ rejectedCount: outcome.rejectedCount,
19
+ confidenceMin: outcome.confidenceMin,
20
+ },
21
+ };
22
+ return { type: "decision_fallback", data: { ...identity, reason: outcome.reason } };
23
+ }
24
+ /**
25
+ * `ToolContext.decisions` of one tool call (ADR-8): the consumer surface of the service, bound to
26
+ * the run. Metrics are attributed to the session; the event is announced only while
27
+ * `decisions.telemetry` is on (read per call, so a live change applies at once).
28
+ */
29
+ export function bindDecisions(service, telemetry, call, announce) {
30
+ return {
31
+ available: () => service.available(),
32
+ activeProvider: () => service.activeProvider(),
33
+ async tryDecide(req, opts) {
34
+ const { response, outcome } = await service.attempt(req, opts, { sessionId: call.sessionId });
35
+ if (outcome && telemetry()) {
36
+ const event = outcomeEvent(outcome);
37
+ // The observer path never invalidates a decision that already happened.
38
+ try {
39
+ announce(event.type, event.data);
40
+ }
41
+ catch {
42
+ /* The consumer still gets its answer. */
43
+ }
44
+ }
45
+ return response;
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,35 @@
1
+ export type CircuitState = "closed" | "open" | "half-open";
2
+ export interface CircuitBreakerOptions {
3
+ /** Consecutive counted failures that open the circuit. */
4
+ threshold?: number;
5
+ /** How long the circuit stays open before a single probe is allowed. */
6
+ openMs?: number;
7
+ now?: () => number;
8
+ }
9
+ /**
10
+ * Circuit breaker with an injected clock. The caller reports only the failures that count (the
11
+ * service maps provider errors to `failure()`, `success()` or `neutral()`):
12
+ * - `closed`: calls flow; `threshold` consecutive failures open it.
13
+ * - `open`: `allow()` answers "open" until `openMs` pass, then the next caller is the single probe.
14
+ * - `half-open`: one probe in flight; concurrent callers get "open". The probe's `success()`
15
+ * closes the circuit and its `failure()` re-opens it for another `openMs`.
16
+ * `neutral()` ends a call that says nothing about the provider's health (a fast `not_ready`, a
17
+ * caller abort): it releases the probe without counting or resetting anything.
18
+ */
19
+ export declare class CircuitBreaker {
20
+ private readonly threshold;
21
+ private readonly openMs;
22
+ private readonly now;
23
+ private failures;
24
+ private openedAt;
25
+ private probing;
26
+ constructor(options?: CircuitBreakerOptions);
27
+ /** Asks to run one call. "half-open" means the caller IS the single probe. */
28
+ allow(): CircuitState;
29
+ success(): void;
30
+ failure(): void;
31
+ neutral(): void;
32
+ reset(): void;
33
+ /** Read-only view for diagnostics; it never consumes the probe. */
34
+ state(): CircuitState;
35
+ }