akanjs 3.0.0-alpha.40 → 3.0.0-alpha.42
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/dictionary/agent.dictionary.ts +4 -4
- package/dictionary/base.dictionary.ts +4 -0
- 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/service/predefinedAdaptor/deepseekLlm.ts +33 -4
- package/service/predefinedAdaptor/llm.adaptor.ts +11 -2
- package/signal/agentTurnStream.ts +13 -1
- package/store/agent/StateWait.ts +139 -0
- package/store/agent/StoreSurfaceSource.ts +61 -0
- package/store/agent/index.ts +4 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- 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/service/predefinedAdaptor/deepseekLlm.d.ts +6 -0
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +11 -2
- package/types/signal/agentTurnStream.d.ts +8 -0
- package/types/store/agent/StateWait.d.ts +36 -0
- package/types/store/agent/index.d.ts +2 -0
- package/types/ui/Agent/Chat.d.ts +7 -2
- package/types/vendor/use-agentic/AgentAbort.d.ts +17 -0
- package/types/vendor/use-agentic/AgentSession.d.ts +16 -0
- package/types/vendor/use-agentic/Compaction.d.ts +45 -0
- package/types/vendor/use-agentic/index.d.ts +2 -0
- package/types/vendor/use-agentic/types.d.ts +13 -1
- package/ui/Agent/Bubble.tsx +10 -1
- package/ui/Agent/Chat.tsx +14 -1
- package/ui/Agent/ChatCommands.ts +11 -1
- package/ui/Agent/fetchRunner.ts +17 -3
- package/ui/Load/Units.tsx +2 -2
- package/vendor/use-agentic/AgentAbort.ts +29 -0
- package/vendor/use-agentic/AgentSession.ts +134 -7
- package/vendor/use-agentic/Compaction.ts +110 -0
- package/vendor/use-agentic/WIRE.md +6 -1
- package/vendor/use-agentic/httpRunner.ts +21 -5
- package/vendor/use-agentic/index.ts +2 -0
- package/vendor/use-agentic/types.ts +11 -1
|
@@ -21,11 +21,11 @@ export const agentDictionary = serviceDictionary(["en", "ko"])
|
|
|
21
21
|
}))
|
|
22
22
|
.error({
|
|
23
23
|
llmUnavailable: [
|
|
24
|
-
"The agent
|
|
25
|
-
"
|
|
24
|
+
"The agent is unavailable — this app has no language model configured",
|
|
25
|
+
"에이전트를 사용할 수 없습니다. 이 앱에 언어 모델이 설정되어 있지 않습니다",
|
|
26
26
|
],
|
|
27
27
|
deepseekRequestFailed: [
|
|
28
|
-
"DeepSeek
|
|
29
|
-
"DeepSeek
|
|
28
|
+
"DeepSeek refused this turn with status {status}. Reason: {reason}",
|
|
29
|
+
"DeepSeek가 이번 턴을 거절했습니다 (status {status}). 사유: {reason}",
|
|
30
30
|
],
|
|
31
31
|
});
|
|
@@ -64,6 +64,7 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
64
64
|
agentKeepGoing: ["Keep going", "계속하기"],
|
|
65
65
|
agentCmdNew: ["Start a new conversation", "새 대화 시작"],
|
|
66
66
|
agentCmdRetry: ["Send the last message again", "마지막 메시지 다시 보내기"],
|
|
67
|
+
agentCmdCompact: ["Summarize the conversation so far", "지금까지의 대화 요약하기"],
|
|
67
68
|
agentCmdCopy: ["Copy this conversation", "이 대화 복사"],
|
|
68
69
|
agentCmdHelp: ["What you can do here", "여기서 할 수 있는 것"],
|
|
69
70
|
agentCmdTools: ["List this screen's tools", "이 화면의 툴 목록"],
|
|
@@ -76,6 +77,9 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
76
77
|
"에이전트는 혼자 결정하면 안 되는 일을 하기 전에 물어보며, 중지는 진행 중인 턴을 끝냅니다.",
|
|
77
78
|
],
|
|
78
79
|
agentNothingToRetry: ["There is no message to send again.", "다시 보낼 메시지가 없습니다."],
|
|
80
|
+
agentNothingToCompact: ["There is nothing to summarize yet.", "아직 요약할 대화가 없습니다."],
|
|
81
|
+
agentCompacted: ["Summarized the conversation so far.", "지금까지의 대화를 요약했습니다."],
|
|
82
|
+
agentSummary: ["Summary of the earlier conversation", "이전 대화 요약"],
|
|
79
83
|
agentBusy: ["A turn is still running. Stop it first.", "진행 중인 턴이 있습니다. 먼저 중지하세요."],
|
|
80
84
|
agentCopied: ["Conversation copied to the clipboard.", "대화를 클립보드에 복사했습니다."],
|
|
81
85
|
agentCopyFailed: ["Could not reach the clipboard.", "클립보드에 접근할 수 없습니다."],
|
|
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
|
|
@@ -54,8 +54,9 @@ export class DeepseekLlm
|
|
|
54
54
|
const body = await this.#apiStream("/chat/completions", DeepseekLlm.requestBody(this.#model, request, true));
|
|
55
55
|
return await DeepseekLlm.consumeStream(body, onDelta);
|
|
56
56
|
} catch (error) {
|
|
57
|
+
|
|
57
58
|
this.logger.error(`DeepSeek turn failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
-
|
|
59
|
+
throw error;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -67,7 +68,7 @@ export class DeepseekLlm
|
|
|
67
68
|
|
|
68
69
|
signal: AbortSignal.timeout(120_000),
|
|
69
70
|
});
|
|
70
|
-
if (!response.ok) throw
|
|
71
|
+
if (!response.ok) throw await DeepseekLlm.refusal(response);
|
|
71
72
|
return (await response.json()) as T;
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -78,11 +79,31 @@ export class DeepseekLlm
|
|
|
78
79
|
body: JSON.stringify(body),
|
|
79
80
|
signal: AbortSignal.timeout(120_000),
|
|
80
81
|
});
|
|
81
|
-
if (!response.ok || !response.body)
|
|
82
|
-
throw new Err("agent.error.deepseekRequestFailed", { status: String(response.status) });
|
|
82
|
+
if (!response.ok || !response.body) throw await DeepseekLlm.refusal(response);
|
|
83
83
|
return response.body;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The dialect answers a refusal as `{ error: { message } }`, and that sentence is the useful half — a request
|
|
88
|
+
* past the context window says exactly which limit it passed. Carried on the `Err` so the chat can print it.
|
|
89
|
+
*/
|
|
90
|
+
static async refusal(response: Response): Promise<Error> {
|
|
91
|
+
return new Err("agent.error.deepseekRequestFailed", {
|
|
92
|
+
status: String(response.status),
|
|
93
|
+
reason: await DeepseekLlm.reasonOf(response),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static async reasonOf(response: Response): Promise<string> {
|
|
98
|
+
try {
|
|
99
|
+
const body = (await response.json()) as { error?: { message?: unknown } | string };
|
|
100
|
+
const message = typeof body.error === "string" ? body.error : body.error?.message;
|
|
101
|
+
if (typeof message === "string" && message) return message;
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
return response.statusText || "no reason given";
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
/**
|
|
87
108
|
* The dialect streams `data: {chunk}` SSE lines ending with `data: [DONE]`. Tool calls arrive fragmented — the
|
|
88
109
|
* first fragment of an index carries id/name, later ones append to the arguments string — so they are assembled
|
|
@@ -175,6 +196,14 @@ export class DeepseekLlm
|
|
|
175
196
|
}
|
|
176
197
|
|
|
177
198
|
static providerMessages(message: AgentWireMessage): DeepseekMessage[] {
|
|
199
|
+
|
|
200
|
+
if (message.summary)
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
role: "system" as const,
|
|
204
|
+
content: `Summary of the earlier conversation, standing in for the messages it replaced:\n\n${message.text ?? ""}`,
|
|
205
|
+
},
|
|
206
|
+
];
|
|
178
207
|
if (message.role === "tool")
|
|
179
208
|
return (message.toolResults ?? []).map((result) => ({
|
|
180
209
|
role: "tool" as const,
|
|
@@ -37,6 +37,11 @@ export interface AgentWireMessage {
|
|
|
37
37
|
toolCalls?: AgentWireToolCall[];
|
|
38
38
|
toolResults?: AgentWireToolResult[];
|
|
39
39
|
error?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Stands in for the messages the client's own compaction replaced. It arrives with the user's role because the
|
|
42
|
+
* wire has no other, but it is history rather than an ask, so a provider mapping frames it as one.
|
|
43
|
+
*/
|
|
44
|
+
summary?: boolean;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export interface AgentWireTool {
|
|
@@ -68,8 +73,12 @@ export interface LlmTurnAnswer {
|
|
|
68
73
|
/**
|
|
69
74
|
* The provider seam for one stateless agent turn: the whole transcript in, one assistant answer out. The server
|
|
70
75
|
* relays — it never executes a client tool — so this is the only surface a provider integration fills. An
|
|
71
|
-
* implementation is an `adapt()` class in a lib's `srvkit
|
|
72
|
-
*
|
|
76
|
+
* implementation is an `adapt()` class in a lib's `srvkit/`.
|
|
77
|
+
*
|
|
78
|
+
* `null` means this provider is not configured, and the caller turns it into the one sentence that says so. A
|
|
79
|
+
* failure the provider explained is logged and **thrown** instead, as an `Err` whose text the chat prints: a
|
|
80
|
+
* refused turn and an unconfigured app are different things to be told, and collapsing both into `null` left a
|
|
81
|
+
* user reading "no model is configured" about a conversation that had merely outgrown the context window.
|
|
73
82
|
*/
|
|
74
83
|
export interface LlmAdaptor {
|
|
75
84
|
/**
|
|
@@ -16,6 +16,18 @@ export class AgentTurnStream {
|
|
|
16
16
|
return !!request.headers.get("accept")?.includes("text/event-stream");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A domain `Err` carries its dictionary key as the message and the values its text interpolates as `data`, so
|
|
21
|
+
* both travel: the key alone would reach the chat as `agent.error.…` with its placeholders unfilled.
|
|
22
|
+
*/
|
|
23
|
+
static failure(error: unknown): { message: string; data?: Record<string, string | number> } {
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
const data = (error as { data?: unknown } | null)?.data;
|
|
26
|
+
return data && typeof data === "object" && !Array.isArray(data)
|
|
27
|
+
? { message, data: data as Record<string, string | number> }
|
|
28
|
+
: { message };
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
static response(run: (onDelta: (delta: string) => void) => Promise<StreamedTurn>): Response {
|
|
20
32
|
const encoder = new TextEncoder();
|
|
21
33
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -35,7 +47,7 @@ export class AgentTurnStream {
|
|
|
35
47
|
send({ type: "done", stop: turn.stop === "toolUse" || toolCalls.length ? "toolUse" : "end" });
|
|
36
48
|
} catch (error) {
|
|
37
49
|
|
|
38
|
-
send({ type: "error",
|
|
50
|
+
send({ type: "error", ...AgentTurnStream.failure(error) });
|
|
39
51
|
} finally {
|
|
40
52
|
controller.close();
|
|
41
53
|
}
|
|
@@ -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
|
+
}
|