@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,188 @@
1
+ import { toolResultMessage } from "../plugins/tool-message.js";
2
+ /**
3
+ * A sink that records what it was handed. `retractable` is the whole decision
4
+ * the text stream turns on, so it is the one required argument.
5
+ */
6
+ export function recordingSink(retractable) {
7
+ const events = [];
8
+ return {
9
+ retractable,
10
+ events,
11
+ emit(event) {
12
+ events.push(event);
13
+ },
14
+ };
15
+ }
16
+ export function freshState(overrides = {}) {
17
+ return {
18
+ messages: [
19
+ { role: "system", content: "sys" },
20
+ { role: "user", content: "do the thing" },
21
+ ],
22
+ inputTokens: 0,
23
+ outputTokens: 0,
24
+ costCents: 0,
25
+ lastPromptTokens: 0,
26
+ lastOutputTokens: 0,
27
+ hasFreshTokenCount: false,
28
+ toolCalls: 0,
29
+ ...overrides,
30
+ };
31
+ }
32
+ /** An assistant message, with tool calls when given names. */
33
+ export function assistant(content, toolCalls) {
34
+ return {
35
+ role: "assistant",
36
+ content,
37
+ refusal: null,
38
+ ...(toolCalls === undefined
39
+ ? {}
40
+ : {
41
+ tool_calls: toolCalls.map((tc) => ({
42
+ id: tc.id,
43
+ type: "function",
44
+ function: { name: tc.name, arguments: tc.args ?? "{}" },
45
+ })),
46
+ }),
47
+ };
48
+ }
49
+ /**
50
+ * A successful tool result, encoded exactly as production encodes one.
51
+ *
52
+ * Routed through `toolResultMessage` rather than a bare `JSON.stringify` so a
53
+ * fixture-built transcript has the same shape a real run produces. A fixture
54
+ * that invents its own envelope reintroduces the "two formats in one
55
+ * transcript" problem that encoder exists to remove, and any test asserting on
56
+ * transcript shape would be pinning something production never emits.
57
+ * (`tool-message` is type-only internally, so this pulls no zod into
58
+ * `@juno-ai/bind/testing`.)
59
+ */
60
+ export function toolOutcome(id, data = { ok: true }) {
61
+ return { toolMessage: toolResultMessage(id, { success: true, data }) };
62
+ }
63
+ /**
64
+ * Declare one tool call. `args` is serialized for you; pass a string to
65
+ * script malformed JSON on purpose (which is a case worth testing — models
66
+ * emit it).
67
+ */
68
+ export function toolCall(name, args = {}, id = name) {
69
+ return {
70
+ id,
71
+ name,
72
+ args: typeof args === "string" ? args : JSON.stringify(args),
73
+ };
74
+ }
75
+ /** A turn where the model asks for tools, optionally alongside some text. */
76
+ export function toolCallTurn(calls, opts = {}) {
77
+ return { ...opts, message: assistant(opts.content ?? null, calls) };
78
+ }
79
+ /**
80
+ * A turn with text and no tool calls — which is how the loop *ends*. A script
81
+ * that omits it runs to `maxIterations` (or exhausts the queue), so this is
82
+ * the difference between testing `stopReason: "done"` and testing
83
+ * `"iteration_limit"`.
84
+ */
85
+ export function finalAnswer(content, opts = {}) {
86
+ return { ...opts, message: assistant(content) };
87
+ }
88
+ /**
89
+ * Turn a script into a `callModel` implementation.
90
+ *
91
+ * Exhausting the queue throws rather than looping forever or returning an
92
+ * empty turn: a script that ran out is a test that did not describe what it
93
+ * meant to, and the loop's own `maxIterations` cutoff would otherwise absorb
94
+ * the mistake and report a plausible-looking `iteration_limit`.
95
+ */
96
+ export function scriptedModel(turns) {
97
+ const seenIds = new Set();
98
+ for (const turn of turns) {
99
+ for (const tc of turn.message.tool_calls ?? []) {
100
+ if (seenIds.has(tc.id)) {
101
+ throw new Error(`scriptedModel: duplicate tool-call id ${JSON.stringify(tc.id)}. ` +
102
+ `Ids must be unique across the whole script — pass an explicit id ` +
103
+ `to toolCall() when the same tool is called more than once.`);
104
+ }
105
+ seenIds.add(tc.id);
106
+ }
107
+ }
108
+ const queue = [...turns];
109
+ return async () => {
110
+ const next = queue.shift();
111
+ if (next === undefined) {
112
+ throw new Error(`scriptedModel: the script has ${turns.length} turn(s) and was already ` +
113
+ `exhausted on call ${turns.length + 1}. Add a turn, or end the script ` +
114
+ `with finalAnswer() so the loop stops.`);
115
+ }
116
+ return {
117
+ message: next.message,
118
+ inputTokens: next.inputTokens ?? 10,
119
+ outputTokens: next.outputTokens ?? 5,
120
+ costCents: next.costCents ?? 1,
121
+ // Present-only: an absent key and an explicit `null` both mean "not
122
+ // reported", and the loop treats them identically.
123
+ ...(next.cachedInputTokens === undefined
124
+ ? {}
125
+ : { cachedInputTokens: next.cachedInputTokens }),
126
+ };
127
+ };
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 function loopHarness(responses, overrides = {}) {
139
+ const state = overrides.state ?? freshState();
140
+ const ran = [];
141
+ const sideEffects = [];
142
+ const runTool = overrides.runToolCall ??
143
+ (async (tc) => {
144
+ sideEffects.push(tc.id);
145
+ return toolOutcome(tc.id);
146
+ });
147
+ const params = {
148
+ state,
149
+ maxIterations: 10,
150
+ callModel: scriptedModel(responses),
151
+ buildTools: () => [],
152
+ ...overrides,
153
+ // Wrapped AFTER the spread, so `ran` is recorded even when a caller
154
+ // overrides `runToolCall` — which is the documented way to reach the
155
+ // interesting states. Recording dispatch is the whole reason this harness
156
+ // exists, and making it the override's job to remember meant the first
157
+ // test to forget silently lost the distinction it was written to check.
158
+ runToolCall: async (tc) => {
159
+ ran.push(tc.id);
160
+ return await runTool(tc);
161
+ },
162
+ };
163
+ return { params, state, ran, sideEffects };
164
+ }
165
+ /**
166
+ * A clock that advances a fixed amount on every read. Makes the model-time and
167
+ * tool-time figures in `ToolLoopResult.stats` exactly predictable, which
168
+ * `Date.now` cannot be.
169
+ */
170
+ export function steppingClock(stepMs = 1, startMs = 0) {
171
+ let current = startMs;
172
+ return () => {
173
+ const value = current;
174
+ current += stepMs;
175
+ return value;
176
+ };
177
+ }
178
+ /**
179
+ * Resolve after `ms` of real time. Kept tiny so suites stay fast.
180
+ *
181
+ * Deliberately not cancellable — it is for driving the loop's own micro-timers
182
+ * inside a test, not for long-lived waiting. Reach for your own scheduler if
183
+ * you need a wait that outlives the run, so a torn-down run cannot leave a
184
+ * timer resolving into nothing.
185
+ */
186
+ export function sleep(ms) {
187
+ return new Promise((resolve) => setTimeout(resolve, ms));
188
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The character set stripped from any untrusted text the harness interpolates
3
+ * into a message a model, a log, or a person will read.
4
+ *
5
+ * One definition, two call sites with different needs — a run interrupt's user
6
+ * id (removed outright, so it cannot forge lines in the `[Interrupt from user
7
+ * ...]` header) and a tool-argument path or name quoted back in a validation
8
+ * failure (replaced with a space, so adjacent tokens stay separated, and
9
+ * truncated). The *replacement* is the caller's choice; the *set* must not be,
10
+ * because it is the security decision. Two copies means a future addition —
11
+ * bidi overrides, say — lands in one and is forgotten in the other.
12
+ *
13
+ * Deliberately dependency-free: `@juno-ai/bind/loop` uses it, and that
14
+ * subpath's module graph is kept free of runtime `zod`.
15
+ */
16
+ /**
17
+ * Remove control characters from `text`, optionally bounding its length.
18
+ *
19
+ * @param replacement what each stripped character becomes — `""` to delete it,
20
+ * `" "` to keep surrounding words apart.
21
+ * @param maxLength truncate the result to at most this many characters.
22
+ */
23
+ export declare function stripControlChars(text: string, replacement?: string, maxLength?: number): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The character set stripped from any untrusted text the harness interpolates
3
+ * into a message a model, a log, or a person will read.
4
+ *
5
+ * One definition, two call sites with different needs — a run interrupt's user
6
+ * id (removed outright, so it cannot forge lines in the `[Interrupt from user
7
+ * ...]` header) and a tool-argument path or name quoted back in a validation
8
+ * failure (replaced with a space, so adjacent tokens stay separated, and
9
+ * truncated). The *replacement* is the caller's choice; the *set* must not be,
10
+ * because it is the security decision. Two copies means a future addition —
11
+ * bidi overrides, say — lands in one and is forgotten in the other.
12
+ *
13
+ * Deliberately dependency-free: `@juno-ai/bind/loop` uses it, and that
14
+ * subpath's module graph is kept free of runtime `zod`.
15
+ */
16
+ /**
17
+ * C0 controls, DEL, C1 controls, and the Unicode line/paragraph separators.
18
+ *
19
+ * Module-private because it carries the `g` flag and is therefore stateful
20
+ * under `.test()` / `.exec()`. `String.replace` resets `lastIndex` itself, so
21
+ * routing every caller through {@link stripControlChars} keeps that hazard in
22
+ * one place.
23
+ */
24
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
25
+ /**
26
+ * Remove control characters from `text`, optionally bounding its length.
27
+ *
28
+ * @param replacement what each stripped character becomes — `""` to delete it,
29
+ * `" "` to keep surrounding words apart.
30
+ * @param maxLength truncate the result to at most this many characters.
31
+ */
32
+ export function stripControlChars(text, replacement = "", maxLength) {
33
+ const stripped = text.replace(CONTROL_CHARS, replacement);
34
+ return maxLength === undefined ? stripped : stripped.slice(0, maxLength);
35
+ }