akanjs 3.0.0-beta.0 → 3.0.0-beta.10

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 (107) hide show
  1. package/common/index.ts +1 -0
  2. package/common/pathGet.ts +12 -2
  3. package/common/pathSet.ts +2 -3
  4. package/common/toPathSegments.ts +10 -0
  5. package/common/types.ts +5 -1
  6. package/dictionary/agent.dictionary.ts +8 -0
  7. package/dictionary/agentTurn.dictionary.ts +6 -2
  8. package/dictionary/base.dictionary.ts +6 -0
  9. package/fetch/agentTurn.ts +1 -1
  10. package/fetch/client/fetchClient.ts +20 -2
  11. package/fetch/client/httpClient.ts +4 -0
  12. package/fetch/client/wsClient.ts +4 -4
  13. package/index.ts +5 -0
  14. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  15. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  16. package/package.json +1 -1
  17. package/server/di/diLifecycle.ts +5 -1
  18. package/service/agent.service.ts +99 -4
  19. package/service/predefinedAdaptor/anthropicLlm.ts +409 -0
  20. package/service/predefinedAdaptor/deepseekLlm.ts +21 -211
  21. package/service/predefinedAdaptor/index.ts +3 -0
  22. package/service/predefinedAdaptor/llm.adaptor.ts +42 -1
  23. package/service/predefinedAdaptor/openaiDialect.ts +267 -0
  24. package/service/predefinedAdaptor/openaiLlm.ts +91 -0
  25. package/signal/agentTurnStream.ts +5 -2
  26. package/signal/middleware.ts +82 -47
  27. package/signal/serializer/fetch.serializer.ts +1 -0
  28. package/signal/signalContext.ts +8 -1
  29. package/signal/types.ts +24 -0
  30. package/store/agentic/index.ts +1 -0
  31. package/store/agentic/useAgentReference.ts +49 -0
  32. package/store/hooks.ts +1 -1
  33. package/types/common/index.d.ts +1 -0
  34. package/types/common/toPathSegments.d.ts +9 -0
  35. package/types/common/types.d.ts +5 -1
  36. package/types/dictionary/agent.dictionary.d.ts +1 -1
  37. package/types/dictionary/base.dictionary.d.ts +1 -1
  38. package/types/dictionary/dictionary.d.ts +9 -9
  39. package/types/fetch/agentTurn.d.ts +3 -3
  40. package/types/fetch/client/fetchClient.d.ts +8 -1
  41. package/types/fetch/client/httpClient.d.ts +2 -0
  42. package/types/index.d.ts +5 -0
  43. package/types/service/agent.service.d.ts +55 -1
  44. package/types/service/predefinedAdaptor/anthropicLlm.d.ts +131 -0
  45. package/types/service/predefinedAdaptor/deepseekLlm.d.ts +10 -67
  46. package/types/service/predefinedAdaptor/index.d.ts +3 -0
  47. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +41 -1
  48. package/types/service/predefinedAdaptor/openaiDialect.d.ts +103 -0
  49. package/types/service/predefinedAdaptor/openaiLlm.d.ts +24 -0
  50. package/types/signal/agent.signal.d.ts +1 -1
  51. package/types/signal/agentTurn.d.ts +1 -1
  52. package/types/signal/agentTurnStream.d.ts +1 -1
  53. package/types/signal/middleware.d.ts +19 -10
  54. package/types/signal/signalContext.d.ts +6 -1
  55. package/types/signal/types.d.ts +24 -0
  56. package/types/store/agentic/index.d.ts +1 -0
  57. package/types/store/agentic/useAgentReference.d.ts +32 -0
  58. package/types/store/hooks.d.ts +1 -1
  59. package/types/ui/Agent/Attach.d.ts +5 -2
  60. package/types/ui/Agent/Chat.d.ts +17 -1
  61. package/types/ui/Agent/Composer.d.ts +11 -2
  62. package/types/ui/Agent/Menu.d.ts +4 -2
  63. package/types/ui/Agent/Refer.d.ts +13 -0
  64. package/types/ui/Agent/Steps.d.ts +33 -0
  65. package/types/ui/Agent/useChatAttachments.d.ts +1 -0
  66. package/types/ui/Agent/useChatQueue.d.ts +3 -1
  67. package/types/ui/Agent/useChatReferences.d.ts +26 -0
  68. package/types/ui/Agent/useReferenceMenu.d.ts +43 -0
  69. package/types/ui/Field/lightRefCache.d.ts +19 -0
  70. package/types/ui/Field/useRelationOptions.d.ts +39 -0
  71. package/types/ui/Loading/Spin.d.ts +19 -3
  72. package/types/ui/Select.d.ts +2 -0
  73. package/types/ui/UiOverride/context.d.ts +2 -0
  74. package/types/ui/index.d.ts +6 -1
  75. package/types/vendor/use-agentic/AgentSession.d.ts +54 -1
  76. package/types/vendor/use-agentic/Reference.d.ts +56 -0
  77. package/types/vendor/use-agentic/Transcript.d.ts +2 -1
  78. package/types/vendor/use-agentic/index.d.ts +1 -0
  79. package/types/vendor/use-agentic/types.d.ts +37 -1
  80. package/ui/Agent/Attach.tsx +17 -4
  81. package/ui/Agent/Bubble.tsx +2 -0
  82. package/ui/Agent/Chat.tsx +73 -13
  83. package/ui/Agent/Composer.tsx +29 -3
  84. package/ui/Agent/Menu.tsx +8 -3
  85. package/ui/Agent/Queued.tsx +2 -0
  86. package/ui/Agent/Refer.tsx +44 -0
  87. package/ui/Agent/Steps.tsx +49 -0
  88. package/ui/Agent/sessionHistory.ts +38 -12
  89. package/ui/Agent/useChatAttachments.ts +6 -0
  90. package/ui/Agent/useChatQueue.ts +11 -1
  91. package/ui/Agent/useChatReferences.ts +67 -0
  92. package/ui/Agent/useReferenceMenu.ts +108 -0
  93. package/ui/Field/Relation.tsx +70 -150
  94. package/ui/Field/lightRefCache.ts +73 -0
  95. package/ui/Field/useRelationOptions.ts +106 -0
  96. package/ui/Loading/Spin.tsx +24 -4
  97. package/ui/Select.tsx +24 -14
  98. package/ui/UiOverride/context.ts +2 -0
  99. package/ui/index.ts +11 -0
  100. package/vendor/use-agentic/AgentSession.ts +142 -3
  101. package/vendor/use-agentic/Compaction.ts +9 -1
  102. package/vendor/use-agentic/Reference.ts +99 -0
  103. package/vendor/use-agentic/Transcript.ts +9 -3
  104. package/vendor/use-agentic/WIRE.md +6 -1
  105. package/vendor/use-agentic/httpRunner.ts +1 -1
  106. package/vendor/use-agentic/index.ts +1 -0
  107. package/vendor/use-agentic/types.ts +39 -1
package/common/index.ts CHANGED
@@ -111,6 +111,7 @@ export { sleep } from "./sleep";
111
111
  export { splitVersion } from "./splitVersion";
112
112
  export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute";
113
113
  export { TrustedProxy } from "./TrustedProxy";
114
+ export { toPathSegments } from "./toPathSegments";
114
115
  export type * from "./types";
115
116
  export {
116
117
  type WebsocketAuthAckData,
package/common/pathGet.ts CHANGED
@@ -1,13 +1,23 @@
1
+ import { toPathSegments } from "./toPathSegments";
2
+
1
3
  type Indexable = Record<string | number, unknown>;
4
+ type PathSegment = string | number;
2
5
 
3
6
  const isIndexable = (value: unknown): value is Indexable => Object(value) === value;
4
7
 
8
+ const readChild = (container: Indexable, key: PathSegment) =>
9
+ container instanceof Map ? (container as Map<PathSegment, unknown>).get(key) : container[key];
10
+
5
11
  export const pathGet = (
6
12
  path: string | (string | number)[],
7
13
  obj: unknown,
8
14
  separator = ".",
9
15
  fallback: unknown = null,
10
16
  ): unknown => {
11
- const properties = Array.isArray(path) ? path : path.split(separator);
12
- return properties.reduce((prev, curr) => (isIndexable(prev) ? (prev[curr] ?? fallback) : fallback), obj);
17
+
18
+ const properties = separator === "." ? toPathSegments(path) : Array.isArray(path) ? [...path] : path.split(separator);
19
+ return properties.reduce<unknown>(
20
+ (prev, curr) => (isIndexable(prev) ? (readChild(prev, curr) ?? fallback) : fallback),
21
+ obj,
22
+ );
13
23
  };
package/common/pathSet.ts CHANGED
@@ -1,10 +1,9 @@
1
+ import { toPathSegments } from "./toPathSegments";
2
+
1
3
  type MutableIndexable = Record<string | number, unknown>;
2
4
  type PathSegment = string | number;
3
5
  type Container = MutableIndexable | Map<PathSegment, unknown>;
4
6
 
5
- const toPathSegments = (path: string | readonly PathSegment[]) =>
6
- Array.isArray(path) ? [...path] : path.toString().match(/[^.[\]]+/g) || [];
7
-
8
7
  const readChild = (container: Container, key: PathSegment) =>
9
8
  container instanceof Map ? container.get(key) : container[key];
10
9
 
@@ -0,0 +1,10 @@
1
+ type PathSegment = string | number;
2
+
3
+ /**
4
+ * The one definition of what a dotted path's segments are, so a path that writes and a path that reads cannot
5
+ * disagree about it. `a.0.b` and `a[0].b` are the same three segments — the bracket form is what a form field
6
+ * hands `writeOn<Model>`, and a read of the same path has to accept the same spelling or the agent can write
7
+ * somewhere it cannot read back.
8
+ */
9
+ export const toPathSegments = (path: string | readonly PathSegment[]) =>
10
+ Array.isArray(path) ? [...path] : path.toString().match(/[^.[\]]+/g) || [];
package/common/types.ts CHANGED
@@ -5,7 +5,11 @@ export interface FetchPolicy<Returns = unknown> {
5
5
  onError?: (error: string) => void;
6
6
  token?: string;
7
7
  partial?: string[];
8
- timeout?: number;
8
+ /**
9
+ * Milliseconds before this call is abandoned, `false` to wait as long as the runtime will. Overrides the
10
+ * endpoint's declared `timeout`, which overrides the client's own default.
11
+ */
12
+ timeout?: number | false;
9
13
  /**
10
14
  * A `pubsub` subscription only: called after the room has been resubscribed following a dropped connection.
11
15
  *
@@ -28,4 +28,12 @@ export const agentDictionary = serviceDictionary(["en", "ko"])
28
28
  "DeepSeek refused this turn with status {status}. Reason: {reason}",
29
29
  "DeepSeek가 이번 턴을 거절했습니다 (status {status}). 사유: {reason}",
30
30
  ],
31
+ openaiRequestFailed: [
32
+ "OpenAI refused this turn with status {status}. Reason: {reason}",
33
+ "OpenAI가 이번 턴을 거절했습니다 (status {status}). 사유: {reason}",
34
+ ],
35
+ anthropicRequestFailed: [
36
+ "Anthropic refused this turn with status {status}. Reason: {reason}",
37
+ "Anthropic이 이번 턴을 거절했습니다 (status {status}). 사유: {reason}",
38
+ ],
31
39
  });
@@ -19,11 +19,15 @@ export const agentTurnDictionary = scalarDictionary(["en", "ko"])
19
19
  "클라이언트가 실행할 툴 호출 목록 ({ id, name, args })",
20
20
  ]),
21
21
  stop: t(["Stop", "종료 사유"]).desc([
22
- "Why the turn ended — end, or toolUse when tool results are awaited",
23
- "턴이 끝난 이유 — end 또는 툴 결과를 기다리는 toolUse",
22
+ "Why the turn ended — end, toolUse when tool results are awaited, or length when the provider cut it off",
23
+ "턴이 끝난 이유 — end, 툴 결과를 기다리는 toolUse, 프로바이더가 잘라낸 length",
24
24
  ]),
25
25
  }))
26
26
  .enum<AgentStop>("agentStop", (t) => ({
27
27
  end: t(["End", "종료"]).desc(["The final answer", "최종 응답"]),
28
28
  toolUse: t(["Tool Use", "툴 사용"]).desc(["The model awaits tool results", "모델이 툴 결과를 기다린다"]),
29
+ length: t(["Length", "길이 초과"]).desc([
30
+ "The provider's answer ceiling cut the turn off, so it is incomplete",
31
+ "프로바이더의 응답 상한에 걸려 턴이 잘렸다. 미완성이다",
32
+ ]),
29
33
  }));
@@ -80,6 +80,12 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
80
80
  agentVoiceFailed: ["The microphone could not be used.", "마이크를 사용할 수 없습니다."],
81
81
  agentAttach: ["Attach a file", "파일 첨부"],
82
82
  agentAttachRemove: ["Remove attachment", "첨부 제거"],
83
+ agentAttachReading: ["Reading…", "읽는 중…"],
84
+ agentReferenceRemove: ["Remove reference", "참조 제거"],
85
+ agentReferenceFailed: [
86
+ "{label} could not be read, so the agent has only its name.",
87
+ "{label}을(를) 읽지 못해 이름만 전달됩니다.",
88
+ ],
83
89
  agentAttachTooLarge: ["{name} is too large to attach.", "{name}은(는) 용량이 너무 커서 첨부할 수 없습니다."],
84
90
  agentAttachUnsupported: ["{name} cannot be attached here.", "{name}은(는) 여기에 첨부할 수 없습니다."],
85
91
  agentAttachDuplicate: ["{name} is already attached.", "{name}은(는) 이미 첨부되어 있습니다."],
@@ -1,7 +1,7 @@
1
1
  import { Any, enumOf } from "akanjs/base";
2
2
  import { ConstantRegistry, via } from "akanjs/constant";
3
3
 
4
- export class AgentStop extends enumOf("agentStop", ["end", "toolUse"] as const) {}
4
+ export class AgentStop extends enumOf("agentStop", ["end", "toolUse", "length"] as const) {}
5
5
 
6
6
  export class AgentTurn extends via((field) => ({
7
7
  text: field(String, { default: "" }), // the assistant's words; empty when the turn is only tool calls
@@ -173,6 +173,13 @@ export class FetchClient {
173
173
  }
174
174
  : signal;
175
175
  }
176
+ /**
177
+ * The budget for every call that neither names one nor is served by an endpoint declaring one. `false` waits
178
+ * as long as the runtime will, which is the browser's own limit — minutes.
179
+ */
180
+ setTimeout(timeout?: number | false) {
181
+ this.http.setTimeout(timeout);
182
+ }
176
183
  setErrorConstructor(ErrorCls?: ErrorConstructor) {
177
184
  this.ErrorCls = ErrorCls;
178
185
  this.http.setErrorConstructor(ErrorCls);
@@ -316,8 +323,9 @@ export class FetchClient {
316
323
  const url = FetchClient.makeHttpUrl(key, endpoint, prefix, argMap);
317
324
  const headers = this.#makeAuthHeaders(option);
318
325
  const baseUrl = option?.origin;
326
+ const timeout = option?.timeout ?? endpoint.timeout;
319
327
 
320
- const requestQuery = () => this.http.get(url, { headers, baseUrl });
328
+ const requestQuery = () => this.http.get(url, { headers, baseUrl, timeout });
321
329
 
322
330
  const claim = baseUrl
323
331
  ? { value: requestQuery(), owned: true }
@@ -339,6 +347,7 @@ export class FetchClient {
339
347
  const response = await this.http.send(endpoint.method ?? "POST", url, body, {
340
348
  headers: this.#makeAuthHeaders(option),
341
349
  baseUrl: option?.origin,
350
+ timeout: option?.timeout ?? endpoint.timeout,
342
351
  });
343
352
  const parsedReturn = parseReturn(response, { crystalize: option?.crystalize ?? true });
344
353
  return parsedReturn;
@@ -827,7 +836,15 @@ export class FetchClient {
827
836
  connect = false,
828
837
  base,
829
838
  Err,
830
- }: { origin?: string; connect?: boolean; base?: FetchProxy; Err?: ErrorConstructor } = {},
839
+ timeout,
840
+ }: {
841
+ origin?: string;
842
+ connect?: boolean;
843
+ base?: FetchProxy;
844
+ Err?: ErrorConstructor;
845
+ /** This app's own default request budget, for calls no endpoint and no caller gave one. */
846
+ timeout?: number | false;
847
+ } = {},
831
848
  ): {
832
849
  sig: ClientSignalMap<SigType>;
833
850
  fetch: SigType["fetch"];
@@ -838,6 +855,7 @@ export class FetchClient {
838
855
  const proxy =
839
856
  shared ??
840
857
  FetchClient.#makeProxy<unknown, Record<string, SliceMeta>>(new FetchClient(origin, {}, serializedSignal, Err));
858
+ if (timeout !== undefined) proxy.instance.setTimeout(timeout);
841
859
  if (connect) proxy.instance.connect();
842
860
  const sig = {} as any;
843
861
  Object.entries(serializedSignal).forEach(([refName, serializedSignal]) => {
@@ -49,6 +49,10 @@ export class HttpClient {
49
49
  setErrorConstructor(ErrorCls?: ErrorConstructor) {
50
50
  this.ErrorCls = ErrorCls;
51
51
  }
52
+ /** The budget every call that names none takes. `false` waits as long as the runtime will. */
53
+ setTimeout(timeout?: number | false) {
54
+ this.#timeout = timeout;
55
+ }
52
56
  #resolveBaseUrl(baseUrl?: string) {
53
57
  return (baseUrl ?? this.baseUrl).replace(/\/$/, "");
54
58
  }
@@ -171,13 +171,13 @@ export class WsClient {
171
171
  break;
172
172
  }
173
173
  } catch (error) {
174
- this.logger.warn("WebSocket message process failed");
175
- console.error(error);
174
+ const errMsg = error instanceof Error ? error.message : String(error);
175
+ this.logger.warn(`WebSocket message process failed ${errMsg}`);
176
176
  }
177
177
  };
178
178
  this.#ws.onerror = (e) => {
179
- this.logger.debug(`WebSocket error`);
180
- console.error(e);
179
+ const errMsg = e instanceof Error ? e.message : String(e);
180
+ this.logger.verbose(`WebSocket error ${errMsg}`);
181
181
  };
182
182
  this.#ws.onclose = (event) => {
183
183
  this.logger.debug(`WebSocket closed: ${event.code} ${event.reason}`);
package/index.ts CHANGED
@@ -292,6 +292,11 @@ export interface SubspaceDeclaration {
292
292
  repo: string;
293
293
  /** Apps this subspace serves. Libraries are never listed — they are derived from each app's closure. */
294
294
  apps: string[];
295
+ /**
296
+ * The cloud workspace this subspace deploys from — its own `AKAN_WORKSPACE_ID`, not this workspace's.
297
+ * `akan subspace upload-env` is the only thing that reads it.
298
+ */
299
+ workspaceId?: string;
295
300
  }
296
301
 
297
302
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.0",
3
+ "version": "3.0.0-beta.10",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -17,7 +17,7 @@ import { agentTurnConstant, agentTurnDocument } from "../../signal/agentTurn";
17
17
  import { Base, BaseEndpoint, BaseInternal } from "../../signal/base.signal";
18
18
  import type { Endpoint } from "../../signal/endpoint";
19
19
  import type { Internal } from "../../signal/internal";
20
- import { Logging, type MiddlewareCls } from "../../signal/middleware";
20
+ import { Cache, Logging, type MiddlewareCls, Timeout } from "../../signal/middleware";
21
21
  import type { ServerSignal, ServerSignalCls } from "../../signal/serverSignal";
22
22
  import { SignalRegistry } from "../../signal/signalRegistry";
23
23
  import type { AkanLib, DatabaseModule, ScalarModule, ServiceModule } from "../akanLib";
@@ -126,6 +126,10 @@ export class DiLifecycle {
126
126
  : null;
127
127
  if (frameworkAgent) this.#service.set("agent", frameworkAgent);
128
128
  this.#middleware.set(Logging.refName, Logging);
129
+
130
+ this.#middleware.set(Timeout.refName, Timeout);
131
+
132
+ this.#middleware.set(Cache.refName, Cache);
129
133
  const defaultOption = createDefaultAkanOption();
130
134
  defaultOption.getMiddlewares().forEach((middleware) => {
131
135
  this.#middleware.set(middleware.refName, middleware);
@@ -2,6 +2,7 @@ import { Err } from "akanjs/dictionary";
2
2
  import type {
3
3
  AgentWireAttachment,
4
4
  AgentWireMessage,
5
+ AgentWireReference,
5
6
  LlmAccepts,
6
7
  LlmTurnRequest,
7
8
  } from "./predefinedAdaptor/llm.adaptor";
@@ -16,7 +17,9 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
16
17
 
17
18
  const names = ToolNames.of(request);
18
19
  const prepared = names.encode(
19
- AgentService.instructed(AgentService.readable(AgentService.explained(request), this.llm.accepts)),
20
+ AgentService.instructed(
21
+ AgentService.readable(AgentService.referenced(AgentService.explained(request)), this.llm.accepts),
22
+ ),
20
23
  );
21
24
  const answer = await this.llm.chat(prepared, onDelta);
22
25
  if (!answer) throw new Err("agent.error.llmUnavailable");
@@ -68,6 +71,94 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
68
71
  return { ...rest, text: [message.text, `[The turn failed: ${error}]`].filter(Boolean).join("\n\n") };
69
72
  }
70
73
 
74
+ /**
75
+ * The ceiling one reference's value may add to a turn, mirroring the client's own — the browser clips before it
76
+ * stages, and this is the same answer given again where nothing can route around it. `runTurn` is the only path
77
+ * to `chat()`, so a host that builds the wire itself, an older client, and a replayed transcript all pass here.
78
+ */
79
+ static readonly referenceLimit = 20_000;
80
+
81
+ /**
82
+ * Folds what the user pointed at into the message they pointed with, as text.
83
+ *
84
+ * Text rather than a carrier of its own for the same reason the note in `readable` is text: it is the one field
85
+ * every provider mapping already reads, so Anthropic, the OpenAI dialect and DeepSeek need no change between
86
+ * them and none of them can drop a reference quietly.
87
+ *
88
+ * The heading rides once per message rather than once per reference, and lives here rather than in `preamble`
89
+ * because most conversations never carry one — a sentence about mention tokens in every turn of every app is
90
+ * paid by every app that has no references at all.
91
+ */
92
+ static referenced(request: LlmTurnRequest): LlmTurnRequest {
93
+ if (!request.messages.some((message) => message.references?.length)) return request;
94
+ return { ...request, messages: request.messages.map((message) => AgentService.referencedMessage(message)) };
95
+ }
96
+
97
+ private static referencedMessage(message: AgentWireMessage): AgentWireMessage {
98
+ const { references = [], ...rest } = message;
99
+ if (!references.length) return message;
100
+ const block = [AgentService.referenceHeading, ...references.map(AgentService.referenceLine)].join("\n\n");
101
+ return { ...rest, text: [message.text, block].filter(Boolean).join("\n\n") };
102
+ }
103
+
104
+ /**
105
+ * Not decoration, and not free to shorten. Both halves of the sentence were observed doing their job, against
106
+ * Anthropic, with the same referenced field and only the published tools changed:
107
+ *
108
+ * - With no tool on the screen to re-read with, the model opened its answer by saying so — that it could see
109
+ * only the snapshot and the field might have been edited since. Unprompted, ahead of the answer.
110
+ * - With the screen's own tools passed (a state read and a write), it issued the read instead of answering,
111
+ * and stopped the turn there.
112
+ *
113
+ * So it reads as an instruction rather than as framing: it re-reads where it can and says it cannot where it
114
+ * cannot, which is the pair a turn confidently quoting a stale value is bought against. Two scenarios against
115
+ * one provider, not the per-cell runs behind `preamble` — enough to keep the sentence, not enough to call it
116
+ * measured.
117
+ */
118
+ static readonly referenceHeading =
119
+ "[Referenced data: the user pointed at this while writing the message above, with the @[label](mention:…) " +
120
+ "tokens in it. Each value is what it was at the moment they sent the message, not what it is now — read it " +
121
+ "again with a tool before relying on it, and do not assume an edit you have made since is reflected here.]";
122
+
123
+ /**
124
+ * A string value is printed as itself rather than as JSON. It is the common case — one field of one document —
125
+ * and a quoted, escaped copy of a paragraph is harder for a model to read back and to quote from than the
126
+ * paragraph. It is also what a clipped value already is, so the cut JSON prints as the fragment it is.
127
+ *
128
+ * The pointer leads the line rather than riding the wire alone, and that is what the label cannot do. Pointed
129
+ * at a saved document while a *different* document of the same model sat open on the screen, the model
130
+ * compared the two ids, said the edit it could make would land on the wrong one, and stopped to ask — with no
131
+ * write call. Two references sharing a label are still two ids here, so keep the id ahead of the label.
132
+ */
133
+ private static referenceLine(reference: AgentWireReference): string {
134
+ const at = `${reference.refName}/${reference.refId}${reference.path ? `#${reference.path}` : ""}`;
135
+ const head = `${at} (${reference.label}):`;
136
+ if (reference.value === undefined)
137
+ return `${head} [not read: ${reference.note ?? "the value was not carried into this conversation"}]`;
138
+ const text =
139
+ typeof reference.value === "string" ? reference.value : (JSON.stringify(reference.value, null, 2) ?? "null");
140
+ const body =
141
+ text.length <= AgentService.referenceLimit
142
+ ? text
143
+ : `${text.slice(0, AgentService.referenceLimit)}…\n[Clipped at ${AgentService.referenceLimit} characters.]`;
144
+ return `${head}\n${AgentService.fenced(body)}${reference.note ? `\n[${reference.note}]` : ""}`;
145
+ }
146
+
147
+ /**
148
+ * Where a value ends. A multi-line one — the usual shape of the prose field somebody points at — otherwise runs
149
+ * straight into the next reference's heading, and the model reads one value that swallowed the next label.
150
+ *
151
+ * The fence grows past the longest backtick run inside the value, which is CommonMark's own answer to the same
152
+ * problem: a fixed fence is one that a value containing a fence breaks out of, and a value containing a fence is
153
+ * ordinary here, because the thing being pointed at is often something a person wrote.
154
+ */
155
+ private static fenced(text: string): string {
156
+ const runs = text.match(/`+/g);
157
+ const longest = runs ? Math.max(...runs.map((run) => run.length)) : 0;
158
+ const fence = "`".repeat(Math.max(3, longest + 1));
159
+ return `${fence}\n${text}\n${fence}`;
160
+ }
161
+
71
162
  /**
72
163
  * Replaces every attachment the provider cannot read with a note naming it, so no adaptor has to think about
73
164
  * attachments it does not support and none can lose one quietly. The model has to be *told*, not merely spared:
@@ -98,14 +189,18 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
98
189
  private static isReadable(attachment: AgentWireAttachment, accepts: LlmAccepts): boolean {
99
190
  if (attachment.text) return true;
100
191
  if (!attachment.data && !attachment.url) return false;
192
+
193
+ if (typeof attachment.mimeType !== "string") return false;
101
194
  return attachment.mimeType.startsWith("image/") ? !!accepts.image : !!accepts.document;
102
195
  }
103
196
 
104
197
  private static note(attachment: AgentWireAttachment): string {
105
198
  const why =
106
- attachment.data || attachment.url
107
- ? "this model cannot read that type"
108
- : "its content is no longer available, as a reloaded conversation keeps the name and not the bytes";
199
+ !attachment.data && !attachment.url
200
+ ? "its content is no longer available, as a reloaded conversation keeps the name and not the bytes"
201
+ : typeof attachment.mimeType === "string"
202
+ ? "this model cannot read that type"
203
+ : "it names no type it could be read as";
109
204
  return `[Attachment not read: ${attachment.name} (${attachment.mimeType}) — ${why}. Tell the user it was not read instead of guessing what it holds, and ask for the text if the answer needs it.]`;
110
205
  }
111
206
  }