akanjs 3.0.0-beta.1 → 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 (96) 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/agentTurn.dictionary.ts +6 -2
  7. package/dictionary/base.dictionary.ts +5 -0
  8. package/fetch/agentTurn.ts +1 -1
  9. package/fetch/client/fetchClient.ts +20 -2
  10. package/fetch/client/httpClient.ts +4 -0
  11. package/fetch/client/wsClient.ts +4 -4
  12. package/index.ts +5 -0
  13. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  14. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  15. package/package.json +1 -1
  16. package/server/di/diLifecycle.ts +5 -1
  17. package/service/agent.service.ts +99 -4
  18. package/service/predefinedAdaptor/anthropicLlm.ts +39 -6
  19. package/service/predefinedAdaptor/llm.adaptor.ts +24 -1
  20. package/service/predefinedAdaptor/openaiDialect.ts +40 -10
  21. package/signal/agentTurnStream.ts +5 -2
  22. package/signal/middleware.ts +82 -47
  23. package/signal/serializer/fetch.serializer.ts +1 -0
  24. package/signal/signalContext.ts +8 -1
  25. package/signal/types.ts +24 -0
  26. package/store/agentic/index.ts +1 -0
  27. package/store/agentic/useAgentReference.ts +49 -0
  28. package/store/hooks.ts +1 -1
  29. package/types/common/index.d.ts +1 -0
  30. package/types/common/toPathSegments.d.ts +9 -0
  31. package/types/common/types.d.ts +5 -1
  32. package/types/dictionary/base.dictionary.d.ts +1 -1
  33. package/types/dictionary/dictionary.d.ts +8 -8
  34. package/types/fetch/agentTurn.d.ts +3 -3
  35. package/types/fetch/client/fetchClient.d.ts +8 -1
  36. package/types/fetch/client/httpClient.d.ts +2 -0
  37. package/types/index.d.ts +5 -0
  38. package/types/service/agent.service.d.ts +55 -1
  39. package/types/service/predefinedAdaptor/anthropicLlm.d.ts +21 -0
  40. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +23 -1
  41. package/types/service/predefinedAdaptor/openaiDialect.d.ts +13 -1
  42. package/types/signal/agent.signal.d.ts +1 -1
  43. package/types/signal/agentTurn.d.ts +1 -1
  44. package/types/signal/agentTurnStream.d.ts +1 -1
  45. package/types/signal/middleware.d.ts +19 -10
  46. package/types/signal/signalContext.d.ts +6 -1
  47. package/types/signal/types.d.ts +24 -0
  48. package/types/store/agentic/index.d.ts +1 -0
  49. package/types/store/agentic/useAgentReference.d.ts +32 -0
  50. package/types/store/hooks.d.ts +1 -1
  51. package/types/ui/Agent/Attach.d.ts +1 -1
  52. package/types/ui/Agent/Chat.d.ts +17 -1
  53. package/types/ui/Agent/Composer.d.ts +9 -2
  54. package/types/ui/Agent/Menu.d.ts +4 -2
  55. package/types/ui/Agent/Refer.d.ts +13 -0
  56. package/types/ui/Agent/Steps.d.ts +33 -0
  57. package/types/ui/Agent/useChatQueue.d.ts +3 -1
  58. package/types/ui/Agent/useChatReferences.d.ts +26 -0
  59. package/types/ui/Agent/useReferenceMenu.d.ts +43 -0
  60. package/types/ui/Field/lightRefCache.d.ts +19 -0
  61. package/types/ui/Field/useRelationOptions.d.ts +39 -0
  62. package/types/ui/Loading/Spin.d.ts +19 -3
  63. package/types/ui/Select.d.ts +2 -0
  64. package/types/ui/UiOverride/context.d.ts +2 -0
  65. package/types/ui/index.d.ts +6 -1
  66. package/types/vendor/use-agentic/AgentSession.d.ts +54 -1
  67. package/types/vendor/use-agentic/Reference.d.ts +56 -0
  68. package/types/vendor/use-agentic/Transcript.d.ts +2 -1
  69. package/types/vendor/use-agentic/index.d.ts +1 -0
  70. package/types/vendor/use-agentic/types.d.ts +37 -1
  71. package/ui/Agent/Attach.tsx +4 -3
  72. package/ui/Agent/Bubble.tsx +2 -0
  73. package/ui/Agent/Chat.tsx +72 -13
  74. package/ui/Agent/Composer.tsx +18 -1
  75. package/ui/Agent/Menu.tsx +8 -3
  76. package/ui/Agent/Queued.tsx +2 -0
  77. package/ui/Agent/Refer.tsx +44 -0
  78. package/ui/Agent/Steps.tsx +49 -0
  79. package/ui/Agent/sessionHistory.ts +38 -12
  80. package/ui/Agent/useChatQueue.ts +11 -1
  81. package/ui/Agent/useChatReferences.ts +67 -0
  82. package/ui/Agent/useReferenceMenu.ts +108 -0
  83. package/ui/Field/Relation.tsx +70 -150
  84. package/ui/Field/lightRefCache.ts +73 -0
  85. package/ui/Field/useRelationOptions.ts +106 -0
  86. package/ui/Loading/Spin.tsx +24 -4
  87. package/ui/Select.tsx +24 -14
  88. package/ui/UiOverride/context.ts +2 -0
  89. package/ui/index.ts +11 -0
  90. package/vendor/use-agentic/AgentSession.ts +142 -3
  91. package/vendor/use-agentic/Compaction.ts +9 -1
  92. package/vendor/use-agentic/Reference.ts +99 -0
  93. package/vendor/use-agentic/Transcript.ts +9 -3
  94. package/vendor/use-agentic/httpRunner.ts +1 -1
  95. package/vendor/use-agentic/index.ts +1 -0
  96. 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
  *
@@ -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
  }));
@@ -81,6 +81,11 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
81
81
  agentAttach: ["Attach a file", "파일 첨부"],
82
82
  agentAttachRemove: ["Remove attachment", "첨부 제거"],
83
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
+ ],
84
89
  agentAttachTooLarge: ["{name} is too large to attach.", "{name}은(는) 용량이 너무 커서 첨부할 수 없습니다."],
85
90
  agentAttachUnsupported: ["{name} cannot be attached here.", "{name}은(는) 여기에 첨부할 수 없습니다."],
86
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.1",
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
  }
@@ -58,6 +58,18 @@ export class AnthropicLlm
58
58
  */
59
59
  static readonly defaultMaxTokens = 8192;
60
60
 
61
+ /**
62
+ * The four the API's image block reads. An exact set rather than an `image/*` prefix, because by the time an
63
+ * attachment reaches here `accepts.image` has already carried it past `AgentService.readable`: a phone's
64
+ * `image/heic` — the iPhone camera default, so the likeliest non-canonical image an app sees — arrives as bytes,
65
+ * becomes a block the API refuses, and takes the **whole turn** down on a 400 rather than going unread.
66
+ *
67
+ * The app cannot gate it either: `AttachReader` answers `null` for "not mine", which falls through to the
68
+ * built-in reader that base64s any `image/*`, so there is no way for a reader to refuse one. The check belongs
69
+ * where the block vocabulary is known, which is here.
70
+ */
71
+ static readonly imageTypes = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
72
+
61
73
  get #host() {
62
74
  return this.llmOption.host ?? "https://api.anthropic.com/v1";
63
75
  }
@@ -101,7 +113,7 @@ export class AnthropicLlm
101
113
  * so it is one line to diagnose rather than a model that appears to have refused.
102
114
  */
103
115
  #reported(answer: LlmTurnAnswer): LlmTurnAnswer {
104
- if (!answer.text && !answer.toolCalls?.length)
116
+ if (!answer.text && !answer.toolCalls?.length && answer.stop !== "length")
105
117
  this.logger.warn(
106
118
  `Anthropic answered with no text and no tool call. If this repeats, raise option.setLlm({ maxTokens }) — currently ${this.llmOption.maxTokens ?? AnthropicLlm.defaultMaxTokens}.`,
107
119
  );
@@ -266,9 +278,13 @@ export class AnthropicLlm
266
278
  ];
267
279
  const source = AnthropicLlm.sourceOf(attachment);
268
280
  if (!source) return [];
269
- if (accepts?.image && attachment.mimeType.startsWith("image/")) return [{ type: "image", source }];
281
+
282
+ const mimeType = attachment.mimeType.split(";")[0].trim().toLowerCase();
283
+ if (accepts?.image && AnthropicLlm.imageTypes.has(mimeType))
284
+ return [{ type: "image", source: AnthropicLlm.typed(source, mimeType) }];
270
285
 
271
- if (accepts?.document && attachment.mimeType === "application/pdf") return [{ type: "document", source }];
286
+ if (accepts?.document && mimeType === "application/pdf")
287
+ return [{ type: "document", source: AnthropicLlm.typed(source, mimeType) }];
272
288
  notes.push(`[Attachment not read: ${attachment.name} (${attachment.mimeType}) — this API has no block for it.]`);
273
289
  return [];
274
290
  });
@@ -276,9 +292,20 @@ export class AnthropicLlm
276
292
  return [...(text ? [{ type: "text" as const, text }] : []), ...blocks];
277
293
  }
278
294
 
295
+ /** The block's `media_type` is the essence, not whatever parameters the browser attached to it. */
296
+ static typed(source: AnthropicSource, mimeType: string): AnthropicSource {
297
+ return source.type === "base64" ? { ...source, media_type: mimeType } : source;
298
+ }
299
+
300
+ /**
301
+ * Bytes beat an address when a host sent both: it already paid for them on the way in, and the address it also
302
+ * sent is the one it renders — which the default storage backend serves on a path only the app can resolve.
303
+ * Picking that costs a confident answer about a picture nothing fetched; picking the bytes costs one hop that
304
+ * already carries them. A URL the provider really can reach travels alone.
305
+ */
279
306
  static sourceOf(attachment: AgentWireAttachment): AnthropicSource | null {
280
- if (attachment.url) return { type: "url", url: attachment.url };
281
307
  if (attachment.data) return { type: "base64", media_type: attachment.mimeType, data: attachment.data };
308
+ if (attachment.url) return { type: "url", url: attachment.url };
282
309
  return null;
283
310
  }
284
311
 
@@ -294,10 +321,16 @@ export class AnthropicLlm
294
321
  return {
295
322
  ...(text ? { text } : {}),
296
323
  ...(toolCalls.length ? { toolCalls } : {}),
297
- stop: answer.stop_reason === "tool_use" || toolCalls.length ? "toolUse" : "end",
324
+ stop: AnthropicLlm.stopOf(answer.stop_reason, toolCalls.length),
298
325
  };
299
326
  }
300
327
 
328
+ /** The ceiling wins over the calls that did arrive — see `OpenaiDialect.stopOf` for why. */
329
+ static stopOf(reason: string | null | undefined, calls: number): LlmTurnAnswer["stop"] {
330
+ if (reason === "max_tokens") return "length";
331
+ return reason === "tool_use" || calls ? "toolUse" : "end";
332
+ }
333
+
301
334
  /**
302
335
  * The API streams named SSE events rather than one chunk shape. A tool call opens as `content_block_start`
303
336
  * carrying its id and name and then arrives as `input_json_delta` fragments of a JSON string, so it is assembled
@@ -359,7 +392,7 @@ export class AnthropicLlm
359
392
  return {
360
393
  ...(text ? { text } : {}),
361
394
  ...(toolCalls.length ? { toolCalls } : {}),
362
- stop: stopReason === "tool_use" || toolCalls.length ? "toolUse" : "end",
395
+ stop: AnthropicLlm.stopOf(stopReason, toolCalls.length),
363
396
  };
364
397
  }
365
398
 
@@ -28,6 +28,22 @@ export interface AgentWireAttachment {
28
28
  ref?: string;
29
29
  }
30
30
 
31
+ /**
32
+ * Data the caller pointed at while writing one message, rather than a file they attached. `value` is a snapshot
33
+ * taken when the message was sent and already masked by the host — the server has no model class to mask it with,
34
+ * so what the browser staged is what leaves. `refName`/`refId`/`path` are the way back to the current value, which
35
+ * is why they travel even when the value itself does not.
36
+ */
37
+ export interface AgentWireReference {
38
+ refName: string;
39
+ refId: string;
40
+ label: string;
41
+ path?: string;
42
+ value?: unknown;
43
+ /** Read by the model in place of a value there is none of — clipped, unreadable, or gone from a restored chat. */
44
+ note?: string;
45
+ }
46
+
31
47
  /**
32
48
  * One transcript message of the in-page agent wire (`use-agentic`'s WIRE.md), typed at both ends independently —
33
49
  * the wire is the contract, so the server never imports the client package.
@@ -36,6 +52,7 @@ export interface AgentWireMessage {
36
52
  role: "user" | "assistant" | "tool";
37
53
  text?: string;
38
54
  attachments?: AgentWireAttachment[];
55
+ references?: AgentWireReference[];
39
56
  toolCalls?: AgentWireToolCall[];
40
57
  toolResults?: AgentWireToolResult[];
41
58
  error?: string;
@@ -68,7 +85,13 @@ export interface LlmTurnRequest {
68
85
  export interface LlmTurnAnswer {
69
86
  text?: string;
70
87
  toolCalls?: AgentWireToolCall[];
71
- stop: "end" | "toolUse";
88
+ /**
89
+ * Why the turn ended. `"length"` is the provider's ceiling — `finish_reason: "length"`, `stop_reason:
90
+ * "max_tokens"` — and it is distinguished from `"end"` because the two are indistinguishable downstream
91
+ * otherwise: a truncated answer reads as a complete one, and a turn cut off mid tool call carries no complete
92
+ * call at all, so it would end the loop looking exactly like a model that chose to stop.
93
+ */
94
+ stop: "end" | "toolUse" | "length";
72
95
  }
73
96
 
74
97
  /**
@@ -34,6 +34,14 @@ export interface OpenaiMessage {
34
34
  * read to a note in the text.
35
35
  */
36
36
  export class OpenaiDialect {
37
+ /**
38
+ * The types this dialect's image part reads. Exact rather than an `image/*` prefix and declared apart from
39
+ * Anthropic's identical-looking set, because the two are each a provider's own list and only happen to agree:
40
+ * an unsupported one passed through is a refused *request*, not an unread attachment, so the safe direction is
41
+ * to name what is known to work and note the rest.
42
+ */
43
+ static readonly imageTypes = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
44
+
37
45
  static requestBody(
38
46
  model: string,
39
47
  request: LlmTurnRequest,
@@ -114,20 +122,33 @@ export class OpenaiDialect {
114
122
  * Text attachments are labelled into the message, because a model handed two unlabelled documents can no longer
115
123
  * cite either one. Images become their own parts only when the provider said it reads them; the dialect carries
116
124
  * one as a `data:` URL, which is the same encoding whether the bytes were inlined or already addressable, so
117
- * both carriers take one branch.
125
+ * both carriers take one branch — the inlined bytes first, for the reason named at the branch.
118
126
  */
119
127
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): string | OpenaiContentPart[] {
120
128
  const attachments = message.attachments ?? [];
129
+ const notes: string[] = [];
121
130
  const blocks = attachments.flatMap((attachment) =>
122
131
  attachment.text ? [`--- attachment: ${attachment.name} (${attachment.mimeType}) ---\n${attachment.text}`] : [],
123
132
  );
124
- const text = [message.text, ...blocks].filter(Boolean).join("\n\n");
125
- if (!accepts?.image) return text;
126
- const images = attachments.flatMap((attachment) => {
127
- if (!attachment.mimeType.startsWith("image/")) return [];
128
- const url = attachment.url ?? (attachment.data ? `data:${attachment.mimeType};base64,${attachment.data}` : "");
129
- return url ? [{ type: "image_url" as const, image_url: { url } }] : [];
130
- });
133
+ const images = !accepts?.image
134
+ ? []
135
+ : attachments.flatMap((attachment) => {
136
+ if (attachment.text) return [];
137
+
138
+ const mimeType = attachment.mimeType.split(";")[0].trim().toLowerCase();
139
+
140
+ if (!mimeType.startsWith("image/")) return [];
141
+ if (!OpenaiDialect.imageTypes.has(mimeType)) {
142
+ notes.push(
143
+ `[Attachment not read: ${attachment.name} (${attachment.mimeType}) — this API reads no image of that type.]`,
144
+ );
145
+ return [];
146
+ }
147
+
148
+ const url = attachment.data ? `data:${mimeType};base64,${attachment.data}` : (attachment.url ?? "");
149
+ return url ? [{ type: "image_url" as const, image_url: { url } }] : [];
150
+ });
151
+ const text = [message.text, ...blocks, ...notes].filter(Boolean).join("\n\n");
131
152
  if (!images.length) return text;
132
153
  return [...(text ? [{ type: "text" as const, text }] : []), ...images];
133
154
  }
@@ -193,10 +214,19 @@ export class OpenaiDialect {
193
214
  return {
194
215
  ...(text ? { text } : {}),
195
216
  ...(toolCalls.length ? { toolCalls } : {}),
196
- stop: finish === "tool_calls" || toolCalls.length ? "toolUse" : "end",
217
+ stop: OpenaiDialect.stopOf(finish, toolCalls.length),
197
218
  };
198
219
  }
199
220
 
221
+ /**
222
+ * The ceiling wins over the calls that did arrive. A turn the provider cut short is one whose last call may be
223
+ * missing, so running the batch it did finish is acting on half an intention.
224
+ */
225
+ static stopOf(finish: string | null | undefined, calls: number): LlmTurnAnswer["stop"] {
226
+ if (finish === "length") return "length";
227
+ return finish === "tool_calls" || calls ? "toolUse" : "end";
228
+ }
229
+
200
230
  static turnAnswer(answer: OpenaiAnswer): LlmTurnAnswer {
201
231
  const choice = answer.choices?.[0];
202
232
  const toolCalls = (choice?.message?.tool_calls ?? []).flatMap((call) => {
@@ -206,7 +236,7 @@ export class OpenaiDialect {
206
236
  return {
207
237
  ...(choice?.message?.content ? { text: choice.message.content } : {}),
208
238
  ...(toolCalls.length ? { toolCalls } : {}),
209
- stop: choice?.finish_reason === "tool_calls" || toolCalls.length ? "toolUse" : "end",
239
+ stop: OpenaiDialect.stopOf(choice?.finish_reason, toolCalls.length),
210
240
  };
211
241
  }
212
242
 
@@ -3,7 +3,7 @@ import type { AgentWireToolCall } from "akanjs/service";
3
3
  interface StreamedTurn {
4
4
  text?: string;
5
5
  toolCalls?: AgentWireToolCall[];
6
- stop?: "end" | "toolUse";
6
+ stop?: "end" | "toolUse" | "length";
7
7
  }
8
8
 
9
9
  /**
@@ -44,7 +44,10 @@ export class AgentTurnStream {
44
44
  if (!streamed && turn.text) send({ type: "text", delta: turn.text });
45
45
  const toolCalls = turn.toolCalls ?? [];
46
46
  for (const call of toolCalls) send({ type: "toolCall", id: call.id, name: call.name, args: call.args });
47
- send({ type: "done", stop: turn.stop === "toolUse" || toolCalls.length ? "toolUse" : "end" });
47
+
48
+ const stop =
49
+ turn.stop === "length" ? "length" : turn.stop === "toolUse" || toolCalls.length ? "toolUse" : "end";
50
+ send({ type: "done", stop });
48
51
  } catch (error) {
49
52
 
50
53
  send({ type: "error", ...AgentTurnStream.failure(error) });