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.
- package/common/index.ts +1 -0
- package/common/pathGet.ts +12 -2
- package/common/pathSet.ts +2 -3
- package/common/toPathSegments.ts +10 -0
- package/common/types.ts +5 -1
- package/dictionary/agentTurn.dictionary.ts +6 -2
- package/dictionary/base.dictionary.ts +5 -0
- package/fetch/agentTurn.ts +1 -1
- package/fetch/client/fetchClient.ts +20 -2
- package/fetch/client/httpClient.ts +4 -0
- package/fetch/client/wsClient.ts +4 -4
- package/index.ts +5 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/di/diLifecycle.ts +5 -1
- package/service/agent.service.ts +99 -4
- package/service/predefinedAdaptor/anthropicLlm.ts +39 -6
- package/service/predefinedAdaptor/llm.adaptor.ts +24 -1
- package/service/predefinedAdaptor/openaiDialect.ts +40 -10
- package/signal/agentTurnStream.ts +5 -2
- package/signal/middleware.ts +82 -47
- package/signal/serializer/fetch.serializer.ts +1 -0
- package/signal/signalContext.ts +8 -1
- package/signal/types.ts +24 -0
- package/store/agentic/index.ts +1 -0
- package/store/agentic/useAgentReference.ts +49 -0
- package/store/hooks.ts +1 -1
- package/types/common/index.d.ts +1 -0
- package/types/common/toPathSegments.d.ts +9 -0
- package/types/common/types.d.ts +5 -1
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/fetch/agentTurn.d.ts +3 -3
- package/types/fetch/client/fetchClient.d.ts +8 -1
- package/types/fetch/client/httpClient.d.ts +2 -0
- package/types/index.d.ts +5 -0
- package/types/service/agent.service.d.ts +55 -1
- package/types/service/predefinedAdaptor/anthropicLlm.d.ts +21 -0
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +23 -1
- package/types/service/predefinedAdaptor/openaiDialect.d.ts +13 -1
- package/types/signal/agent.signal.d.ts +1 -1
- package/types/signal/agentTurn.d.ts +1 -1
- package/types/signal/agentTurnStream.d.ts +1 -1
- package/types/signal/middleware.d.ts +19 -10
- package/types/signal/signalContext.d.ts +6 -1
- package/types/signal/types.d.ts +24 -0
- package/types/store/agentic/index.d.ts +1 -0
- package/types/store/agentic/useAgentReference.d.ts +32 -0
- package/types/store/hooks.d.ts +1 -1
- package/types/ui/Agent/Attach.d.ts +1 -1
- package/types/ui/Agent/Chat.d.ts +17 -1
- package/types/ui/Agent/Composer.d.ts +9 -2
- package/types/ui/Agent/Menu.d.ts +4 -2
- package/types/ui/Agent/Refer.d.ts +13 -0
- package/types/ui/Agent/Steps.d.ts +33 -0
- package/types/ui/Agent/useChatQueue.d.ts +3 -1
- package/types/ui/Agent/useChatReferences.d.ts +26 -0
- package/types/ui/Agent/useReferenceMenu.d.ts +43 -0
- package/types/ui/Field/lightRefCache.d.ts +19 -0
- package/types/ui/Field/useRelationOptions.d.ts +39 -0
- package/types/ui/Loading/Spin.d.ts +19 -3
- package/types/ui/Select.d.ts +2 -0
- package/types/ui/UiOverride/context.d.ts +2 -0
- package/types/ui/index.d.ts +6 -1
- package/types/vendor/use-agentic/AgentSession.d.ts +54 -1
- package/types/vendor/use-agentic/Reference.d.ts +56 -0
- package/types/vendor/use-agentic/Transcript.d.ts +2 -1
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/types/vendor/use-agentic/types.d.ts +37 -1
- package/ui/Agent/Attach.tsx +4 -3
- package/ui/Agent/Bubble.tsx +2 -0
- package/ui/Agent/Chat.tsx +72 -13
- package/ui/Agent/Composer.tsx +18 -1
- package/ui/Agent/Menu.tsx +8 -3
- package/ui/Agent/Queued.tsx +2 -0
- package/ui/Agent/Refer.tsx +44 -0
- package/ui/Agent/Steps.tsx +49 -0
- package/ui/Agent/sessionHistory.ts +38 -12
- package/ui/Agent/useChatQueue.ts +11 -1
- package/ui/Agent/useChatReferences.ts +67 -0
- package/ui/Agent/useReferenceMenu.ts +108 -0
- package/ui/Field/Relation.tsx +70 -150
- package/ui/Field/lightRefCache.ts +73 -0
- package/ui/Field/useRelationOptions.ts +106 -0
- package/ui/Loading/Spin.tsx +24 -4
- package/ui/Select.tsx +24 -14
- package/ui/UiOverride/context.ts +2 -0
- package/ui/index.ts +11 -0
- package/vendor/use-agentic/AgentSession.ts +142 -3
- package/vendor/use-agentic/Compaction.ts +9 -1
- package/vendor/use-agentic/Reference.ts +99 -0
- package/vendor/use-agentic/Transcript.ts +9 -3
- package/vendor/use-agentic/httpRunner.ts +1 -1
- package/vendor/use-agentic/index.ts +1 -0
- 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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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,
|
|
23
|
-
"턴이 끝난 이유 — end
|
|
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}은(는) 이미 첨부되어 있습니다."],
|
package/fetch/agentTurn.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -171,13 +171,13 @@ export class WsClient {
|
|
|
171
171
|
break;
|
|
172
172
|
}
|
|
173
173
|
} catch (error) {
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
/**
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/di/diLifecycle.ts
CHANGED
|
@@ -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);
|
package/service/agent.service.ts
CHANGED
|
@@ -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(
|
|
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
|
|
107
|
-
? "
|
|
108
|
-
:
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) });
|