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.
Files changed (46) hide show
  1. package/dictionary/agent.dictionary.ts +4 -4
  2. package/dictionary/base.dictionary.ts +4 -0
  3. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  4. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  5. package/package.json +1 -1
  6. package/server/akanApp.ts +8 -0
  7. package/server/akanOption.ts +3 -3
  8. package/server/akanServer.ts +10 -2
  9. package/server/di/diLifecycle.ts +89 -10
  10. package/server/di/utils.ts +40 -0
  11. package/service/predefinedAdaptor/deepseekLlm.ts +33 -4
  12. package/service/predefinedAdaptor/llm.adaptor.ts +11 -2
  13. package/signal/agentTurnStream.ts +13 -1
  14. package/store/agent/StateWait.ts +139 -0
  15. package/store/agent/StoreSurfaceSource.ts +61 -0
  16. package/store/agent/index.ts +4 -0
  17. package/types/dictionary/base.dictionary.d.ts +1 -1
  18. package/types/dictionary/dictionary.d.ts +8 -8
  19. package/types/server/akanApp.d.ts +5 -0
  20. package/types/server/akanOption.d.ts +2 -1
  21. package/types/server/akanServer.d.ts +6 -0
  22. package/types/server/di/diLifecycle.d.ts +9 -1
  23. package/types/server/di/utils.d.ts +16 -0
  24. package/types/service/predefinedAdaptor/deepseekLlm.d.ts +6 -0
  25. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +11 -2
  26. package/types/signal/agentTurnStream.d.ts +8 -0
  27. package/types/store/agent/StateWait.d.ts +36 -0
  28. package/types/store/agent/index.d.ts +2 -0
  29. package/types/ui/Agent/Chat.d.ts +7 -2
  30. package/types/vendor/use-agentic/AgentAbort.d.ts +17 -0
  31. package/types/vendor/use-agentic/AgentSession.d.ts +16 -0
  32. package/types/vendor/use-agentic/Compaction.d.ts +45 -0
  33. package/types/vendor/use-agentic/index.d.ts +2 -0
  34. package/types/vendor/use-agentic/types.d.ts +13 -1
  35. package/ui/Agent/Bubble.tsx +10 -1
  36. package/ui/Agent/Chat.tsx +14 -1
  37. package/ui/Agent/ChatCommands.ts +11 -1
  38. package/ui/Agent/fetchRunner.ts +17 -3
  39. package/ui/Load/Units.tsx +2 -2
  40. package/vendor/use-agentic/AgentAbort.ts +29 -0
  41. package/vendor/use-agentic/AgentSession.ts +134 -7
  42. package/vendor/use-agentic/Compaction.ts +110 -0
  43. package/vendor/use-agentic/WIRE.md +6 -1
  44. package/vendor/use-agentic/httpRunner.ts +21 -5
  45. package/vendor/use-agentic/index.ts +2 -0
  46. 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 model is unavailable — no LLM adaptor answered this turn",
25
- "에이전트 모델을 사용할 수 없습니다. 이번 턴에 응답한 LLM 어댑터가 없습니다",
24
+ "The agent is unavailable — this app has no language model configured",
25
+ "에이전트를 사용할 수 없습니다. 앱에 언어 모델이 설정되어 있지 않습니다",
26
26
  ],
27
27
  deepseekRequestFailed: [
28
- "DeepSeek request failed with status {status}",
29
- "DeepSeek 요청이 실패했습니다 (status {status})",
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.", "클립보드에 접근할 수 없습니다."],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.40",
3
+ "version": "3.0.0-alpha.42",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
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",
@@ -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
- getUses(env: Env): Record<string, PromiseOrObject<unknown>> {
72
- const uses = this.#getUses.map((fn) => fn(env));
73
- return Object.assign({}, ...uses);
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;
@@ -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
- this.#di = new DiLifecycle(this.env, serverMode, ...libs);
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
 
@@ -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: BackendEnv, serverMode: "federation" | "batch" | "all", ...libs: AkanLib[]) {
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 adaptor of collectAdaptors(services)) {
159
- this.#adaptor.set(adaptor.refName, adaptor);
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 uses = Object.assign(
397
- { llmOption: Object.assign({}, ...this.#libs.map((lib) => lib.option.getLlm(this.#env))) },
398
- ...this.#libs.map((lib) => lib.option.getUses(this.#env)),
399
- );
400
- const entries = Object.entries(uses);
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(([key, value]) => ({
482
+ entries.map(({ key, value }) => ({
404
483
  label: `uses:${key}`,
405
484
  run: async () => {
406
485
  const useValue = value instanceof Promise ? await value : value;
@@ -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
- return null;
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 new Err("agent.error.deepseekRequestFailed", { status: String(response.status) });
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/` and follows the adapter convention: failures are
72
- * logged and answered as `null`, and the calling service decides what that means.
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", message: error instanceof Error ? error.message : String(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
+ }