@juno-ai/bind 8.0.0 → 10.0.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/README.md +405 -58
- package/completion/index.d.ts +1 -0
- package/completion/index.js +1 -0
- package/completion/text-stream.d.ts +311 -0
- package/completion/text-stream.js +273 -0
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +77 -2
- package/contracts/turn.js +35 -2
- package/loop/index.d.ts +2 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +131 -12
- package/loop/tool-loop.js +285 -49
- package/package.json +5 -1
- package/plugins/dispatch.d.ts +130 -0
- package/plugins/dispatch.js +241 -0
- package/plugins/index.d.ts +2 -0
- package/plugins/index.js +2 -0
- package/plugins/tool-message.d.ts +23 -0
- package/plugins/tool-message.js +31 -0
- package/plugins/tool.d.ts +1 -1
- package/run/index.d.ts +1 -1
- package/run/index.js +1 -1
- package/run/tool-batch.d.ts +51 -1
- package/run/tool-batch.js +59 -1
- package/testing/index.d.ts +153 -0
- package/testing/index.js +188 -0
- package/tools/control-chars.d.ts +23 -0
- package/tools/control-chars.js +35 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoring and dispatching a tool: the mechanical half of writing one, which
|
|
3
|
+
* every host had been reimplementing.
|
|
4
|
+
*
|
|
5
|
+
* A `ToolPlugin` is a bundle whose `execute` dispatches by tool name, because
|
|
6
|
+
* that is the shape a plugin with shared setup wants. It is not the shape a
|
|
7
|
+
* *single* tool wants, and a host with a flat list of tools ends up writing the
|
|
8
|
+
* same four steps for each: switch on the name, parse the arguments, map a
|
|
9
|
+
* parse failure onto a result the model can read, and encode the result as a
|
|
10
|
+
* `role:"tool"` message. All four are mechanical, all four are easy to get
|
|
11
|
+
* subtly wrong (the usual bug is a parse failure thrown rather than returned,
|
|
12
|
+
* which turns a recoverable "you passed the wrong argument" into a dead run),
|
|
13
|
+
* and none of them are where a host's judgement belongs.
|
|
14
|
+
*
|
|
15
|
+
* {@link defineTool} and {@link pluginFromTools} do those steps. They are a
|
|
16
|
+
* convenience over the vocabulary in `./tool`, not a replacement for it: a
|
|
17
|
+
* plugin that needs shared setup across its tools, or whose dispatch is genuinely
|
|
18
|
+
* one decision, still writes `ToolPlugin` by hand and loses nothing.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { sanitizeToolSchema } from "../tools/sanitize-schema.js";
|
|
22
|
+
import { stripControlChars } from "../tools/control-chars.js";
|
|
23
|
+
/**
|
|
24
|
+
* Define one tool from its schema and implementation.
|
|
25
|
+
*
|
|
26
|
+
* The returned value is an ordinary {@link ToolDef} with an `execute` attached,
|
|
27
|
+
* so it drops into anything that already consumes `ToolDef` — a catalog
|
|
28
|
+
* renderer, a schema regression test — without an adapter.
|
|
29
|
+
*/
|
|
30
|
+
export function defineTool(spec) {
|
|
31
|
+
// Hoisted so the closures below narrow it once; reading `spec.normalizeArgs`
|
|
32
|
+
// inside each would re-widen it to possibly-undefined on every call.
|
|
33
|
+
const normalize = spec.normalizeArgs;
|
|
34
|
+
return {
|
|
35
|
+
name: spec.name,
|
|
36
|
+
description: spec.description,
|
|
37
|
+
parameters: spec.schema,
|
|
38
|
+
...(spec.annotations === undefined ? {} : { annotations: spec.annotations }),
|
|
39
|
+
...(spec.hidden === undefined ? {} : { hidden: spec.hidden }),
|
|
40
|
+
...(spec.supportsProgress === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { supportsProgress: spec.supportsProgress }),
|
|
43
|
+
...(spec.rawJsonSchema === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { rawJsonSchema: spec.rawJsonSchema }),
|
|
46
|
+
...(normalize === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: {
|
|
49
|
+
// Handed straight through, NOT re-parsed. `ToolDef.normalizeArgs` is
|
|
50
|
+
// documented as post-parse canonicalization and every dispatcher
|
|
51
|
+
// calls it that way, so re-parsing here was pure harm: it ran the
|
|
52
|
+
// schema a second time, and on any input the schema could not
|
|
53
|
+
// re-accept it silently returned the *un-normalized* value — which
|
|
54
|
+
// is the idempotency hash, so two equivalent calls stopped agreeing
|
|
55
|
+
// exactly when canonicalization mattered most.
|
|
56
|
+
//
|
|
57
|
+
// The cast is confined to this line. It is safe by the same contract:
|
|
58
|
+
// the value is post-parse, so it is a `z.output<TSchema>`.
|
|
59
|
+
normalizeArgs: normalize,
|
|
60
|
+
}),
|
|
61
|
+
...(spec.summarizeActivity === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { summarizeActivity: spec.summarizeActivity }),
|
|
64
|
+
// Parses, and deliberately does NOT normalize.
|
|
65
|
+
//
|
|
66
|
+
// Normalization is the dispatcher's step — it has to happen before the
|
|
67
|
+
// idempotency hash, which the harness never sees — and a dispatcher that
|
|
68
|
+
// applies `normalizeArgs` and then calls this would otherwise apply it
|
|
69
|
+
// twice. Once is a no-op for an idempotent canonicalizer and wrong for
|
|
70
|
+
// anything else (`n => n + 1` reached `execute` as `n + 2`).
|
|
71
|
+
//
|
|
72
|
+
// The parse stays because this is also the entry point for a host with no
|
|
73
|
+
// dispatcher of its own, and re-parsing an already-parsed value is a
|
|
74
|
+
// no-op for any schema that can legally be a tool schema — `.transform()`
|
|
75
|
+
// cannot (`z.toJSONSchema` rejects it, so the tool could never be
|
|
76
|
+
// advertised), and `.default()` / `z.coerce` are both parse-idempotent.
|
|
77
|
+
// A tool that supplies `rawJsonSchema` to bypass that conversion owns the
|
|
78
|
+
// requirement itself; see `ToolSpec.rawJsonSchema`.
|
|
79
|
+
execute: (args, ctx) => {
|
|
80
|
+
const parsed = spec.schema.safeParse(args);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
return validationFailure(spec.name, args, parsed.error);
|
|
83
|
+
}
|
|
84
|
+
return spec.execute(parsed.data, ctx);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Cap on the issues quoted back. A tool taking an array validates every
|
|
90
|
+
* element, so one malformed argument can produce thousands — and the result
|
|
91
|
+
* message is not transient: it is appended to the transcript and re-sent to
|
|
92
|
+
* the provider on every remaining turn of the run. Ten is enough for a model
|
|
93
|
+
* to act on; the rest are counted, not quoted.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_QUOTED_ISSUES = 10;
|
|
96
|
+
/**
|
|
97
|
+
* Quote model-supplied text back at it safely. A zod issue path can contain
|
|
98
|
+
* input *keys* (via `z.record`), and this string reaches a host's logs and, in
|
|
99
|
+
* Monad, a member-visible activity row — so control characters are replaced
|
|
100
|
+
* with a space rather than deleted, keeping adjacent words apart, and the
|
|
101
|
+
* result is bounded.
|
|
102
|
+
*/
|
|
103
|
+
function safeQuote(text) {
|
|
104
|
+
// `Array.from` iterates code points, so the bound cannot slice an astral
|
|
105
|
+
// character in half and put a lone surrogate in the transcript.
|
|
106
|
+
return Array.from(stripControlChars(text, " ")).slice(0, 200).join("");
|
|
107
|
+
}
|
|
108
|
+
/** How a tool's arguments failed to parse, phrased for the model. */
|
|
109
|
+
function validationFailure(toolName, args, error) {
|
|
110
|
+
const quoted = error.issues.slice(0, MAX_QUOTED_ISSUES).map((issue) => {
|
|
111
|
+
const path = issue.path.map((segment) => String(segment)).join(".");
|
|
112
|
+
const where = path ? safeQuote(path) : "(top level)";
|
|
113
|
+
// zod renders an omitted key as "expected string, received undefined",
|
|
114
|
+
// which reads as a *type* error — and a model that reads it that way
|
|
115
|
+
// retries with the literal string "undefined" instead of supplying the
|
|
116
|
+
// field. Resolving the path against the input is what tells the two apart.
|
|
117
|
+
const omitted = issue.code === "invalid_type" && pathIsAbsent(args, issue.path);
|
|
118
|
+
return `- ${where}: ${omitted ? "required, but missing" : safeQuote(issue.message)}`;
|
|
119
|
+
});
|
|
120
|
+
const hidden = error.issues.length - quoted.length;
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
kind: "validation",
|
|
124
|
+
// Leads with the outcome, itemizes what to change, and ends with the
|
|
125
|
+
// action — the shape `ABORTED_TOOL_CALL_MESSAGE` established. A message
|
|
126
|
+
// that only diagnoses leaves the model to guess whether to retry.
|
|
127
|
+
error: `Invalid arguments for ${toolName} — the call did not run and nothing changed.\n` +
|
|
128
|
+
quoted.join("\n") +
|
|
129
|
+
(hidden > 0 ? `\n- (and ${hidden} more problems)` : "") +
|
|
130
|
+
`\nFix these fields and call ${toolName} again with the same intent. ` +
|
|
131
|
+
`Do not resend the same arguments.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Is the key a zod issue points at genuinely absent?
|
|
136
|
+
*
|
|
137
|
+
* Checked with `in` rather than by comparing the value to `undefined`, so a
|
|
138
|
+
* field explicitly present as `undefined` is reported as the wrong type rather
|
|
139
|
+
* than as missing. `JSON.parse` never produces `undefined`, so this only
|
|
140
|
+
* matters for a host dispatching pre-parsed arguments — which is exactly the
|
|
141
|
+
* caller `defineTool` supports.
|
|
142
|
+
*/
|
|
143
|
+
function pathIsAbsent(value, path) {
|
|
144
|
+
let current = value;
|
|
145
|
+
for (const segment of path) {
|
|
146
|
+
if (current === null || typeof current !== "object")
|
|
147
|
+
return true;
|
|
148
|
+
if (!(segment in current))
|
|
149
|
+
return true;
|
|
150
|
+
current = current[segment];
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Bundle self-contained tools into a {@link ToolPlugin}.
|
|
156
|
+
*
|
|
157
|
+
* The generated `execute` is only a name resolver — each tool already validates
|
|
158
|
+
* its own arguments (see {@link DefinedTool}). An unknown name is a *returned*
|
|
159
|
+
* `not_found` failure rather than a throw: it happens whenever a resumed
|
|
160
|
+
* session's history references a tool that has since been retired, and a run
|
|
161
|
+
* should survive that.
|
|
162
|
+
*/
|
|
163
|
+
export function pluginFromTools(spec) {
|
|
164
|
+
const byName = new Map();
|
|
165
|
+
for (const tool of spec.tools) {
|
|
166
|
+
if (byName.has(tool.name)) {
|
|
167
|
+
// Thrown, not returned: two tools sharing a name is an authoring mistake
|
|
168
|
+
// that makes one of them permanently unreachable, and it should fail at
|
|
169
|
+
// construction rather than at whichever call happens to resolve first.
|
|
170
|
+
throw new Error(`pluginFromTools: plugin "${spec.name}" declares two tools named "${tool.name}".`);
|
|
171
|
+
}
|
|
172
|
+
byName.set(tool.name, tool);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
name: spec.name,
|
|
176
|
+
description: spec.description,
|
|
177
|
+
...(spec.systemMessage === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: { systemMessage: spec.systemMessage }),
|
|
180
|
+
...(spec.icon === undefined ? {} : { icon: spec.icon }),
|
|
181
|
+
...(spec.isAvailable === undefined
|
|
182
|
+
? {}
|
|
183
|
+
: { isAvailable: spec.isAvailable }),
|
|
184
|
+
tools: [...spec.tools],
|
|
185
|
+
async execute(toolName, args, ctx) {
|
|
186
|
+
const tool = byName.get(toolName);
|
|
187
|
+
if (!tool) {
|
|
188
|
+
return {
|
|
189
|
+
success: false,
|
|
190
|
+
kind: "not_found",
|
|
191
|
+
// Leads with the outcome and ends with an action. A bare "unknown
|
|
192
|
+
// tool" is indistinguishable from a transient miss, and a model
|
|
193
|
+
// reading it that way re-issues the same call every iteration until
|
|
194
|
+
// the budget runs out — which is exactly the state this arm exists
|
|
195
|
+
// to survive (a resumed session referencing a retired tool).
|
|
196
|
+
error: `No tool named "${safeQuote(toolName)}" exists on plugin "${spec.name}" — ` +
|
|
197
|
+
`nothing ran and nothing changed, and calling it again will fail the ` +
|
|
198
|
+
`same way. Use one of the tools currently listed for "${spec.name}", ` +
|
|
199
|
+
`or finish the task without it.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return await tool.execute(args, ctx);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Convert a tool to the wire definition a provider is shown.
|
|
208
|
+
*
|
|
209
|
+
* `rawJsonSchema` wins when present (an MCP tool forwards its server's schema
|
|
210
|
+
* verbatim); otherwise the zod schema is converted. Either way the result goes
|
|
211
|
+
* through {@link sanitizeToolSchema}, because a strict validator rejects the
|
|
212
|
+
* *entire* request on the first unsupported construct — one bad tool takes
|
|
213
|
+
* every other tool down with it.
|
|
214
|
+
*
|
|
215
|
+
* `wireName` exists because tool naming is host policy: Monad encodes
|
|
216
|
+
* `plugin__tool` so it can route a call back to its plugin, and a host with a
|
|
217
|
+
* flat namespace does not need to. Defaults to the tool's own name.
|
|
218
|
+
*
|
|
219
|
+
* Returns the narrow `ChatCompletionFunctionTool` rather than the
|
|
220
|
+
* `ChatCompletionTool` union — a tool built from a parameter schema is always
|
|
221
|
+
* the function variant, and returning the union would make every caller narrow
|
|
222
|
+
* past a `custom` case that cannot occur. It still assigns to the union.
|
|
223
|
+
*
|
|
224
|
+
* Converts whatever it is handed. **Skip `hidden` tools in the caller's catalog
|
|
225
|
+
* loop** — a hidden tool stays runnable so a resumed session's history still
|
|
226
|
+
* resolves, but advertising it puts a retired tool back in front of the model.
|
|
227
|
+
*/
|
|
228
|
+
export function toolWireDefinition(tool, wireName = tool.name) {
|
|
229
|
+
const jsonSchema = tool.rawJsonSchema ??
|
|
230
|
+
// zod's converter is typed as its own JSON Schema shape; the wire wants a
|
|
231
|
+
// plain object, which is what it structurally is.
|
|
232
|
+
z.toJSONSchema(tool.parameters);
|
|
233
|
+
return {
|
|
234
|
+
type: "function",
|
|
235
|
+
function: {
|
|
236
|
+
name: wireName,
|
|
237
|
+
description: tool.description,
|
|
238
|
+
parameters: sanitizeToolSchema(jsonSchema),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/plugins/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { hasContentParts, type ToolAnnotations, type ToolDef, type ToolFailureKind, type ToolPlugin, type ToolResult, type RegistrablePlugin, type SuspendDirective, } from "./tool.js";
|
|
2
|
+
export { defineTool, pluginFromTools, toolWireDefinition, type DefinedTool, type ToolSpec, type PluginSpec, } from "./dispatch.js";
|
|
3
|
+
export { toolResultMessage } from "./tool-message.js";
|
|
2
4
|
export { createToolRegistry, type PluginSummary, type ToolRegistry, type ToolRegistryOptions, } from "./registry.js";
|
|
3
5
|
export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, type ActivationDropReason, type CatalogPartition, type DroppedActivation, type RehydrateOptions, type RehydrateResult, } from "./activation.js";
|
package/plugins/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { hasContentParts, } from "./tool.js";
|
|
2
|
+
export { defineTool, pluginFromTools, toolWireDefinition, } from "./dispatch.js";
|
|
3
|
+
export { toolResultMessage } from "./tool-message.js";
|
|
2
4
|
export { createToolRegistry, } from "./registry.js";
|
|
3
5
|
export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, } from "./activation.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encoding a tool result as the message that answers its call.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately its own module with **type-only** imports: the tool loop uses
|
|
5
|
+
* this encoder, and `./dispatch` imports zod at runtime for schema conversion.
|
|
6
|
+
* Folding the two together would pull zod into the module graph of every host
|
|
7
|
+
* that imports only `@juno-ai/bind/loop`, which today needs none of it.
|
|
8
|
+
*/
|
|
9
|
+
import type OpenAI from "openai";
|
|
10
|
+
import type { ToolResult } from "./tool.js";
|
|
11
|
+
/**
|
|
12
|
+
* Encode a {@link ToolResult} as the `role:"tool"` message that answers a call.
|
|
13
|
+
*
|
|
14
|
+
* This is the encoding the loop itself synthesizes for a failed or refused
|
|
15
|
+
* call, exported so a host's own results are shaped identically — a model that
|
|
16
|
+
* sees `{"success":false,"kind":…,"error":…}` from the harness and something
|
|
17
|
+
* else from the host has to learn two error formats in one transcript.
|
|
18
|
+
*
|
|
19
|
+
* `contentParts` and `suspend` are deliberately omitted: both are control
|
|
20
|
+
* signals for the host, not text for the model. A host relaying multimodal
|
|
21
|
+
* parts attaches them alongside this message.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toolResultMessage(toolCallId: string, result: ToolResult<unknown>): OpenAI.ChatCompletionToolMessageParam;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encode a {@link ToolResult} as the `role:"tool"` message that answers a call.
|
|
3
|
+
*
|
|
4
|
+
* This is the encoding the loop itself synthesizes for a failed or refused
|
|
5
|
+
* call, exported so a host's own results are shaped identically — a model that
|
|
6
|
+
* sees `{"success":false,"kind":…,"error":…}` from the harness and something
|
|
7
|
+
* else from the host has to learn two error formats in one transcript.
|
|
8
|
+
*
|
|
9
|
+
* `contentParts` and `suspend` are deliberately omitted: both are control
|
|
10
|
+
* signals for the host, not text for the model. A host relaying multimodal
|
|
11
|
+
* parts attaches them alongside this message.
|
|
12
|
+
*/
|
|
13
|
+
export function toolResultMessage(toolCallId, result) {
|
|
14
|
+
const body = result.success
|
|
15
|
+
? { success: true, data: result.data }
|
|
16
|
+
: {
|
|
17
|
+
success: false,
|
|
18
|
+
// `success` and `kind` lead so a model skimming a batch of results
|
|
19
|
+
// reads the verdict before the prose, and the order is fixed —
|
|
20
|
+
// `success`, `kind`, `error`, then `data` when present — so the same
|
|
21
|
+
// result always serializes to the same bytes.
|
|
22
|
+
...(result.kind === undefined ? {} : { kind: result.kind }),
|
|
23
|
+
error: result.error,
|
|
24
|
+
...(result.data === undefined ? {} : { data: result.data }),
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
role: "tool",
|
|
28
|
+
tool_call_id: toolCallId,
|
|
29
|
+
content: JSON.stringify(body),
|
|
30
|
+
};
|
|
31
|
+
}
|
package/plugins/tool.d.ts
CHANGED
|
@@ -80,7 +80,7 @@ export interface ToolDef {
|
|
|
80
80
|
* status, a retry decision — without matching on the error string, which is
|
|
81
81
|
* brittle and locale-dependent. Plugins should set it explicitly.
|
|
82
82
|
*/
|
|
83
|
-
export type ToolFailureKind = "authz" | "validation" | "not_found" | "conflict" | "external" | "system";
|
|
83
|
+
export type ToolFailureKind = "authz" | "validation" | "not_found" | "conflict" | "external" | "not_run" | "system";
|
|
84
84
|
/**
|
|
85
85
|
* Human-in-the-loop suspend directive. A first-party tool returns this on a
|
|
86
86
|
* successful result to **end the run** and record its open tool-call as
|
package/run/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
|
|
2
|
-
export { runToolCallsPooledByTool } from "./tool-batch.js";
|
|
2
|
+
export { runToolCallsPooledByTool, AbortedToolCallError, ABORTED_TOOL_CALL_MESSAGE, type ToolBatchOptions, } from "./tool-batch.js";
|
|
3
3
|
export { rootChain, descendChain, admitChildRun, createPollSchedule, type ChainRef, type ChainRule, type ChildAdmission, type PollStep, type PollSchedule, type PollScheduleOptions, } from "./children.js";
|
|
4
4
|
export { toolCallReceiptKeyString, canonicalJson, toolCallArgsHash, decideToolCallReceipt, type ToolCallReceiptKey, type DigestFn, type ReceiptState, type EffectResumability, type ReceiptDecision, } from "./receipts.js";
|
package/run/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
|
|
2
|
-
export { runToolCallsPooledByTool } from "./tool-batch.js";
|
|
2
|
+
export { runToolCallsPooledByTool, AbortedToolCallError, ABORTED_TOOL_CALL_MESSAGE, } from "./tool-batch.js";
|
|
3
3
|
export { rootChain, descendChain, admitChildRun, createPollSchedule, } from "./children.js";
|
|
4
4
|
export { toolCallReceiptKeyString, canonicalJson, toolCallArgsHash, decideToolCallReceipt, } from "./receipts.js";
|
package/run/tool-batch.d.ts
CHANGED
|
@@ -12,5 +12,55 @@ import type OpenAI from "openai";
|
|
|
12
12
|
* harness-owned, while the shape a host derives from a completed call (which
|
|
13
13
|
* plugin activated, whether the run should compact, a suspend directive, …)
|
|
14
14
|
* stays with the host.
|
|
15
|
+
*
|
|
16
|
+
* **`signal` bounds the batch, and bounding it is the point.** A batch of many
|
|
17
|
+
* calls to one tool runs five at a time, so the rest sit queued — and without a
|
|
18
|
+
* signal the pool claims every one of them however long ago the run's deadline
|
|
19
|
+
* fired or the user pressed stop. Each queued call is a side effect nobody
|
|
20
|
+
* wants any more: a write, an email, a third-party POST. Once the signal
|
|
21
|
+
* aborts, workers stop claiming new work.
|
|
22
|
+
*
|
|
23
|
+
* Calls that never ran still come back — as `rejected` with an
|
|
24
|
+
* {@link AbortedToolCallError} — because every `tool_call_id` in the assistant
|
|
25
|
+
* message needs a response either way, and a caller that received a shorter
|
|
26
|
+
* array than it passed would silently mispair the rest. A call already in
|
|
27
|
+
* flight is left alone: this cannot reach inside a host's tool, and cancelling
|
|
28
|
+
* one mid-write is the host's problem to solve with the same signal.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The message a refused call carries into the transcript, and therefore **into
|
|
32
|
+
* the model's next turn**. Shared by both refusal sites — the pool and the
|
|
33
|
+
* loop's serial phase — because to the only reader they are the same fact, and
|
|
34
|
+
* two literals for one fact drift.
|
|
35
|
+
*
|
|
36
|
+
* Three things it deliberately does *not* do. It does not name the batch, the
|
|
37
|
+
* worker, or the claim: those are this module's internals, the model cannot act
|
|
38
|
+
* on them, and they appear nowhere else in its context. It does not interpolate
|
|
39
|
+
* the call id, which the `tool` message's own `tool_call_id` already carries —
|
|
40
|
+
* restating it spends the sentence's most-attended position on something the
|
|
41
|
+
* reader has. And it does not lead with the cause: the actionable fact is that
|
|
42
|
+
* *nothing happened*, and a model skimming a batch of results reads the first
|
|
43
|
+
* clause.
|
|
44
|
+
*/
|
|
45
|
+
export declare const ABORTED_TOOL_CALL_MESSAGE: string;
|
|
46
|
+
/**
|
|
47
|
+
* A call the batch refused because it was aborted before any worker claimed it.
|
|
48
|
+
*
|
|
49
|
+
* Carries the id as a **field** rather than in the message, so a host's
|
|
50
|
+
* rejection observer and operator log can filter on it (`CLAUDE.md`: identifying
|
|
51
|
+
* context is a field, not string interpolation).
|
|
15
52
|
*/
|
|
16
|
-
export declare
|
|
53
|
+
export declare class AbortedToolCallError extends Error {
|
|
54
|
+
readonly toolCallId: string;
|
|
55
|
+
readonly name = "AbortedToolCallError";
|
|
56
|
+
constructor(toolCallId: string);
|
|
57
|
+
}
|
|
58
|
+
export interface ToolBatchOptions {
|
|
59
|
+
/**
|
|
60
|
+
* Stop claiming queued calls once this aborts. Combine a run deadline with a
|
|
61
|
+
* cancellation signal (`AbortSignal.any`) before passing it — the pool does
|
|
62
|
+
* not care which one fired, only that no further work should start.
|
|
63
|
+
*/
|
|
64
|
+
readonly signal?: AbortSignal | undefined;
|
|
65
|
+
}
|
|
66
|
+
export declare function runToolCallsPooledByTool<TOutcome>(calls: OpenAI.ChatCompletionMessageToolCall[], run: (tc: OpenAI.ChatCompletionMessageToolCall) => Promise<TOutcome>, options?: ToolBatchOptions): Promise<PromiseSettledResult<TOutcome>[]>;
|
package/run/tool-batch.js
CHANGED
|
@@ -36,10 +36,57 @@ function poolKey(call) {
|
|
|
36
36
|
* harness-owned, while the shape a host derives from a completed call (which
|
|
37
37
|
* plugin activated, whether the run should compact, a suspend directive, …)
|
|
38
38
|
* stays with the host.
|
|
39
|
+
*
|
|
40
|
+
* **`signal` bounds the batch, and bounding it is the point.** A batch of many
|
|
41
|
+
* calls to one tool runs five at a time, so the rest sit queued — and without a
|
|
42
|
+
* signal the pool claims every one of them however long ago the run's deadline
|
|
43
|
+
* fired or the user pressed stop. Each queued call is a side effect nobody
|
|
44
|
+
* wants any more: a write, an email, a third-party POST. Once the signal
|
|
45
|
+
* aborts, workers stop claiming new work.
|
|
46
|
+
*
|
|
47
|
+
* Calls that never ran still come back — as `rejected` with an
|
|
48
|
+
* {@link AbortedToolCallError} — because every `tool_call_id` in the assistant
|
|
49
|
+
* message needs a response either way, and a caller that received a shorter
|
|
50
|
+
* array than it passed would silently mispair the rest. A call already in
|
|
51
|
+
* flight is left alone: this cannot reach inside a host's tool, and cancelling
|
|
52
|
+
* one mid-write is the host's problem to solve with the same signal.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The message a refused call carries into the transcript, and therefore **into
|
|
56
|
+
* the model's next turn**. Shared by both refusal sites — the pool and the
|
|
57
|
+
* loop's serial phase — because to the only reader they are the same fact, and
|
|
58
|
+
* two literals for one fact drift.
|
|
59
|
+
*
|
|
60
|
+
* Three things it deliberately does *not* do. It does not name the batch, the
|
|
61
|
+
* worker, or the claim: those are this module's internals, the model cannot act
|
|
62
|
+
* on them, and they appear nowhere else in its context. It does not interpolate
|
|
63
|
+
* the call id, which the `tool` message's own `tool_call_id` already carries —
|
|
64
|
+
* restating it spends the sentence's most-attended position on something the
|
|
65
|
+
* reader has. And it does not lead with the cause: the actionable fact is that
|
|
66
|
+
* *nothing happened*, and a model skimming a batch of results reads the first
|
|
67
|
+
* clause.
|
|
39
68
|
*/
|
|
40
|
-
export
|
|
69
|
+
export const ABORTED_TOOL_CALL_MESSAGE = "This call was never started, so nothing happened and nothing changed. " +
|
|
70
|
+
"The run is ending — do not retry it and do not report it as a failure.";
|
|
71
|
+
/**
|
|
72
|
+
* A call the batch refused because it was aborted before any worker claimed it.
|
|
73
|
+
*
|
|
74
|
+
* Carries the id as a **field** rather than in the message, so a host's
|
|
75
|
+
* rejection observer and operator log can filter on it (`CLAUDE.md`: identifying
|
|
76
|
+
* context is a field, not string interpolation).
|
|
77
|
+
*/
|
|
78
|
+
export class AbortedToolCallError extends Error {
|
|
79
|
+
toolCallId;
|
|
80
|
+
name = "AbortedToolCallError";
|
|
81
|
+
constructor(toolCallId) {
|
|
82
|
+
super(ABORTED_TOOL_CALL_MESSAGE);
|
|
83
|
+
this.toolCallId = toolCallId;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function runToolCallsPooledByTool(calls, run, options = {}) {
|
|
41
87
|
if (calls.length === 0)
|
|
42
88
|
return [];
|
|
89
|
+
const signal = options.signal;
|
|
43
90
|
const results = new Array(calls.length);
|
|
44
91
|
const groups = new Map();
|
|
45
92
|
for (let i = 0; i < calls.length; i++) {
|
|
@@ -69,6 +116,17 @@ export async function runToolCallsPooledByTool(calls, run) {
|
|
|
69
116
|
if (pos >= indices.length)
|
|
70
117
|
return;
|
|
71
118
|
const callIdx = indices[pos];
|
|
119
|
+
// Checked at claim time rather than before the loop: a batch that
|
|
120
|
+
// was fine when it started can be aborted while its first workers
|
|
121
|
+
// are in flight, and that is the ordinary case — the deadline fires
|
|
122
|
+
// or the user hits stop DURING the batch, not before it.
|
|
123
|
+
if (signal?.aborted === true) {
|
|
124
|
+
results[callIdx] = {
|
|
125
|
+
status: "rejected",
|
|
126
|
+
reason: new AbortedToolCallError(calls[callIdx].id),
|
|
127
|
+
};
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
72
130
|
try {
|
|
73
131
|
const value = await run(calls[callIdx]);
|
|
74
132
|
results[callIdx] = { status: "fulfilled", value };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@juno-ai/bind/testing` — fixtures for testing an agent against the harness
|
|
3
|
+
* without a provider, a network, or a credential.
|
|
4
|
+
*
|
|
5
|
+
* The loop's hardest behaviour to get right is also the hardest to test: what
|
|
6
|
+
* happens across several turns, with several tools, when one of them fails or
|
|
7
|
+
* the run is cut short. Reaching that state normally means mocking a streaming
|
|
8
|
+
* chat-completions client, which is a lot of scaffolding to write before the
|
|
9
|
+
* first assertion — so most consumers write it once, badly, and then only test
|
|
10
|
+
* the happy path.
|
|
11
|
+
*
|
|
12
|
+
* These are the fixtures this package's own cross-module suites use, published
|
|
13
|
+
* so a consumer does not rewrite them. They are ordinary values with no magic:
|
|
14
|
+
* a scripted model is a queue of prepared turns, and a harness is a
|
|
15
|
+
* {@link ToolLoopParams} you can override any field of.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { loopHarness, toolCall, toolCallTurn, finalAnswer } from "@juno-ai/bind/testing";
|
|
19
|
+
* import { runToolLoop } from "@juno-ai/bind/loop";
|
|
20
|
+
*
|
|
21
|
+
* const h = loopHarness([
|
|
22
|
+
* toolCallTurn([toolCall("search", { q: "bind" })]),
|
|
23
|
+
* finalAnswer("Found it."),
|
|
24
|
+
* ]);
|
|
25
|
+
* const { stopReason, stats } = await runToolLoop(h.params);
|
|
26
|
+
* expect(stopReason).toBe("done");
|
|
27
|
+
* expect(h.ran).toEqual(["search"]);
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* This module ships in the published package rather than living beside the
|
|
31
|
+
* tests, so it is held to the same portability fences as `src/`: no Node
|
|
32
|
+
* builtins, no `process`, no framework, peer dependencies only.
|
|
33
|
+
*/
|
|
34
|
+
import type OpenAI from "openai";
|
|
35
|
+
import type { ToolCallOutcome, ToolLoopParams, ToolLoopState } from "../loop/tool-loop.js";
|
|
36
|
+
import type { TurnStreamEvent, TurnStreamSink } from "../completion/text-stream.js";
|
|
37
|
+
/**
|
|
38
|
+
* A sink that records what it was handed. `retractable` is the whole decision
|
|
39
|
+
* the text stream turns on, so it is the one required argument.
|
|
40
|
+
*/
|
|
41
|
+
export declare function recordingSink(retractable: boolean): TurnStreamSink & {
|
|
42
|
+
readonly events: TurnStreamEvent[];
|
|
43
|
+
};
|
|
44
|
+
export declare function freshState(overrides?: Partial<ToolLoopState>): ToolLoopState;
|
|
45
|
+
/** An assistant message, with tool calls when given names. */
|
|
46
|
+
export declare function assistant(content: string | null, toolCalls?: ReadonlyArray<{
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
args?: string;
|
|
50
|
+
}>): OpenAI.ChatCompletionMessage;
|
|
51
|
+
/**
|
|
52
|
+
* A successful tool result, encoded exactly as production encodes one.
|
|
53
|
+
*
|
|
54
|
+
* Routed through `toolResultMessage` rather than a bare `JSON.stringify` so a
|
|
55
|
+
* fixture-built transcript has the same shape a real run produces. A fixture
|
|
56
|
+
* that invents its own envelope reintroduces the "two formats in one
|
|
57
|
+
* transcript" problem that encoder exists to remove, and any test asserting on
|
|
58
|
+
* transcript shape would be pinning something production never emits.
|
|
59
|
+
* (`tool-message` is type-only internally, so this pulls no zod into
|
|
60
|
+
* `@juno-ai/bind/testing`.)
|
|
61
|
+
*/
|
|
62
|
+
export declare function toolOutcome(id: string, data?: unknown): ToolCallOutcome;
|
|
63
|
+
/** One tool call in a scripted turn. */
|
|
64
|
+
export interface ScriptedToolCall {
|
|
65
|
+
/** Defaults to the tool name — unique across the whole script, see
|
|
66
|
+
* {@link scriptedModel}, which rejects a duplicate rather than letting it
|
|
67
|
+
* produce a baffling transcript failure ten frames deep in the loop. */
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly args: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Declare one tool call. `args` is serialized for you; pass a string to
|
|
74
|
+
* script malformed JSON on purpose (which is a case worth testing — models
|
|
75
|
+
* emit it).
|
|
76
|
+
*/
|
|
77
|
+
export declare function toolCall(name: string, args?: unknown, id?: string): ScriptedToolCall;
|
|
78
|
+
/** Per-turn accounting overrides. Defaults are small non-zero numbers so a
|
|
79
|
+
* test asserting "usage was recorded" cannot pass on an all-zero fixture. */
|
|
80
|
+
export interface TurnCost {
|
|
81
|
+
readonly inputTokens?: number;
|
|
82
|
+
readonly outputTokens?: number;
|
|
83
|
+
readonly costCents?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Provider-reported cached input tokens. Omit it to script a transport that
|
|
86
|
+
* cannot report one — the run's total then skips this turn rather than
|
|
87
|
+
* counting a zero, which is the distinction `RunStats.cachedInputTokens`
|
|
88
|
+
* turns on. Pass `null` for the same effect explicitly.
|
|
89
|
+
*/
|
|
90
|
+
readonly cachedInputTokens?: number | null;
|
|
91
|
+
}
|
|
92
|
+
/** One scripted model turn: the message to return, plus its usage. */
|
|
93
|
+
export interface ModelResponse extends TurnCost {
|
|
94
|
+
readonly message: OpenAI.ChatCompletionMessage;
|
|
95
|
+
}
|
|
96
|
+
/** A turn where the model asks for tools, optionally alongside some text. */
|
|
97
|
+
export declare function toolCallTurn(calls: readonly ScriptedToolCall[], opts?: TurnCost & {
|
|
98
|
+
readonly content?: string;
|
|
99
|
+
}): ModelResponse;
|
|
100
|
+
/**
|
|
101
|
+
* A turn with text and no tool calls — which is how the loop *ends*. A script
|
|
102
|
+
* that omits it runs to `maxIterations` (or exhausts the queue), so this is
|
|
103
|
+
* the difference between testing `stopReason: "done"` and testing
|
|
104
|
+
* `"iteration_limit"`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function finalAnswer(content: string, opts?: TurnCost): ModelResponse;
|
|
107
|
+
/**
|
|
108
|
+
* Turn a script into a `callModel` implementation.
|
|
109
|
+
*
|
|
110
|
+
* Exhausting the queue throws rather than looping forever or returning an
|
|
111
|
+
* empty turn: a script that ran out is a test that did not describe what it
|
|
112
|
+
* meant to, and the loop's own `maxIterations` cutoff would otherwise absorb
|
|
113
|
+
* the mistake and report a plausible-looking `iteration_limit`.
|
|
114
|
+
*/
|
|
115
|
+
export declare function scriptedModel(turns: readonly ModelResponse[]): NonNullable<ToolLoopParams["callModel"]>;
|
|
116
|
+
export interface LoopHarness {
|
|
117
|
+
params: ToolLoopParams;
|
|
118
|
+
state: ToolLoopState;
|
|
119
|
+
/** Ids the loop dispatched, in order. Recorded for you even if you override
|
|
120
|
+
* `runToolCall`. */
|
|
121
|
+
ran: string[];
|
|
122
|
+
/** Ids whose tool actually reached its side effect. The default
|
|
123
|
+
* `runToolCall` records one here on completion, so `ran` and `sideEffects`
|
|
124
|
+
* match until an override makes them diverge — a tool that throws, hangs
|
|
125
|
+
* past a deadline, or is torn down mid-flight. That divergence is the
|
|
126
|
+
* question every cancellation test is really asking. */
|
|
127
|
+
sideEffects: string[];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A loop wired to a scripted model queue. `runToolCall` records dispatch and
|
|
131
|
+
* completion separately, so a test can tell "the loop asked for this call" from
|
|
132
|
+
* "this call's side effect happened" — the distinction every deadline and
|
|
133
|
+
* cancellation question turns on.
|
|
134
|
+
*
|
|
135
|
+
* Everything is overridable: pass `{ runToolCall }` to make a tool fail,
|
|
136
|
+
* `{ signal }` to abort mid-batch, `{ now }` to make timings deterministic.
|
|
137
|
+
*/
|
|
138
|
+
export declare function loopHarness(responses: readonly ModelResponse[], overrides?: Partial<ToolLoopParams>): LoopHarness;
|
|
139
|
+
/**
|
|
140
|
+
* A clock that advances a fixed amount on every read. Makes the model-time and
|
|
141
|
+
* tool-time figures in `ToolLoopResult.stats` exactly predictable, which
|
|
142
|
+
* `Date.now` cannot be.
|
|
143
|
+
*/
|
|
144
|
+
export declare function steppingClock(stepMs?: number, startMs?: number): () => number;
|
|
145
|
+
/**
|
|
146
|
+
* Resolve after `ms` of real time. Kept tiny so suites stay fast.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately not cancellable — it is for driving the loop's own micro-timers
|
|
149
|
+
* inside a test, not for long-lived waiting. Reach for your own scheduler if
|
|
150
|
+
* you need a wait that outlives the run, so a torn-down run cannot leave a
|
|
151
|
+
* timer resolving into nothing.
|
|
152
|
+
*/
|
|
153
|
+
export declare function sleep(ms: number): Promise<void>;
|