akanjs 3.0.0-alpha.102 → 3.0.0-alpha.103
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/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/signal/serializer/fetch.serializer.ts +1 -1
- package/store/agent/AgentBridge.ts +10 -2
- package/types/vendor/use-agentic/AgentSession.d.ts +1 -1
- package/types/vendor/use-agentic/ToolRunner.d.ts +70 -0
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/vendor/use-agentic/AgentSession.ts +20 -91
- package/vendor/use-agentic/ToolRunner.ts +174 -0
- package/vendor/use-agentic/index.ts +1 -0
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -3,7 +3,6 @@ import { Logger } from "akanjs/common";
|
|
|
3
3
|
import { ConstantRegistry, type ConstantType } from "akanjs/constant";
|
|
4
4
|
import { type FilterArgInfo, getFilterArgInfos, getFilterMeta } from "akanjs/document";
|
|
5
5
|
import type { LiveRegistry } from "akanjs/service";
|
|
6
|
-
import { refusesAgents } from "../guard";
|
|
7
6
|
import type {
|
|
8
7
|
ArgInfo,
|
|
9
8
|
EndpointArgProps,
|
|
@@ -19,6 +18,7 @@ import type {
|
|
|
19
18
|
SliceCls,
|
|
20
19
|
SliceInfo,
|
|
21
20
|
} from "akanjs/signal";
|
|
21
|
+
import { refusesAgents } from "../guard";
|
|
22
22
|
|
|
23
23
|
export class FetchSerializer {
|
|
24
24
|
static logger = new Logger("FetchSerializer");
|
|
@@ -61,9 +61,11 @@ export class AgentBridge {
|
|
|
61
61
|
*/
|
|
62
62
|
read(key: string, viewKey = ""): unknown {
|
|
63
63
|
const entry = this.#state[key];
|
|
64
|
-
if (!entry) throw new Error(`Unknown state key: ${key}`);
|
|
64
|
+
if (!entry) throw new Error(`Unknown state key: ${key}.${this.#readableInstead(viewKey)}`);
|
|
65
65
|
if (!this.#instance.liveKeysIn(viewKey).has(key))
|
|
66
|
-
throw new Error(
|
|
66
|
+
throw new Error(
|
|
67
|
+
`State key "${key}" is not read by this screen, so it is not part of its surface.${this.#readableInstead(viewKey)}`,
|
|
68
|
+
);
|
|
67
69
|
const value = AgentBridge.#unwrap(this.#instance.get()[key]);
|
|
68
70
|
if (entry.refName && entry.modelType) {
|
|
69
71
|
const model = ConstantRegistry.getModelRef(entry.refName, entry.modelType) as MaskModel;
|
|
@@ -75,6 +77,12 @@ export class AgentBridge {
|
|
|
75
77
|
);
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/** Named the way `readScreen` and `highlight` name theirs: a refusal with no way forward costs a whole turn. */
|
|
81
|
+
#readableInstead(viewKey: string): string {
|
|
82
|
+
const keys = this.readableKeys(viewKey);
|
|
83
|
+
return keys.length ? ` Readable here: ${keys.join(", ")}.` : " This screen reads no state keys.";
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
static #unwrap(value: unknown) {
|
|
79
87
|
return value instanceof DataList ? value.values : value;
|
|
80
88
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AgentProgressReport } from "./AgentProgress.d.ts";
|
|
2
2
|
import { type CompactOptions } from "./Compaction.d.ts";
|
|
3
3
|
import type { AgentRunner, ChatMessage, ContextBlock, PublishedTool, SurfaceView } from "./types.d.ts";
|
|
4
4
|
export interface PendingApproval {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type AgentProgressReport } from "./AgentProgress.d.ts";
|
|
2
|
+
import type { SurfaceView, ToolCallRequest, ToolCallResult, ToolEntry } from "./types.d.ts";
|
|
3
|
+
export interface ToolApprovalRequest {
|
|
4
|
+
callId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
args: Record<string, unknown>;
|
|
7
|
+
/** What the user is asked, already resolved from the entry's `confirm`. */
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
/** `report` is `null` once the call is over, carrying the id so a host can ignore a clear that is not its own. */
|
|
11
|
+
export interface ToolProgress {
|
|
12
|
+
callId: string;
|
|
13
|
+
report: AgentProgressReport | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What a consumer of the runner has to answer for. Every field is optional and every omission is a narrowing,
|
|
17
|
+
* never a widening: a host that cannot ask the user refuses the calls that need asking.
|
|
18
|
+
*/
|
|
19
|
+
export interface ToolRunnerHost {
|
|
20
|
+
/**
|
|
21
|
+
* Parks the call until the user decides — `true` runs it, a string is the refusal the model reads.
|
|
22
|
+
*
|
|
23
|
+
* Omitting it does **not** run the tool unasked. A tool reaches this only when its own declaration said a
|
|
24
|
+
* person has to agree first (`confirm`, or a `remove*` name), so a host with nowhere to render the question is
|
|
25
|
+
* a host that may not perform the action, and the refusal says so rather than silently downgrading the gate.
|
|
26
|
+
*/
|
|
27
|
+
approve?: (request: ToolApprovalRequest, signal: AbortSignal) => Promise<true | string>;
|
|
28
|
+
/**
|
|
29
|
+
* Awaited after a tool that changed something and before its change report is taken. A surface is read
|
|
30
|
+
* synchronously and a screen does not settle synchronously, so without this the report describes the moment
|
|
31
|
+
* before the change landed.
|
|
32
|
+
*/
|
|
33
|
+
settle?: () => Promise<void> | void;
|
|
34
|
+
progress?: (progress: ToolProgress) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Answers a name the surface does not carry — where a consumer puts a built-in of its own. Reached only after
|
|
37
|
+
* the surface came up empty, so a registered tool of the same name shadows it.
|
|
38
|
+
*/
|
|
39
|
+
fallback?: (call: ToolCallRequest, signal: AbortSignal) => Promise<ToolCallResult> | ToolCallResult;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One tool call, start to finish: look the name up, gate it on the user, run it, wait for the screen, report what
|
|
43
|
+
* changed, and bound what all of that may add to a transcript.
|
|
44
|
+
*
|
|
45
|
+
* It lives apart from `AgentSession` because the pipeline is not the conversation's — the same six steps have to
|
|
46
|
+
* happen for any caller that drives this surface, and the steps that can be skipped are the ones that must not
|
|
47
|
+
* be: an approval, a settle, a change report and an output ceiling are each invisible by absence. A second
|
|
48
|
+
* consumer assembling them again would not fail, it would quietly do less, which is how the session and a zone
|
|
49
|
+
* came to disagree about which options they honoured.
|
|
50
|
+
*/
|
|
51
|
+
export declare class ToolRunner {
|
|
52
|
+
#private;
|
|
53
|
+
constructor(surface: SurfaceView, host?: ToolRunnerHost);
|
|
54
|
+
/** Never throws: a refused guard, a declined approval and an unknown name are all results the model reads. */
|
|
55
|
+
run(call: ToolCallRequest, signal: AbortSignal): Promise<ToolCallResult>;
|
|
56
|
+
/**
|
|
57
|
+
* The call, or the abort — whichever lands first.
|
|
58
|
+
*
|
|
59
|
+
* A tool is handed the signal through `AgentAbort` and may stop itself, but nothing obliges it to, and a tool
|
|
60
|
+
* that waits on a two-minute job is exactly the one a user reaches for Stop during. Without this race the
|
|
61
|
+
* caller stays parked inside the call for those two minutes with the chat still showing a turn in flight.
|
|
62
|
+
*
|
|
63
|
+
* The losing promise is left running rather than cancelled: the work is usually a job a server is already
|
|
64
|
+
* doing, and throwing away a result that is about to land helps nobody. Both of its outcomes are handled here,
|
|
65
|
+
* so a late failure settles nothing instead of surfacing as an unhandled rejection.
|
|
66
|
+
*/
|
|
67
|
+
static raced<T>(work: Promise<T>, signal: AbortSignal): Promise<T>;
|
|
68
|
+
/** `null` when this call needs no asking — an absent `confirm`, or one that decided against it per arguments. */
|
|
69
|
+
static confirmMessage(name: string, entry: ToolEntry, args: Record<string, unknown>): string | null;
|
|
70
|
+
}
|
|
@@ -10,6 +10,7 @@ export * from "./httpRunner.d.ts";
|
|
|
10
10
|
export * from "./sharedContext.d.ts";
|
|
11
11
|
export * from "./surfaceContext.d.ts";
|
|
12
12
|
export * from "./ToolOutput.d.ts";
|
|
13
|
+
export * from "./ToolRunner.d.ts";
|
|
13
14
|
export * from "./Transcript.d.ts";
|
|
14
15
|
export * from "./types.d.ts";
|
|
15
16
|
export * from "./useAgent.d.ts";
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { AgentProgress, type AgentProgressReport } from "./AgentProgress";
|
|
1
|
+
import type { AgentProgressReport } from "./AgentProgress";
|
|
3
2
|
import { Compaction, type CompactOptions } from "./Compaction";
|
|
4
|
-
import {
|
|
3
|
+
import { type ToolApprovalRequest, ToolRunner } from "./ToolRunner";
|
|
5
4
|
import { Transcript } from "./Transcript";
|
|
6
5
|
import type {
|
|
7
6
|
AgentRunner,
|
|
@@ -12,7 +11,6 @@ import type {
|
|
|
12
11
|
SurfaceView,
|
|
13
12
|
ToolCallRequest,
|
|
14
13
|
ToolCallResult,
|
|
15
|
-
ToolEntry,
|
|
16
14
|
} from "./types";
|
|
17
15
|
|
|
18
16
|
export interface PendingApproval {
|
|
@@ -127,6 +125,7 @@ export class AgentSession {
|
|
|
127
125
|
readonly #surface: SurfaceView;
|
|
128
126
|
readonly #runner: AgentRunner;
|
|
129
127
|
readonly #options: AgentSessionOptions;
|
|
128
|
+
readonly #tools: ToolRunner;
|
|
130
129
|
#messages: ChatMessage[] = [];
|
|
131
130
|
#running = false;
|
|
132
131
|
#pending: PendingApproval | null = null;
|
|
@@ -150,6 +149,20 @@ export class AgentSession {
|
|
|
150
149
|
this.#surface = surface;
|
|
151
150
|
this.#runner = runner;
|
|
152
151
|
this.#options = options;
|
|
152
|
+
this.#tools = new ToolRunner(surface, {
|
|
153
|
+
approve: (request, signal) => this.#awaitApproval(request, signal),
|
|
154
|
+
settle: () => this.#options.settle?.(),
|
|
155
|
+
progress: ({ callId, report }) => {
|
|
156
|
+
if (report) this.#progress = { ...report, callId };
|
|
157
|
+
else if (this.#progress?.callId === callId) this.#progress = null;
|
|
158
|
+
else return;
|
|
159
|
+
this.#notify();
|
|
160
|
+
},
|
|
161
|
+
fallback: async (call, signal) =>
|
|
162
|
+
call.name === AgentSession.askUserTool.name
|
|
163
|
+
? await this.#ask(call, signal)
|
|
164
|
+
: { id: call.id, name: call.name, error: `Unknown tool: ${call.name}` },
|
|
165
|
+
});
|
|
153
166
|
this.#history = options.history;
|
|
154
167
|
this.#onCompact = options.onCompact;
|
|
155
168
|
const restored = AgentSession.#restored(options.history);
|
|
@@ -256,9 +269,7 @@ export class AgentSession {
|
|
|
256
269
|
toolResults.push(
|
|
257
270
|
controller.signal.aborted
|
|
258
271
|
? { id: call.id, name: call.name, error: Transcript.unanswered }
|
|
259
|
-
:
|
|
260
|
-
|
|
261
|
-
ToolOutput.clipped(await this.#execute(call, controller.signal)),
|
|
272
|
+
: await this.#tools.run(call, controller.signal),
|
|
262
273
|
);
|
|
263
274
|
this.#append({ role: "tool", toolResults });
|
|
264
275
|
}
|
|
@@ -495,47 +506,6 @@ export class AgentSession {
|
|
|
495
506
|
return { toolCalls, stop };
|
|
496
507
|
}
|
|
497
508
|
|
|
498
|
-
async #execute(call: ToolCallRequest, signal: AbortSignal): Promise<ToolCallResult> {
|
|
499
|
-
const base = { id: call.id, name: call.name };
|
|
500
|
-
const entry = this.#surface.tool(call.name);
|
|
501
|
-
if (!entry) {
|
|
502
|
-
if (call.name === AgentSession.askUserTool.name) return await this.#ask(call, signal);
|
|
503
|
-
return { ...base, error: `Unknown tool: ${call.name}` };
|
|
504
|
-
}
|
|
505
|
-
const message = AgentSession.#confirmMessage(call.name, entry, call.args);
|
|
506
|
-
if (message) {
|
|
507
|
-
const approved = await this.#awaitApproval(call, message, signal);
|
|
508
|
-
if (approved !== true) return { ...base, error: approved };
|
|
509
|
-
}
|
|
510
|
-
const before = this.#surface.snapshot();
|
|
511
|
-
try {
|
|
512
|
-
const result = await AgentAbort.run(signal, () =>
|
|
513
|
-
AgentProgress.run(
|
|
514
|
-
(report) => {
|
|
515
|
-
this.#progress = { ...report, callId: call.id };
|
|
516
|
-
this.#notify();
|
|
517
|
-
},
|
|
518
|
-
() => AgentSession.#raced(this.#surface.call(call.name, call.args), signal),
|
|
519
|
-
),
|
|
520
|
-
);
|
|
521
|
-
|
|
522
|
-
if (entry.settle !== false) await this.#options.settle?.();
|
|
523
|
-
const changes = this.#surface.diffSince(before);
|
|
524
|
-
return {
|
|
525
|
-
...base,
|
|
526
|
-
...(result !== undefined ? { result } : {}),
|
|
527
|
-
...(changes.length ? { changes } : {}),
|
|
528
|
-
};
|
|
529
|
-
} catch (error) {
|
|
530
|
-
return { ...base, error: error instanceof Error ? error.message : String(error) };
|
|
531
|
-
} finally {
|
|
532
|
-
if (this.#progress?.callId === call.id) {
|
|
533
|
-
this.#progress = null;
|
|
534
|
-
this.#notify();
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
509
|
/** `null` when the user declined or no ask is configured: both mean stop and record why. */
|
|
540
510
|
async #askToContinue(turns: number, signal: AbortSignal): Promise<string | null> {
|
|
541
511
|
const ask = this.#options.continueAsk?.();
|
|
@@ -591,7 +561,7 @@ export class AgentSession {
|
|
|
591
561
|
});
|
|
592
562
|
}
|
|
593
563
|
|
|
594
|
-
#awaitApproval(
|
|
564
|
+
#awaitApproval(request: ToolApprovalRequest, signal: AbortSignal): Promise<true | string> {
|
|
595
565
|
return new Promise((resolve) => {
|
|
596
566
|
const settle = (value: true | string) => {
|
|
597
567
|
this.#pending = null;
|
|
@@ -602,10 +572,7 @@ export class AgentSession {
|
|
|
602
572
|
const onAbort = () => settle("The user aborted the turn.");
|
|
603
573
|
signal.addEventListener("abort", onAbort);
|
|
604
574
|
this.#pending = {
|
|
605
|
-
|
|
606
|
-
name: call.name,
|
|
607
|
-
args: call.args,
|
|
608
|
-
message,
|
|
575
|
+
...request,
|
|
609
576
|
approve: () => settle(true),
|
|
610
577
|
reject: (reason) => settle(reason ?? "The user declined."),
|
|
611
578
|
};
|
|
@@ -613,44 +580,6 @@ export class AgentSession {
|
|
|
613
580
|
});
|
|
614
581
|
}
|
|
615
582
|
|
|
616
|
-
/**
|
|
617
|
-
* The call, or the abort — whichever lands first.
|
|
618
|
-
*
|
|
619
|
-
* A tool is handed the signal through `AgentAbort` and may stop itself, but nothing obliges it to, and a tool
|
|
620
|
-
* that waits on a two-minute job is exactly the one a user reaches for Stop during. Without this race the loop
|
|
621
|
-
* stays parked inside the call for those two minutes with the chat still showing a turn in flight.
|
|
622
|
-
*
|
|
623
|
-
* The losing promise is left running rather than cancelled: the work is usually a job a server is already
|
|
624
|
-
* doing, and throwing away a result that is about to land helps nobody. Both of its outcomes are handled here,
|
|
625
|
-
* so a late failure settles nothing instead of surfacing as an unhandled rejection.
|
|
626
|
-
*/
|
|
627
|
-
static #raced<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
628
|
-
return new Promise<T>((resolve, reject) => {
|
|
629
|
-
const onAbort = () => reject(new Error("The user aborted the turn."));
|
|
630
|
-
work.then(
|
|
631
|
-
(value) => {
|
|
632
|
-
signal.removeEventListener("abort", onAbort);
|
|
633
|
-
resolve(value);
|
|
634
|
-
},
|
|
635
|
-
(error: unknown) => {
|
|
636
|
-
signal.removeEventListener("abort", onAbort);
|
|
637
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
638
|
-
},
|
|
639
|
-
);
|
|
640
|
-
if (signal.aborted) onAbort();
|
|
641
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
static #confirmMessage(name: string, entry: ToolEntry, args: Record<string, unknown>): string | null {
|
|
646
|
-
const confirm = entry.confirm;
|
|
647
|
-
if (confirm === undefined || confirm === false) return null;
|
|
648
|
-
if (typeof confirm === "string") return confirm;
|
|
649
|
-
const verdict = confirm === true ? true : confirm(args);
|
|
650
|
-
if (verdict === false) return null;
|
|
651
|
-
return verdict === true ? `Run ${name}?` : verdict;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
583
|
static #defaultContext(surface: SurfaceView): ContextBlock[] {
|
|
655
584
|
const { resources, scopes } = surface.snapshot();
|
|
656
585
|
return [
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { AgentAbort } from "./AgentAbort";
|
|
2
|
+
import { AgentProgress, type AgentProgressReport } from "./AgentProgress";
|
|
3
|
+
import { ToolOutput } from "./ToolOutput";
|
|
4
|
+
import type { SurfaceView, ToolCallRequest, ToolCallResult, ToolEntry } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface ToolApprovalRequest {
|
|
7
|
+
callId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
args: Record<string, unknown>;
|
|
10
|
+
/** What the user is asked, already resolved from the entry's `confirm`. */
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `report` is `null` once the call is over, carrying the id so a host can ignore a clear that is not its own. */
|
|
15
|
+
export interface ToolProgress {
|
|
16
|
+
callId: string;
|
|
17
|
+
report: AgentProgressReport | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a consumer of the runner has to answer for. Every field is optional and every omission is a narrowing,
|
|
22
|
+
* never a widening: a host that cannot ask the user refuses the calls that need asking.
|
|
23
|
+
*/
|
|
24
|
+
export interface ToolRunnerHost {
|
|
25
|
+
/**
|
|
26
|
+
* Parks the call until the user decides — `true` runs it, a string is the refusal the model reads.
|
|
27
|
+
*
|
|
28
|
+
* Omitting it does **not** run the tool unasked. A tool reaches this only when its own declaration said a
|
|
29
|
+
* person has to agree first (`confirm`, or a `remove*` name), so a host with nowhere to render the question is
|
|
30
|
+
* a host that may not perform the action, and the refusal says so rather than silently downgrading the gate.
|
|
31
|
+
*/
|
|
32
|
+
approve?: (request: ToolApprovalRequest, signal: AbortSignal) => Promise<true | string>;
|
|
33
|
+
/**
|
|
34
|
+
* Awaited after a tool that changed something and before its change report is taken. A surface is read
|
|
35
|
+
* synchronously and a screen does not settle synchronously, so without this the report describes the moment
|
|
36
|
+
* before the change landed.
|
|
37
|
+
*/
|
|
38
|
+
settle?: () => Promise<void> | void;
|
|
39
|
+
progress?: (progress: ToolProgress) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Answers a name the surface does not carry — where a consumer puts a built-in of its own. Reached only after
|
|
42
|
+
* the surface came up empty, so a registered tool of the same name shadows it.
|
|
43
|
+
*/
|
|
44
|
+
fallback?: (call: ToolCallRequest, signal: AbortSignal) => Promise<ToolCallResult> | ToolCallResult;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One tool call, start to finish: look the name up, gate it on the user, run it, wait for the screen, report what
|
|
49
|
+
* changed, and bound what all of that may add to a transcript.
|
|
50
|
+
*
|
|
51
|
+
* It lives apart from `AgentSession` because the pipeline is not the conversation's — the same six steps have to
|
|
52
|
+
* happen for any caller that drives this surface, and the steps that can be skipped are the ones that must not
|
|
53
|
+
* be: an approval, a settle, a change report and an output ceiling are each invisible by absence. A second
|
|
54
|
+
* consumer assembling them again would not fail, it would quietly do less, which is how the session and a zone
|
|
55
|
+
* came to disagree about which options they honoured.
|
|
56
|
+
*/
|
|
57
|
+
export class ToolRunner {
|
|
58
|
+
/**
|
|
59
|
+
* Tool execution is serialized across every runner in the page, because `AgentAbort` and `AgentProgress` reach
|
|
60
|
+
* the running call through a module slot rather than a parameter — two calls in flight restore each other's
|
|
61
|
+
* slot and the second one's progress goes nowhere. Two agents on one screen (a zone and the root chat) are
|
|
62
|
+
* already two callers, so the invariant those slots assume has to be kept somewhere they both pass through.
|
|
63
|
+
*
|
|
64
|
+
* A call that neither settles nor aborts holds the queue; every consumer races the call against its own abort
|
|
65
|
+
* signal, and an abort releases it. Approval waits outside the queue on purpose — a question parked in front of
|
|
66
|
+
* the user is not work, and holding the lock across it would let one agent's unanswered card freeze the other's.
|
|
67
|
+
*/
|
|
68
|
+
static #queue: Promise<void> = Promise.resolve();
|
|
69
|
+
|
|
70
|
+
readonly #surface: SurfaceView;
|
|
71
|
+
readonly #host: ToolRunnerHost;
|
|
72
|
+
|
|
73
|
+
constructor(surface: SurfaceView, host: ToolRunnerHost = {}) {
|
|
74
|
+
this.#surface = surface;
|
|
75
|
+
this.#host = host;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Never throws: a refused guard, a declined approval and an unknown name are all results the model reads. */
|
|
79
|
+
async run(call: ToolCallRequest, signal: AbortSignal): Promise<ToolCallResult> {
|
|
80
|
+
|
|
81
|
+
return ToolOutput.clipped(await this.#answer(call, signal));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async #answer(call: ToolCallRequest, signal: AbortSignal): Promise<ToolCallResult> {
|
|
85
|
+
const base = { id: call.id, name: call.name };
|
|
86
|
+
const entry = this.#surface.tool(call.name);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
const fallback = this.#host.fallback;
|
|
89
|
+
return fallback ? await fallback(call, signal) : { ...base, error: `Unknown tool: ${call.name}` };
|
|
90
|
+
}
|
|
91
|
+
const message = ToolRunner.confirmMessage(call.name, entry, call.args);
|
|
92
|
+
if (message) {
|
|
93
|
+
const approve = this.#host.approve;
|
|
94
|
+
if (!approve)
|
|
95
|
+
return { ...base, error: `${call.name} needs the user's approval, and nothing here can ask them for it.` };
|
|
96
|
+
const approved = await approve({ callId: call.id, name: call.name, args: call.args, message }, signal);
|
|
97
|
+
if (approved !== true) return { ...base, error: approved };
|
|
98
|
+
}
|
|
99
|
+
return await ToolRunner.#serialized(() => this.#execute(call, entry, signal));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async #execute(call: ToolCallRequest, entry: ToolEntry, signal: AbortSignal): Promise<ToolCallResult> {
|
|
103
|
+
const base = { id: call.id, name: call.name };
|
|
104
|
+
const before = this.#surface.snapshot();
|
|
105
|
+
try {
|
|
106
|
+
const result = await AgentAbort.run(signal, () =>
|
|
107
|
+
AgentProgress.run(
|
|
108
|
+
(report) => this.#host.progress?.({ callId: call.id, report }),
|
|
109
|
+
() => ToolRunner.raced(this.#surface.call(call.name, call.args), signal),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (entry.settle !== false) await this.#host.settle?.();
|
|
114
|
+
const changes = this.#surface.diffSince(before);
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
...(result !== undefined ? { result } : {}),
|
|
118
|
+
...(changes.length ? { changes } : {}),
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { ...base, error: error instanceof Error ? error.message : String(error) };
|
|
122
|
+
} finally {
|
|
123
|
+
this.#host.progress?.({ callId: call.id, report: null });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static #serialized<T>(work: () => Promise<T>): Promise<T> {
|
|
128
|
+
const run = ToolRunner.#queue.then(work);
|
|
129
|
+
ToolRunner.#queue = run.then(
|
|
130
|
+
() => undefined,
|
|
131
|
+
() => undefined,
|
|
132
|
+
);
|
|
133
|
+
return run;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The call, or the abort — whichever lands first.
|
|
138
|
+
*
|
|
139
|
+
* A tool is handed the signal through `AgentAbort` and may stop itself, but nothing obliges it to, and a tool
|
|
140
|
+
* that waits on a two-minute job is exactly the one a user reaches for Stop during. Without this race the
|
|
141
|
+
* caller stays parked inside the call for those two minutes with the chat still showing a turn in flight.
|
|
142
|
+
*
|
|
143
|
+
* The losing promise is left running rather than cancelled: the work is usually a job a server is already
|
|
144
|
+
* doing, and throwing away a result that is about to land helps nobody. Both of its outcomes are handled here,
|
|
145
|
+
* so a late failure settles nothing instead of surfacing as an unhandled rejection.
|
|
146
|
+
*/
|
|
147
|
+
static raced<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
148
|
+
return new Promise<T>((resolve, reject) => {
|
|
149
|
+
const onAbort = () => reject(new Error("The user aborted the turn."));
|
|
150
|
+
work.then(
|
|
151
|
+
(value) => {
|
|
152
|
+
signal.removeEventListener("abort", onAbort);
|
|
153
|
+
resolve(value);
|
|
154
|
+
},
|
|
155
|
+
(error: unknown) => {
|
|
156
|
+
signal.removeEventListener("abort", onAbort);
|
|
157
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
if (signal.aborted) onAbort();
|
|
161
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `null` when this call needs no asking — an absent `confirm`, or one that decided against it per arguments. */
|
|
166
|
+
static confirmMessage(name: string, entry: ToolEntry, args: Record<string, unknown>): string | null {
|
|
167
|
+
const confirm = entry.confirm;
|
|
168
|
+
if (confirm === undefined || confirm === false) return null;
|
|
169
|
+
if (typeof confirm === "string") return confirm;
|
|
170
|
+
const verdict = confirm === true ? true : confirm(args);
|
|
171
|
+
if (verdict === false) return null;
|
|
172
|
+
return verdict === true ? `Run ${name}?` : verdict;
|
|
173
|
+
}
|
|
174
|
+
}
|