@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/CHANGELOG.md +29 -0
- package/README.md +52 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/events.d.ts +8 -3
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +100 -39
- package/dist/events.js.map +1 -1
- package/dist/loop.d.ts +72 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +107 -0
- package/dist/loop.js.map +1 -0
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +31 -16
- package/dist/module.js.map +1 -1
- package/dist/procedures/llm/complete.d.ts +2 -1
- package/dist/procedures/llm/complete.d.ts.map +1 -1
- package/dist/procedures/llm/complete.js +28 -46
- package/dist/procedures/llm/complete.js.map +1 -1
- package/dist/procedures/llm/stream.d.ts +8 -2
- package/dist/procedures/llm/stream.d.ts.map +1 -1
- package/dist/procedures/llm/stream.js +22 -4
- package/dist/procedures/llm/stream.js.map +1 -1
- package/dist/tools.d.ts +11 -9
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +34 -10
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +50 -15
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/events.ts +114 -43
- package/src/loop.ts +159 -0
- package/src/module.ts +33 -16
- package/src/procedures/llm/complete.ts +30 -48
- package/src/procedures/llm/stream.ts +26 -7
- package/src/tools.ts +35 -10
- package/src/types.ts +53 -17
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
|
-
/**
|
|
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
|
|
72
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
124
|
-
if (
|
|
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
|
|
204
|
+
const open = this.blocks.get(index);
|
|
205
|
+
if (open === undefined) return [];
|
|
137
206
|
this.blocks.delete(index);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 [
|
|
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
|
|
176
|
-
|
|
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))
|
|
185
|
-
|
|
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
|
|
202
|
-
*
|
|
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,
|
|
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
|
-
|
|
282
|
+
const progress: TryProgress = { started: false };
|
|
208
283
|
try {
|
|
209
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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:
|
|
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
|
|
170
|
-
stopReason: z.string().describe("Why the model stopped"),
|
|
171
|
-
usage,
|
|
172
|
-
text: z.string().describe("The text of the
|
|
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`
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import type { LlmCompleteInput, LlmCompleteOutput
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
14
|
+
import { DEFAULT_LLM_SETTINGS, type LlmSettings } from "../../anthropic.js";
|
|
10
15
|
import { streamMessages } from "../../events.js";
|
|
11
|
-
import {
|
|
12
|
-
import type {
|
|
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?:
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|