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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.102",
3
+ "version": "3.0.0-alpha.103",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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(`State key "${key}" is not read by this screen, so it is not part of its surface.`);
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 { type AgentProgressReport } from "./AgentProgress.d.ts";
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 { AgentAbort } from "./AgentAbort";
2
- import { AgentProgress, type AgentProgressReport } from "./AgentProgress";
1
+ import type { AgentProgressReport } from "./AgentProgress";
3
2
  import { Compaction, type CompactOptions } from "./Compaction";
4
- import { ToolOutput } from "./ToolOutput";
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(call: ToolCallRequest, message: string, signal: AbortSignal): Promise<true | string> {
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
- callId: call.id,
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
+ }
@@ -11,6 +11,7 @@ export * from "./httpRunner";
11
11
  export * from "./sharedContext";
12
12
  export * from "./surfaceContext";
13
13
  export * from "./ToolOutput";
14
+ export * from "./ToolRunner";
14
15
  export * from "./Transcript";
15
16
  export * from "./types";
16
17
  export * from "./useAgent";