akanjs 3.0.0-beta.1 → 3.0.0-beta.3

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.
@@ -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
  }));
@@ -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
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.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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,6 +292,11 @@ 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
+
279
300
  static sourceOf(attachment: AgentWireAttachment): AnthropicSource | null {
280
301
  if (attachment.url) return { type: "url", url: attachment.url };
281
302
  if (attachment.data) return { type: "base64", media_type: attachment.mimeType, data: attachment.data };
@@ -294,10 +315,16 @@ export class AnthropicLlm
294
315
  return {
295
316
  ...(text ? { text } : {}),
296
317
  ...(toolCalls.length ? { toolCalls } : {}),
297
- stop: answer.stop_reason === "tool_use" || toolCalls.length ? "toolUse" : "end",
318
+ stop: AnthropicLlm.stopOf(answer.stop_reason, toolCalls.length),
298
319
  };
299
320
  }
300
321
 
322
+ /** The ceiling wins over the calls that did arrive — see `OpenaiDialect.stopOf` for why. */
323
+ static stopOf(reason: string | null | undefined, calls: number): LlmTurnAnswer["stop"] {
324
+ if (reason === "max_tokens") return "length";
325
+ return reason === "tool_use" || calls ? "toolUse" : "end";
326
+ }
327
+
301
328
  /**
302
329
  * The API streams named SSE events rather than one chunk shape. A tool call opens as `content_block_start`
303
330
  * carrying its id and name and then arrives as `input_json_delta` fragments of a JSON string, so it is assembled
@@ -359,7 +386,7 @@ export class AnthropicLlm
359
386
  return {
360
387
  ...(text ? { text } : {}),
361
388
  ...(toolCalls.length ? { toolCalls } : {}),
362
- stop: stopReason === "tool_use" || toolCalls.length ? "toolUse" : "end",
389
+ stop: AnthropicLlm.stopOf(stopReason, toolCalls.length),
363
390
  };
364
391
  }
365
392
 
@@ -68,7 +68,13 @@ export interface LlmTurnRequest {
68
68
  export interface LlmTurnAnswer {
69
69
  text?: string;
70
70
  toolCalls?: AgentWireToolCall[];
71
- stop: "end" | "toolUse";
71
+ /**
72
+ * Why the turn ended. `"length"` is the provider's ceiling — `finish_reason: "length"`, `stop_reason:
73
+ * "max_tokens"` — and it is distinguished from `"end"` because the two are indistinguishable downstream
74
+ * otherwise: a truncated answer reads as a complete one, and a turn cut off mid tool call carries no complete
75
+ * call at all, so it would end the loop looking exactly like a model that chose to stop.
76
+ */
77
+ stop: "end" | "toolUse" | "length";
72
78
  }
73
79
 
74
80
  /**
@@ -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,
@@ -118,16 +126,28 @@ export class OpenaiDialect {
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
+ const url = attachment.url ?? (attachment.data ? `data:${mimeType};base64,${attachment.data}` : "");
148
+ return url ? [{ type: "image_url" as const, image_url: { url } }] : [];
149
+ });
150
+ const text = [message.text, ...blocks, ...notes].filter(Boolean).join("\n\n");
131
151
  if (!images.length) return text;
132
152
  return [...(text ? [{ type: "text" as const, text }] : []), ...images];
133
153
  }
@@ -193,10 +213,19 @@ export class OpenaiDialect {
193
213
  return {
194
214
  ...(text ? { text } : {}),
195
215
  ...(toolCalls.length ? { toolCalls } : {}),
196
- stop: finish === "tool_calls" || toolCalls.length ? "toolUse" : "end",
216
+ stop: OpenaiDialect.stopOf(finish, toolCalls.length),
197
217
  };
198
218
  }
199
219
 
220
+ /**
221
+ * The ceiling wins over the calls that did arrive. A turn the provider cut short is one whose last call may be
222
+ * missing, so running the batch it did finish is acting on half an intention.
223
+ */
224
+ static stopOf(finish: string | null | undefined, calls: number): LlmTurnAnswer["stop"] {
225
+ if (finish === "length") return "length";
226
+ return finish === "tool_calls" || calls ? "toolUse" : "end";
227
+ }
228
+
200
229
  static turnAnswer(answer: OpenaiAnswer): LlmTurnAnswer {
201
230
  const choice = answer.choices?.[0];
202
231
  const toolCalls = (choice?.message?.tool_calls ?? []).flatMap((call) => {
@@ -206,7 +235,7 @@ export class OpenaiDialect {
206
235
  return {
207
236
  ...(choice?.message?.content ? { text: choice.message.content } : {}),
208
237
  ...(toolCalls.length ? { toolCalls } : {}),
209
- stop: choice?.finish_reason === "tool_calls" || toolCalls.length ? "toolUse" : "end",
238
+ stop: OpenaiDialect.stopOf(choice?.finish_reason, toolCalls.length),
210
239
  };
211
240
  }
212
241
 
@@ -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) });
@@ -1,5 +1,5 @@
1
1
  import { Any } from "akanjs/base";
2
- declare const AgentStop_base: import("akanjs/base").EnumInstance<"agentStop", "end" | "toolUse">;
2
+ declare const AgentStop_base: import("akanjs/base").EnumInstance<"agentStop", "length" | "end" | "toolUse">;
3
3
  export declare class AgentStop extends AgentStop_base {
4
4
  }
5
5
  declare const AgentTurn_base: import("akanjs/constant").ConstantCls<import("akanjs/constant").ExtractFieldInfoObject<{
@@ -466,10 +466,10 @@ export declare class AgentTurn extends AgentTurn_base {
466
466
  export declare const agentTurnConstant: import("akanjs/constant").ScalarConstantModel<"agentTurn", AgentTurn, import("akanjs/constant").DefaultOf<AgentTurn>, {
467
467
  text: string;
468
468
  toolCalls: unknown[];
469
- stop: NonNullable<"end" | "toolUse">;
469
+ stop: NonNullable<"length" | "end" | "toolUse">;
470
470
  } & {}, {
471
471
  text: string;
472
472
  toolCalls: unknown[];
473
- stop: "end" | "toolUse";
473
+ stop: "length" | "end" | "toolUse";
474
474
  } & {}>;
475
475
  export {};
@@ -6,7 +6,7 @@ export declare class AgentService extends AgentService_base {
6
6
  runTurn(request: LlmTurnRequest, onDelta?: (delta: string) => void): Promise<{
7
7
  text: string;
8
8
  toolCalls: import("./predefinedAdaptor.d.ts").AgentWireToolCall[];
9
- stop: "end" | "toolUse";
9
+ stop: "length" | "end" | "toolUse";
10
10
  }>;
11
11
  /**
12
12
  * The framework's half of the system prompt, ahead of whatever the app said so the app's text stays the more
@@ -64,6 +64,17 @@ export declare class AnthropicLlm extends AnthropicLlm_base implements LlmAdapto
64
64
  * model refusing, so it is `option.setLlm({ maxTokens })` and not a constant.
65
65
  */
66
66
  static readonly defaultMaxTokens = 8192;
67
+ /**
68
+ * The four the API's image block reads. An exact set rather than an `image/*` prefix, because by the time an
69
+ * attachment reaches here `accepts.image` has already carried it past `AgentService.readable`: a phone's
70
+ * `image/heic` — the iPhone camera default, so the likeliest non-canonical image an app sees — arrives as bytes,
71
+ * becomes a block the API refuses, and takes the **whole turn** down on a 400 rather than going unread.
72
+ *
73
+ * The app cannot gate it either: `AttachReader` answers `null` for "not mine", which falls through to the
74
+ * built-in reader that base64s any `image/*`, so there is no way for a reader to refuse one. The check belongs
75
+ * where the block vocabulary is known, which is here.
76
+ */
77
+ static readonly imageTypes: Set<string>;
67
78
  /** What the API's blocks carry. A model of the family that reads neither takes the `accepts` override. */
68
79
  get accepts(): LlmAccepts;
69
80
  chat(request: LlmTurnRequest, onDelta?: (delta: string) => void): Promise<LlmTurnAnswer | null>;
@@ -96,8 +107,12 @@ export declare class AnthropicLlm extends AnthropicLlm_base implements LlmAdapto
96
107
  static providerMessages(messages: AgentWireMessage[], accepts?: LlmAccepts): AnthropicMessage[];
97
108
  static providerMessage(message: AgentWireMessage, accepts?: LlmAccepts): AnthropicMessage;
98
109
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): AnthropicBlock[];
110
+ /** The block's `media_type` is the essence, not whatever parameters the browser attached to it. */
111
+ static typed(source: AnthropicSource, mimeType: string): AnthropicSource;
99
112
  static sourceOf(attachment: AgentWireAttachment): AnthropicSource | null;
100
113
  static turnAnswer(answer: AnthropicAnswer): LlmTurnAnswer;
114
+ /** The ceiling wins over the calls that did arrive — see `OpenaiDialect.stopOf` for why. */
115
+ static stopOf(reason: string | null | undefined, calls: number): LlmTurnAnswer["stop"];
101
116
  /**
102
117
  * The API streams named SSE events rather than one chunk shape. A tool call opens as `content_block_start`
103
118
  * carrying its id and name and then arrives as `input_json_delta` fragments of a JSON string, so it is assembled
@@ -61,7 +61,13 @@ export interface LlmTurnRequest {
61
61
  export interface LlmTurnAnswer {
62
62
  text?: string;
63
63
  toolCalls?: AgentWireToolCall[];
64
- stop: "end" | "toolUse";
64
+ /**
65
+ * Why the turn ended. `"length"` is the provider's ceiling — `finish_reason: "length"`, `stop_reason:
66
+ * "max_tokens"` — and it is distinguished from `"end"` because the two are indistinguishable downstream
67
+ * otherwise: a truncated answer reads as a complete one, and a turn cut off mid tool call carries no complete
68
+ * call at all, so it would end the loop looking exactly like a model that chose to stop.
69
+ */
70
+ stop: "end" | "toolUse" | "length";
65
71
  }
66
72
  /**
67
73
  * The provider seam for one stateless agent turn: the whole transcript in, one assistant answer out. The server
@@ -47,6 +47,13 @@ export interface OpenaiMessage {
47
47
  * read to a note in the text.
48
48
  */
49
49
  export declare class OpenaiDialect {
50
+ /**
51
+ * The types this dialect's image part reads. Exact rather than an `image/*` prefix and declared apart from
52
+ * Anthropic's identical-looking set, because the two are each a provider's own list and only happen to agree:
53
+ * an unsupported one passed through is a refused *request*, not an unread attachment, so the safe direction is
54
+ * to name what is known to work and note the rest.
55
+ */
56
+ static readonly imageTypes: Set<string>;
50
57
  static requestBody(model: string, request: LlmTurnRequest, { accepts, stream }?: {
51
58
  accepts?: LlmAccepts;
52
59
  stream?: boolean;
@@ -79,6 +86,11 @@ export declare class OpenaiDialect {
79
86
  * by index and parsed once at the end; only assistant text is worth reporting as it arrives.
80
87
  */
81
88
  static consumeStream(body: ReadableStream<Uint8Array>, onDelta: (delta: string) => void): Promise<LlmTurnAnswer>;
89
+ /**
90
+ * The ceiling wins over the calls that did arrive. A turn the provider cut short is one whose last call may be
91
+ * missing, so running the batch it did finish is acting on half an intention.
92
+ */
93
+ static stopOf(finish: string | null | undefined, calls: number): LlmTurnAnswer["stop"];
82
94
  static turnAnswer(answer: OpenaiAnswer): LlmTurnAnswer;
83
95
  /** The provider sends arguments as a JSON string; an unparsable one becomes an empty call rather than a crash. */
84
96
  static parsedArgs(raw: string | undefined): Record<string, unknown>;
@@ -12,7 +12,7 @@ declare const AgentEndpoint_base: import("./endpoint.d.ts").EndpointCls<import("
12
12
  }, ["messages", "tools", "context", "instructions"], [arg: unknown[], arg: unknown[], arg: unknown[], arg?: string | null | undefined], [arg: Bun.BunRequest<string>], [arg: unknown[], arg: unknown[], arg: unknown[], arg: string | undefined], typeof AgentTurn, AgentTurn, Promise<AgentTurn | {
13
13
  text: string;
14
14
  toolCalls: import("akanjs/service").AgentWireToolCall[];
15
- stop: "end" | "toolUse";
15
+ stop: "length" | "end" | "toolUse";
16
16
  }>, false>;
17
17
  }>;
18
18
  export declare class AgentEndpoint extends AgentEndpoint_base {
@@ -2,7 +2,7 @@ export { AgentStop, AgentTurn, agentTurnConstant } from "akanjs/fetch";
2
2
  declare const AgentTurnDocument_base: import("akanjs/document").DatabaseCls<{
3
3
  text: string;
4
4
  toolCalls: unknown[];
5
- stop: NonNullable<"end" | "toolUse">;
5
+ stop: NonNullable<"length" | "end" | "toolUse">;
6
6
  } & {}>;
7
7
  export declare class AgentTurnDocument extends AgentTurnDocument_base {
8
8
  }
@@ -2,7 +2,7 @@ import type { AgentWireToolCall } from "akanjs/service";
2
2
  interface StreamedTurn {
3
3
  text?: string;
4
4
  toolCalls?: AgentWireToolCall[];
5
- stop?: "end" | "toolUse";
5
+ stop?: "end" | "toolUse" | "length";
6
6
  }
7
7
  /**
8
8
  * The streaming half of the agent turn wire (use-agentic WIRE.md): the same endpoint answers `text/event-stream`
@@ -70,6 +70,11 @@ export interface ChatProps {
70
70
  * (images as bytes, text as text). This is where an app puts what needs a parser — a PDF's text, a spreadsheet's
71
71
  * cells — since the framework carries attachments but depends on nothing that can extract one. It runs before
72
72
  * the built-in, so it can also replace how an image is prepared.
73
+ *
74
+ * **A `url` is handed to the provider as the address it will fetch**, so answer `data` whenever the provider
75
+ * cannot reach it. A reader that uploads is the natural place to get this wrong: the default storage backend
76
+ * serves a path only this app can resolve, and a model handed one answers about a picture it never saw, with
77
+ * nothing anywhere reporting a failure.
73
78
  */
74
79
  attach?: AttachReader;
75
80
  /**
@@ -114,6 +114,12 @@ export interface ToolCallResult {
114
114
  * model cannot read and says so in the transcript, because a silently dropped file is one the model then
115
115
  * hallucinates about.
116
116
  */
117
+ /**
118
+ * Why an assistant turn ended. `length` is the provider's own ceiling rather than the model's choice, so the turn
119
+ * is incomplete — a truncated answer and a turn cut off before its tool call finished both arrive this way, and
120
+ * neither is distinguishable from `end` without it.
121
+ */
122
+ export type TurnStop = "end" | "toolUse" | "length";
117
123
  export interface MessageAttachment {
118
124
  name: string;
119
125
  mimeType: string;
@@ -165,7 +171,7 @@ export type RunnerEvent = {
165
171
  args: Record<string, unknown>;
166
172
  } | {
167
173
  type: "done";
168
- stop: "end" | "toolUse";
174
+ stop: TurnStop;
169
175
  }
170
176
  /**
171
177
  * `data` accompanies a message that is a code rather than a sentence — the values whoever resolves the code
package/ui/Agent/Chat.tsx CHANGED
@@ -109,6 +109,11 @@ export interface ChatProps {
109
109
  * (images as bytes, text as text). This is where an app puts what needs a parser — a PDF's text, a spreadsheet's
110
110
  * cells — since the framework carries attachments but depends on nothing that can extract one. It runs before
111
111
  * the built-in, so it can also replace how an image is prepared.
112
+ *
113
+ * **A `url` is handed to the provider as the address it will fetch**, so answer `data` whenever the provider
114
+ * cannot reach it. A reader that uploads is the natural place to get this wrong: the default storage backend
115
+ * serves a path only this app can resolve, and a model handed one answers about a picture it never saw, with
116
+ * nothing anywhere reporting a failure.
112
117
  */
113
118
  attach?: AttachReader;
114
119
  /**
@@ -12,6 +12,7 @@ import type {
12
12
  SurfaceView,
13
13
  ToolCallRequest,
14
14
  ToolCallResult,
15
+ TurnStop,
15
16
  } from "./types";
16
17
 
17
18
  export interface PendingApproval {
@@ -264,6 +265,15 @@ export class AgentSession {
264
265
  this.#unanswered(toolCalls);
265
266
  return;
266
267
  }
268
+
269
+ if (stop === "length") {
270
+
271
+ this.#fail(
272
+ "The model ran out of room mid-answer, so this turn is incomplete. Ask again, or raise the answer limit.",
273
+ );
274
+ if (toolCalls.length) this.#unanswered(toolCalls);
275
+ return;
276
+ }
267
277
  if (stop !== "toolUse" || !toolCalls.length) return;
268
278
  const toolResults: ToolCallResult[] = [];
269
279
  for (const call of toolCalls)
@@ -478,7 +488,7 @@ export class AgentSession {
478
488
  return text;
479
489
  }
480
490
 
481
- async #assistantTurn(signal: AbortSignal): Promise<{ toolCalls: ToolCallRequest[]; stop: "end" | "toolUse" }> {
491
+ async #assistantTurn(signal: AbortSignal): Promise<{ toolCalls: ToolCallRequest[]; stop: TurnStop }> {
482
492
  const { tools, guides } = this.#surface.snapshot();
483
493
  const instructions = [this.#options.instructions, ...guides].filter(Boolean).join("\n\n");
484
494
  const request: RunnerRequest = {
@@ -493,7 +503,7 @@ export class AgentSession {
493
503
  this.#append({ role: "assistant" });
494
504
  let text = "";
495
505
  const toolCalls: ToolCallRequest[] = [];
496
- let stop: "end" | "toolUse" = "end";
506
+ let stop: TurnStop = "end";
497
507
  for await (const event of this.#runner.run(request)) {
498
508
  if (signal.aborted) break;
499
509
  if (event.type === "text") {
@@ -10,7 +10,7 @@ export interface HttpRunnerOptions {
10
10
  interface TurnAnswer {
11
11
  text?: string;
12
12
  toolCalls?: ToolCallRequest[];
13
- stop?: "end" | "toolUse";
13
+ stop?: "end" | "toolUse" | "length";
14
14
  }
15
15
 
16
16
  const eventTypes = new Set(["text", "toolCall", "done", "error"]);
@@ -131,6 +131,13 @@ export interface ToolCallResult {
131
131
  * model cannot read and says so in the transcript, because a silently dropped file is one the model then
132
132
  * hallucinates about.
133
133
  */
134
+ /**
135
+ * Why an assistant turn ended. `length` is the provider's own ceiling rather than the model's choice, so the turn
136
+ * is incomplete — a truncated answer and a turn cut off before its tool call finished both arrive this way, and
137
+ * neither is distinguishable from `end` without it.
138
+ */
139
+ export type TurnStop = "end" | "toolUse" | "length";
140
+
134
141
  export interface MessageAttachment {
135
142
  name: string;
136
143
  mimeType: string;
@@ -178,7 +185,7 @@ export interface ContextBlock {
178
185
  export type RunnerEvent =
179
186
  | { type: "text"; delta: string }
180
187
  | { type: "toolCall"; id: string; name: string; args: Record<string, unknown> }
181
- | { type: "done"; stop: "end" | "toolUse" }
188
+ | { type: "done"; stop: TurnStop }
182
189
  /**
183
190
  * `data` accompanies a message that is a code rather than a sentence — the values whoever resolves the code
184
191
  * interpolates into its text. A host that does not know the code shows the message as it stands.