akanjs 3.0.0-beta.1 → 3.0.0-beta.2

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.2",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -101,7 +101,7 @@ export class AnthropicLlm
101
101
  * so it is one line to diagnose rather than a model that appears to have refused.
102
102
  */
103
103
  #reported(answer: LlmTurnAnswer): LlmTurnAnswer {
104
- if (!answer.text && !answer.toolCalls?.length)
104
+ if (!answer.text && !answer.toolCalls?.length && answer.stop !== "length")
105
105
  this.logger.warn(
106
106
  `Anthropic answered with no text and no tool call. If this repeats, raise option.setLlm({ maxTokens }) — currently ${this.llmOption.maxTokens ?? AnthropicLlm.defaultMaxTokens}.`,
107
107
  );
@@ -294,10 +294,16 @@ export class AnthropicLlm
294
294
  return {
295
295
  ...(text ? { text } : {}),
296
296
  ...(toolCalls.length ? { toolCalls } : {}),
297
- stop: answer.stop_reason === "tool_use" || toolCalls.length ? "toolUse" : "end",
297
+ stop: AnthropicLlm.stopOf(answer.stop_reason, toolCalls.length),
298
298
  };
299
299
  }
300
300
 
301
+ /** The ceiling wins over the calls that did arrive — see `OpenaiDialect.stopOf` for why. */
302
+ static stopOf(reason: string | null | undefined, calls: number): LlmTurnAnswer["stop"] {
303
+ if (reason === "max_tokens") return "length";
304
+ return reason === "tool_use" || calls ? "toolUse" : "end";
305
+ }
306
+
301
307
  /**
302
308
  * The API streams named SSE events rather than one chunk shape. A tool call opens as `content_block_start`
303
309
  * carrying its id and name and then arrives as `input_json_delta` fragments of a JSON string, so it is assembled
@@ -359,7 +365,7 @@ export class AnthropicLlm
359
365
  return {
360
366
  ...(text ? { text } : {}),
361
367
  ...(toolCalls.length ? { toolCalls } : {}),
362
- stop: stopReason === "tool_use" || toolCalls.length ? "toolUse" : "end",
368
+ stop: AnthropicLlm.stopOf(stopReason, toolCalls.length),
363
369
  };
364
370
  }
365
371
 
@@ -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
  /**
@@ -193,10 +193,19 @@ export class OpenaiDialect {
193
193
  return {
194
194
  ...(text ? { text } : {}),
195
195
  ...(toolCalls.length ? { toolCalls } : {}),
196
- stop: finish === "tool_calls" || toolCalls.length ? "toolUse" : "end",
196
+ stop: OpenaiDialect.stopOf(finish, toolCalls.length),
197
197
  };
198
198
  }
199
199
 
200
+ /**
201
+ * The ceiling wins over the calls that did arrive. A turn the provider cut short is one whose last call may be
202
+ * missing, so running the batch it did finish is acting on half an intention.
203
+ */
204
+ static stopOf(finish: string | null | undefined, calls: number): LlmTurnAnswer["stop"] {
205
+ if (finish === "length") return "length";
206
+ return finish === "tool_calls" || calls ? "toolUse" : "end";
207
+ }
208
+
200
209
  static turnAnswer(answer: OpenaiAnswer): LlmTurnAnswer {
201
210
  const choice = answer.choices?.[0];
202
211
  const toolCalls = (choice?.message?.tool_calls ?? []).flatMap((call) => {
@@ -206,7 +215,7 @@ export class OpenaiDialect {
206
215
  return {
207
216
  ...(choice?.message?.content ? { text: choice.message.content } : {}),
208
217
  ...(toolCalls.length ? { toolCalls } : {}),
209
- stop: choice?.finish_reason === "tool_calls" || toolCalls.length ? "toolUse" : "end",
218
+ stop: OpenaiDialect.stopOf(choice?.finish_reason, toolCalls.length),
210
219
  };
211
220
  }
212
221
 
@@ -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
@@ -98,6 +98,8 @@ export declare class AnthropicLlm extends AnthropicLlm_base implements LlmAdapto
98
98
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): AnthropicBlock[];
99
99
  static sourceOf(attachment: AgentWireAttachment): AnthropicSource | null;
100
100
  static turnAnswer(answer: AnthropicAnswer): LlmTurnAnswer;
101
+ /** The ceiling wins over the calls that did arrive — see `OpenaiDialect.stopOf` for why. */
102
+ static stopOf(reason: string | null | undefined, calls: number): LlmTurnAnswer["stop"];
101
103
  /**
102
104
  * The API streams named SSE events rather than one chunk shape. A tool call opens as `content_block_start`
103
105
  * 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
@@ -79,6 +79,11 @@ export declare class OpenaiDialect {
79
79
  * by index and parsed once at the end; only assistant text is worth reporting as it arrives.
80
80
  */
81
81
  static consumeStream(body: ReadableStream<Uint8Array>, onDelta: (delta: string) => void): Promise<LlmTurnAnswer>;
82
+ /**
83
+ * The ceiling wins over the calls that did arrive. A turn the provider cut short is one whose last call may be
84
+ * missing, so running the batch it did finish is acting on half an intention.
85
+ */
86
+ static stopOf(finish: string | null | undefined, calls: number): LlmTurnAnswer["stop"];
82
87
  static turnAnswer(answer: OpenaiAnswer): LlmTurnAnswer;
83
88
  /** The provider sends arguments as a JSON string; an unparsable one becomes an empty call rather than a crash. */
84
89
  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`
@@ -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
@@ -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.