@juno-ai/bind 9.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.
@@ -0,0 +1,130 @@
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 type OpenAI from "openai";
22
+ import type { ToolAnnotations, ToolDef, ToolPlugin, ToolResult } from "./tool.js";
23
+ /**
24
+ * A tool that carries its own implementation.
25
+ *
26
+ * `execute` takes `unknown` and does the parsing itself, which is what lets a
27
+ * heterogeneous array of tools — each with a different argument type — sit in
28
+ * one `tools: DefinedTool[]` without a cast anywhere. It also makes a defined
29
+ * tool independently useful: dispatch it directly and you still get argument
30
+ * validation, without going through {@link pluginFromTools}.
31
+ */
32
+ export interface DefinedTool<TCtx, TContentPart = unknown> extends ToolDef {
33
+ /**
34
+ * Run this tool against unvalidated arguments, parsing with `parameters`
35
+ * first. A parse failure comes back as a `validation` result rather than a
36
+ * throw, because the model can fix it on the next turn and a throw would end
37
+ * the run instead.
38
+ *
39
+ * Does **not** apply `normalizeArgs` — that is the dispatcher's step, run
40
+ * before the idempotency hash. Applying it here too would apply it twice.
41
+ */
42
+ execute(args: unknown, ctx: TCtx): Promise<ToolResult<TContentPart>> | ToolResult<TContentPart>;
43
+ }
44
+ /**
45
+ * The declaration side of {@link defineTool}. `schema` is the single source of
46
+ * truth: it is what the model is shown (converted to JSON Schema) and what the
47
+ * model's arguments are validated against, so the two can never drift.
48
+ */
49
+ export interface ToolSpec<TSchema extends z.ZodType, TCtx, TContentPart> {
50
+ readonly name: string;
51
+ readonly description: string;
52
+ readonly schema: TSchema;
53
+ execute(args: z.output<TSchema>, ctx: TCtx): Promise<ToolResult<TContentPart>> | ToolResult<TContentPart>;
54
+ readonly annotations?: ToolAnnotations;
55
+ readonly hidden?: boolean;
56
+ readonly supportsProgress?: boolean;
57
+ /**
58
+ * Pre-computed JSON Schema, for a tool authored as raw JSON Schema rather
59
+ * than zod. `schema` still validates the arguments.
60
+ *
61
+ * It is also the one way to author a tool whose `schema` is not
62
+ * parse-idempotent (a `.transform()`, which `z.toJSONSchema` refuses to
63
+ * convert). Doing so makes the schema's idempotence your responsibility:
64
+ * `execute` parses defensively, so a dispatcher that already parsed hands
65
+ * this a value the schema must still accept.
66
+ */
67
+ readonly rawJsonSchema?: Record<string, unknown>;
68
+ /**
69
+ * Pure canonicalization of already-validated arguments — sorting a set-like
70
+ * array, lower-casing a key. Runs **after** the schema parse, per
71
+ * `ToolDef.normalizeArgs`, so it receives defaults already applied and a
72
+ * shape it can rely on; a normalizer handed raw model output would have to
73
+ * re-check everything the schema just checked.
74
+ *
75
+ * Applied by the **dispatcher**, before the idempotency hash — not by
76
+ * `execute`. It must be idempotent anyway (`f(f(x)) === f(x)`), because
77
+ * nothing can stop a host applying it more than once.
78
+ */
79
+ readonly normalizeArgs?: (args: z.output<TSchema>) => z.output<TSchema>;
80
+ readonly summarizeActivity?: (args: unknown) => string | null;
81
+ }
82
+ /**
83
+ * Define one tool from its schema and implementation.
84
+ *
85
+ * The returned value is an ordinary {@link ToolDef} with an `execute` attached,
86
+ * so it drops into anything that already consumes `ToolDef` — a catalog
87
+ * renderer, a schema regression test — without an adapter.
88
+ */
89
+ export declare function defineTool<TSchema extends z.ZodType, TCtx = unknown, TContentPart = unknown>(spec: ToolSpec<TSchema, TCtx, TContentPart>): DefinedTool<TCtx, TContentPart>;
90
+ export interface PluginSpec<TCtx, TContentPart> {
91
+ readonly name: string;
92
+ readonly description: string;
93
+ readonly tools: readonly DefinedTool<TCtx, TContentPart>[];
94
+ readonly systemMessage?: string;
95
+ readonly icon?: string;
96
+ readonly isAvailable?: () => boolean;
97
+ }
98
+ /**
99
+ * Bundle self-contained tools into a {@link ToolPlugin}.
100
+ *
101
+ * The generated `execute` is only a name resolver — each tool already validates
102
+ * its own arguments (see {@link DefinedTool}). An unknown name is a *returned*
103
+ * `not_found` failure rather than a throw: it happens whenever a resumed
104
+ * session's history references a tool that has since been retired, and a run
105
+ * should survive that.
106
+ */
107
+ export declare function pluginFromTools<TCtx = unknown, TContentPart = unknown>(spec: PluginSpec<TCtx, TContentPart>): ToolPlugin<TCtx, TContentPart>;
108
+ /**
109
+ * Convert a tool to the wire definition a provider is shown.
110
+ *
111
+ * `rawJsonSchema` wins when present (an MCP tool forwards its server's schema
112
+ * verbatim); otherwise the zod schema is converted. Either way the result goes
113
+ * through {@link sanitizeToolSchema}, because a strict validator rejects the
114
+ * *entire* request on the first unsupported construct — one bad tool takes
115
+ * every other tool down with it.
116
+ *
117
+ * `wireName` exists because tool naming is host policy: Monad encodes
118
+ * `plugin__tool` so it can route a call back to its plugin, and a host with a
119
+ * flat namespace does not need to. Defaults to the tool's own name.
120
+ *
121
+ * Returns the narrow `ChatCompletionFunctionTool` rather than the
122
+ * `ChatCompletionTool` union — a tool built from a parameter schema is always
123
+ * the function variant, and returning the union would make every caller narrow
124
+ * past a `custom` case that cannot occur. It still assigns to the union.
125
+ *
126
+ * Converts whatever it is handed. **Skip `hidden` tools in the caller's catalog
127
+ * loop** — a hidden tool stays runnable so a resumed session's history still
128
+ * resolves, but advertising it puts a retired tool back in front of the model.
129
+ */
130
+ export declare function toolWireDefinition(tool: ToolDef, wireName?: string): OpenAI.ChatCompletionFunctionTool;
@@ -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
+ }
@@ -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
+ }
@@ -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>;