@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.
- package/dist/application.d.ts +7 -0
- package/dist/application.js +90 -6
- package/dist/changelog/data.js +36 -0
- package/dist/commands/builtins.js +6 -0
- package/dist/commands/catalog.d.ts +8 -1
- package/dist/commands/catalog.js +62 -0
- package/dist/config.d.ts +37 -3
- package/dist/config.js +109 -7
- package/dist/core/contracts.d.ts +10 -1
- package/dist/core/human-wait.d.ts +25 -0
- package/dist/core/human-wait.js +54 -0
- package/dist/core/run-clock.d.ts +47 -0
- package/dist/core/run-clock.js +111 -0
- package/dist/core/run-stats.d.ts +2 -0
- package/dist/core/runner.d.ts +15 -1
- package/dist/core/runner.js +70 -18
- package/dist/core/timeout.js +2 -2
- package/dist/decisions/bound.d.ts +23 -0
- package/dist/decisions/bound.js +48 -0
- package/dist/decisions/breaker.d.ts +35 -0
- package/dist/decisions/breaker.js +61 -0
- package/dist/decisions/format.d.ts +15 -0
- package/dist/decisions/format.js +89 -0
- package/dist/decisions/index.d.ts +7 -0
- package/dist/decisions/index.js +7 -0
- package/dist/decisions/metrics.d.ts +45 -0
- package/dist/decisions/metrics.js +79 -0
- package/dist/decisions/registry.d.ts +22 -0
- package/dist/decisions/registry.js +64 -0
- package/dist/decisions/service.d.ts +137 -0
- package/dist/decisions/service.js +377 -0
- package/dist/decisions/validate.d.ts +26 -0
- package/dist/decisions/validate.js +182 -0
- package/dist/goal/host.d.ts +2 -0
- package/dist/goal/host.js +2 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -0
- package/dist/plugins/host.d.ts +43 -2
- package/dist/plugins/host.js +111 -16
- package/package.json +2 -2
package/dist/core/contracts.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/run-stats.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/core/runner.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
611
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
const
|
|
765
|
-
const
|
|
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 (
|
|
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 {
|
|
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 {
|
|
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, {
|
|
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);
|
package/dist/core/timeout.js
CHANGED
|
@@ -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}
|
|
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}
|
|
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
|
+
}
|