@juno-ai/bind 9.0.0 → 11.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 +375 -15
- 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/index.d.ts +6 -2
- package/index.js +6 -2
- package/loop/index.d.ts +2 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +117 -12
- package/loop/tool-loop.js +242 -67
- package/package.json +10 -2
- 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/skills/activation.d.ts +64 -0
- package/skills/activation.js +39 -0
- package/skills/admission.d.ts +61 -0
- package/skills/admission.js +41 -0
- package/skills/catalog.d.ts +54 -0
- package/skills/catalog.js +77 -0
- package/skills/discovery.d.ts +82 -0
- package/skills/discovery.js +91 -0
- package/skills/index.d.ts +19 -0
- package/skills/index.js +19 -0
- package/skills/refs.d.ts +21 -0
- package/skills/refs.js +27 -0
- package/skills/registry.d.ts +57 -0
- package/skills/registry.js +94 -0
- package/skills/resolve.d.ts +89 -0
- package/skills/resolve.js +124 -0
- package/skills/sha.d.ts +53 -0
- package/skills/sha.js +60 -0
- package/skills/sha256.d.ts +38 -0
- package/skills/sha256.js +122 -0
- package/skills/skill-md.d.ts +73 -0
- package/skills/skill-md.js +149 -0
- package/skills/types.d.ts +174 -0
- package/skills/types.js +55 -0
- 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
package/skills/types.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill vocabulary — the *knowledge* sibling of `src/plugins/tool.ts`.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a markdown-shaped procedure or reference module, disclosed to a
|
|
5
|
+
* model in three tiers: a one-line catalog entry it always sees (Tier 1), a
|
|
6
|
+
* body injected into the system prompt once loaded (Tier 2), and bundled
|
|
7
|
+
* resources it may read after loading (Tier 3). That progression is the whole
|
|
8
|
+
* point: a workspace's accumulated know-how does not fit in a context window,
|
|
9
|
+
* and a catalog line costs ~50 tokens where a body costs thousands.
|
|
10
|
+
*
|
|
11
|
+
* Two sources of skill exist and they differ in exactly one way — where the
|
|
12
|
+
* body comes from. A **code skill** is registered from the deployment's own
|
|
13
|
+
* source (see {@link SkillDef}) and its body is a build artifact; an
|
|
14
|
+
* **external skill** is data the host stores and can edit at runtime. The
|
|
15
|
+
* harness owns the first entirely and knows nothing about the second beyond
|
|
16
|
+
* the shape it resolves to, which is why {@link SkillSummary} is the only type
|
|
17
|
+
* both sides share.
|
|
18
|
+
*/
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Bounds
|
|
21
|
+
//
|
|
22
|
+
// Every one of these bounds the *prompt*, not a database. They are defaults: a
|
|
23
|
+
// host passes its own where the call takes one. What they must not be is
|
|
24
|
+
// absent — an unbounded catalog and an unbounded active set are the two ways a
|
|
25
|
+
// skill library silently degrades every run in the deployment rather than
|
|
26
|
+
// failing one of them.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Rough token budget for the Tier-1 catalog. Past it the catalog demotes its
|
|
30
|
+
* overflow to names only rather than dropping entries: a name the model can
|
|
31
|
+
* still pass to `load_skill` beats a skill it cannot discover at all.
|
|
32
|
+
*/
|
|
33
|
+
export const SKILL_CATALOG_TOKEN_BUDGET = 2500;
|
|
34
|
+
/**
|
|
35
|
+
* Max skills one session may hold loaded at once. The count half of the active
|
|
36
|
+
* bound — cheap to check before anything is resolved.
|
|
37
|
+
*/
|
|
38
|
+
export const SKILL_MAX_ACTIVE_PER_SESSION = 12;
|
|
39
|
+
/**
|
|
40
|
+
* Combined token budget for all loaded bodies. The other half of the active
|
|
41
|
+
* bound, and the one that actually protects the context window: twelve small
|
|
42
|
+
* skills are fine and three large ones are not, so a count cap alone does not
|
|
43
|
+
* bound the prompt.
|
|
44
|
+
*/
|
|
45
|
+
export const SKILL_MAX_ACTIVE_BODY_TOKENS = 40_000;
|
|
46
|
+
/**
|
|
47
|
+
* Characters per token, for every estimate in this module. Deliberately crude:
|
|
48
|
+
* these budgets choose between "render the line" and "render the name", and a
|
|
49
|
+
* real tokenizer would cost more than the decision is worth.
|
|
50
|
+
*/
|
|
51
|
+
export const SKILL_CHARS_PER_TOKEN = 4;
|
|
52
|
+
/** The module's shared estimator. */
|
|
53
|
+
export function estimateSkillTokens(text) {
|
|
54
|
+
return Math.ceil(text.length / SKILL_CHARS_PER_TOKEN);
|
|
55
|
+
}
|
|
@@ -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>;
|
package/testing/index.js
ADDED
|
@@ -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
|
+
}
|