@procedures/llm 0.7.0 → 0.8.0

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/src/events.ts CHANGED
@@ -10,12 +10,16 @@
10
10
  *
11
11
  * A stream gets a new try only before its first item. After the first item, an error goes to the
12
12
  * caller.
13
+ *
14
+ * The stream of one request gives the `start`, `text` and `tool_use` items. Its return value is
15
+ * the answer with its content blocks. `llm.stream` gives the `stop` item at the end of its tool loop.
13
16
  */
14
17
 
15
18
  import { HttpError, sseEvents, type ServerSentEvent } from "@procedures/http";
16
19
  import { InvocationError } from "@procedures/client";
17
- import { openScope, postMessages, safeMessage, usageOf, NO_USAGE, type CallLimits, type LlmSettings, type MessagesRequest } from "./anthropic.js";
20
+ import { openScope, postMessages, safeMessage, usageOf, NO_USAGE, type CallLimits, type ContentBlock, type LlmSettings, type MessagesRequest } from "./anthropic.js";
18
21
  import { LlmError } from "./errors.js";
22
+ import type { TurnAnswer } from "./loop.js";
19
23
  import { waitToRetry } from "./retry.js";
20
24
  import type { LlmStreamOutput, LlmToolUse, LlmUsage } from "./types.js";
21
25
 
@@ -66,15 +70,39 @@ function streamError(data: Record<string, unknown>): LlmError {
66
70
  return new LlmError("API_ERROR", `llm: the API sent an error event: ${type}: ${message}`, { retryable: RETRYABLE_ERROR_TYPES.has(type) });
67
71
  }
68
72
 
69
- /** One content block of a stream while its deltas come. */
73
+ /**
74
+ * One content block of a stream while its deltas come. `block` is the block as the event
75
+ * `content_block_start` gave it, with the deltas added.
76
+ */
70
77
  interface OpenBlock {
71
- readonly type: string;
72
- readonly id: string;
73
- readonly name: string;
78
+ readonly block: Record<string, unknown>;
79
+ /** The parts of `input_json_delta`, joined. */
74
80
  json: string;
75
81
  }
76
82
 
77
- /** The state of one stream: it turns each event into the items of the procedure. */
83
+ /** The type of block that each delta changes. An `input_json_delta` changes each block with an input. */
84
+ const DELTA_TARGETS: ReadonlyMap<string, string> = new Map([
85
+ ["text_delta", "text"],
86
+ ["citations_delta", "text"],
87
+ ["thinking_delta", "thinking"],
88
+ ["signature_delta", "thinking"],
89
+ ]);
90
+
91
+ /** The `input` of a block: the JSON of its deltas, parsed. A JSON that does not parse gives null and the text. */
92
+ function inputOf(block: OpenBlock): { input: unknown; partialJson?: string } {
93
+ if (block.json.trim() === "") return { input: block.block["input"] ?? {} };
94
+ try {
95
+ return { input: JSON.parse(block.json) as unknown };
96
+ } catch {
97
+ return { input: null, partialJson: block.json };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The state of one stream. It turns each event into the items of the procedure. It also puts
103
+ * the content blocks of the answer together, as the JSON answer has them. The next request of the
104
+ * tool loop sends them back.
105
+ */
78
106
  class StreamState {
79
107
  private model: string;
80
108
  private id = "";
@@ -83,6 +111,7 @@ class StreamState {
83
111
  private category: string | undefined;
84
112
  private text = "";
85
113
  private readonly blocks = new Map<number, OpenBlock>();
114
+ private readonly content: Array<readonly [number, ContentBlock]> = [];
86
115
  private readonly toolUses: LlmToolUse[] = [];
87
116
  /** True after the event `message_stop`. */
88
117
  stopped = false;
@@ -91,6 +120,49 @@ class StreamState {
91
120
  this.model = model;
92
121
  }
93
122
 
123
+ /** The answer, after the event `message_stop`. */
124
+ answer(): TurnAnswer {
125
+ return {
126
+ model: this.model,
127
+ content: [...this.content].sort((a, b) => a[0] - b[0]).map(([, block]) => block),
128
+ stopReason: this.stopReason!,
129
+ usage: this.usage,
130
+ text: this.text,
131
+ toolUses: [...this.toolUses],
132
+ };
133
+ }
134
+
135
+ /**
136
+ * This function adds a delta to its open block. A delta for a block of another type, and a
137
+ * delta of a type that the procedure does not know, change nothing.
138
+ */
139
+ private addDelta(index: number, delta: Record<string, unknown>): void {
140
+ const open = this.blocks.get(index);
141
+ const type = stringOf(delta["type"]) ?? "";
142
+ const target = DELTA_TARGETS.get(type);
143
+ if (open === undefined || (target !== undefined && open.block["type"] !== target)) return;
144
+ const { block } = open;
145
+ switch (type) {
146
+ case "text_delta":
147
+ block["text"] = (stringOf(block["text"]) ?? "") + (stringOf(delta["text"]) ?? "");
148
+ return;
149
+ case "input_json_delta":
150
+ open.json += stringOf(delta["partial_json"]) ?? "";
151
+ return;
152
+ case "thinking_delta":
153
+ block["thinking"] = (stringOf(block["thinking"]) ?? "") + (stringOf(delta["thinking"]) ?? "");
154
+ return;
155
+ case "signature_delta":
156
+ block["signature"] = stringOf(delta["signature"]) ?? "";
157
+ return;
158
+ case "citations_delta":
159
+ block["citations"] = [...(Array.isArray(block["citations"]) ? (block["citations"] as unknown[]) : []), delta["citation"]];
160
+ return;
161
+ default:
162
+ return;
163
+ }
164
+ }
165
+
94
166
  /** The items of one event. The event `error` and a refusal at the end throw. */
95
167
  apply(event: ServerSentEvent): LlmStreamOutput[] {
96
168
  if (event.event === "ping") return [];
@@ -105,12 +177,12 @@ class StreamState {
105
177
  return [{ type: "start", id: this.id, model: this.model, usage: this.usage }];
106
178
  }
107
179
  case "content_block_start": {
108
- const block = recordOf(data["content_block"]);
180
+ const block = { ...recordOf(data["content_block"]) };
109
181
  const type = stringOf(block["type"]) ?? "";
110
182
  // A server-side fallback puts a fallback block at the change of the model
111
183
  const to = stringOf(recordOf(block["to"])["model"]);
112
184
  if (type === "fallback" && to) this.model = to;
113
- this.blocks.set(index, { type, id: stringOf(block["id"]) ?? "", name: stringOf(block["name"]) ?? "", json: "" });
185
+ this.blocks.set(index, { block, json: "" });
114
186
  const text = stringOf(block["text"]);
115
187
  if (type === "text" && text) {
116
188
  this.text += text;
@@ -120,34 +192,28 @@ class StreamState {
120
192
  }
121
193
  case "content_block_delta": {
122
194
  const delta = recordOf(data["delta"]);
123
- const deltaType = stringOf(delta["type"]);
124
- if (deltaType === "text_delta") {
195
+ this.addDelta(index, delta);
196
+ if (stringOf(delta["type"]) === "text_delta") {
125
197
  const text = stringOf(delta["text"]) ?? "";
126
198
  this.text += text;
127
199
  return text === "" ? [] : [{ type: "text", index, text }];
128
200
  }
129
- if (deltaType === "input_json_delta") {
130
- const block = this.blocks.get(index);
131
- if (block) block.json += stringOf(delta["partial_json"]) ?? "";
132
- }
133
201
  return [];
134
202
  }
135
203
  case "content_block_stop": {
136
- const block = this.blocks.get(index);
204
+ const open = this.blocks.get(index);
205
+ if (open === undefined) return [];
137
206
  this.blocks.delete(index);
138
- if (block?.type !== "tool_use") return [];
139
- let input: unknown = {};
140
- let partialJson: string | undefined;
141
- if (block.json.trim() !== "") {
142
- try {
143
- input = JSON.parse(block.json);
144
- } catch {
145
- input = null;
146
- partialJson = block.json;
147
- }
148
- }
149
- this.toolUses.push({ id: block.id, name: block.name, input });
150
- return [{ type: "tool_use", index, id: block.id, name: block.name, input, ...(partialJson !== undefined ? { partialJson } : {}) }];
207
+ const { block } = open;
208
+ const { input, partialJson } = inputOf(open);
209
+ // A tool_use block and a server_tool_use block get their input from the deltas
210
+ if (open.json !== "" || block["type"] === "tool_use") block["input"] = input;
211
+ this.content.push([index, block as ContentBlock]);
212
+ if (block["type"] !== "tool_use") return [];
213
+ const id = stringOf(block["id"]) ?? "";
214
+ const name = stringOf(block["name"]) ?? "";
215
+ this.toolUses.push({ id, name, input });
216
+ return [{ type: "tool_use", index, id, name, input, ...(partialJson !== undefined ? { partialJson } : {}) }];
151
217
  }
152
218
  case "message_delta": {
153
219
  const delta = recordOf(data["delta"]);
@@ -162,7 +228,7 @@ class StreamState {
162
228
  throw new LlmError("REFUSAL", `llm: a safety classifier declined the request (category: ${this.category ?? "not given"})`);
163
229
  }
164
230
  this.stopped = true;
165
- return [{ type: "stop", model: this.model, stopReason: this.stopReason, usage: this.usage, text: this.text, toolUses: [...this.toolUses] }];
231
+ return [];
166
232
  }
167
233
  case "error":
168
234
  throw streamError(data);
@@ -172,8 +238,13 @@ class StreamState {
172
238
  }
173
239
  }
174
240
 
175
- /** The items of one try of a stream, in one request scope. */
176
- async function* streamOnce(request: MessagesRequest, settings: LlmSettings, limits: CallLimits): AsyncGenerator<LlmStreamOutput, void, undefined> {
241
+ /** The progress of one try: true after its first item. */
242
+ interface TryProgress {
243
+ started: boolean;
244
+ }
245
+
246
+ /** The items of one try of a stream, in one request scope. The return value is the answer. */
247
+ async function* streamOnce(request: MessagesRequest, settings: LlmSettings, limits: CallLimits, progress: TryProgress): AsyncGenerator<LlmStreamOutput, TurnAnswer, undefined> {
177
248
  const scope = openScope(settings, limits);
178
249
  try {
179
250
  const response = await postMessages(request, settings, scope);
@@ -181,8 +252,11 @@ async function* streamOnce(request: MessagesRequest, settings: LlmSettings, limi
181
252
  const state = new StreamState(request.model);
182
253
  try {
183
254
  for await (const event of sseEvents(chunksOf(response.body), MAX_EVENT_BYTES, limits.path.join("."))) {
184
- for (const item of state.apply(event)) yield item;
185
- if (state.stopped) return;
255
+ for (const item of state.apply(event)) {
256
+ progress.started = true;
257
+ yield item;
258
+ }
259
+ if (state.stopped) return state.answer();
186
260
  }
187
261
  } catch (error) {
188
262
  if (error instanceof LlmError || error instanceof InvocationError || scope.stopped() !== undefined) throw error;
@@ -198,21 +272,18 @@ async function* streamOnce(request: MessagesRequest, settings: LlmSettings, limi
198
272
  }
199
273
 
200
274
  /**
201
- * This function sends a request with `"stream": true` and gives each item of the answer. Before
202
- * the first item, a retryable error gets a new try, at most `retries` times.
275
+ * This function sends a request with `"stream": true` and gives each item of the answer: the
276
+ * `start`, `text` and `tool_use` items. The return value is the answer. Before the first item, a
277
+ * retryable error gets a new try, at most `retries` times.
203
278
  */
204
- export async function* streamMessages(request: MessagesRequest, settings: LlmSettings, limits: CallLimits, retries: number): AsyncGenerator<LlmStreamOutput, void, undefined> {
279
+ export async function* streamMessages(request: MessagesRequest, settings: LlmSettings, limits: CallLimits, retries: number): AsyncGenerator<LlmStreamOutput, TurnAnswer, undefined> {
205
280
  const body: MessagesRequest = { ...request, stream: true };
206
281
  for (let retry = 1; ; retry++) {
207
- let started = false;
282
+ const progress: TryProgress = { started: false };
208
283
  try {
209
- for await (const item of streamOnce(body, settings, limits)) {
210
- started = true;
211
- yield item;
212
- }
213
- return;
284
+ return yield* streamOnce(body, settings, limits, progress);
214
285
  } catch (error) {
215
- if (started || !(await waitToRetry(error, retry, { backoff: settings.backoff, retries }, limits))) throw error;
286
+ if (progress.started || !(await waitToRetry(error, retry, { backoff: settings.backoff, retries }, limits))) throw error;
216
287
  }
217
288
  }
218
289
  }
package/src/loop.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The tool loop of `llm.complete` and `llm.stream`.
3
+ *
4
+ * The loop sends a request and reads the answer. When the answer asks only for procedure tools,
5
+ * the loop starts them, sends the answer and the results back, and asks again. It stops when the
6
+ * model stops, or when `maxRounds` requests went out. The two procedures differ only in one
7
+ * step: `llm.complete` gets each answer as JSON, and `llm.stream` reads the event stream of each
8
+ * answer. A `SendTurn` function does that step.
9
+ */
10
+
11
+ import type { ProcedurePath } from "@procedures/client";
12
+ import { addUsage, NO_USAGE, type CallLimits, type ContentBlock, type LlmSettings, type MessagesRequest } from "./anthropic.js";
13
+ import { addTools, baseRequest, retriesOf, timeoutOf, toolParam } from "./request.js";
14
+ import { DEFAULT_MAX_ROUNDS, procedureTools, runToolUse, type ToolContext } from "./tools.js";
15
+ import type { LlmRequestInput, LlmStreamToolCall, LlmStreamToolResult, LlmToolCall, LlmToolInput, LlmToolUse, LlmUsage } from "./types.js";
16
+
17
+ /** One answer of the model, as the loop reads it. */
18
+ export interface TurnAnswer {
19
+ /** The model that answered. */
20
+ readonly model: string;
21
+ /** The content blocks of the answer. The next request sends them back, after `continuationOf()`. */
22
+ readonly content: readonly ContentBlock[];
23
+ /** Why the model stopped. */
24
+ readonly stopReason: string;
25
+ /** The tokens of the request. */
26
+ readonly usage: LlmUsage;
27
+ /** The text of the answer. */
28
+ readonly text: string;
29
+ /** The `tool_use` blocks of the answer. */
30
+ readonly toolUses: readonly LlmToolUse[];
31
+ }
32
+
33
+ /**
34
+ * One request of the loop. The function sends the request, with at most `retries` new tries.
35
+ * It gives the items of the answer. Its return value is the answer.
36
+ */
37
+ export type SendTurn<T> = (request: MessagesRequest, settings: LlmSettings, limits: CallLimits, retries: number) => AsyncGenerator<T, TurnAnswer, undefined>;
38
+
39
+ /** An item of the loop: the start or the end of a procedure tool. */
40
+ export type ToolEvent = LlmStreamToolCall | LlmStreamToolResult;
41
+
42
+ /** The end of the loop. */
43
+ export interface LoopEnd {
44
+ /** The last answer. */
45
+ readonly answer: TurnAnswer;
46
+ /** The model of the last answer. Without one, it is the model of the request. */
47
+ readonly model: string;
48
+ /** The tokens of all the requests, added together. */
49
+ readonly usage: LlmUsage;
50
+ /** The text of all the answers, joined. */
51
+ readonly text: string;
52
+ /** The calls of procedure tools, in their sequence. */
53
+ readonly toolCalls: LlmToolCall[];
54
+ /** The number of requests to the model. */
55
+ readonly rounds: number;
56
+ /** True when `maxRounds` stopped the loop before the model stopped. */
57
+ readonly roundLimit: boolean;
58
+ }
59
+
60
+ /** The block types that the next turn drops before the final `fallback` block. */
61
+ const DROPPED_BEFORE_FALLBACK = new Set(["thinking", "redacted_thinking", "connector_text", "tool_use"]);
62
+
63
+ /**
64
+ * The content of an answer as the next request sends it back. After a server-side fallback in
65
+ * the middle of an answer, the blocks before the final `fallback` block came from the model that
66
+ * declined. The next turn drops their thinking, their `connector_text` and their client
67
+ * `tool_use` blocks, and each server tool use without its result. The `fallback` block, the
68
+ * text and each block after the final `fallback` block stay where they are. Without a `fallback`
69
+ * block, the content stays as it came. The rules are in the API docs, "Refusals and fallback",
70
+ * section "Continuing the conversation". Before, the loop sent the content back unchanged, and
71
+ * the next request could fail (tracker O34).
72
+ */
73
+ export function continuationOf(content: readonly ContentBlock[]): ContentBlock[] {
74
+ let last = -1;
75
+ for (let index = content.length - 1; index >= 0; index--) {
76
+ if (content[index]!.type === "fallback") {
77
+ last = index;
78
+ break;
79
+ }
80
+ }
81
+ if (last < 0) return [...content];
82
+ // A server tool result names its tool use. The results can come after the fallback too.
83
+ const results = new Set(content.map((block) => block["tool_use_id"]).filter((id): id is string => typeof id === "string"));
84
+ const kept = content.slice(0, last).filter((block) => {
85
+ if (DROPPED_BEFORE_FALLBACK.has(block.type)) return false;
86
+ if (block.type.endsWith("_tool_use")) return typeof block["id"] === "string" && results.has(block["id"]);
87
+ return true;
88
+ });
89
+ return [...kept, ...content.slice(last)];
90
+ }
91
+
92
+ /** A forced `tool_choice` (`any` or `tool`) applies to the first request only. After it, the model can stop. */
93
+ function releaseToolChoice(request: MessagesRequest): void {
94
+ const choice = request.tool_choice;
95
+ if (choice === undefined || (choice.type !== "any" && choice.type !== "tool")) return;
96
+ const { disable_parallel_tool_use } = choice;
97
+ request.tool_choice = { type: "auto", ...(disable_parallel_tool_use !== undefined ? { disable_parallel_tool_use } : {}) };
98
+ }
99
+
100
+ /**
101
+ * This function does the tool loop of one call. It gives the items of each answer and an item at
102
+ * the start and at the end of each procedure tool. The return value is the end of the loop. It
103
+ * throws before the first request for a tool that the host refuses or that is not valid.
104
+ */
105
+ export async function* toolLoop<T>(
106
+ input: LlmRequestInput & LlmToolInput,
107
+ ctx: ToolContext | undefined,
108
+ settings: LlmSettings,
109
+ path: ProcedurePath,
110
+ send: SendTurn<T>,
111
+ ): AsyncGenerator<T | ToolEvent, LoopEnd, undefined> {
112
+ const request = baseRequest(input, settings, input.system);
113
+ const procedures = procedureTools(input.procedures ?? [], ctx, path);
114
+ addTools(request, [...(input.tools ?? []).map(toolParam), ...procedures.map((tool) => tool.param)], input.toolChoice);
115
+ const byName = new Map(procedures.map((tool) => [tool.param.name, tool]));
116
+ const limits: CallLimits = { path, ctx, timeoutMs: timeoutOf(input) };
117
+ const retries = retriesOf(input.retries, settings);
118
+ const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
119
+
120
+ let usage: LlmUsage = NO_USAGE;
121
+ let text = "";
122
+ const toolCalls: LlmToolCall[] = [];
123
+ for (let round = 1; ; round++) {
124
+ const answer = yield* send(request, settings, limits, retries);
125
+ usage = addUsage(usage, answer.usage);
126
+ text += answer.text;
127
+ // Only the tool uses that the next turn sends back can get a result: none before a fallback
128
+ const content = continuationOf(answer.content);
129
+ const sent = new Set(content.filter((block) => block.type === "tool_use").map((block) => block["id"]));
130
+ const uses = answer.toolUses.filter((use) => sent.has(use.id));
131
+ // The procedure runs the tools only when each tool of the answer is a procedure tool
132
+ const runnable = answer.stopReason === "tool_use" && uses.length > 0 && uses.every((use) => byName.has(use.name));
133
+ if (!runnable || round >= maxRounds) {
134
+ return { answer, model: answer.model || request.model, usage, text, toolCalls, rounds: round, roundLimit: runnable };
135
+ }
136
+ // One at a time, in the sequence of the answer: a procedure can change data that the next one reads
137
+ const results: ContentBlock[] = [];
138
+ for (const use of uses) {
139
+ const tool = byName.get(use.name)!;
140
+ yield { type: "tool_call", round, id: use.id, name: use.name, procedure: tool.key, input: use.input };
141
+ const { call, result } = await runToolUse(use, tool, ctx!);
142
+ toolCalls.push(call);
143
+ results.push(result);
144
+ yield { type: "tool_result", round, ...call };
145
+ }
146
+ // The next request continues the conversation: the answer (without the blocks that a fallback
147
+ // replaced), then the results
148
+ request.messages = [...request.messages, { role: "assistant", content }, { role: "user", content: results }];
149
+ releaseToolChoice(request);
150
+ }
151
+ }
152
+
153
+ /** This function reads a generator to its end and gives its return value. It ignores the items. */
154
+ export async function returnOf<R>(generator: AsyncGenerator<unknown, R, undefined>): Promise<R> {
155
+ for (;;) {
156
+ const step = await generator.next();
157
+ if (step.done) return step.value;
158
+ }
159
+ }
package/src/module.ts CHANGED
@@ -110,16 +110,17 @@ const toolCall = z.object({
110
110
  error: z.string().optional().describe("The error of the procedure"),
111
111
  });
112
112
 
113
- const LlmCompleteInputSchema = z.object({
114
- ...requestFields,
115
- ...toolFields,
113
+ /** The fields of procedures as tools, for `llm.complete` and `llm.stream`. */
114
+ const procedureFields = {
116
115
  procedures: z
117
116
  .array(z.string().regex(/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/, "A procedure key, for example weather.current"))
118
117
  .max(64)
119
118
  .optional()
120
- .describe("The procedures that the model can use as tools: llm.complete calls them and sends the results back"),
119
+ .describe("The procedures that the model can use as tools: the procedure calls them and sends the results back"),
121
120
  maxRounds: z.number().int().min(1).max(64).optional().describe("The most requests to the model in one call, 1 to 64 (default 8)"),
122
- });
121
+ };
122
+
123
+ const LlmCompleteInputSchema = z.object({ ...requestFields, ...toolFields, ...procedureFields });
123
124
 
124
125
  const LlmCompleteOutputSchema = z.object({
125
126
  text: z.string().describe("The text of the last answer"),
@@ -148,10 +149,12 @@ const LlmStructuredOutputSchema = z.object({
148
149
  unchecked: z.array(z.string()).describe("The keywords of the schema that the check does not examine fully"),
149
150
  });
150
151
 
151
- const LlmStreamInputSchema = z.object({ ...requestFields, ...toolFields });
152
+ const LlmStreamInputSchema = z.object({ ...requestFields, ...toolFields, ...procedureFields });
152
153
 
153
154
  const index = z.number().int().describe("The index of the content block");
154
155
 
156
+ const round = z.number().int().describe("The number of the request whose answer asked for the tool");
157
+
155
158
  const LlmStreamOutputSchema = z
156
159
  .discriminatedUnion("type", [
157
160
  z.object({ type: z.literal("start"), id: z.string().describe("The ID of the message"), model: z.string().describe("The model that answers"), usage }),
@@ -164,16 +167,28 @@ const LlmStreamOutputSchema = z
164
167
  input: z.unknown().describe("The input of the call, or null when its JSON is not complete"),
165
168
  partialJson: z.string().optional().describe("The JSON text of an input that does not parse"),
166
169
  }),
170
+ z.object({
171
+ type: z.literal("tool_call"),
172
+ round,
173
+ id: z.string().describe("The ID of the tool_use block"),
174
+ name: z.string().describe("The name of the tool"),
175
+ procedure: z.string().describe("The key of the procedure"),
176
+ input: z.unknown().describe("The input that the model gave"),
177
+ }),
178
+ toolCall.extend({ type: z.literal("tool_result"), round }),
167
179
  z.object({
168
180
  type: z.literal("stop"),
169
- model: z.string().describe("The model that answered"),
170
- stopReason: z.string().describe("Why the model stopped"),
171
- usage,
172
- text: z.string().describe("The text of the answer: all the text deltas, joined"),
173
- toolUses: z.array(toolUse).describe("The tool_use blocks of the answer"),
181
+ model: z.string().describe("The model of the last answer"),
182
+ stopReason: z.string().describe("Why the model stopped in the last answer"),
183
+ usage: usage.describe("The tokens of all the requests, added together"),
184
+ text: z.string().describe("The text of all the answers: all the text deltas, joined"),
185
+ toolUses: z.array(toolUse).describe("The tool_use blocks of the last answer that no procedure ran"),
186
+ toolCalls: z.array(toolCall).optional().describe("The calls of the procedure tools, in their sequence. Only with procedures"),
187
+ rounds: z.number().int().optional().describe("The number of requests to the model. Only with procedures"),
188
+ roundLimit: z.boolean().optional().describe("True when maxRounds stopped the loop before the model stopped. Only with procedures"),
174
189
  }),
175
190
  ])
176
- .describe("One item of the stream: start, text, tool_use or stop");
191
+ .describe("One item of the stream: start, text, tool_use, tool_call, tool_result or stop");
177
192
 
178
193
  // =============================================================================
179
194
  // Procedures
@@ -182,8 +197,8 @@ const LlmStreamOutputSchema = z
182
197
  /**
183
198
  * The effects of each procedure: it sends data to a system outside the host and gets an answer.
184
199
  * A call costs money and can change the state of an account, so it declares a write. It deletes
185
- * nothing and a second call is a new call. The procedure tools of `llm.complete` are nested calls:
186
- * the admission of the host examines each one.
200
+ * nothing and a second call is a new call. The procedure tools of `llm.complete` and `llm.stream`
201
+ * are nested calls: the admission of the host examines each one.
187
202
  */
188
203
  const SENDS_TO_MODEL: Effects = Object.freeze({ reads: Object.freeze(["network"]), writes: Object.freeze(["network"]), destructive: false, idempotent: false });
189
204
 
@@ -228,8 +243,8 @@ function llmProcedures(settings: LlmSettings): AnyProcedure[] {
228
243
  .input(zodAdapter<LlmStreamInput>(LlmStreamInputSchema))
229
244
  .output(zodAdapter<LlmStreamOutput>(LlmStreamOutputSchema))
230
245
  .meta({
231
- description: "Send a prompt to a language model and stream the events of the answer",
232
- tags: ["llm", "anthropic", "stream"],
246
+ description: "Send a prompt to a language model and stream the events of the answer. The model can use tools and procedures",
247
+ tags: ["llm", "anthropic", "stream", "tools"],
233
248
  args: ["prompt"],
234
249
  shorts: { model: "m", system: "s" },
235
250
  output: "json",
@@ -240,6 +255,8 @@ function llmProcedures(settings: LlmSettings): AnyProcedure[] {
240
255
  })
241
256
  .handler(((input: LlmStreamInput, ctx: ProcedureContext) => llmStream(input, ctx, settings)) as ProcedureHandler<LlmStreamInput, LlmStreamOutput>)
242
257
  .build();
258
+ // The same rule as for llm.complete: the input names the procedures that the model can call
259
+ markDataDriven(llmStreamProcedure);
243
260
 
244
261
  return [llmCompleteProcedure, llmStructuredProcedure, llmStreamProcedure];
245
262
  }
@@ -7,61 +7,43 @@
7
7
  * With `tools`, the output gives each `tool_use` block of the answer, and the caller starts them.
8
8
  * With `procedures`, the procedure starts the tools. It calls each procedure that the model asks
9
9
  * for through `ctx.client` and sends the results back. Then it asks again, until the model stops
10
- * or `maxRounds` requests went out.
10
+ * or `maxRounds` requests went out. `toolLoop` of `loop.ts` has the loop, and `llm.stream` uses
11
+ * the same loop.
11
12
  */
12
13
 
13
- import { addUsage, DEFAULT_LLM_SETTINGS, NO_USAGE, sendWithRetries, textOf, type CallLimits, type ContentBlock, type LlmSettings } from "../../anthropic.js";
14
- import { addTools, baseRequest, retriesOf, timeoutOf, toolParam } from "../../request.js";
15
- import { DEFAULT_MAX_ROUNDS, procedureTools, runToolUse, toolUsesOf, type ToolContext } from "../../tools.js";
16
- import type { LlmCompleteInput, LlmCompleteOutput, LlmToolCall, LlmUsage } from "../../types.js";
14
+ import { DEFAULT_LLM_SETTINGS, sendWithRetries, textOf, type CallLimits, type LlmSettings, type MessagesRequest } from "../../anthropic.js";
15
+ import { returnOf, toolLoop, type TurnAnswer } from "../../loop.js";
16
+ import { toolUsesOf, type ToolContext } from "../../tools.js";
17
+ import type { LlmCompleteInput, LlmCompleteOutput } from "../../types.js";
17
18
 
18
19
  const PATH = ["llm", "complete"];
19
20
 
21
+ /** One request of `llm.complete`: it gives no items. Its return value is the JSON answer. */
22
+ async function* sendTurn(request: MessagesRequest, settings: LlmSettings, limits: CallLimits, retries: number): AsyncGenerator<never, TurnAnswer, undefined> {
23
+ const answer = await sendWithRetries(request, settings, limits, retries);
24
+ return {
25
+ model: answer.model,
26
+ content: answer.content,
27
+ stopReason: answer.stopReason,
28
+ usage: answer.usage,
29
+ text: textOf(answer.content),
30
+ toolUses: toolUsesOf(answer.content),
31
+ };
32
+ }
33
+
20
34
  /**
21
35
  * Send a prompt to a language model and give the text and the usage
22
36
  */
23
37
  export async function llmComplete(input: LlmCompleteInput, ctx?: ToolContext, settings: LlmSettings = DEFAULT_LLM_SETTINGS): Promise<LlmCompleteOutput> {
24
- const request = baseRequest(input, settings, input.system);
25
- const procedures = procedureTools(input.procedures ?? [], ctx, PATH);
26
- addTools(request, [...(input.tools ?? []).map(toolParam), ...procedures.map((tool) => tool.param)], input.toolChoice);
27
- const byName = new Map(procedures.map((tool) => [tool.param.name, tool]));
28
- const limits: CallLimits = { path: PATH, ctx, timeoutMs: timeoutOf(input) };
29
- const retries = retriesOf(input.retries, settings);
30
- const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
31
-
32
- let usage: LlmUsage = NO_USAGE;
33
- const toolCalls: LlmToolCall[] = [];
34
- for (let round = 1; ; round++) {
35
- const answer = await sendWithRetries(request, settings, limits, retries);
36
- usage = addUsage(usage, answer.usage);
37
- const uses = toolUsesOf(answer.content);
38
- // The procedure runs the tools only when each tool of the answer is a procedure tool
39
- const runnable = answer.stopReason === "tool_use" && uses.length > 0 && uses.every((use) => byName.has(use.name));
40
- if (!runnable || round >= maxRounds) {
41
- return {
42
- text: textOf(answer.content),
43
- model: answer.model || request.model,
44
- stopReason: answer.stopReason,
45
- usage,
46
- toolUses: uses,
47
- toolCalls,
48
- rounds: round,
49
- roundLimit: runnable,
50
- };
51
- }
52
- // One at a time, in the sequence of the answer: a procedure can change data that the next one reads
53
- const results: ContentBlock[] = [];
54
- for (const use of uses) {
55
- const { call, result } = await runToolUse(use, byName.get(use.name)!, ctx!);
56
- toolCalls.push(call);
57
- results.push(result);
58
- }
59
- // The next request continues the conversation: the answer as it came, then the results
60
- request.messages = [...request.messages, { role: "assistant", content: answer.content }, { role: "user", content: results }];
61
- // A forced tool_choice applies to the first request only: then the model can stop
62
- if (request.tool_choice !== undefined && (request.tool_choice.type === "any" || request.tool_choice.type === "tool")) {
63
- const { disable_parallel_tool_use } = request.tool_choice;
64
- request.tool_choice = { type: "auto", ...(disable_parallel_tool_use !== undefined ? { disable_parallel_tool_use } : {}) };
65
- }
66
- }
38
+ const end = await returnOf(toolLoop(input, ctx, settings, PATH, sendTurn));
39
+ return {
40
+ text: end.answer.text,
41
+ model: end.model,
42
+ stopReason: end.answer.stopReason,
43
+ usage: end.usage,
44
+ toolUses: [...end.answer.toolUses],
45
+ toolCalls: end.toolCalls,
46
+ rounds: end.rounds,
47
+ roundLimit: end.roundLimit,
48
+ };
67
49
  }
@@ -4,20 +4,39 @@
4
4
  * Send a prompt to a language model and stream the events of the answer. The request has
5
5
  * `"stream": true`. The items are the start of the message, the text deltas and each complete
6
6
  * `tool_use` block. The last item has the stop reason, the usage and the full text.
7
+ *
8
+ * With `procedures`, the procedure starts the tools, as `llm.complete` does: `toolLoop` of
9
+ * `loop.ts` has the loop. When an answer stops for its procedure tools, the stream gives a
10
+ * `tool_call` item and a `tool_result` item for each tool. Then the next answer streams in the
11
+ * same output.
7
12
  */
8
13
 
9
- import { DEFAULT_LLM_SETTINGS, type CallLimits, type LlmSettings } from "../../anthropic.js";
14
+ import { DEFAULT_LLM_SETTINGS, type LlmSettings } from "../../anthropic.js";
10
15
  import { streamMessages } from "../../events.js";
11
- import { addTools, baseRequest, retriesOf, timeoutOf, toolParam } from "../../request.js";
12
- import type { LlmStreamInput, LlmStreamOutput } from "../../types.js";
16
+ import { toolLoop } from "../../loop.js";
17
+ import type { ToolContext } from "../../tools.js";
18
+ import type { LlmStreamInput, LlmStreamOutput, LlmStreamStop } from "../../types.js";
13
19
 
14
20
  const PATH = ["llm", "stream"];
15
21
 
16
22
  /**
17
23
  * Send a prompt to a language model and stream the events of the answer
18
24
  */
19
- export async function* llmStream(input: LlmStreamInput, ctx?: CallLimits["ctx"], settings: LlmSettings = DEFAULT_LLM_SETTINGS): AsyncGenerator<LlmStreamOutput, void, undefined> {
20
- const request = baseRequest(input, settings, input.system);
21
- addTools(request, (input.tools ?? []).map(toolParam), input.toolChoice);
22
- yield* streamMessages(request, settings, { path: PATH, ctx, timeoutMs: timeoutOf(input) }, retriesOf(input.retries, settings));
25
+ export async function* llmStream(input: LlmStreamInput, ctx?: ToolContext, settings: LlmSettings = DEFAULT_LLM_SETTINGS): AsyncGenerator<LlmStreamOutput, void, undefined> {
26
+ const end = yield* toolLoop(input, ctx, settings, PATH, streamMessages);
27
+ const stop: LlmStreamStop = {
28
+ type: "stop",
29
+ model: end.model,
30
+ stopReason: end.answer.stopReason,
31
+ usage: end.usage,
32
+ text: end.text,
33
+ toolUses: [...end.answer.toolUses],
34
+ };
35
+ // Without procedures, the stop item keeps the fields that it had before the tool loop
36
+ if ((input.procedures ?? []).length > 0) {
37
+ stop.toolCalls = end.toolCalls;
38
+ stop.rounds = end.rounds;
39
+ stop.roundLimit = end.roundLimit;
40
+ }
41
+ yield stop;
23
42
  }