@juno-ai/bind 2.0.0 → 4.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 +1153 -60
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +31 -7
- package/contracts/turn.js +45 -0
- package/index.d.ts +16 -5
- package/index.js +16 -5
- package/loop/index.d.ts +1 -0
- package/loop/index.js +1 -0
- package/loop/tool-loop.d.ts +260 -0
- package/loop/tool-loop.js +276 -0
- package/package.json +22 -2
- package/plugins/activation.d.ts +67 -0
- package/plugins/activation.js +61 -0
- package/plugins/index.d.ts +3 -0
- package/plugins/index.js +3 -0
- package/plugins/registry.d.ts +52 -0
- package/plugins/registry.js +54 -0
- package/plugins/tool.d.ts +164 -0
- package/plugins/tool.js +9 -0
- package/routing/billing-basis.d.ts +48 -0
- package/routing/billing-basis.js +67 -0
- package/routing/circuit-breaker.d.ts +2 -2
- package/routing/errors.d.ts +1 -1
- package/routing/executor.d.ts +3 -3
- package/routing/executor.js +1 -1
- package/routing/index.d.ts +11 -9
- package/routing/index.js +11 -9
- package/routing/plan-degradation.d.ts +34 -0
- package/routing/plan-degradation.js +38 -0
- package/routing/plan.d.ts +2 -2
- package/routing/planner.d.ts +4 -4
- package/routing/planner.js +1 -1
- package/routing/policy.d.ts +1 -1
- package/routing/policy.js +1 -1
- package/routing/transport.d.ts +2 -2
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/harness.d.ts +94 -0
- package/run/harness.js +140 -0
- package/run/index.d.ts +3 -0
- package/run/index.js +3 -0
- package/run/tool-batch.d.ts +16 -0
- package/run/tool-batch.js +83 -0
- package/tools/index.d.ts +1 -0
- package/tools/index.js +1 -0
- package/tools/sanitize-schema.d.ts +150 -0
- package/tools/sanitize-schema.js +683 -0
- package/transcript/index.d.ts +1 -0
- package/transcript/index.js +1 -0
- package/transcript/validate.d.ts +54 -0
- package/transcript/validate.js +226 -0
package/README.md
CHANGED
|
@@ -3,88 +3,1181 @@
|
|
|
3
3
|
**`@juno-ai/bind` — an agent harness.**
|
|
4
4
|
|
|
5
5
|
The agent loop is a bind chain: each turn sequences a model completion into
|
|
6
|
-
tool effects into the next turn's context. `bind` is the harness that runs
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
tool effects into the next turn's context. `bind` is the harness that runs that
|
|
7
|
+
chain — the runtime-agnostic core of a production agent loop, extracted from
|
|
8
|
+
[Monad](https://onmonad.ai).
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
This document is organised on the [Diátaxis](https://diataxis.fr) axes.
|
|
11
|
+
**Tutorial** and **How-to** are practical; **Reference** and **Explanation** are
|
|
12
|
+
theoretical. If you are a coding agent working against this package, read the
|
|
13
|
+
exported types for signatures — they are the specification — and use
|
|
14
|
+
[Reference](#reference) for the contracts those types cannot express,
|
|
15
|
+
[Usage scenarios](#usage-scenarios) for porting shapes, and
|
|
16
|
+
[Rules for automated contributors](#rules-for-automated-contributors) for the
|
|
17
|
+
constraints that will fail CI if you break them.
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
19
|
+
| I want to… | Go to |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Understand what this is and whether I need it | [Explanation](#explanation) |
|
|
22
|
+
| Get something running end to end | [Tutorial](#tutorial-route-one-completion) |
|
|
23
|
+
| Solve one specific problem | [How-to guides](#how-to-guides) |
|
|
24
|
+
| Know which entry point to use, and what holds between calls | [Reference](#reference) |
|
|
25
|
+
| Know how versions work before installing | [Versioning](#versioning) |
|
|
26
|
+
| Port an existing agent runtime onto this | [Usage scenarios](#usage-scenarios) |
|
|
27
|
+
| Know what is deliberately not here yet | [Roadmap](#roadmap) |
|
|
28
|
+
| Change this package safely | [Rules for automated contributors](#rules-for-automated-contributors) |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Explanation
|
|
33
|
+
|
|
34
|
+
*Understanding-oriented. Read this to know why the package is shaped the way it
|
|
35
|
+
is; you do not need it to use the package.*
|
|
36
|
+
|
|
37
|
+
### What a harness is, and what it is not
|
|
38
|
+
|
|
39
|
+
A harness owns the parts of an agent loop that are the same for everyone: the
|
|
40
|
+
iteration itself — call the model, run what it asked for, repeat — plus
|
|
41
|
+
deciding which provider to call and what to do when it fails, bounding a run in
|
|
42
|
+
wall-clock time, keeping a transcript in a shape providers accept, rewriting
|
|
43
|
+
tool schemas that strict validators reject, and tracking which tools are
|
|
44
|
+
currently loaded.
|
|
45
|
+
|
|
46
|
+
A *runtime* owns the parts that are yours: identity, authorization, persistence,
|
|
47
|
+
transports, prompt voice, and product behaviour. `bind` deliberately contains
|
|
48
|
+
none of that. It never reads the environment, never touches a filesystem, and
|
|
49
|
+
holds no secrets — which is what lets the same code run on Bun, Node, and edge
|
|
50
|
+
runtimes such as Cloudflare workerd.
|
|
51
|
+
|
|
52
|
+
### The fences, and why they exist
|
|
53
|
+
|
|
54
|
+
Four constraints, enforced by lint:
|
|
55
|
+
|
|
56
|
+
- **No `@/*` application imports.** Anything the harness needs from the host
|
|
57
|
+
arrives through an injected function or value — a port — never a direct
|
|
58
|
+
import.
|
|
59
|
+
- **No Node builtins, no `process`.** No filesystem, no environment reads.
|
|
60
|
+
Configuration is explicit input. Clocks are injectable, with a `Date.now`
|
|
61
|
+
default as the one sanctioned exception.
|
|
62
|
+
- **No framework imports.**
|
|
63
|
+
- **Peer dependencies only** (`zod`, plus `openai` as an optional *type-only*
|
|
64
|
+
peer). The host supplies the instances, so schemas never split across
|
|
65
|
+
duplicate copies — a failure that stays invisible until two zod instances
|
|
66
|
+
disagree about the same schema at runtime.
|
|
67
|
+
|
|
68
|
+
The fences are not stylistic. They are the reason a Cloudflare Worker and a
|
|
69
|
+
long-lived Node server can share this code unmodified.
|
|
70
|
+
|
|
71
|
+
### Why the plugin types are generic
|
|
72
|
+
|
|
73
|
+
`ToolPlugin` is generic over the invocation context (`TCtx`) rather than
|
|
74
|
+
shipping a concrete one. Almost nothing in a real tool context is common: the
|
|
75
|
+
abort signal is, and the rest is the host's own identity model, its
|
|
76
|
+
authorization, and its product features. Two applications comparing notes here
|
|
77
|
+
will typically find they share one field. A concrete context would therefore be
|
|
78
|
+
either a lowest-common-denominator or the union of several products' identity
|
|
79
|
+
models — so the context is a type parameter you supply, and you add your own
|
|
80
|
+
plugin fields by ordinary interface extension.
|
|
81
|
+
|
|
82
|
+
`ToolResult` is separately generic over the content-part type for a smaller
|
|
83
|
+
reason: the wire shape is the provider's, but turning bytes into a multimodal
|
|
84
|
+
part needs runtime-specific APIs that differ between Node and the edge. The
|
|
85
|
+
harness carries parts through without interpreting them, which keeps those APIs
|
|
86
|
+
— and the dependencies they imply — out of a package that must run on workerd.
|
|
87
|
+
|
|
88
|
+
### Why the registry is a factory
|
|
89
|
+
|
|
90
|
+
`createToolRegistry` returns an instance rather than exposing a module-level
|
|
91
|
+
map. Module state is per-isolate on edge runtimes and its lifetime is not the
|
|
92
|
+
host's; it also makes tests share state implicitly. A host that wants singleton
|
|
93
|
+
ergonomics wraps one instance in a module — the choice belongs to the host.
|
|
94
|
+
|
|
95
|
+
### Why a restored activation set is a hint
|
|
96
|
+
|
|
97
|
+
Progressive tool disclosure persists plugin **names**; implementations resolve
|
|
98
|
+
at load time. Between two runs a plugin can be renamed, gated off, or (if it is
|
|
99
|
+
dynamically connected) fail to reconnect. `rehydrateActivation` therefore
|
|
100
|
+
re-validates every persisted entry against current reality and **drops** what no
|
|
101
|
+
longer resolves, returning the drops with a reason rather than failing the run.
|
|
102
|
+
Reporting a plugin as active when its tools cannot be called is worse than
|
|
103
|
+
losing it.
|
|
104
|
+
|
|
105
|
+
### What stays with your application
|
|
106
|
+
|
|
107
|
+
Transports' request construction and wire-error classification, credentials and
|
|
108
|
+
environment parsing, your routing policy configuration, billing accounting
|
|
109
|
+
(persistence and charging), inference logging, authorization, prompt rendering,
|
|
110
|
+
and run orchestration — the queue a run is scheduled on, and the enqueuing and
|
|
111
|
+
storage behind any child runs it spawns. The harness decides whether a child is
|
|
112
|
+
*allowed*; putting it on a queue is yours (see
|
|
113
|
+
[Spawning child runs](#spawning-child-runs-sub-agents)).
|
|
114
|
+
|
|
115
|
+
The loop is here, but the **driver** around it is not: starting a run, recording
|
|
116
|
+
what it did, delivering its output, and deciding when to run it again. That is
|
|
117
|
+
where a runtime's identity, storage, and product behaviour live, and it is why
|
|
118
|
+
`runToolLoop` takes a dozen observers instead of doing any of it.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Tutorial: route one completion
|
|
123
|
+
|
|
124
|
+
*Learning-oriented. Follow these steps in order on a scratch file; the goal is a
|
|
125
|
+
working mental model, not production code.*
|
|
126
|
+
|
|
127
|
+
You will plan a route across two providers, execute it against a fake
|
|
128
|
+
transport, and watch the failure policy fall over to the second provider.
|
|
129
|
+
|
|
130
|
+
**1. Install.** `zod` is a peer dependency — bring your own v4 instance. Add
|
|
131
|
+
`openai` too if you use `/run`, `/transcript`, `/contracts`, or the package
|
|
132
|
+
root: they reference its message types (type-only, erased at runtime).
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
bun add @juno-ai/bind zod openai
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**2. Describe your providers.** A `PlannerTransport` answers two questions:
|
|
139
|
+
are you available, and can you serve this model? It returns a candidate, a
|
|
140
|
+
recorded skip, or `unserved`.
|
|
43
141
|
|
|
44
142
|
```ts
|
|
45
143
|
import {
|
|
46
|
-
buildRoutePlan,
|
|
47
|
-
executeRoutePlan,
|
|
48
|
-
createCircuitBreaker,
|
|
49
144
|
canonicalModelIdSchema,
|
|
145
|
+
providerIdSchema,
|
|
146
|
+
type PlannerTransport,
|
|
50
147
|
} from "@juno-ai/bind/routing";
|
|
51
148
|
|
|
149
|
+
const model = canonicalModelIdSchema.parse("openai/gpt-example");
|
|
150
|
+
const primaryId = providerIdSchema.parse("primary");
|
|
151
|
+
const backupId = providerIdSchema.parse("backup");
|
|
152
|
+
|
|
153
|
+
function fakeTransport(id: typeof primaryId): PlannerTransport {
|
|
154
|
+
return {
|
|
155
|
+
id,
|
|
156
|
+
getAvailability: () => ({ available: true }),
|
|
157
|
+
resolveCandidate: () => ({
|
|
158
|
+
kind: "candidate",
|
|
159
|
+
candidate: {
|
|
160
|
+
providerId: id,
|
|
161
|
+
canonicalModelId: model,
|
|
162
|
+
providerInvocationModel: "gpt-example",
|
|
163
|
+
credentialSource: "platform",
|
|
164
|
+
creditEligible: true,
|
|
165
|
+
capabilities: new Set(["chat_completions"]),
|
|
166
|
+
maxCompletionTokens: null,
|
|
167
|
+
pricingBasis: { kind: "provider_reported" },
|
|
168
|
+
bindingFingerprint: `${id}:gpt-example`,
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**3. Build the plan.** Policy order is the only ordering input. The result is
|
|
176
|
+
frozen, secret-free, and safe to log or snapshot in a test.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { buildRoutePlan } from "@juno-ai/bind/routing";
|
|
180
|
+
|
|
52
181
|
const { plan, skips } = buildRoutePlan({
|
|
53
|
-
primaryModel:
|
|
182
|
+
primaryModel: model,
|
|
54
183
|
fallbackModel: null,
|
|
55
|
-
requirements: {
|
|
56
|
-
|
|
57
|
-
|
|
184
|
+
requirements: {
|
|
185
|
+
capabilities: new Set(["chat_completions"]),
|
|
186
|
+
requestedMaxCompletionTokens: null,
|
|
187
|
+
},
|
|
188
|
+
policyFor: () => ({ mode: "ordered", providers: [primaryId, backupId] }),
|
|
189
|
+
transports: new Map([
|
|
190
|
+
[primaryId, fakeTransport(primaryId)],
|
|
191
|
+
[backupId, fakeTransport(backupId)],
|
|
192
|
+
]),
|
|
58
193
|
});
|
|
59
194
|
|
|
195
|
+
console.log(plan.stages[0].candidates.map((c) => c.providerId)); // primary, backup
|
|
196
|
+
console.log(skips); // [] — nothing was filtered out
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**4. Execute it.** Your `attempt` function performs the real call and
|
|
200
|
+
classifies any failure into facts. It never decides route order — that is the
|
|
201
|
+
executor's job.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { executeRoutePlan, createCircuitBreaker } from "@juno-ai/bind/routing";
|
|
205
|
+
|
|
60
206
|
const result = await executeRoutePlan({
|
|
61
207
|
plan,
|
|
62
208
|
breaker: createCircuitBreaker(),
|
|
63
|
-
attempt: async (candidate, cursor) =>
|
|
209
|
+
attempt: async (candidate, cursor) => {
|
|
210
|
+
if (candidate.providerId === primaryId) {
|
|
211
|
+
return {
|
|
212
|
+
kind: "failure",
|
|
213
|
+
error: {
|
|
214
|
+
kind: "http",
|
|
215
|
+
category: "server_error",
|
|
216
|
+
statusCode: 503,
|
|
217
|
+
retryAfterMs: null,
|
|
218
|
+
target: {
|
|
219
|
+
cursor,
|
|
220
|
+
providerId: candidate.providerId,
|
|
221
|
+
canonicalModelId: candidate.canonicalModelId,
|
|
222
|
+
providerInvocationModel: candidate.providerInvocationModel,
|
|
223
|
+
durationMs: 12,
|
|
224
|
+
},
|
|
225
|
+
cause: new Error("upstream unavailable"),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { kind: "success", value: "hello from backup" };
|
|
230
|
+
},
|
|
64
231
|
});
|
|
232
|
+
|
|
233
|
+
if (result.ok) {
|
|
234
|
+
console.log(result.value); // "hello from backup"
|
|
235
|
+
console.log(result.served.providerId); // backup
|
|
236
|
+
console.log(result.fallbackKind); // "provider"
|
|
237
|
+
}
|
|
65
238
|
```
|
|
66
239
|
|
|
67
|
-
|
|
240
|
+
**What you just saw.** A 503 classifies as a retriable transport failure, so the
|
|
241
|
+
disposition table allows traversal to the next provider and records a breaker
|
|
242
|
+
failure against the first endpoint. You did not write that logic, and you cannot
|
|
243
|
+
accidentally reorder it from inside a transport.
|
|
244
|
+
|
|
245
|
+
**5. Next.** Add a wall-clock budget with
|
|
246
|
+
[`createRunDeadline`](#how-to-bound-a-run-in-wall-clock-time), or add
|
|
247
|
+
progressive tool disclosure with
|
|
248
|
+
[`createToolRegistry`](#how-to-add-progressive-tool-disclosure).
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## How-to guides
|
|
253
|
+
|
|
254
|
+
*Goal-oriented. Each answers one question and assumes you know roughly what you
|
|
255
|
+
are doing.*
|
|
256
|
+
|
|
257
|
+
### How to run the loop
|
|
258
|
+
|
|
259
|
+
`runToolLoop` is the engine: it calls the model, runs the tools the model asks
|
|
260
|
+
for, and repeats until the model stops asking, a tool suspends the run, a caller
|
|
261
|
+
stops it, or `maxIterations` is reached. Everything that *happens* as a result is
|
|
262
|
+
a callback you supply.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { runToolLoop, type ToolLoopState } from "@juno-ai/bind/loop";
|
|
266
|
+
|
|
267
|
+
const state: ToolLoopState = {
|
|
268
|
+
messages: [systemMessage, userMessage],
|
|
269
|
+
inputTokens: 0,
|
|
270
|
+
outputTokens: 0,
|
|
271
|
+
costCents: 0,
|
|
272
|
+
lastPromptTokens: 0,
|
|
273
|
+
lastOutputTokens: 0,
|
|
274
|
+
hasFreshTokenCount: false,
|
|
275
|
+
toolCalls: 0,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
await runToolLoop({
|
|
279
|
+
state,
|
|
280
|
+
activePlugins,
|
|
281
|
+
maxIterations: 30,
|
|
282
|
+
|
|
283
|
+
callModel: (messages, tools) => llm.complete({ messages, tools }),
|
|
284
|
+
buildTools: () => registry.toolDefinitions([...activePlugins]),
|
|
285
|
+
runToolCall: (call) => dispatch(call),
|
|
286
|
+
activatePlugins: (names) => names.forEach((n) => activePlugins.add(n)),
|
|
287
|
+
activateSkills: (refs) => loadInstructions(refs),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// `state` is mutated in place — read totals off it after, or mid-run from a
|
|
291
|
+
// heartbeat.
|
|
292
|
+
console.log(state.inputTokens, state.outputTokens, state.toolCalls);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`state` is mutated rather than returned so a heartbeat can read live totals while
|
|
296
|
+
the run is still going; a returned result could not report anything until the
|
|
297
|
+
run ended.
|
|
298
|
+
|
|
299
|
+
### How to make a tool take effect mid-batch
|
|
300
|
+
|
|
301
|
+
A model can request several tools at once, and one of them may change what the
|
|
302
|
+
others can do — activating a plugin, loading an instruction module. Those must
|
|
303
|
+
run first, alone, or a dependent call in the same batch executes against the old
|
|
304
|
+
tool surface.
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
runsSerially: (call) =>
|
|
308
|
+
call.type === "function" && ACTIVATION_TOOLS.has(call.function.name),
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The loop runs those one at a time, applies each outcome immediately, then fans
|
|
312
|
+
the rest out concurrently (pooled per tool name). Outcomes are reassembled in the
|
|
313
|
+
model's original order either way — every `tool_call_id` gets its answer in the
|
|
314
|
+
sequence the provider expects.
|
|
315
|
+
|
|
316
|
+
Unwired, nothing is serial. That is correct for a host whose tools do not reshape
|
|
317
|
+
the tool surface, and wrong the moment one does.
|
|
318
|
+
|
|
319
|
+
### How to decide which tool failures kill the run
|
|
320
|
+
|
|
321
|
+
By default a thrown tool becomes a tool error the model can read and recover
|
|
322
|
+
from, which is what you want for an isolated failure. Two kinds are not that:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
isFatalToolError: (error) =>
|
|
326
|
+
error instanceof RunCancelledError || // must abort, not be answered
|
|
327
|
+
error instanceof PersistenceError, // we could not RECORD the outcome
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Cancellation has to propagate even when no `ensureNotCancelled` observer is
|
|
331
|
+
wired. A persistence failure matters for a subtler reason: synthesizing "the tool
|
|
332
|
+
failed" over a write you could not record tells the model a lie about work that
|
|
333
|
+
may well have happened.
|
|
334
|
+
|
|
335
|
+
Everything not fatal is answered and observed:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
onToolCallRejected: (toolCallId, error) => log.warn("tool rejected", { toolCallId, error }),
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Wire it. The model sees these failures either way; without the observer, nothing
|
|
342
|
+
else does.
|
|
343
|
+
|
|
344
|
+
### How to pin a model to one provider
|
|
345
|
+
|
|
346
|
+
Use a hard `only` fence. Plan-time filtering and runtime traversal both respect
|
|
347
|
+
it — a pinned provider that fails at request time is never retried elsewhere.
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
policyFor: () => ({ mode: "only", provider: complianceProviderId });
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Useful for evals (reproducible plans) and for compliance routing.
|
|
354
|
+
|
|
355
|
+
### How to bound a run in wall-clock time
|
|
356
|
+
|
|
357
|
+
Create the deadline where you classify the outcome, and dispose it in a
|
|
358
|
+
`finally`.
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import { createRunDeadline, classifyRunFailure } from "@juno-ai/bind/run";
|
|
362
|
+
|
|
363
|
+
const deadline = createRunDeadline({ timeoutMs: 60 * 60 * 1000, label: "agent run" });
|
|
364
|
+
try {
|
|
365
|
+
for (;;) {
|
|
366
|
+
deadline.throwIfTimedOut();
|
|
367
|
+
await callModel({ signal: deadline.withExternal(cancellationSignal) });
|
|
368
|
+
}
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const status = classifyRunFailure(deadline, error); // "timed_out" | "failed"
|
|
371
|
+
} finally {
|
|
372
|
+
deadline.dispose();
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Ownership rule:** whoever needs to read `deadline.timedOut` must create and
|
|
377
|
+
own the deadline. A loop handed one must not dispose it; a loop given none
|
|
378
|
+
should mint its own, so a turn is never unbounded. Handle caller-driven
|
|
379
|
+
cancellation *before* calling `classifyRunFailure` — a cancellation is not a
|
|
380
|
+
timeout, and `timedOut` stays false when only a combined external signal fires.
|
|
381
|
+
|
|
382
|
+
### How to stop a run's progress writes from stampeding
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createCoalescedHeartbeat } from "@juno-ai/bind/run";
|
|
386
|
+
|
|
387
|
+
const heartbeat = createCoalescedHeartbeat({
|
|
388
|
+
coalesceMs: 10_000,
|
|
389
|
+
flush: () => db.bumpRunRow(runId),
|
|
390
|
+
onError: (error) => log.warn("heartbeat flush failed", { error }),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await heartbeat.beat(); // coalesced
|
|
394
|
+
await heartbeat.beat({ force: true }); // always flushes, resolves after the write
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Flush errors go to `onError` and are swallowed, so a transient database hiccup
|
|
398
|
+
never aborts a run. Flushes drain one at a time, so two overlapping writes can
|
|
399
|
+
never land out of order.
|
|
400
|
+
|
|
401
|
+
### How to keep a provider from rejecting your whole tool list
|
|
402
|
+
|
|
403
|
+
Strict validators reject the **entire** request — every tool — on the first
|
|
404
|
+
schema violation. Run each tool's JSON Schema through the sanitizer before it
|
|
405
|
+
reaches the model.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
import { sanitizeToolSchema } from "@juno-ai/bind/tools";
|
|
409
|
+
|
|
410
|
+
const wireTools = tools.map((tool) => ({
|
|
411
|
+
type: "function",
|
|
412
|
+
function: {
|
|
413
|
+
name: tool.name,
|
|
414
|
+
description: tool.description,
|
|
415
|
+
parameters: sanitizeToolSchema(tool.inputSchema),
|
|
416
|
+
},
|
|
417
|
+
}));
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Every transform is correctness-preserving and was bisected against live
|
|
421
|
+
inference. It fixes, among others: a `type` array where a provider requires a
|
|
422
|
+
scalar; `enum` on a non-string type; `required` entries with no matching
|
|
423
|
+
property; a boolean `additionalProperties: false` on a nested object; and a
|
|
424
|
+
parameter literally named `properties`. Run it on third-party (e.g. MCP) tool
|
|
425
|
+
schemas too — those are where the violations usually come from.
|
|
426
|
+
|
|
427
|
+
### How to repair a transcript before sending it
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
import { validateAndHealMessages } from "@juno-ai/bind/transcript";
|
|
431
|
+
|
|
432
|
+
const { messages, issues } = validateAndHealMessages(transcript);
|
|
433
|
+
if (issues.length > 0) log.warn("healed transcript", { issues });
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Detects and repairs orphan tool results, dangling unanswered tool calls, empty
|
|
437
|
+
assistant messages mid-conversation, and a trailing assistant turn — which is
|
|
438
|
+
prefill on one provider's native API and a hard rejection through another.
|
|
439
|
+
|
|
440
|
+
### How to add progressive tool disclosure
|
|
441
|
+
|
|
442
|
+
Model tool-selection accuracy degrades past a few dozen tools, and every tool's
|
|
443
|
+
schema is resent on every turn. Load a small core set, announce the rest as a
|
|
444
|
+
catalog, and activate on demand.
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
import {
|
|
448
|
+
createToolRegistry,
|
|
449
|
+
initialActivePlugins,
|
|
450
|
+
partitionPluginCatalog,
|
|
451
|
+
} from "@juno-ai/bind/plugins";
|
|
452
|
+
|
|
453
|
+
const registry = createToolRegistry<MyPlugin>({
|
|
454
|
+
corePlugins: ["messaging", "memory"],
|
|
455
|
+
aliases: { "old-name": "new-name" },
|
|
456
|
+
onRegister: (plugin) => indexPluginSkills(plugin),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
registry.register(myPlugin);
|
|
460
|
+
|
|
461
|
+
const active = new Set(initialActivePlugins(registry.corePlugins()));
|
|
462
|
+
const { active: shown, loadable } = partitionPluginCatalog(active, registry.summaries());
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`aliases` are permanent: persisted activation state stores names, so an alias is
|
|
466
|
+
how a rename avoids silently stripping capabilities from live sessions without a
|
|
467
|
+
data migration.
|
|
468
|
+
|
|
469
|
+
`partitionPluginCatalog` returns **data, not prose** — catalog wording is your
|
|
470
|
+
system prompt's business, and re-rendering it byte-identically for unchanged
|
|
471
|
+
inputs is what preserves a provider's prompt-cache prefix.
|
|
472
|
+
|
|
473
|
+
### How to restore a persisted activation set
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
import { rehydrateActivation } from "@juno-ai/bind/plugins";
|
|
477
|
+
|
|
478
|
+
const { active, dropped } = await rehydrateActivation(persistedNames, {
|
|
479
|
+
canonicalizeName: (name) => registry.canonicalizeName(name),
|
|
480
|
+
resolve: async (name) => {
|
|
481
|
+
if (isGatedOffThisRun(name)) return "unavailable";
|
|
482
|
+
if (!isDynamic(name)) return registry.get(name) ? true : "unknown";
|
|
483
|
+
return (await connect(name)) ? true : "unreachable";
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
for (const drop of dropped) log.warn("activation dropped", drop);
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
The walk canonicalizes, de-duplicates (two legacy names collapsing to one plugin
|
|
491
|
+
resolve **once**, so a reconnect is not paid twice), and drops rather than
|
|
492
|
+
throws. `resolve` is async precisely so a reconnect can happen inside it.
|
|
493
|
+
|
|
494
|
+
### How to scope the circuit breaker per tenant
|
|
495
|
+
|
|
496
|
+
Pass a non-secret `credentialScope`. Without it, one tenant's revoked
|
|
497
|
+
bring-your-own key opens the circuit for every tenant sharing the same
|
|
498
|
+
`credentialSource`.
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
breaker.recordFailure({
|
|
502
|
+
providerId,
|
|
503
|
+
invocationModel,
|
|
504
|
+
credentialSource: "tenant",
|
|
505
|
+
credentialScope: tenantTag, // opaque, non-secret — it lands in state keys
|
|
506
|
+
});
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
If a half-open probe ends without a recordable outcome (an abort, a propagated
|
|
510
|
+
client error), call `releaseProbe` so the slot cannot stick.
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## Reference
|
|
515
|
+
|
|
516
|
+
*Information-oriented. The exported types are the specification — read them in
|
|
517
|
+
your editor. This section covers what the types cannot say: which entry point
|
|
518
|
+
to reach for, and the contracts that hold between calls.*
|
|
519
|
+
|
|
520
|
+
Every export is re-exported from the package root, but prefer the subpath — it
|
|
521
|
+
keeps a consumer who only wants routing from pulling in the rest.
|
|
522
|
+
|
|
523
|
+
| Import | Owns | Reach for it when |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
|
|
526
|
+
| `@juno-ai/bind/contracts` | Turn vocabulary — `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` and its folds | You want one seam between your loop and any LLM client, and comparable per-run metrics |
|
|
527
|
+
| `@juno-ai/bind/loop` | `runToolLoop` — the iteration engine: model turn, two-phase tool batch, activation, compaction, interrupts, suspend | You want the agent loop itself, not just the pieces to build one |
|
|
528
|
+
| `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
|
|
529
|
+
| `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
|
|
530
|
+
| `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
|
|
531
|
+
| `@juno-ai/bind/plugins` | Tool/plugin vocabulary, the registry factory, progressive-disclosure activation | You have more tools than fit comfortably in one prompt |
|
|
532
|
+
|
|
533
|
+
### Contracts the types do not carry
|
|
534
|
+
|
|
535
|
+
**Routing — attempt order is fixed.** Structured-output attempt → model stage →
|
|
536
|
+
provider candidate → same-endpoint retry. Nothing else reorders it: not
|
|
537
|
+
transport registration order, not map iteration, not the clock.
|
|
538
|
+
|
|
539
|
+
**Routing — transports classify, they never decide.** An `AttemptFn` reports a
|
|
540
|
+
fact (`aborted` / `completion_defect` / `network` / `http` + category);
|
|
541
|
+
`failureDisposition` alone decides retry, next-provider, fallback-model, or
|
|
542
|
+
propagate. Putting routing logic in a transport is the one way to break the
|
|
543
|
+
guarantee that identical inputs produce identical plans.
|
|
544
|
+
|
|
545
|
+
**Routing — a healthy endpoint must not be punished for a bad request.**
|
|
546
|
+
Request-shaped rejections (a 400, a moderation refusal) traverse to another
|
|
547
|
+
provider but record **no** breaker failure. Otherwise one caller's malformed or
|
|
548
|
+
flagged prompt degrades a shared endpoint for everyone.
|
|
549
|
+
|
|
550
|
+
**Routing — a fallback model equal to the primary is ignored**, so you can pass
|
|
551
|
+
a configured fallback through unconditionally without producing a duplicate
|
|
552
|
+
stage.
|
|
553
|
+
|
|
554
|
+
**Routing — breaker keys must stay secret-free.** `credentialScope` lands in
|
|
555
|
+
state keys; pass an opaque tag, never key material. Omitting it means one
|
|
556
|
+
tenant's revoked key opens the circuit for every tenant sharing that
|
|
557
|
+
`credentialSource`. Defaults: open after 3 consecutive failures, 60 s cooldown,
|
|
558
|
+
60 s ceiling including `Retry-After` extensions. Resolve a half-open probe that
|
|
559
|
+
ended without a recordable outcome via `releaseProbe`, or the slot sticks.
|
|
560
|
+
|
|
561
|
+
**Run — a child is admitted before it exists, never after.** `admitChildRun`
|
|
562
|
+
judges counts you supply; call it ahead of enqueuing. A chain bounded only once
|
|
563
|
+
its runs are on the queue is not bounded, it is billed. A rule whose measurement
|
|
564
|
+
is `NaN` or `Infinity` refuses rather than admits — every comparison is false
|
|
565
|
+
against `NaN`, so the naive reading of a broken count is an unbounded chain.
|
|
68
566
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
567
|
+
**Run — chain lineage is "null means me".** A root run's `rootRunId` and
|
|
568
|
+
`parentRunId` are both `null`, because a chain's origin has no id to point at
|
|
569
|
+
until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
|
|
570
|
+
`descendChain` does that when it hands the id down, so every descendant carries
|
|
571
|
+
a concrete root. Adopting the parent's id at *every* level instead makes each
|
|
572
|
+
generation a fresh chain, and every per-chain bound then counts the wrong set
|
|
573
|
+
and silently never fires.
|
|
574
|
+
|
|
575
|
+
**Run — whoever classifies the outcome owns the deadline.** `timedOut` reflects
|
|
576
|
+
*that* deadline firing, and stays false when only a combined external signal
|
|
577
|
+
aborts — which is what separates a timeout from a cancellation. Handle
|
|
578
|
+
cancellation before calling `classifyRunFailure`. A loop handed a deadline must
|
|
579
|
+
not dispose it; a loop given none should mint its own, so a turn is never
|
|
580
|
+
unbounded.
|
|
581
|
+
|
|
582
|
+
**Run — pooling preserves input order and answers everything.**
|
|
583
|
+
`runToolCallsPooledByTool` caps concurrent calls to the *same* tool at 5 while
|
|
584
|
+
different tools fan out fully, and returns `PromiseSettledResult`s in input
|
|
585
|
+
order. A rejection still needs a synthesized error tool message, or the next
|
|
586
|
+
request carries an unanswered `tool_call_id`.
|
|
587
|
+
|
|
588
|
+
**Routing — a policy that names a provider you have not configured degrades,
|
|
589
|
+
it does not fail.** `buildRoutePlanWithConfigDegradation` drops such a stage at
|
|
590
|
+
*plan time* and records why, so a missing key costs you that provider rather
|
|
591
|
+
than making the model unavailable. Runtime failures keep the fence: a configured
|
|
592
|
+
`only` provider that errors at request time is still never retried elsewhere.
|
|
593
|
+
|
|
594
|
+
**Run — heartbeat flushes drain one at a time**, so two overlapping writes
|
|
595
|
+
cannot land out of order. Flush errors go to `onError` and are swallowed; a
|
|
596
|
+
transient storage failure never aborts a run.
|
|
597
|
+
|
|
598
|
+
**Contracts — model time and tool time stay separate.** Task wall-clock
|
|
599
|
+
conflates provider inference speed with tool execution. `RunStats` reports both,
|
|
600
|
+
plus a per-tool breakdown; field shapes follow the [StirrupJS](https://github.com/stirrupjs/stirrup)
|
|
601
|
+
`speedStats` methodology, so numbers stay comparable with published benchmarks
|
|
602
|
+
and a slow tool never reads as a slow model.
|
|
603
|
+
|
|
604
|
+
**Transcript — the second argument is an assertion, not a silence.** Ids passed
|
|
605
|
+
as `allowedOpenToolCallIds` are *legitimately* open (a suspended call awaiting a
|
|
606
|
+
human answer). Because this is the universal heal site and a correct transcript
|
|
607
|
+
never reaches it with a suspended call unpaired, an allowed id arriving unpaired
|
|
608
|
+
means resume failed — and it fails loudly rather than being quietly kept.
|
|
609
|
+
|
|
610
|
+
**Tools — sanitize third-party schemas too.** Strict validators reject the
|
|
611
|
+
*entire* request, every tool, on the first violation, and third-party schemas
|
|
612
|
+
are where violations usually originate. Every transform is
|
|
613
|
+
correctness-preserving and was bisected against live inference.
|
|
614
|
+
|
|
615
|
+
**Plugins — `rawJsonSchema` overrides, it does not replace.** `parameters` is
|
|
616
|
+
always required; a host authoring tools as raw JSON Schema supplies a
|
|
617
|
+
placeholder there and puts the real schema on `rawJsonSchema`, which is what
|
|
618
|
+
reaches the model.
|
|
619
|
+
|
|
620
|
+
**Plugins — availability is evaluated per call.** `get()` returns `undefined`
|
|
621
|
+
for an unavailable plugin while `all()` still includes it, so a plugin can stay
|
|
622
|
+
registered for configuration purposes while being gated off for a run. A
|
|
623
|
+
connection going unhealthy mid-process takes its tools out of the catalog with
|
|
624
|
+
no re-registration.
|
|
625
|
+
|
|
626
|
+
**Plugins — aliases are permanent.** Activation state persists *names*, so an
|
|
627
|
+
alias is how a rename avoids stripping capabilities from live sessions without a
|
|
628
|
+
data migration. Removing one silently downgrades every session that still stores
|
|
629
|
+
the old name.
|
|
630
|
+
|
|
631
|
+
**Plugins — `hidden: true` keeps a tool callable but unadvertised**, for resumed
|
|
632
|
+
sessions whose history references a tool you have retired from the prompt.
|
|
633
|
+
|
|
634
|
+
**Plugins — rehydration drops, it never throws.** `resolve` returns `true` to
|
|
635
|
+
keep or a drop reason to discard, and may be async so a reconnect can happen
|
|
636
|
+
inside it. The walk canonicalizes and de-duplicates first, so two legacy names
|
|
637
|
+
collapsing to one plugin resolve once — a reconnect is not paid twice.
|
|
638
|
+
|
|
639
|
+
**Plugins — catalog partitioning returns data, not prose.** Wording belongs to
|
|
640
|
+
your system prompt, where re-rendering byte-identically for unchanged inputs is
|
|
641
|
+
what preserves a provider's prompt-cache prefix.
|
|
642
|
+
|
|
643
|
+
### Suspend semantics
|
|
644
|
+
|
|
645
|
+
A first-party tool returns a `SuspendDirective` on a **successful** result to end
|
|
646
|
+
the run with its call recorded as awaiting resolution. The resume kind decides
|
|
647
|
+
what happens to the tool message:
|
|
648
|
+
|
|
649
|
+
- `"answer"` — the reply becomes this call's `role:"tool"` result, so the loop
|
|
650
|
+
**withholds** the message. At most one may be open per run.
|
|
651
|
+
- `"wake"` — a time or event resume that re-enters via a prompt and **keeps** the
|
|
652
|
+
message.
|
|
653
|
+
|
|
654
|
+
`request` is an opaque render/route payload, validated by the consumer and never
|
|
655
|
+
inspected by the loop.
|
|
656
|
+
|
|
657
|
+
---
|
|
658
|
+
|
|
659
|
+
## Usage scenarios
|
|
660
|
+
|
|
661
|
+
*Sketches, not runnable files — they name types loosely and omit imports. Each
|
|
662
|
+
shows the shape of one migration an existing agent runtime typically has to
|
|
663
|
+
make. Read the [Reference](#reference) for exact signatures.*
|
|
664
|
+
|
|
665
|
+
### Tools authored as raw JSON Schema, not zod
|
|
666
|
+
|
|
667
|
+
`ToolDef.parameters` is a zod schema, but a host whose tools are already JSON
|
|
668
|
+
Schema does not need to rewrite them — set `rawJsonSchema` and it is used
|
|
669
|
+
verbatim. Keep `parameters` as a permissive placeholder if you validate
|
|
670
|
+
elsewhere.
|
|
671
|
+
|
|
672
|
+
```ts
|
|
673
|
+
const toolDef: ToolDef = {
|
|
674
|
+
name: "search",
|
|
675
|
+
description: "Search one page of results.",
|
|
676
|
+
parameters: z.unknown(), // unused when rawJsonSchema is present
|
|
677
|
+
rawJsonSchema: existingJsonSchema, // your hand-written schema, as-is
|
|
678
|
+
annotations: { readOnlyHint: true },
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
// Sanitize on the way to the model, regardless of which form it came from.
|
|
682
|
+
const wire = {
|
|
683
|
+
type: "function",
|
|
684
|
+
function: {
|
|
685
|
+
name: toolDef.name,
|
|
686
|
+
description: toolDef.description,
|
|
687
|
+
parameters: sanitizeToolSchema(toolDef.rawJsonSchema ?? z.toJSONSchema(toolDef.parameters)),
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### One plugin per integration
|
|
693
|
+
|
|
694
|
+
A natural plugin boundary is the external system a group of tools talks to.
|
|
695
|
+
|
|
696
|
+
`isAvailable` gates *whether the deployment has this integration at all* — a
|
|
697
|
+
build flag, a missing binding, an edition that does not ship it. It must return
|
|
698
|
+
the same answer for the whole life of the process: the catalog is re-rendered
|
|
699
|
+
several times per run, and a value that flips mid-run changes the system prompt
|
|
700
|
+
between turns, which costs the prompt-cache prefix and can strand a tool call
|
|
701
|
+
the model already issued.
|
|
702
|
+
|
|
703
|
+
Health that changes at runtime belongs at *invocation* instead. Let the call
|
|
704
|
+
fail, and return the reason:
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
function integrationPlugin(adapter: Adapter, conn: Connection): ToolPlugin<Ctx> {
|
|
708
|
+
return {
|
|
709
|
+
name: adapter.id, // "calendar", "issues", "docs", …
|
|
710
|
+
description: adapter.catalogLine, // one line; it lands in the prompt catalog
|
|
711
|
+
isAvailable: () => adapter.installed, // constant for this process
|
|
712
|
+
tools: adapter.tools.filter((t) => conn.grants.has(t.capability)),
|
|
713
|
+
execute: async (toolName, args, ctx) => {
|
|
714
|
+
if (conn.status !== "healthy") {
|
|
715
|
+
// The model can retry, or route around it — a dropped catalog entry
|
|
716
|
+
// tells it nothing.
|
|
717
|
+
return { content: `${adapter.id} is unreachable right now.`, isError: true };
|
|
718
|
+
}
|
|
719
|
+
return adapter.invoke(toolName, args, ctx);
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
for (const conn of connections) {
|
|
725
|
+
registry.register(integrationPlugin(adapterFor(conn), conn));
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
For a plugin whose *connection* is established per run rather than per process,
|
|
730
|
+
restoring a persisted activation is where the reconnect belongs — see
|
|
731
|
+
[How to restore a persisted activation set](#how-to-restore-a-persisted-activation-set).
|
|
732
|
+
|
|
733
|
+
Splitting one large integration into several plugins (`mail`, `calendar`,
|
|
734
|
+
`files`) is usually worth it: the unit of activation should match the unit of
|
|
735
|
+
intent, and a 20-tool plugin is a large thing to load for one call.
|
|
736
|
+
|
|
737
|
+
### A per-request registry on an edge runtime
|
|
738
|
+
|
|
739
|
+
Module-level state is per-isolate and outlives a single request unpredictably.
|
|
740
|
+
Build the registry inside the request instead — the factory is cheap.
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
export default {
|
|
744
|
+
async fetch(request: Request, env: Env) {
|
|
745
|
+
const registry = createToolRegistry<MyPlugin>({
|
|
746
|
+
corePlugins: ["core", "discovery"],
|
|
747
|
+
});
|
|
748
|
+
for (const plugin of await loadPluginsFor(env, request)) {
|
|
749
|
+
registry.register(plugin);
|
|
750
|
+
}
|
|
751
|
+
return handle(request, registry);
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### A single-provider transport
|
|
757
|
+
|
|
758
|
+
You do not need multiple providers to benefit from the plan/execute split — one
|
|
759
|
+
transport still gets you the failure taxonomy, the retry budget, and the
|
|
760
|
+
breaker.
|
|
761
|
+
|
|
762
|
+
```ts
|
|
763
|
+
const transport: PlannerTransport = {
|
|
764
|
+
id: providerIdSchema.parse("openrouter"),
|
|
765
|
+
getAvailability: () =>
|
|
766
|
+
env.API_KEY ? { available: true } : { available: false, reason: "no key" },
|
|
767
|
+
resolveCandidate: (model, requirements) => {
|
|
768
|
+
if (!supports(model, requirements.capabilities)) {
|
|
769
|
+
return { kind: "skip", skip: { canonicalModelId: model, providerId: id, reason: "capability_mismatch" } };
|
|
770
|
+
}
|
|
771
|
+
return { kind: "candidate", candidate: candidateFor(model) };
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### Replacing a hand-rolled retry/fallback loop
|
|
777
|
+
|
|
778
|
+
A typical hand-rolled loop is `for (model) for (attempt)` with an ad-hoc
|
|
779
|
+
`retry | fallback | terminal` decision. Move the decision to the taxonomy: your
|
|
780
|
+
transport reports **facts**, the executor decides order.
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
// Before: the transport decided what to do next.
|
|
784
|
+
// if (status === 429 || status >= 500) retry();
|
|
785
|
+
// else if (status === 401) throw;
|
|
786
|
+
// else tryNextModel();
|
|
787
|
+
|
|
788
|
+
// After: the transport only classifies.
|
|
789
|
+
const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
|
|
790
|
+
const startedAt = performance.now();
|
|
791
|
+
try {
|
|
792
|
+
return { kind: "success", value: await callProvider(candidate) };
|
|
793
|
+
} catch (cause) {
|
|
794
|
+
const target = {
|
|
795
|
+
cursor,
|
|
796
|
+
providerId: candidate.providerId,
|
|
797
|
+
canonicalModelId: candidate.canonicalModelId,
|
|
798
|
+
providerInvocationModel: candidate.providerInvocationModel,
|
|
799
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
800
|
+
};
|
|
801
|
+
if (isAbort(cause)) return { kind: "failure", error: { kind: "aborted", target, cause } };
|
|
802
|
+
if (!cause.status) return { kind: "failure", error: { kind: "network", target, cause } };
|
|
803
|
+
return {
|
|
804
|
+
kind: "failure",
|
|
805
|
+
error: {
|
|
806
|
+
kind: "http",
|
|
807
|
+
category: categorizeHttpStatus(cause.status),
|
|
808
|
+
statusCode: cause.status,
|
|
809
|
+
retryAfterMs: parseRetryAfter(cause),
|
|
810
|
+
target,
|
|
811
|
+
cause,
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
Two behaviours you get for free and probably did not have: a request-shaped
|
|
819
|
+
rejection (a 400, a moderation refusal) traverses to another provider **without**
|
|
820
|
+
opening the breaker, and an empty or truncated completion is retried on the same
|
|
821
|
+
endpoint before any fallback.
|
|
822
|
+
|
|
823
|
+
### Adapting a non-SDK client to `TurnFn`
|
|
824
|
+
|
|
825
|
+
`TurnFn` is the seam between a loop and any client. If you hand-roll SSE, adapt
|
|
826
|
+
at this boundary and the rest of the harness does not care.
|
|
827
|
+
|
|
828
|
+
```ts
|
|
829
|
+
const turn: TurnFn = async (messages, tools, signal) => {
|
|
830
|
+
const startedAt = performance.now();
|
|
831
|
+
let ttftMs: number | null = null;
|
|
832
|
+
|
|
833
|
+
const { message, usage } = await streamCompletion({
|
|
834
|
+
messages, tools, signal,
|
|
835
|
+
onFirstToken: () => { ttftMs ??= Math.round(performance.now() - startedAt); },
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
message,
|
|
840
|
+
usage: {
|
|
841
|
+
inputTokens: usage.prompt_tokens ?? 0,
|
|
842
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
843
|
+
cachedInputTokens: usage.cached_tokens ?? null,
|
|
844
|
+
costCents: usage.cost != null ? usage.cost * 100 : null,
|
|
845
|
+
},
|
|
846
|
+
timings: { ttftMs, generationMs: Math.round(performance.now() - startedAt) },
|
|
847
|
+
};
|
|
848
|
+
};
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
### Accumulating run statistics
|
|
852
|
+
|
|
853
|
+
Fold each turn and each tool call as they complete; `RunStats` keeps model time
|
|
854
|
+
and tool time separate so a slow tool never looks like a slow model.
|
|
855
|
+
|
|
856
|
+
```ts
|
|
857
|
+
let stats = emptyRunStats();
|
|
858
|
+
|
|
859
|
+
for (const turn of turns) {
|
|
860
|
+
const result = await turnFn(messages, tools, signal);
|
|
861
|
+
stats = accumulateTurn(stats, result);
|
|
862
|
+
|
|
863
|
+
for (const call of result.message.tool_calls ?? []) {
|
|
864
|
+
const startedAt = performance.now();
|
|
865
|
+
await dispatch(call);
|
|
866
|
+
stats = accumulateToolCall(stats, call.function.name, performance.now() - startedAt);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
await persistRun({ ...stats, stopReason: "done" satisfies StopReason });
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
### Batching a turn's tool calls
|
|
874
|
+
|
|
875
|
+
Reads can overlap; writes usually should not. Pool the reads and keep the
|
|
876
|
+
transcript in the model's original call order.
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
const reads = calls.filter((c) => isReadOnly(c));
|
|
880
|
+
const writes = calls.filter((c) => !isReadOnly(c));
|
|
881
|
+
|
|
882
|
+
const readResults = await runToolCallsPooledByTool(reads, (call) => dispatch(call));
|
|
883
|
+
|
|
884
|
+
const writeResults: Outcome[] = [];
|
|
885
|
+
for (const call of writes) writeResults.push(await dispatch(call));
|
|
886
|
+
|
|
887
|
+
// Re-emit in the order the model asked for, so every tool_call_id is answered.
|
|
888
|
+
for (const call of calls) {
|
|
889
|
+
messages.push(toolMessageFor(call, resultFor(call, readResults, writeResults)));
|
|
890
|
+
}
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
`runToolCallsPooledByTool` returns `PromiseSettledResult`s — a rejection still
|
|
894
|
+
needs a synthesized error tool message, or the next request has an unanswered
|
|
895
|
+
call.
|
|
896
|
+
|
|
897
|
+
### Pausing a run for human input
|
|
898
|
+
|
|
899
|
+
A tool that needs an answer returns a `suspend` directive on a **successful**
|
|
900
|
+
result. Pick the resume kind by whether the answer becomes the tool's result.
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
// The run resumes later via a prompt; the tool message is kept.
|
|
904
|
+
return {
|
|
905
|
+
success: true,
|
|
906
|
+
data: { askedAt: nowIso },
|
|
907
|
+
suspend: { reason: "awaiting user selection", resumeKind: "wake", request: formSpec },
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// The human's reply IS this call's tool result; the loop withholds the message.
|
|
911
|
+
return {
|
|
912
|
+
success: true,
|
|
913
|
+
data: null,
|
|
914
|
+
suspend: { reason: "awaiting answer", resumeKind: "answer", request: formSpec },
|
|
915
|
+
};
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
If your runtime ends the whole run and starts a fresh one on reply, `wake` is
|
|
919
|
+
the kind you want — `answer` only pays off when you thread the reply back into
|
|
920
|
+
the same transcript as the matching `role:"tool"` message.
|
|
921
|
+
|
|
922
|
+
### Carrying disclosure across a run boundary
|
|
923
|
+
|
|
924
|
+
When a run ends and a later one continues the same conversation, persist the
|
|
925
|
+
**names** and re-validate on the way back in.
|
|
926
|
+
|
|
927
|
+
```ts
|
|
928
|
+
// End of run: names only, never plugin objects.
|
|
929
|
+
await db.saveActivation(conversationId, [...activePlugins]);
|
|
930
|
+
|
|
931
|
+
// Start of the next run.
|
|
932
|
+
const persisted = await db.loadActivation(conversationId);
|
|
933
|
+
const { active, dropped } = await rehydrateActivation(persisted, {
|
|
934
|
+
canonicalizeName: (n) => registry.canonicalizeName(n),
|
|
935
|
+
resolve: async (n) => (registry.get(n) ? true : "unknown"),
|
|
936
|
+
});
|
|
937
|
+
// Seed core, then add what survived — core plugins load unconditionally.
|
|
938
|
+
const activePlugins = new Set([...initialActivePlugins(registry.corePlugins()), ...active]);
|
|
939
|
+
for (const drop of dropped) log.info("plugin not restored", drop);
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
Without this, every boundary silently resets the agent to core-only and it
|
|
943
|
+
re-discovers from scratch — which costs a round-trip per resumption.
|
|
944
|
+
|
|
945
|
+
### Assembling a bounded run
|
|
946
|
+
|
|
947
|
+
The pieces compose; nothing here knows about the others.
|
|
948
|
+
|
|
949
|
+
```ts
|
|
950
|
+
const deadline = createRunDeadline({ timeoutMs: RUN_BUDGET_MS, label: "run" });
|
|
951
|
+
const heartbeat = createCoalescedHeartbeat({
|
|
952
|
+
coalesceMs: 10_000,
|
|
953
|
+
flush: () => db.touchRun(runId),
|
|
954
|
+
});
|
|
955
|
+
let stats = emptyRunStats();
|
|
956
|
+
let stopReason: StopReason = "done";
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
960
|
+
deadline.throwIfTimedOut();
|
|
961
|
+
const { messages: healed } = validateAndHealMessages(transcript);
|
|
962
|
+
const result = await turnFn(healed, wireTools, deadline.withExternal(cancelSignal));
|
|
963
|
+
stats = accumulateTurn(stats, result);
|
|
964
|
+
await heartbeat.beat();
|
|
965
|
+
if (!result.message.tool_calls?.length) break;
|
|
966
|
+
if (await runToolBatch(result.message.tool_calls)) { stopReason = "suspended"; break; }
|
|
967
|
+
}
|
|
968
|
+
} catch (error) {
|
|
969
|
+
stopReason = classifyRunFailure(deadline, error) === "timed_out" ? "deadline" : "aborted";
|
|
970
|
+
} finally {
|
|
971
|
+
await heartbeat.beat({ force: true });
|
|
972
|
+
deadline.dispose();
|
|
973
|
+
}
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
### Spawning child runs (sub-agents)
|
|
977
|
+
|
|
978
|
+
A sub-agent is not a special kind of thing. It is **a run that another run
|
|
979
|
+
asked for**, so everything the harness already gives a run applies unchanged:
|
|
980
|
+
its own deadline, its own heartbeat, its own `RunStats`, its own route plan.
|
|
981
|
+
Three things are genuinely new — lineage, admission, and waiting — and
|
|
982
|
+
`@juno-ai/bind/run` owns the decision in each. It owns none of the measuring:
|
|
983
|
+
counting runs in a chain needs your database, and judging whether a count is
|
|
984
|
+
too high does not.
|
|
985
|
+
|
|
986
|
+
**1. The spawn tool is an ordinary plugin.** Nothing special is needed here —
|
|
987
|
+
the tool vocabulary is already shared.
|
|
988
|
+
|
|
989
|
+
```ts
|
|
990
|
+
const orchestration: ToolPlugin<Ctx> = {
|
|
991
|
+
name: "orchestration",
|
|
992
|
+
description: "Delegate focused work to a child run.",
|
|
993
|
+
icon: "share",
|
|
994
|
+
tools: [
|
|
995
|
+
{
|
|
996
|
+
name: "spawn",
|
|
997
|
+
description: "Run one focused objective as a child and return its id.",
|
|
998
|
+
parameters: spawnArgsSchema, // z.object({ objective: z.string(), plugins: z.array(z.string()) })
|
|
999
|
+
},
|
|
1000
|
+
],
|
|
1001
|
+
async execute(toolName, args, ctx) {
|
|
1002
|
+
const { objective, plugins } = spawnArgsSchema.parse(args);
|
|
1003
|
+
|
|
1004
|
+
const admission = admitChildRun([
|
|
1005
|
+
{ kind: "depth", parentDepth: ctx.chain.depth, maxDepth: 5 },
|
|
1006
|
+
{ kind: "chain_budget", runsInChain: await countRuns(ctx.chain), maxRuns: 50 },
|
|
1007
|
+
]);
|
|
1008
|
+
if (!admission.admitted) {
|
|
1009
|
+
return { success: false, kind: "validation", error: admission.reason };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const childRunId = await queue.enqueueRun({
|
|
1013
|
+
objective,
|
|
1014
|
+
plugins,
|
|
1015
|
+
chain: descendChain(ctx.runId, ctx.chain),
|
|
1016
|
+
});
|
|
1017
|
+
return { success: true, data: { childRunId } };
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
**2. Admission is the part worth getting right.** An agent that can spawn can
|
|
1023
|
+
spawn agents that spawn. Bound it *before* enqueuing, not after — an unbounded
|
|
1024
|
+
chain is a runaway spend, and the failure mode is silent.
|
|
1025
|
+
|
|
1026
|
+
Each rule carries its limit **and** the measurement it judges, so a bound you
|
|
1027
|
+
configure but never wired a count for is not expressible. That shape exists
|
|
1028
|
+
because the alternative fails quietly: a bounds object beside a facts object
|
|
1029
|
+
lets a limit sit in config and never fire, and nothing looks wrong.
|
|
1030
|
+
|
|
1031
|
+
```ts
|
|
1032
|
+
import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";
|
|
1033
|
+
|
|
1034
|
+
const rules: ChainRule[] = [
|
|
1035
|
+
{ kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
|
|
1036
|
+
{ kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
|
|
1037
|
+
{ kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
|
|
1038
|
+
{ kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
|
|
1039
|
+
];
|
|
1040
|
+
|
|
1041
|
+
const admission = admitChildRun(rules);
|
|
1042
|
+
if (!admission.admitted) {
|
|
1043
|
+
log.warn("child run refused", { rule: admission.rule });
|
|
1044
|
+
return { success: false, kind: "validation", error: admission.reason };
|
|
1045
|
+
}
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Rules are evaluated in order and the first refusal wins, so you choose which
|
|
1049
|
+
reason the model sees. Pick the set against your own cost model: depth caps
|
|
1050
|
+
runaway recursion, a chain budget caps a chain that stays shallow but keeps
|
|
1051
|
+
fanning out, a pair cooldown stops two runs ping-ponging, and a tenant ceiling
|
|
1052
|
+
covers what none of the chain rules can — someone starting a thousand
|
|
1053
|
+
independent chains. A broken measurement (`NaN`, `Infinity`) refuses rather
|
|
1054
|
+
than admits: every comparison is false against `NaN`, so the naive reading
|
|
1055
|
+
would turn a broken count into an unbounded chain.
|
|
1056
|
+
|
|
1057
|
+
`descendChain` handles the lineage arithmetic, including the root-id fallback
|
|
1058
|
+
that is easy to get backwards — a first-generation child adopts its parent's
|
|
1059
|
+
*id* as the chain root, later generations keep the root the parent already
|
|
1060
|
+
carries. Getting that wrong makes every generation its own chain, and every
|
|
1061
|
+
per-chain bound then counts the wrong set and never fires.
|
|
1062
|
+
|
|
1063
|
+
**3. Waiting.** Two shapes work. Suspend the parent and let child completion
|
|
1064
|
+
wake it, which frees the worker slot:
|
|
1065
|
+
|
|
1066
|
+
```ts
|
|
1067
|
+
return {
|
|
1068
|
+
success: true,
|
|
1069
|
+
data: { childRunIds },
|
|
1070
|
+
suspend: { reason: "awaiting child runs", resumeKind: "wake", request: { childRunIds } },
|
|
1071
|
+
};
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
…or poll inside the tool, which keeps the parent's transcript intact but holds
|
|
1075
|
+
its slot — so budget the poll well under the parent's own deadline.
|
|
1076
|
+
`createPollSchedule` is a doubling backoff bounded by that budget; it clamps
|
|
1077
|
+
the final delay so a sleep can never overshoot the deadline you promised.
|
|
1078
|
+
|
|
1079
|
+
```ts
|
|
1080
|
+
import { createPollSchedule } from "@juno-ai/bind/run";
|
|
1081
|
+
|
|
1082
|
+
const startedAt = Date.now();
|
|
1083
|
+
const poll = createPollSchedule({
|
|
1084
|
+
initialDelayMs: 500,
|
|
1085
|
+
maxDelayMs: 30_000,
|
|
1086
|
+
budgetMs: 10 * 60_000,
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
while (true) {
|
|
1090
|
+
const children = await loadChildRuns(childRunIds);
|
|
1091
|
+
if (children.every((c) => c.finished)) return { success: true, data: { children } };
|
|
1092
|
+
|
|
1093
|
+
const step = poll.next(Date.now() - startedAt);
|
|
1094
|
+
if (step.kind === "expired") {
|
|
1095
|
+
return { success: true, data: { children, timedOut: true } };
|
|
1096
|
+
}
|
|
1097
|
+
await sleep(step.delayMs, signal);
|
|
1098
|
+
}
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
Elapsed time is an argument rather than something the schedule reads off a
|
|
1102
|
+
clock, so a test can drive the whole backoff without waiting for any of it.
|
|
1103
|
+
|
|
1104
|
+
**4. Rolling results up.** `accumulateTurn` and `accumulateToolCall` fold a
|
|
1105
|
+
run's *own* activity; `accumulateRun` folds one whole run into another, which
|
|
1106
|
+
is what a chain's totals are made of.
|
|
1107
|
+
|
|
1108
|
+
```ts
|
|
1109
|
+
import { accumulateRun, emptyRunStats } from "@juno-ai/bind/contracts";
|
|
1110
|
+
|
|
1111
|
+
const chainTotals = childStats.reduce(accumulateRun, parentStats);
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
The fold is associative, so a chain reduces in whatever order its children
|
|
1115
|
+
finish. Two properties are worth expecting rather than debugging: `modelTimeMs`
|
|
1116
|
+
will exceed the chain's wall-clock once children run in parallel (the sum is
|
|
1117
|
+
what the chain *cost*, not how long it took), and `outputTokensPerSecond` is
|
|
1118
|
+
recomputed from the merged totals rather than averaged across runs — averaging
|
|
1119
|
+
two rates weights a 10-token run like a 10,000-token one.
|
|
1120
|
+
|
|
1121
|
+
---
|
|
1122
|
+
|
|
1123
|
+
## Roadmap
|
|
1124
|
+
|
|
1125
|
+
Named, not scheduled. Listed so a consumer can tell a deliberate omission from
|
|
1126
|
+
an oversight.
|
|
1127
|
+
|
|
1128
|
+
- **One `RunStats` per turn.** The loop accounts usage with `ToolLoopTurn`
|
|
1129
|
+
(tokens and cost) while `ModelTurnResult` carries timings too. They converge
|
|
1130
|
+
when the loop folds `RunStats` directly; today a host that wants throughput
|
|
1131
|
+
metrics accumulates them alongside.
|
|
1132
|
+
- **A streaming turn contract.** `callModel` reports an output-token estimate
|
|
1133
|
+
mid-stream; the assistant message itself still arrives whole.
|
|
1134
|
+
|
|
1135
|
+
---
|
|
1136
|
+
|
|
1137
|
+
## Rules for automated contributors
|
|
1138
|
+
|
|
1139
|
+
*Invariants of this package. Read this before changing anything under `src/`;
|
|
1140
|
+
each is enforced by lint, typecheck, or CI where it is developed.*
|
|
1141
|
+
|
|
1142
|
+
1. **Never import `@/*`, a Node builtin, `process`, or a framework.** Take a
|
|
1143
|
+
port instead. An ESLint block scoped to `packages/bind/**` enforces this.
|
|
1144
|
+
2. **Never add a runtime dependency.** New third-party code must be a peer
|
|
1145
|
+
dependency, and only with a strong reason. `openai` is type-only.
|
|
1146
|
+
3. **Keep every module I/O-free.** If a change needs a clock, a random source,
|
|
1147
|
+
the environment, or a network call, take it as an explicit input. A
|
|
1148
|
+
`Date.now` default on an injectable clock is the only sanctioned exception.
|
|
1149
|
+
4. **Stay deterministic.** Identical inputs must produce identical plans.
|
|
1150
|
+
Nothing about registration order, map iteration, or wall-clock time may
|
|
1151
|
+
reorder candidates.
|
|
1152
|
+
5. **Do not add module-level mutable state.** Export a factory. Module state is
|
|
1153
|
+
per-isolate on edge runtimes and leaks between tests.
|
|
1154
|
+
6. **Do not collapse a generic into a concrete host type.** `TCtx` and
|
|
1155
|
+
`TContentPart` are parameters because hosts genuinely diverge there; binding
|
|
1156
|
+
them to one application's types would fork the package.
|
|
1157
|
+
7. **Version the policy, do not mutate it.** If routing order or meaning
|
|
1158
|
+
changes, bump `ROUTE_POLICY_VERSION`.
|
|
1159
|
+
8. **Put tests in `src/**/__tests__/`.** They run under bare `bun test` with no
|
|
1160
|
+
DOM and no database. Anything needing either belongs in the host.
|
|
1161
|
+
9. **Do not edit `version` in `package.json`.** It is a placeholder; the
|
|
1162
|
+
published version is stamped at release time.
|
|
1163
|
+
|
|
1164
|
+
---
|
|
1165
|
+
|
|
1166
|
+
## Versioning
|
|
1167
|
+
|
|
1168
|
+
**Versioning is not semver.** Each published release increments the major and
|
|
1169
|
+
resets the rest — `1.0.0`, `2.0.0`, `3.0.0` — so the major is a release counter,
|
|
1170
|
+
not a compatibility signal, and a bump does not by itself mean the surface
|
|
1171
|
+
changed. Pin an exact version and read the changes between releases until this
|
|
1172
|
+
stabilizes.
|
|
78
1173
|
|
|
79
1174
|
## Development
|
|
80
1175
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
releases
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
npm release has. Issues are welcome here; code changes land in the canonical
|
|
87
|
-
repo and flow out with the next export.
|
|
1176
|
+
The source lives in Monad's canonical repository. `juno-ai-labs/agent-harness`
|
|
1177
|
+
on GitHub is a read-only **archive mirror** of it, exported via Copybara, and
|
|
1178
|
+
npm releases are published from the canonical repository rather than from either
|
|
1179
|
+
mirror. Issues are welcome on the GitHub mirror; code changes land in the
|
|
1180
|
+
canonical repo and flow out with the next export.
|
|
88
1181
|
|
|
89
1182
|
Run the tests with [Bun](https://bun.sh):
|
|
90
1183
|
|