akanjs 3.0.0-alpha.40 → 3.0.0-alpha.41
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/server/akanApp.ts +8 -0
- package/server/akanOption.ts +3 -3
- package/server/akanServer.ts +10 -2
- package/server/di/diLifecycle.ts +89 -10
- package/server/di/utils.ts +40 -0
- package/store/agent/StateWait.ts +139 -0
- package/store/agent/StoreSurfaceSource.ts +61 -0
- package/store/agent/index.ts +4 -0
- package/types/server/akanApp.d.ts +5 -0
- package/types/server/akanOption.d.ts +2 -1
- package/types/server/akanServer.d.ts +6 -0
- package/types/server/di/diLifecycle.d.ts +9 -1
- package/types/server/di/utils.d.ts +16 -0
- package/types/store/agent/StateWait.d.ts +36 -0
- package/types/store/agent/index.d.ts +2 -0
- package/types/vendor/use-agentic/AgentAbort.d.ts +17 -0
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/vendor/use-agentic/AgentAbort.ts +29 -0
- package/vendor/use-agentic/AgentSession.ts +38 -6
- package/vendor/use-agentic/index.ts +1 -0
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -54,6 +54,11 @@ export interface AkanAppOptions {
|
|
|
54
54
|
port?: number;
|
|
55
55
|
wsBasePort?: number;
|
|
56
56
|
openapi?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Boot only these modules and the ones they reach, in every child. Omitted or empty mounts every enabled
|
|
59
|
+
* module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
|
|
60
|
+
*/
|
|
61
|
+
modules?: string[];
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
interface AkanReplicaConfig {
|
|
@@ -86,6 +91,7 @@ export class AkanApp {
|
|
|
86
91
|
readonly #port: number;
|
|
87
92
|
readonly #wsBasePort: number;
|
|
88
93
|
readonly #openapi?: boolean;
|
|
94
|
+
readonly #modules: string[];
|
|
89
95
|
readonly #children = new Map<number, ChildState>();
|
|
90
96
|
readonly #roomChildren = new Map<string, Set<number>>();
|
|
91
97
|
readonly #childRooms = new Map<number, Set<string>>();
|
|
@@ -127,6 +133,7 @@ export class AkanApp {
|
|
|
127
133
|
this.#port = Number(resolvedOptions.port ?? process.env.PORT ?? 8282);
|
|
128
134
|
this.#wsBasePort = Number(resolvedOptions.wsBasePort ?? process.env.AKAN_WS_BASE_PORT ?? this.#port + 10_000);
|
|
129
135
|
this.#openapi = resolvedOptions.openapi;
|
|
136
|
+
this.#modules = resolvedOptions.modules ?? [];
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
static #resolveServerPath(serverPath: string) {
|
|
@@ -297,6 +304,7 @@ export class AkanApp {
|
|
|
297
304
|
AKAN_CHILD_SOCKET: upstream.http.socketPath,
|
|
298
305
|
AKAN_CHILD_WS_PORT: upstream.ws ? String(upstream.ws.port) : "",
|
|
299
306
|
...(this.#openapi === undefined ? {} : { AKAN_OPENAPI: this.#openapi ? "true" : "false" }),
|
|
307
|
+
...(this.#modules.length ? { AKAN_MODULES: this.#modules.join(",") } : {}),
|
|
300
308
|
},
|
|
301
309
|
ipc: (message) => this.#handleMessage(idx, message as AkanIpcMessage, proc),
|
|
302
310
|
stdout: "pipe",
|
package/server/akanOption.ts
CHANGED
|
@@ -68,9 +68,9 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
|
|
|
68
68
|
else this.#getLlms.push(() => llmOrFn);
|
|
69
69
|
return this;
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return Object.
|
|
71
|
+
/** Every entry in declaration order, duplicates kept: the boot stage rejects a key claimed twice. */
|
|
72
|
+
getUses(env: Env): [string, PromiseOrObject<unknown>][] {
|
|
73
|
+
return this.#getUses.flatMap((fn) => Object.entries(fn(env)));
|
|
74
74
|
}
|
|
75
75
|
getMiddlewares(): MiddlewareCls[] {
|
|
76
76
|
return this.#middlewares;
|
package/server/akanServer.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface AkanServerOptions {
|
|
|
41
41
|
openapi?: boolean;
|
|
42
42
|
/** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
|
|
43
43
|
mcp?: boolean | McpServerOption;
|
|
44
|
+
/**
|
|
45
|
+
* Boot only these modules and the ones they reach; every other module stays out of the container, so its
|
|
46
|
+
* services, signals, routes and schedules do not exist. Omitted or empty mounts every enabled module.
|
|
47
|
+
*/
|
|
48
|
+
modules?: string[];
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export interface McpServerOption {
|
|
@@ -122,6 +127,7 @@ export class AkanServer {
|
|
|
122
127
|
mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
|
|
123
128
|
mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
|
|
124
129
|
serverMode: "federation" | "batch" | "all";
|
|
130
|
+
modules: string[];
|
|
125
131
|
shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
|
|
126
132
|
|
|
127
133
|
#di: DiLifecycle;
|
|
@@ -152,7 +158,9 @@ export class AkanServer {
|
|
|
152
158
|
});
|
|
153
159
|
this.setMcp(options?.mcp ?? this.mcp);
|
|
154
160
|
this.serverMode = serverMode;
|
|
155
|
-
|
|
161
|
+
|
|
162
|
+
this.modules = options?.modules ?? AkanServer.#envList("AKAN_MODULES") ?? [];
|
|
163
|
+
this.#di = new DiLifecycle({ env: this.env, modules: this.modules }, ...libs);
|
|
156
164
|
}
|
|
157
165
|
setPrefix(prefix: string) {
|
|
158
166
|
this.prefix = prefix;
|
|
@@ -591,7 +599,7 @@ export class AkanServer {
|
|
|
591
599
|
!("database" in value) &&
|
|
592
600
|
!("service" in value) &&
|
|
593
601
|
!("scalar" in value) &&
|
|
594
|
-
("openapi" in value || "mcp" in value),
|
|
602
|
+
("openapi" in value || "mcp" in value || "modules" in value),
|
|
595
603
|
);
|
|
596
604
|
}
|
|
597
605
|
|
package/server/di/diLifecycle.ts
CHANGED
|
@@ -29,17 +29,29 @@ import { getPredefinedAdaptor, predefinedAdaptorRole } from "./predefinedAdaptor
|
|
|
29
29
|
import { collectAdaptors, resolveAdaptorHierarchy } from "./resolveAdaptorHierarchy";
|
|
30
30
|
import { resolveServiceHierarchy } from "./resolveServiceHierarchy";
|
|
31
31
|
import {
|
|
32
|
+
assertUniqueRegistrations,
|
|
32
33
|
type DiModuleCandidate,
|
|
34
|
+
getModuleCascadeRefNames,
|
|
33
35
|
getModuleDependencyRefNames,
|
|
34
36
|
isDestroyableUse,
|
|
35
37
|
normalizeAdaptorRefName,
|
|
36
38
|
normalizeServiceRefName,
|
|
37
39
|
normalizeSignalRefName,
|
|
40
|
+
type Registration,
|
|
38
41
|
reasonMessage,
|
|
39
42
|
runStage,
|
|
40
43
|
toError,
|
|
41
44
|
} from "./utils";
|
|
42
45
|
|
|
46
|
+
export interface DiLifecycleProps {
|
|
47
|
+
env: BackendEnv;
|
|
48
|
+
/**
|
|
49
|
+
* Boot only these modules and the ones they reach, leaving every other module out of the container. Omitted or
|
|
50
|
+
* empty mounts every module whose service is enabled.
|
|
51
|
+
*/
|
|
52
|
+
modules?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
/**
|
|
44
56
|
* Owns the app's DI container state (registry + live maps + init order) and
|
|
45
57
|
* encapsulates every init / destroy step. `AkanServer` delegates to this so the
|
|
@@ -88,7 +100,7 @@ export class DiLifecycle {
|
|
|
88
100
|
return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
constructor(env
|
|
103
|
+
constructor({ env, modules = [] }: DiLifecycleProps, ...libs: AkanLib[]) {
|
|
92
104
|
this.#env = env;
|
|
93
105
|
|
|
94
106
|
this.#predefinedAdaptor = { ...getPredefinedAdaptor(getEnv().databaseMode ?? "single") };
|
|
@@ -133,7 +145,7 @@ export class DiLifecycle {
|
|
|
133
145
|
this.#scalar.set(mod.constant.refName, mod);
|
|
134
146
|
});
|
|
135
147
|
});
|
|
136
|
-
const disabledModules = this.#resolveDisabledModules(databaseCandidates, serviceCandidates);
|
|
148
|
+
const disabledModules = this.#resolveDisabledModules(databaseCandidates, serviceCandidates, modules);
|
|
137
149
|
databaseCandidates.forEach(({ refName, module }) => {
|
|
138
150
|
if (disabledModules.has(refName)) return;
|
|
139
151
|
this.#database.set(refName, module as DatabaseModule);
|
|
@@ -146,23 +158,40 @@ export class DiLifecycle {
|
|
|
146
158
|
this.logger.info("agent relay is provided by a lib module — the framework's is skipped");
|
|
147
159
|
if (!this.#scalar.has("agentTurn"))
|
|
148
160
|
this.#scalar.set("agentTurn", { constant: agentTurnConstant, database: agentTurnDocument });
|
|
161
|
+
const adaptorClaims = new Map<string, AdaptorCls>();
|
|
162
|
+
const adaptorRegistrations: Registration[] = [];
|
|
163
|
+
|
|
164
|
+
const claimAdaptor = (adaptorCls: AdaptorCls, owner: string) => {
|
|
165
|
+
const claimed = adaptorClaims.get(adaptorCls.refName);
|
|
166
|
+
if (claimed === adaptorCls) return;
|
|
167
|
+
if (!claimed) adaptorClaims.set(adaptorCls.refName, adaptorCls);
|
|
168
|
+
adaptorRegistrations.push({ key: adaptorCls.refName, owner });
|
|
169
|
+
};
|
|
170
|
+
for (const [role, adaptorCls] of Object.entries(this.#predefinedAdaptor))
|
|
171
|
+
claimAdaptor(adaptorCls, `predefined adaptor "${role}"`);
|
|
149
172
|
this.#database.forEach((mod) => {
|
|
150
173
|
const { adaptor, schema } = DatabaseResolver.resolveDatabase(mod.constant, mod.database);
|
|
151
174
|
this.#adaptor.set(adaptor.refName, adaptor);
|
|
175
|
+
claimAdaptor(adaptor, `database module "${mod.constant.refName}"`);
|
|
152
176
|
this.#cascade.register(mod.constant, schema, mod.service.srv);
|
|
153
177
|
});
|
|
154
178
|
const services = [
|
|
155
179
|
...[...this.#service.values()].map((mod) => mod.service.srv),
|
|
156
180
|
...[...this.#database.values()].map((mod) => mod.service.srv),
|
|
157
181
|
];
|
|
158
|
-
for (const
|
|
159
|
-
|
|
182
|
+
for (const service of services) {
|
|
183
|
+
for (const adaptor of collectAdaptors([service])) {
|
|
184
|
+
this.#adaptor.set(adaptor.refName, adaptor);
|
|
185
|
+
claimAdaptor(adaptor, `service "${service.refName}"`);
|
|
186
|
+
}
|
|
160
187
|
}
|
|
188
|
+
assertUniqueRegistrations("adaptor", adaptorRegistrations);
|
|
161
189
|
}
|
|
162
190
|
|
|
163
191
|
#resolveDisabledModules(
|
|
164
192
|
databaseCandidates: Map<string, DiModuleCandidate>,
|
|
165
193
|
serviceCandidates: Map<string, DiModuleCandidate>,
|
|
194
|
+
modules: string[],
|
|
166
195
|
) {
|
|
167
196
|
const candidates = new Map<string, DiModuleCandidate>([...databaseCandidates, ...serviceCandidates]);
|
|
168
197
|
const disabledReasons = new Map<string, string>();
|
|
@@ -171,6 +200,14 @@ export class DiLifecycle {
|
|
|
171
200
|
if (!module.service.srv.enabled) disabledReasons.set(refName, "service disabled");
|
|
172
201
|
});
|
|
173
202
|
|
|
203
|
+
const selected = this.#resolveSelectedModules(candidates, modules);
|
|
204
|
+
if (selected) {
|
|
205
|
+
candidates.forEach(({ refName }) => {
|
|
206
|
+
if (!selected.has(refName) && !disabledReasons.has(refName))
|
|
207
|
+
disabledReasons.set(refName, 'not named by the "modules" option');
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
174
211
|
let changed = true;
|
|
175
212
|
while (changed) {
|
|
176
213
|
changed = false;
|
|
@@ -194,6 +231,42 @@ export class DiLifecycle {
|
|
|
194
231
|
return new Set(disabledReasons.keys());
|
|
195
232
|
}
|
|
196
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The named modules closed over everything they reach: the services and signals they inject, and the cascade
|
|
236
|
+
* edges whose absence fails `CascadeRunner.seal`. `null` means no selection was asked for.
|
|
237
|
+
*
|
|
238
|
+
* An unknown name is refused rather than ignored, because a typo would otherwise boot an app with the module
|
|
239
|
+
* silently missing — the one failure this option exists to make impossible.
|
|
240
|
+
*/
|
|
241
|
+
#resolveSelectedModules(candidates: Map<string, DiModuleCandidate>, modules: string[]) {
|
|
242
|
+
if (!modules.length) return null;
|
|
243
|
+
const known = new Set([...candidates.keys(), ...this.#service.keys()]);
|
|
244
|
+
const unknown = modules.filter((refName) => !known.has(refName));
|
|
245
|
+
if (unknown.length) {
|
|
246
|
+
const registered = [...known].sort((a, b) => a.localeCompare(b)).join(", ");
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[DI:modules] unknown module ${unknown.map((refName) => `"${refName}"`).join(", ")}. Registered: ${registered}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const selected = new Set<string>();
|
|
252
|
+
const pending = modules.filter((refName) => candidates.has(refName));
|
|
253
|
+
while (pending.length) {
|
|
254
|
+
const refName = pending.pop();
|
|
255
|
+
if (!refName || selected.has(refName)) continue;
|
|
256
|
+
selected.add(refName);
|
|
257
|
+
const candidate = candidates.get(refName);
|
|
258
|
+
if (!candidate) continue;
|
|
259
|
+
const dependencies = [
|
|
260
|
+
...getModuleDependencyRefNames(candidate.module),
|
|
261
|
+
...getModuleCascadeRefNames(candidate.module),
|
|
262
|
+
];
|
|
263
|
+
for (const dependency of dependencies) if (candidates.has(dependency)) pending.push(dependency);
|
|
264
|
+
}
|
|
265
|
+
const mounted = [...selected].sort((a, b) => a.localeCompare(b)).join(", ");
|
|
266
|
+
this.logger.info(`Mounting ${selected.size} of ${candidates.size} module(s): ${mounted}`);
|
|
267
|
+
return selected;
|
|
268
|
+
}
|
|
269
|
+
|
|
197
270
|
/** Run every init stage in dependency order and collect the generated routes. */
|
|
198
271
|
async initializeAll(): Promise<SignalRoutes> {
|
|
199
272
|
await this.#initializeUses();
|
|
@@ -393,14 +466,20 @@ export class DiLifecycle {
|
|
|
393
466
|
|
|
394
467
|
async #initializeUses() {
|
|
395
468
|
|
|
396
|
-
const
|
|
397
|
-
{
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
469
|
+
const entries = [
|
|
470
|
+
{
|
|
471
|
+
key: "llmOption",
|
|
472
|
+
owner: "the framework",
|
|
473
|
+
value: Object.assign({}, ...this.#libs.map((lib) => lib.option.getLlm(this.#env))) as unknown,
|
|
474
|
+
},
|
|
475
|
+
...this.#libs.flatMap((lib) =>
|
|
476
|
+
lib.option.getUses(this.#env).map(([key, value]) => ({ key, owner: `lib "${lib.name}"`, value })),
|
|
477
|
+
),
|
|
478
|
+
];
|
|
479
|
+
assertUniqueRegistrations("use", entries);
|
|
401
480
|
await runStage(
|
|
402
481
|
"uses",
|
|
403
|
-
entries.map((
|
|
482
|
+
entries.map(({ key, value }) => ({
|
|
404
483
|
label: `uses:${key}`,
|
|
405
484
|
run: async () => {
|
|
406
485
|
const useValue = value instanceof Promise ? await value : value;
|
package/server/di/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { INJECT_META } from "akanjs/base";
|
|
2
2
|
import { lowerlize } from "akanjs/common";
|
|
3
|
+
import { ConstantRegistry } from "akanjs/constant";
|
|
3
4
|
import type { InjectInfo } from "akanjs/service";
|
|
4
5
|
import type { DatabaseModule, ServiceModule } from "../akanLib";
|
|
5
6
|
|
|
@@ -67,6 +68,45 @@ export const getModuleDependencyRefNames = (mod: DatabaseModule | ServiceModule)
|
|
|
67
68
|
return dependencies;
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
|
|
73
|
+
* `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
|
|
74
|
+
* inject graph cannot see. A polymorphic owner list is exempt — it spans optional modules by design.
|
|
75
|
+
*/
|
|
76
|
+
export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) => {
|
|
77
|
+
const dependencies = new Set<string>();
|
|
78
|
+
if (!("constant" in mod)) return dependencies;
|
|
79
|
+
const { cascade } = mod.constant.full;
|
|
80
|
+
for (const modelRef of cascade.removeRef.values()) dependencies.add(ConstantRegistry.getRefName(modelRef));
|
|
81
|
+
for (const path of cascade.removeWith.values()) {
|
|
82
|
+
if (path.typeValues.length) continue;
|
|
83
|
+
dependencies.add(path.refName ?? ConstantRegistry.getRefName(path.modelRef as never));
|
|
84
|
+
}
|
|
85
|
+
return dependencies;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface Registration {
|
|
89
|
+
key: string;
|
|
90
|
+
/** What claimed the key, phrased for a boot error: `predefined adaptor "storage"`, `lib "shared"`. */
|
|
91
|
+
owner: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A key registered twice is silently last-write-wins everywhere downstream — an app that meant to add a second
|
|
96
|
+
* adaptor gets one, and the other's `onInit` never runs. Refuse the boot instead, naming both claimants.
|
|
97
|
+
*/
|
|
98
|
+
export const assertUniqueRegistrations = (kind: string, registrations: Registration[]) => {
|
|
99
|
+
const claimed = new Map<string, string>();
|
|
100
|
+
const clashes: string[] = [];
|
|
101
|
+
for (const { key, owner } of registrations) {
|
|
102
|
+
const previous = claimed.get(key);
|
|
103
|
+
if (previous) clashes.push(` • "${key}" is registered by ${previous} and by ${owner}`);
|
|
104
|
+
else claimed.set(key, owner);
|
|
105
|
+
}
|
|
106
|
+
if (!clashes.length) return;
|
|
107
|
+
throw new Error(`[DI:${kind}] ${clashes.length} duplicate registration(s):\n${clashes.join("\n")}`);
|
|
108
|
+
};
|
|
109
|
+
|
|
70
110
|
/**
|
|
71
111
|
* Run every task in parallel and, if any rejects, throw a single
|
|
72
112
|
* `AggregateError` that enumerates every failing label + cause. This replaces
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { AgentAbort, AgentProgress } from "../../vendor/use-agentic";
|
|
2
|
+
|
|
3
|
+
/** The reading half of the store an in-page wait needs. `AgentBridge` is one; a test can be another. */
|
|
4
|
+
export interface StateSource {
|
|
5
|
+
read(key: string, viewKey?: string): unknown;
|
|
6
|
+
subscribe(listener: () => void): () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StateWaitOptions {
|
|
10
|
+
key: string;
|
|
11
|
+
viewKey?: string;
|
|
12
|
+
/** Settle when the key reads exactly this. Null waits for it to change from whatever it holds now. */
|
|
13
|
+
equals?: string | null;
|
|
14
|
+
/** Straight off the tool argument, so it may be anything; `StateWait.seconds` is what makes it a number. */
|
|
15
|
+
seconds?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One `waitFor` call: park until a published state key settles, or until the timeout says how it is going.
|
|
20
|
+
*
|
|
21
|
+
* Two clocks, because neither covers the other. The store's own subscription catches the value changing, which is
|
|
22
|
+
* the whole point of the tool and has to land immediately. The tick catches what the store never announces —
|
|
23
|
+
* `retainLive` / `releaseLive` mutate the live-key map without notifying any listener, so a page navigated away
|
|
24
|
+
* from mid-wait would otherwise hold the turn until the timeout — and the countdown row needs a tick anyway.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here throws on a wait that ran out. A key still reading `generating` after two minutes is an answer, not
|
|
27
|
+
* a failure, and the model is the one that decides whether to wait again.
|
|
28
|
+
*/
|
|
29
|
+
export class StateWait {
|
|
30
|
+
static readonly tickMs = 1000;
|
|
31
|
+
static readonly defaultSeconds = 120;
|
|
32
|
+
static readonly maxSeconds = 600;
|
|
33
|
+
|
|
34
|
+
/** Clamped rather than refused: a model that asks for an hour gets the longest wait on offer and reads how long. */
|
|
35
|
+
static seconds(value: unknown): number {
|
|
36
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return StateWait.defaultSeconds;
|
|
37
|
+
return Math.min(Math.max(Math.round(value), 1), StateWait.maxSeconds);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A string is itself; everything else is JSON, so a number, a boolean and null each compare as they are written. */
|
|
41
|
+
static print(value: unknown): string {
|
|
42
|
+
if (typeof value === "string") return value;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.stringify(value) ?? String(value);
|
|
45
|
+
} catch {
|
|
46
|
+
return String(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
readonly #source: StateSource;
|
|
51
|
+
readonly #key: string;
|
|
52
|
+
readonly #viewKey: string;
|
|
53
|
+
readonly #equals: string | null;
|
|
54
|
+
readonly #seconds: number;
|
|
55
|
+
#was = "";
|
|
56
|
+
#now = "";
|
|
57
|
+
#elapsed = 0;
|
|
58
|
+
#settled: boolean = false;
|
|
59
|
+
#ticker: ReturnType<typeof setInterval> | undefined;
|
|
60
|
+
#unsubscribe: (() => void) | undefined;
|
|
61
|
+
#signal: AbortSignal | null = null;
|
|
62
|
+
#resolve: ((message: string) => void) | null = null;
|
|
63
|
+
#reject: ((error: Error) => void) | null = null;
|
|
64
|
+
|
|
65
|
+
constructor(source: StateSource, { key, viewKey = "", equals = null, seconds }: StateWaitOptions) {
|
|
66
|
+
this.#source = source;
|
|
67
|
+
this.#key = key;
|
|
68
|
+
this.#viewKey = viewKey;
|
|
69
|
+
this.#equals = equals;
|
|
70
|
+
this.#seconds = StateWait.seconds(seconds);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
run(): Promise<string> {
|
|
74
|
+
this.#was = StateWait.print(this.#source.read(this.#key, this.#viewKey));
|
|
75
|
+
this.#now = this.#was;
|
|
76
|
+
if (this.#equals !== null && this.#was === this.#equals)
|
|
77
|
+
return Promise.resolve(`${this.#key} is already ${this.#was}.`);
|
|
78
|
+
return new Promise<string>((resolve, reject) => {
|
|
79
|
+
this.#resolve = resolve;
|
|
80
|
+
this.#reject = reject;
|
|
81
|
+
this.#signal = AgentAbort.current;
|
|
82
|
+
if (this.#signal?.aborted) {
|
|
83
|
+
this.#abort();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.#signal?.addEventListener("abort", this.#abort);
|
|
87
|
+
this.#unsubscribe = this.#source.subscribe(this.#check);
|
|
88
|
+
this.#ticker = setInterval(this.#tick, StateWait.tickMs);
|
|
89
|
+
AgentProgress.report("", { done: 0, total: this.#seconds });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The session races every call against the same signal; honouring it here is what stops the timer. */
|
|
94
|
+
#abort = () => {
|
|
95
|
+
this.#stop();
|
|
96
|
+
this.#reject?.(new Error("The user aborted the turn."));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
#check = () => {
|
|
100
|
+
if (this.#settled) return;
|
|
101
|
+
let now: string;
|
|
102
|
+
try {
|
|
103
|
+
now = StateWait.print(this.#source.read(this.#key, this.#viewKey));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
|
|
106
|
+
this.#end(`Stopped waiting: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.#now = now;
|
|
110
|
+
if (this.#equals === null ? now !== this.#was : now === this.#equals) this.#end(`${this.#key} is now ${now}.`);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
#tick = () => {
|
|
114
|
+
this.#check();
|
|
115
|
+
if (this.#settled) return;
|
|
116
|
+
this.#elapsed += 1;
|
|
117
|
+
if (this.#elapsed >= this.#seconds) {
|
|
118
|
+
this.#end(
|
|
119
|
+
`${this.#key} is still ${this.#now} after ${this.#seconds}s. Call waitFor again to keep waiting, or tell the user it is taking longer than expected.`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
AgentProgress.report("", { done: this.#elapsed, total: this.#seconds });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
#end(message: string) {
|
|
127
|
+
this.#stop();
|
|
128
|
+
this.#resolve?.(message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#stop() {
|
|
132
|
+
this.#settled = true;
|
|
133
|
+
clearInterval(this.#ticker);
|
|
134
|
+
this.#ticker = undefined;
|
|
135
|
+
this.#unsubscribe?.();
|
|
136
|
+
this.#unsubscribe = undefined;
|
|
137
|
+
this.#signal?.removeEventListener("abort", this.#abort);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -4,6 +4,7 @@ import { AgentBridge } from "./AgentBridge";
|
|
|
4
4
|
import { ScreenReader } from "./ScreenReader";
|
|
5
5
|
import { ScreenSettle } from "./ScreenSettle";
|
|
6
6
|
import { ScreenTarget } from "./ScreenTarget";
|
|
7
|
+
import { StateWait } from "./StateWait";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The tools every akan screen has whatever it declares: where it can go, what it is rendering, what one of the
|
|
@@ -38,6 +39,7 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
38
39
|
StoreSurfaceSource.#goBack(),
|
|
39
40
|
StoreSurfaceSource.#readScreen(viewKey),
|
|
40
41
|
this.#readState(viewKey),
|
|
42
|
+
this.#waitFor(viewKey),
|
|
41
43
|
StoreSurfaceSource.#highlight(viewKey),
|
|
42
44
|
];
|
|
43
45
|
this.#builtins.set(viewKey, builtins);
|
|
@@ -190,6 +192,65 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The wait that costs no model turns.
|
|
197
|
+
*
|
|
198
|
+
* An agent that started something slow has one way to learn it finished: ask again, and again, a full round trip
|
|
199
|
+
* per look. That burns the turn budget in seconds and reads to the user as a loop. This parks the call instead —
|
|
200
|
+
* the session already awaits `run`, so the turn simply takes as long as the work does, and the change report
|
|
201
|
+
* that follows carries whatever landed while it waited.
|
|
202
|
+
*
|
|
203
|
+
* It watches a published state key rather than sleeping for a fixed span. A bare sleep would only make the
|
|
204
|
+
* polling slower, and a screen that can report progress at all reports it into the store; a screen that reports
|
|
205
|
+
* none publishes a key with `st.expose`, which is worth doing anyway, since the key is then readable too.
|
|
206
|
+
*/
|
|
207
|
+
#waitFor(viewKey: string): ToolEntry {
|
|
208
|
+
return {
|
|
209
|
+
name: "waitFor",
|
|
210
|
+
description:
|
|
211
|
+
"Wait here until one of this page's state keys settles, instead of asking again and again. Use it after starting something slow — a generation, an upload, a long job — whenever a state key reports how it is going: the turn pauses with no model round trip and resumes the moment the key moves. Keys are listed in the state context block. Returns what the key holds when the wait ends.",
|
|
212
|
+
parameters: {
|
|
213
|
+
type: "object",
|
|
214
|
+
properties: {
|
|
215
|
+
key: { type: "string", description: "The state key to watch, spelled as the state context block lists it." },
|
|
216
|
+
equals: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description:
|
|
219
|
+
"Wait until the key reads exactly this, compared as text. Omit to wait until it changes from whatever it holds now.",
|
|
220
|
+
},
|
|
221
|
+
timeoutSeconds: {
|
|
222
|
+
type: "number",
|
|
223
|
+
description: `How long to wait before answering with the key's current value. Default ${StateWait.defaultSeconds}, maximum ${StateWait.maxSeconds}. Running out is not a failure — call waitFor again to keep waiting.`,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
required: ["key"],
|
|
227
|
+
additionalProperties: false,
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
effect: "state",
|
|
231
|
+
run: async (args) => {
|
|
232
|
+
this.#bridge ??= AgentBridge.of();
|
|
233
|
+
const key = typeof args.key === "string" ? args.key.trim() : "";
|
|
234
|
+
if (!key) throw new Error("waitFor needs a key to watch.");
|
|
235
|
+
const live = this.#bridge.readableKeys(viewKey);
|
|
236
|
+
if (!live.includes(key))
|
|
237
|
+
throw new Error(
|
|
238
|
+
`No state key named ${key} is read by this screen. ${
|
|
239
|
+
live.length
|
|
240
|
+
? `Keys here: ${live.join(", ")}.`
|
|
241
|
+
: "This screen reads no state keys, so there is nothing to wait for."
|
|
242
|
+
}`,
|
|
243
|
+
);
|
|
244
|
+
return await new StateWait(this.#bridge, {
|
|
245
|
+
key,
|
|
246
|
+
viewKey,
|
|
247
|
+
equals: typeof args.equals === "string" ? args.equals : null,
|
|
248
|
+
seconds: args.timeoutSeconds,
|
|
249
|
+
}).run();
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
193
254
|
/**
|
|
194
255
|
* The ring goes on once the scroll lands, not when it starts: a smooth scroll across a long page takes most of a
|
|
195
256
|
* second, and a flash begun at the top is already fading by the time the user's eye arrives. Settles on the
|
package/store/agent/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export { AgentAbort, AgentProgress } from "../../vendor/use-agentic";
|
|
1
4
|
export * from "./AgentBridge";
|
|
2
5
|
export * from "./AgentContext";
|
|
3
6
|
export * from "./AgentPrompts";
|
|
4
7
|
export * from "./ScreenReader";
|
|
5
8
|
export * from "./ScreenSettle";
|
|
6
9
|
export * from "./ScreenTarget";
|
|
10
|
+
export * from "./StateWait";
|
|
7
11
|
export * from "./StoreCatalogue";
|
|
8
12
|
export * from "./StoreSurfaceSource";
|
|
9
13
|
export * from "./storeSurface";
|
|
@@ -7,6 +7,11 @@ export interface AkanAppOptions {
|
|
|
7
7
|
port?: number;
|
|
8
8
|
wsBasePort?: number;
|
|
9
9
|
openapi?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Boot only these modules and the ones they reach, in every child. Omitted or empty mounts every enabled
|
|
12
|
+
* module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
|
|
13
|
+
*/
|
|
14
|
+
modules?: string[];
|
|
10
15
|
}
|
|
11
16
|
/** Gateway/orchestrator that starts Akan child servers and proxies HTTP/WebSocket traffic. */
|
|
12
17
|
export declare class AkanApp {
|
|
@@ -33,7 +33,8 @@ export declare class AkanOption<Env extends BackendEnv = BackendEnv> {
|
|
|
33
33
|
setAgentAccess(guards: GuardCls | GuardCls[] | null): this;
|
|
34
34
|
/** Settings for whichever adaptor fills `LlmAdaptorRole`, injected into it as the `llmOption` use. */
|
|
35
35
|
setLlm(llmOrFn: LlmOption | ((env: Env) => LlmOption)): this;
|
|
36
|
-
|
|
36
|
+
/** Every entry in declaration order, duplicates kept: the boot stage rejects a key claimed twice. */
|
|
37
|
+
getUses(env: Env): [string, PromiseOrObject<unknown>][];
|
|
37
38
|
getMiddlewares(): MiddlewareCls[];
|
|
38
39
|
getAdaptorOverrides(): AdaptorOverride[];
|
|
39
40
|
getWebProxies(): WebProxyRegistration[];
|
|
@@ -14,6 +14,11 @@ export interface AkanServerOptions {
|
|
|
14
14
|
openapi?: boolean;
|
|
15
15
|
/** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
|
|
16
16
|
mcp?: boolean | McpServerOption;
|
|
17
|
+
/**
|
|
18
|
+
* Boot only these modules and the ones they reach; every other module stays out of the container, so its
|
|
19
|
+
* services, signals, routes and schedules do not exist. Omitted or empty mounts every enabled module.
|
|
20
|
+
*/
|
|
21
|
+
modules?: string[];
|
|
17
22
|
}
|
|
18
23
|
export interface McpServerOption {
|
|
19
24
|
enabled?: boolean;
|
|
@@ -77,6 +82,7 @@ export declare class AkanServer {
|
|
|
77
82
|
mcpAuth: McpAuthOption;
|
|
78
83
|
mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth">;
|
|
79
84
|
serverMode: "federation" | "batch" | "all";
|
|
85
|
+
modules: string[];
|
|
80
86
|
shutdownTimeoutMs: number;
|
|
81
87
|
constructor(name?: string, env?: BackendEnv, serverMode?: "federation" | "batch" | "all", ...libsOrOptions: (AkanLib | AkanServerOptions)[]);
|
|
82
88
|
setPrefix(prefix: string): this;
|
|
@@ -6,6 +6,14 @@ import type { ServerSignal, ServerSignalCls } from "../../signal/serverSignal.d.
|
|
|
6
6
|
import type { AkanLib, DatabaseModule, ScalarModule, ServiceModule } from "../akanLib.d.ts";
|
|
7
7
|
import type { WebProxyRegistration } from "../proxy.d.ts";
|
|
8
8
|
import type { SignalRoutes } from "../types.d.ts";
|
|
9
|
+
export interface DiLifecycleProps {
|
|
10
|
+
env: BackendEnv;
|
|
11
|
+
/**
|
|
12
|
+
* Boot only these modules and the ones they reach, leaving every other module out of the container. Omitted or
|
|
13
|
+
* empty mounts every module whose service is enabled.
|
|
14
|
+
*/
|
|
15
|
+
modules?: string[];
|
|
16
|
+
}
|
|
9
17
|
/**
|
|
10
18
|
* Owns the app's DI container state (registry + live maps + init order) and
|
|
11
19
|
* encapsulates every init / destroy step. `AkanServer` delegates to this so the
|
|
@@ -31,7 +39,7 @@ export declare class DiLifecycle {
|
|
|
31
39
|
adaptor: ReadonlyMap<string, AdaptorCls>;
|
|
32
40
|
middleware: ReadonlyMap<string, MiddlewareCls>;
|
|
33
41
|
};
|
|
34
|
-
constructor(env
|
|
42
|
+
constructor({ env, modules }: DiLifecycleProps, ...libs: AkanLib[]);
|
|
35
43
|
/** Run every init stage in dependency order and collect the generated routes. */
|
|
36
44
|
initializeAll(): Promise<SignalRoutes>;
|
|
37
45
|
destroyAll(): Promise<void>;
|
|
@@ -15,6 +15,22 @@ export declare const normalizeServiceRefName: (refName: string) => string;
|
|
|
15
15
|
export declare const normalizeSignalRefName: (refName: string) => string;
|
|
16
16
|
export declare const normalizeAdaptorRefName: (refName: string) => string;
|
|
17
17
|
export declare const getModuleDependencyRefNames: (mod: DatabaseModule | ServiceModule) => Set<string>;
|
|
18
|
+
/**
|
|
19
|
+
* The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
|
|
20
|
+
* `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
|
|
21
|
+
* inject graph cannot see. A polymorphic owner list is exempt — it spans optional modules by design.
|
|
22
|
+
*/
|
|
23
|
+
export declare const getModuleCascadeRefNames: (mod: DatabaseModule | ServiceModule) => Set<string>;
|
|
24
|
+
export interface Registration {
|
|
25
|
+
key: string;
|
|
26
|
+
/** What claimed the key, phrased for a boot error: `predefined adaptor "storage"`, `lib "shared"`. */
|
|
27
|
+
owner: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A key registered twice is silently last-write-wins everywhere downstream — an app that meant to add a second
|
|
31
|
+
* adaptor gets one, and the other's `onInit` never runs. Refuse the boot instead, naming both claimants.
|
|
32
|
+
*/
|
|
33
|
+
export declare const assertUniqueRegistrations: (kind: string, registrations: Registration[]) => void;
|
|
18
34
|
/**
|
|
19
35
|
* Run every task in parallel and, if any rejects, throw a single
|
|
20
36
|
* `AggregateError` that enumerates every failing label + cause. This replaces
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The reading half of the store an in-page wait needs. `AgentBridge` is one; a test can be another. */
|
|
2
|
+
export interface StateSource {
|
|
3
|
+
read(key: string, viewKey?: string): unknown;
|
|
4
|
+
subscribe(listener: () => void): () => void;
|
|
5
|
+
}
|
|
6
|
+
export interface StateWaitOptions {
|
|
7
|
+
key: string;
|
|
8
|
+
viewKey?: string;
|
|
9
|
+
/** Settle when the key reads exactly this. Null waits for it to change from whatever it holds now. */
|
|
10
|
+
equals?: string | null;
|
|
11
|
+
/** Straight off the tool argument, so it may be anything; `StateWait.seconds` is what makes it a number. */
|
|
12
|
+
seconds?: unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* One `waitFor` call: park until a published state key settles, or until the timeout says how it is going.
|
|
16
|
+
*
|
|
17
|
+
* Two clocks, because neither covers the other. The store's own subscription catches the value changing, which is
|
|
18
|
+
* the whole point of the tool and has to land immediately. The tick catches what the store never announces —
|
|
19
|
+
* `retainLive` / `releaseLive` mutate the live-key map without notifying any listener, so a page navigated away
|
|
20
|
+
* from mid-wait would otherwise hold the turn until the timeout — and the countdown row needs a tick anyway.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here throws on a wait that ran out. A key still reading `generating` after two minutes is an answer, not
|
|
23
|
+
* a failure, and the model is the one that decides whether to wait again.
|
|
24
|
+
*/
|
|
25
|
+
export declare class StateWait {
|
|
26
|
+
#private;
|
|
27
|
+
static readonly tickMs = 1000;
|
|
28
|
+
static readonly defaultSeconds = 120;
|
|
29
|
+
static readonly maxSeconds = 600;
|
|
30
|
+
/** Clamped rather than refused: a model that asks for an hour gets the longest wait on offer and reads how long. */
|
|
31
|
+
static seconds(value: unknown): number;
|
|
32
|
+
/** A string is itself; everything else is JSON, so a number, a boolean and null each compare as they are written. */
|
|
33
|
+
static print(value: unknown): string;
|
|
34
|
+
constructor(source: StateSource, { key, viewKey, equals, seconds }: StateWaitOptions);
|
|
35
|
+
run(): Promise<string>;
|
|
36
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
export { AgentAbort, AgentProgress } from "../../vendor/use-agentic.d.ts";
|
|
1
2
|
export * from "./AgentBridge.d.ts";
|
|
2
3
|
export * from "./AgentContext.d.ts";
|
|
3
4
|
export * from "./AgentPrompts.d.ts";
|
|
4
5
|
export * from "./ScreenReader.d.ts";
|
|
5
6
|
export * from "./ScreenSettle.d.ts";
|
|
6
7
|
export * from "./ScreenTarget.d.ts";
|
|
8
|
+
export * from "./StateWait.d.ts";
|
|
7
9
|
export * from "./StoreCatalogue.d.ts";
|
|
8
10
|
export * from "./StoreSurfaceSource.d.ts";
|
|
9
11
|
export * from "./storeSurface.d.ts";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The abort signal of the tool call running now.
|
|
3
|
+
*
|
|
4
|
+
* Reached through a module slot rather than a parameter for the same reason `AgentProgress` is — work several
|
|
5
|
+
* frames down, a store action or a poll loop, reads it without every signature between here and there growing a
|
|
6
|
+
* channel argument, and a session executes tool calls one at a time. Outside a call `current` is null, so a tool
|
|
7
|
+
* that honours it runs unchanged in a test, under the dock, and on the server.
|
|
8
|
+
*
|
|
9
|
+
* Honouring it is optional: the session races every call against the same signal, so Stop lands whatever a tool
|
|
10
|
+
* does. What reading it buys is the tool's own cleanup — a timer that would otherwise keep ticking for the rest of
|
|
11
|
+
* its timeout with nobody left to answer.
|
|
12
|
+
*/
|
|
13
|
+
export declare class AgentAbort {
|
|
14
|
+
#private;
|
|
15
|
+
static get current(): AbortSignal | null;
|
|
16
|
+
static run<T>(signal: AbortSignal, exec: () => Promise<T> | T): Promise<T>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The abort signal of the tool call running now.
|
|
3
|
+
*
|
|
4
|
+
* Reached through a module slot rather than a parameter for the same reason `AgentProgress` is — work several
|
|
5
|
+
* frames down, a store action or a poll loop, reads it without every signature between here and there growing a
|
|
6
|
+
* channel argument, and a session executes tool calls one at a time. Outside a call `current` is null, so a tool
|
|
7
|
+
* that honours it runs unchanged in a test, under the dock, and on the server.
|
|
8
|
+
*
|
|
9
|
+
* Honouring it is optional: the session races every call against the same signal, so Stop lands whatever a tool
|
|
10
|
+
* does. What reading it buys is the tool's own cleanup — a timer that would otherwise keep ticking for the rest of
|
|
11
|
+
* its timeout with nobody left to answer.
|
|
12
|
+
*/
|
|
13
|
+
export class AgentAbort {
|
|
14
|
+
static #signal: AbortSignal | null = null;
|
|
15
|
+
|
|
16
|
+
static get current(): AbortSignal | null {
|
|
17
|
+
return AgentAbort.#signal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static async run<T>(signal: AbortSignal, exec: () => Promise<T> | T): Promise<T> {
|
|
21
|
+
const outer = AgentAbort.#signal;
|
|
22
|
+
AgentAbort.#signal = signal;
|
|
23
|
+
try {
|
|
24
|
+
return await exec();
|
|
25
|
+
} finally {
|
|
26
|
+
AgentAbort.#signal = outer;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AgentAbort } from "./AgentAbort";
|
|
1
2
|
import { AgentProgress, type AgentProgressReport } from "./AgentProgress";
|
|
2
3
|
import type {
|
|
3
4
|
AgentRunner,
|
|
@@ -300,12 +301,14 @@ export class AgentSession {
|
|
|
300
301
|
}
|
|
301
302
|
const before = this.#surface.snapshot();
|
|
302
303
|
try {
|
|
303
|
-
const result = await
|
|
304
|
-
(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
304
|
+
const result = await AgentAbort.run(signal, () =>
|
|
305
|
+
AgentProgress.run(
|
|
306
|
+
(report) => {
|
|
307
|
+
this.#progress = { ...report, callId: call.id };
|
|
308
|
+
this.#notify();
|
|
309
|
+
},
|
|
310
|
+
() => AgentSession.#raced(this.#surface.call(call.name, call.args), signal),
|
|
311
|
+
),
|
|
309
312
|
);
|
|
310
313
|
|
|
311
314
|
if (entry.effect !== "query") await this.#options.settle?.();
|
|
@@ -402,6 +405,35 @@ export class AgentSession {
|
|
|
402
405
|
});
|
|
403
406
|
}
|
|
404
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The call, or the abort — whichever lands first.
|
|
410
|
+
*
|
|
411
|
+
* A tool is handed the signal through `AgentAbort` and may stop itself, but nothing obliges it to, and a tool
|
|
412
|
+
* that waits on a two-minute job is exactly the one a user reaches for Stop during. Without this race the loop
|
|
413
|
+
* stays parked inside the call for those two minutes with the chat still showing a turn in flight.
|
|
414
|
+
*
|
|
415
|
+
* The losing promise is left running rather than cancelled: the work is usually a job a server is already
|
|
416
|
+
* doing, and throwing away a result that is about to land helps nobody. Both of its outcomes are handled here,
|
|
417
|
+
* so a late failure settles nothing instead of surfacing as an unhandled rejection.
|
|
418
|
+
*/
|
|
419
|
+
static #raced<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
420
|
+
return new Promise<T>((resolve, reject) => {
|
|
421
|
+
const onAbort = () => reject(new Error("The user aborted the turn."));
|
|
422
|
+
work.then(
|
|
423
|
+
(value) => {
|
|
424
|
+
signal.removeEventListener("abort", onAbort);
|
|
425
|
+
resolve(value);
|
|
426
|
+
},
|
|
427
|
+
(error: unknown) => {
|
|
428
|
+
signal.removeEventListener("abort", onAbort);
|
|
429
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
if (signal.aborted) onAbort();
|
|
433
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
405
437
|
static #confirmMessage(name: string, entry: ToolEntry, args: Record<string, unknown>): string | null {
|
|
406
438
|
const confirm = entry.confirm;
|
|
407
439
|
if (confirm === undefined || confirm === false) return null;
|