toolnexus 0.18.0 → 0.19.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 +17 -410
- package/dist/acp.d.ts +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +64 -3
- package/dist/agents/runtime.js +88 -19
- package/dist/builtin.js +18 -12
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +30 -24
- package/dist/client.d.ts +98 -12
- package/dist/client.js +129 -31
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +142 -24
- package/src/builtin.ts +18 -9
- package/src/classifier.ts +42 -26
- package/src/client.ts +156 -43
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
package/src/agents/loop.ts
CHANGED
|
@@ -177,6 +177,33 @@ export async function runGated(
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The spec fields a Loop drive CANNOT honour, named rather than dropped silently.
|
|
182
|
+
*
|
|
183
|
+
* A `Loop` is one client run: it has no runtime, so it has no handle tree to spawn a `team`
|
|
184
|
+
* into, no §10 suspension ledger for `waitFor`, and no per-agent metric sink routing. Those
|
|
185
|
+
* fields are honoured by `Agent.run` / `AgentRuntime` only. `uses.tools` is not honoured either
|
|
186
|
+
* — a Loop is handed its toolkit by the caller.
|
|
187
|
+
*
|
|
188
|
+
* Returns a FIXED CANONICAL vocabulary — `"tools"`, `"team"`, `"waitFor"`, `"onMetric"` —
|
|
189
|
+
* identical in all seven ports, exactly as the `limit` strings are, and NOT this language's own
|
|
190
|
+
* spelling of the field. Empty ⇒ nothing is lost.
|
|
191
|
+
* Additive and advisory: a Loop never refuses to run because of it (that would be breaking).
|
|
192
|
+
*/
|
|
193
|
+
export function loopUnsupported(spec: {
|
|
194
|
+
uses?: { tools?: unknown[] }
|
|
195
|
+
team?: unknown[]
|
|
196
|
+
waitFor?: unknown
|
|
197
|
+
onMetric?: unknown
|
|
198
|
+
}): string[] {
|
|
199
|
+
const out: string[] = []
|
|
200
|
+
if (spec.uses?.tools && spec.uses.tools.length > 0) out.push("tools")
|
|
201
|
+
if (spec.team && spec.team.length > 0) out.push("team")
|
|
202
|
+
if (spec.waitFor) out.push("waitFor")
|
|
203
|
+
if (spec.onMetric) out.push("onMetric")
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
180
207
|
/** A live execution of an Agent. Its only verbs are `run` and reading state. */
|
|
181
208
|
export class Loop {
|
|
182
209
|
#status: Outcome["status"] | "idle" | "running" = "idle"
|
|
@@ -234,10 +261,21 @@ export class Loop {
|
|
|
234
261
|
/** Applies a per-call model override via `requestParams` (`model` is not in the
|
|
235
262
|
* forbidden set — the client forbids only messages/tools/stream). */
|
|
236
263
|
#clientOptions(opts: LoopRunOptions): ClientOptions {
|
|
264
|
+
const spec = this.agent.spec
|
|
237
265
|
const base: ClientOptions = {
|
|
238
266
|
...this.options,
|
|
239
|
-
|
|
240
|
-
|
|
267
|
+
// CALLER-WINS (ADR 0024 / issue #87). A `systemPrompt` the caller put on the client
|
|
268
|
+
// options is an explicit instruction for THIS drive; the soul is the agent's default.
|
|
269
|
+
// js used to let the soul override it, which python and csharp never did.
|
|
270
|
+
systemPrompt: this.options.systemPrompt ?? spec.soul,
|
|
271
|
+
// The spec's model and turn ceiling become Loop DEFAULTS — the same spec drives both
|
|
272
|
+
// doors. `ClientOptions.model` is REQUIRED in js, so "the caller did not choose" is
|
|
273
|
+
// spelled the way the runtime already spells it: absent, or the `"inherit"` sentinel.
|
|
274
|
+
model: !this.options.model || this.options.model === "inherit"
|
|
275
|
+
? (spec.model && spec.model !== "inherit" ? spec.model : this.options.model)
|
|
276
|
+
: this.options.model,
|
|
277
|
+
maxTurns: this.options.maxTurns ?? spec.budget?.maxTurns,
|
|
278
|
+
hooks: guardedHooks(spec.guardrails, spec.hooks ?? this.options.hooks),
|
|
241
279
|
}
|
|
242
280
|
if (!opts.model) return base
|
|
243
281
|
return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } }
|
package/src/agents/runtime.ts
CHANGED
|
@@ -15,7 +15,17 @@
|
|
|
15
15
|
* identical per-handle transition traces against `examples/subagent-*` fixtures.
|
|
16
16
|
*/
|
|
17
17
|
import type { PromptInput } from "../content.js"
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createClient,
|
|
20
|
+
createInProcessFetch,
|
|
21
|
+
InMemoryConversationStore,
|
|
22
|
+
type ClientStyle,
|
|
23
|
+
type ConversationStore,
|
|
24
|
+
type Hooks,
|
|
25
|
+
type InProcessRequest,
|
|
26
|
+
type InProcessResponse,
|
|
27
|
+
type MetricEvent,
|
|
28
|
+
} from "../client.js"
|
|
19
29
|
import { runGated, type Completion } from "./loop.js"
|
|
20
30
|
import { createToolkit, type Toolkit } from "../toolkit.js"
|
|
21
31
|
import { defineTool } from "../native.js"
|
|
@@ -118,6 +128,63 @@ export interface InboxItem {
|
|
|
118
128
|
|
|
119
129
|
export type HandleState = "idle" | "running" | "suspended" | "closed"
|
|
120
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
|
|
133
|
+
* §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
|
|
134
|
+
* `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
|
|
135
|
+
* running — never that an LLM call timed out. Two fields are called `status`; they are not
|
|
136
|
+
* interchangeable (ADR 0027 D2.1).
|
|
137
|
+
*/
|
|
138
|
+
export const TASK_STATUSES = [
|
|
139
|
+
"done", "pending", "incomplete", "interrupted", "closed", "timeout", "error",
|
|
140
|
+
] as const
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
|
|
144
|
+
* run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
|
|
145
|
+
* `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
|
|
146
|
+
* stopped me" is only answerable if the answer is the same word everywhere.
|
|
147
|
+
*
|
|
148
|
+
* A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
|
|
149
|
+
* the field; `canonicalLimit` is the boundary that enforces that here.
|
|
150
|
+
*/
|
|
151
|
+
export const TASK_LIMITS = [
|
|
152
|
+
"maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth",
|
|
153
|
+
"completion", "timeout",
|
|
154
|
+
] as const
|
|
155
|
+
|
|
156
|
+
export type TaskLimit = (typeof TASK_LIMITS)[number]
|
|
157
|
+
|
|
158
|
+
/** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
|
|
159
|
+
* leaked internal name. */
|
|
160
|
+
export function canonicalLimit(limit: string | undefined): TaskLimit | undefined {
|
|
161
|
+
return limit && (TASK_LIMITS as readonly string[]).includes(limit) ? (limit as TaskLimit) : undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
|
|
166
|
+
* file spells its status and limit through these, never as a string literal, so a rename cannot
|
|
167
|
+
* silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
|
|
168
|
+
* empty `limit` in three of the seven ports (addendum A18).
|
|
169
|
+
*/
|
|
170
|
+
export const S: Readonly<Record<TaskStatus, TaskStatus>> = Object.freeze({
|
|
171
|
+
done: "done", pending: "pending", incomplete: "incomplete", interrupted: "interrupted",
|
|
172
|
+
closed: "closed", timeout: "timeout", error: "error",
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export const L: Readonly<Record<TaskLimit, TaskLimit>> = Object.freeze({
|
|
176
|
+
maxTurns: "maxTurns", maxTokens: "maxTokens", maxToolCalls: "maxToolCalls", maxWallMs: "maxWallMs",
|
|
177
|
+
maxChildren: "maxChildren", maxConcurrent: "maxConcurrent", maxDepth: "maxDepth",
|
|
178
|
+
completion: "completion", timeout: "timeout",
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* THE INVARIANT `S`/`L` exist to protect (addendum A18): a LIMIT stop (`incomplete`, `timeout`)
|
|
183
|
+
* names its limit; every other status carries none. It is asserted by the suite, not exported —
|
|
184
|
+
* a predicate that exists so our own tests can state a rule is not public API (A19b, ADR 0019).
|
|
185
|
+
* Ports copy the PREDICATE, not an export.
|
|
186
|
+
*/
|
|
187
|
+
|
|
121
188
|
export type TaskStatus =
|
|
122
189
|
| "done"
|
|
123
190
|
| "pending" // §10 durable suspension — resume via runtime.resume(answer)
|
|
@@ -135,8 +202,23 @@ export interface TaskResult {
|
|
|
135
202
|
/** Present iff status === "pending". `data.path` carries the suspended handle's
|
|
136
203
|
* id path (§10 agent-escalation addendum) — never a field grafted on Request. */
|
|
137
204
|
pending?: Request
|
|
205
|
+
/** Cumulative LLM round trips for this handle's SUBTREE — never per-run, never reset. */
|
|
138
206
|
turns: number
|
|
207
|
+
/**
|
|
208
|
+
* Cumulative TREE total: this handle's own spend plus every descendant's, on EVERY status
|
|
209
|
+
* (ADR 0025 D1). It was already the tree total on four of the seven status branches; this
|
|
210
|
+
* gives the field one meaning. `parent.totalTokens >= child.totalTokens` always holds.
|
|
211
|
+
*/
|
|
139
212
|
totalTokens: number
|
|
213
|
+
/** This handle's OWN accumulated spend, excluding children. */
|
|
214
|
+
ownTokens: number
|
|
215
|
+
/**
|
|
216
|
+
* WHICH limit stopped the run, as a value to branch on rather than prose to match. Drawn from
|
|
217
|
+
* the CLOSED, cross-port `TASK_LIMITS` vocabulary (addendum A14) — it names the `Budget` field,
|
|
218
|
+
* or `completion` / `timeout`. Absent otherwise. A stop you cannot explain to a user is a bug,
|
|
219
|
+
* not a state.
|
|
220
|
+
*/
|
|
221
|
+
limit?: TaskLimit
|
|
140
222
|
}
|
|
141
223
|
|
|
142
224
|
/** Uniform verb failure — returned, never thrown (only the root throws to the host). */
|
|
@@ -206,6 +288,8 @@ export class Handle {
|
|
|
206
288
|
readonly deadline?: number
|
|
207
289
|
/** Total tokens attributed to this handle (own turns + descendant roll-up). */
|
|
208
290
|
usageTotal = 0
|
|
291
|
+
/** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
|
|
292
|
+
ownTokens = 0
|
|
209
293
|
/** Total tool calls attributed to this handle (own + descendant roll-up). */
|
|
210
294
|
toolCallsTotal = 0
|
|
211
295
|
/** LLM round trips run by this handle itself (never reset — resume grows it). */
|
|
@@ -270,8 +354,16 @@ export interface RuntimeOptions {
|
|
|
270
354
|
registry: Record<string, AgentDef>
|
|
271
355
|
/** LLM endpoint. Omit for hermetic runs against an injected `fetch` mock. */
|
|
272
356
|
llm?: { baseUrl?: string; style?: ClientStyle; apiKey?: string; model?: string }
|
|
273
|
-
/** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
|
|
357
|
+
/** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
|
|
358
|
+
* Mutually exclusive with `inProcess` — `new AgentRuntime(...)` throws if both are set. */
|
|
274
359
|
fetch?: typeof fetch
|
|
360
|
+
/** A model running IN THIS PROCESS (ADR 0030): the semantic counterpart to
|
|
361
|
+
* `fetch`, so a host whose model is a plain function never has to build its own
|
|
362
|
+
* `fetch`-shaped adapter. Internally this is turned into a `createInProcessFetch`
|
|
363
|
+
* adapter and wrapped by the SAME global turn gate as `fetch` — there is no second
|
|
364
|
+
* code path. Mutually exclusive with `fetch` and with `llm` — `new AgentRuntime(...)`
|
|
365
|
+
* throws if more than one of `fetch`/`inProcess` is set. */
|
|
366
|
+
inProcess?: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
|
|
275
367
|
/** Time source for every timer/timeout/deadline. Default: the system clock. */
|
|
276
368
|
clock?: Clock
|
|
277
369
|
/** The ONE ConversationStore for all handles (conversation id = handle id).
|
|
@@ -324,6 +416,20 @@ export class AgentRuntime {
|
|
|
324
416
|
private readonly turnQueue: GateWaiter[] = []
|
|
325
417
|
|
|
326
418
|
constructor(opts: RuntimeOptions) {
|
|
419
|
+
if (opts.fetch && opts.inProcess) {
|
|
420
|
+
throw new Error("toolnexus/agents: RuntimeOptions.fetch and RuntimeOptions.inProcess are mutually exclusive — set one, not both")
|
|
421
|
+
}
|
|
422
|
+
if (opts.llm && opts.inProcess) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
"toolnexus/agents: RuntimeOptions.llm and RuntimeOptions.inProcess are mutually exclusive — an in-process model has no wire endpoint to point llm at",
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
if (opts.inProcess) {
|
|
428
|
+
// Reuse the SAME adapter createInProcessClient builds on (ADR 0030) — the
|
|
429
|
+
// runtime's in-process wiring is a CALLER of that export, not a second copy
|
|
430
|
+
// of the request/response assembly.
|
|
431
|
+
opts = { ...opts, fetch: createInProcessFetch(opts.inProcess) }
|
|
432
|
+
}
|
|
327
433
|
this.opts = opts
|
|
328
434
|
this.registry = opts.registry
|
|
329
435
|
this.clock = opts.clock ?? systemClock
|
|
@@ -418,11 +524,11 @@ export class AgentRuntime {
|
|
|
418
524
|
if (opts?.by && h.parent !== opts.by) {
|
|
419
525
|
return Promise.resolve({
|
|
420
526
|
text: `wait refused: only the spawner may wait on ${h.id}`,
|
|
421
|
-
isError: true, status:
|
|
527
|
+
isError: true, status: S.error, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
422
528
|
})
|
|
423
529
|
}
|
|
424
530
|
if (h.state === "closed") {
|
|
425
|
-
return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status:
|
|
531
|
+
return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens })
|
|
426
532
|
}
|
|
427
533
|
if (h.state === "suspended") return Promise.resolve(this.pendingResult(h))
|
|
428
534
|
if (h.state === "idle" && h.lastResult && !this.isQueued(h)) return Promise.resolve(h.lastResult)
|
|
@@ -440,7 +546,7 @@ export class AgentRuntime {
|
|
|
440
546
|
cancel = this.clock.setTimeout(() => {
|
|
441
547
|
const i = h.waiters.indexOf(finish)
|
|
442
548
|
if (i >= 0) h.waiters.splice(i, 1)
|
|
443
|
-
finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status:
|
|
549
|
+
finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: S.timeout, limit: L.timeout, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens })
|
|
444
550
|
}, opts.timeoutMs)
|
|
445
551
|
}
|
|
446
552
|
})
|
|
@@ -496,7 +602,7 @@ export class AgentRuntime {
|
|
|
496
602
|
await h.def.onClose?.(h, reason)
|
|
497
603
|
const prev = h.state
|
|
498
604
|
h.state = "closed"
|
|
499
|
-
const final: TaskResult = { text: "closed", isError: true, status:
|
|
605
|
+
const final: TaskResult = { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
500
606
|
for (const w of h.waiters.splice(0)) w(final)
|
|
501
607
|
this.t(`${h.id}: ${prev}→closed (${reason})`)
|
|
502
608
|
}
|
|
@@ -527,16 +633,19 @@ export class AgentRuntime {
|
|
|
527
633
|
* REATTACHES to the existing child by task key — never spawns a duplicate.
|
|
528
634
|
* The root alone may throw to the host.
|
|
529
635
|
*/
|
|
530
|
-
async resume(answer: Answer): Promise<
|
|
636
|
+
async resume(answer: Answer): Promise<TaskResult> {
|
|
531
637
|
const leaf = this.deepestSuspended(this.root)
|
|
532
638
|
if (!leaf) throw new Error("no suspended handle to resume")
|
|
533
639
|
this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`)
|
|
534
|
-
|
|
640
|
+
// The value is the settled result of the TOPMOST handle the cascade re-ran — what the host
|
|
641
|
+
// would have got from `Agent.run` had the suspension never happened (ADR 0025 D3).
|
|
642
|
+
let topmost = await this.resumeSuspended(leaf, answer, { inline: false })
|
|
535
643
|
for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
|
|
536
644
|
if (p.state !== "suspended") break
|
|
537
645
|
this.t(`${p.id}: cascade resume (reattaching delegated work)`)
|
|
538
|
-
await this.resumeSuspended(p, undefined, { inline: false })
|
|
646
|
+
topmost = await this.resumeSuspended(p, undefined, { inline: false })
|
|
539
647
|
}
|
|
648
|
+
return topmost
|
|
540
649
|
}
|
|
541
650
|
|
|
542
651
|
private deepestSuspended(h: Handle): Handle | null {
|
|
@@ -580,25 +689,28 @@ export class AgentRuntime {
|
|
|
580
689
|
// ---- budgets (live ancestor-chain enforcement + roll-up ledger) --------
|
|
581
690
|
|
|
582
691
|
/** Walk the LIVE ancestor chain; name the first exhausted pool, if any. */
|
|
583
|
-
private poolLimit(h: Handle):
|
|
692
|
+
private poolLimit(h: Handle): TaskLimit | undefined {
|
|
584
693
|
const now = this.clock.now()
|
|
585
694
|
for (let a: Handle | null = h; a; a = a.parent) {
|
|
586
|
-
if (a.pool.tokens <= 0) return
|
|
587
|
-
if (a.pool.toolCalls <= 0) return
|
|
588
|
-
if (a.deadline !== undefined && now >= a.deadline) return
|
|
695
|
+
if (a.pool.tokens <= 0) return L.maxTokens
|
|
696
|
+
if (a.pool.toolCalls <= 0) return L.maxToolCalls
|
|
697
|
+
if (a.deadline !== undefined && now >= a.deadline) return L.maxWallMs
|
|
589
698
|
}
|
|
590
699
|
return undefined
|
|
591
700
|
}
|
|
592
701
|
|
|
593
702
|
/** Lifetime turn cap on the handle itself (turns never reset across resumes). */
|
|
594
|
-
private turnCap(h: Handle):
|
|
595
|
-
return h.turnsTotal >= h.eff.maxTurns ?
|
|
703
|
+
private turnCap(h: Handle): TaskLimit | undefined {
|
|
704
|
+
return h.turnsTotal >= h.eff.maxTurns ? L.maxTurns : undefined
|
|
596
705
|
}
|
|
597
706
|
|
|
598
707
|
/** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
|
|
599
|
-
private settleRefused(h: Handle, limit:
|
|
708
|
+
private settleRefused(h: Handle, limit: TaskLimit): string {
|
|
709
|
+
// `limit` here is already a Budget field name (`poolLimit`/`turnCap` return them verbatim);
|
|
710
|
+
// `canonicalLimit` below is the guard that keeps it that way if either ever changes.
|
|
600
711
|
const text = `budget exhausted (${limit}); partial work preserved`
|
|
601
|
-
|
|
712
|
+
// The exhausted pool is COMPUTED here and used to be discarded into prose. `limit` carries it.
|
|
713
|
+
const r: TaskResult = { text, isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens, limit }
|
|
602
714
|
h.lastResult = r
|
|
603
715
|
for (const w of h.waiters.splice(0)) w(r)
|
|
604
716
|
return text
|
|
@@ -606,6 +718,7 @@ export class AgentRuntime {
|
|
|
606
718
|
|
|
607
719
|
/** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
|
|
608
720
|
private rollUp(h: Handle, tokens: number, toolCalls: number): void {
|
|
721
|
+
h.ownTokens += tokens // own figure: outside the ancestor walk, by construction
|
|
609
722
|
for (let a: Handle | null = h; a; a = a.parent) {
|
|
610
723
|
a.usageTotal += tokens
|
|
611
724
|
a.toolCallsTotal += toolCalls
|
|
@@ -728,7 +841,7 @@ export class AgentRuntime {
|
|
|
728
841
|
|
|
729
842
|
private pendingResult(h: Handle): TaskResult {
|
|
730
843
|
const req = h.pendingRequest
|
|
731
|
-
return { text: req?.prompt ?? "", isError: false, status:
|
|
844
|
+
return { text: req?.prompt ?? "", isError: false, status: S.pending, pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
732
845
|
}
|
|
733
846
|
|
|
734
847
|
/** The escalation seam: this handle's §10 interpreter authority, with the
|
|
@@ -801,7 +914,7 @@ export class AgentRuntime {
|
|
|
801
914
|
h.checkpoint = { input }
|
|
802
915
|
h.drained = [] // drained items ride the checkpointed input, not the inbox
|
|
803
916
|
this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`)
|
|
804
|
-
result = { text: r.text, isError: false, status:
|
|
917
|
+
result = { text: r.text, isError: false, status: S.pending, pending: stamped, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
805
918
|
} else if (r.status === "incomplete") {
|
|
806
919
|
h.state = "idle"
|
|
807
920
|
h.drained = []
|
|
@@ -810,13 +923,17 @@ export class AgentRuntime {
|
|
|
810
923
|
// generic sentence. Reading `limit` is how the runtime tells them apart.
|
|
811
924
|
result = {
|
|
812
925
|
text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
|
|
813
|
-
isError: true, status:
|
|
926
|
+
isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
927
|
+
// The LATENT instance golang found: forwarding the client's `limit` straight through
|
|
928
|
+
// would hand a host `incomplete` with an empty limit whenever the client omits it.
|
|
929
|
+
// The `?? L.maxTurns` is what makes the invariant hold by construction, not by luck.
|
|
930
|
+
limit: canonicalLimit(r.limit) ?? L.maxTurns,
|
|
814
931
|
}
|
|
815
932
|
} else {
|
|
816
933
|
h.state = "idle"
|
|
817
934
|
h.drained = []
|
|
818
935
|
this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`)
|
|
819
|
-
result = { text: r.text, isError: false, status:
|
|
936
|
+
result = { text: r.text, isError: false, status: S.done, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
820
937
|
}
|
|
821
938
|
} catch (e) {
|
|
822
939
|
const aborted = h.abort?.signal.aborted ?? false
|
|
@@ -828,8 +945,9 @@ export class AgentRuntime {
|
|
|
828
945
|
this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`)
|
|
829
946
|
result = {
|
|
830
947
|
text: msg, isError: true,
|
|
831
|
-
|
|
832
|
-
|
|
948
|
+
// None of these three is a limit stop, so none carries a `limit` (A18).
|
|
949
|
+
status: closing ? S.closed : aborted ? S.interrupted : S.error,
|
|
950
|
+
turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
833
951
|
}
|
|
834
952
|
} finally {
|
|
835
953
|
this.releaseChildSlot(h)
|
|
@@ -904,7 +1022,7 @@ export class AgentRuntime {
|
|
|
904
1022
|
} else if (existing.state === "running") {
|
|
905
1023
|
r = await rt.wait(existing, { by: parent })
|
|
906
1024
|
} else {
|
|
907
|
-
r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status:
|
|
1025
|
+
r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: S.error, turns: existing.turnsTotal, totalTokens: existing.usageTotal, ownTokens: existing.ownTokens }
|
|
908
1026
|
}
|
|
909
1027
|
} else {
|
|
910
1028
|
const spawned = rt.spawn(parent, name)
|
package/src/builtin.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { spawn } from "node:child_process"
|
|
11
11
|
import fs from "node:fs/promises"
|
|
12
12
|
import { existsSync, readdirSync } from "node:fs"
|
|
13
|
+
import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js"
|
|
13
14
|
import path from "node:path"
|
|
14
15
|
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
15
16
|
import { pending } from "./types.js"
|
|
@@ -115,7 +116,9 @@ function walkFiles(root: string): string[] {
|
|
|
115
116
|
const dir = stack.pop()!
|
|
116
117
|
let entries
|
|
117
118
|
try {
|
|
118
|
-
|
|
119
|
+
// EXPLICIT (A24). `glob` breaks at its cap, so an unsorted read changes WHICH files the
|
|
120
|
+
// model is shown, not merely their order — the same content bug as <skill_files> had.
|
|
121
|
+
entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }))
|
|
119
122
|
} catch {
|
|
120
123
|
continue
|
|
121
124
|
}
|
|
@@ -332,10 +335,13 @@ function grepTool(): Tool {
|
|
|
332
335
|
const root = args.path ? String(args.path) : process.cwd()
|
|
333
336
|
const include = args.include ? String(args.include) : undefined
|
|
334
337
|
const limit = typeof args.limit === "number" ? args.limit : 100
|
|
335
|
-
|
|
338
|
+
// Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
|
|
339
|
+
// CONTENT and the order of the results were whatever the walk happened to reach first.
|
|
340
|
+
// Now: collect every match, order by relative path (code point) then line number ascending,
|
|
341
|
+
// and truncate last. The emitted path and the sort key are the same string (A28).
|
|
342
|
+
const hits: Array<{ rel: string; line: number; text: string }> = []
|
|
336
343
|
for (const file of walkFiles(root)) {
|
|
337
|
-
|
|
338
|
-
const rel = path.relative(root, file)
|
|
344
|
+
const rel = toPosixPath(path.relative(root, file), path.sep)
|
|
339
345
|
if (include && !matchGlob(rel, include)) continue
|
|
340
346
|
let text: string
|
|
341
347
|
try {
|
|
@@ -345,10 +351,11 @@ function grepTool(): Tool {
|
|
|
345
351
|
}
|
|
346
352
|
const lines = text.split("\n")
|
|
347
353
|
for (let i = 0; i < lines.length; i++) {
|
|
348
|
-
if (
|
|
349
|
-
if (re.test(lines[i])) matches.push(`${file}:${i + 1}:${lines[i]}`)
|
|
354
|
+
if (re.test(lines[i])) hits.push({ rel, line: i + 1, text: lines[i] })
|
|
350
355
|
}
|
|
351
356
|
}
|
|
357
|
+
hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line)
|
|
358
|
+
const matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`)
|
|
352
359
|
return ok(matches.join("\n"), { count: matches.length })
|
|
353
360
|
},
|
|
354
361
|
)
|
|
@@ -373,13 +380,15 @@ function globTool(): Tool {
|
|
|
373
380
|
if (!pattern) return err("glob: pattern is required")
|
|
374
381
|
const root = args.path ? String(args.path) : process.cwd()
|
|
375
382
|
const limit = typeof args.limit === "number" ? args.limit : 100
|
|
383
|
+
// Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
|
|
384
|
+
// the filesystem decide WHICH files the model sees, not merely their order. The emitted
|
|
385
|
+
// string and the sort key are the SAME `/`-separated relative path (A28).
|
|
376
386
|
const found: string[] = []
|
|
377
387
|
for (const file of walkFiles(root)) {
|
|
378
|
-
|
|
379
|
-
const rel = path.relative(root, file)
|
|
388
|
+
const rel = toPosixPath(path.relative(root, file), path.sep)
|
|
380
389
|
if (matchGlob(rel, pattern)) found.push(rel)
|
|
381
390
|
}
|
|
382
|
-
found.sort()
|
|
391
|
+
found.sort(compareCodePoints)
|
|
383
392
|
return ok(found.slice(0, limit).join("\n"), { count: Math.min(found.length, limit) })
|
|
384
393
|
},
|
|
385
394
|
)
|
package/src/classifier.ts
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* here is a security control.
|
|
11
11
|
*/
|
|
12
12
|
import type { Client } from "./client.js"
|
|
13
|
-
import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js"
|
|
13
|
+
import { type ErrorInfo, type ErrorTier, type MetricEvent, redactErrorBody, capErrorBody } from "./client.js"
|
|
14
14
|
import { retryAfterMs, isRetryableStatus } from "./retry.js"
|
|
15
|
-
import type { Toolkit } from "./toolkit.js"
|
|
16
15
|
|
|
17
16
|
// ---------------------------------------------------------------- constants
|
|
18
17
|
|
|
@@ -25,6 +24,19 @@ export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY"
|
|
|
25
24
|
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
26
25
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000
|
|
27
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
|
|
29
|
+
* a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
|
|
30
|
+
* `typesafe/jev-1.13`, and mixing the two is issue #91.
|
|
31
|
+
*/
|
|
32
|
+
export type ClassifierBackend = "typesafe" | "openrouter"
|
|
33
|
+
|
|
34
|
+
/** The two servable pairings, as configurations rather than six independently-choosable cells. */
|
|
35
|
+
export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, { baseUrl: string; model: string; apiKeyEnv: string }>> = {
|
|
36
|
+
typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
|
|
37
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", apiKeyEnv: "OPENROUTER_API_KEY" },
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/** Client-side cap on a choice's named options (§8B). */
|
|
29
41
|
export const MAX_CHOICE_OPTIONS = 255
|
|
30
42
|
/** Bounds of a score rubric (§8B). */
|
|
@@ -392,6 +404,11 @@ export type EvaluateFn = (
|
|
|
392
404
|
export interface ClassifierOptions {
|
|
393
405
|
/** Default `"systemone"`. */
|
|
394
406
|
style?: ClassifierStyle
|
|
407
|
+
/** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
|
|
408
|
+
* UNIT. Default `"typesafe"`. The three individual options still win where given, for a
|
|
409
|
+
* self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
|
|
410
|
+
* as an "Unknown model" 400. */
|
|
411
|
+
backend?: ClassifierBackend
|
|
395
412
|
/** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
|
|
396
413
|
baseUrl?: string
|
|
397
414
|
/** Default `"jev-latest"`. Pin it once thresholds are tuned. */
|
|
@@ -456,9 +473,12 @@ export class Classifier {
|
|
|
456
473
|
|
|
457
474
|
constructor(private readonly opts: ClassifierOptions) {
|
|
458
475
|
this.style = opts.style ?? "systemone"
|
|
459
|
-
|
|
460
|
-
this.
|
|
461
|
-
this.
|
|
476
|
+
const preset = CLASSIFIER_BACKENDS[opts.backend ?? "typesafe"]
|
|
477
|
+
this.baseUrl = opts.baseUrl ?? preset.baseUrl
|
|
478
|
+
this.model = opts.model ?? preset.model
|
|
479
|
+
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
|
|
480
|
+
const mismatch = backendMismatch(this.baseUrl, this.model)
|
|
481
|
+
if (mismatch) throw new Error(mismatch)
|
|
462
482
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
|
|
463
483
|
switch (this.style) {
|
|
464
484
|
case "systemone":
|
|
@@ -706,7 +726,8 @@ export class Classifier {
|
|
|
706
726
|
'{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
|
|
707
727
|
'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
|
|
708
728
|
'"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.'
|
|
709
|
-
|
|
729
|
+
// No toolkit at all: a classifier has no loop and executes nothing (SPEC §0.10, ADR 0023).
|
|
730
|
+
const run = await this.opts.client!.run(prompt, { signal })
|
|
710
731
|
const payload = firstJsonObject(run.text)
|
|
711
732
|
let parsed: unknown
|
|
712
733
|
try {
|
|
@@ -778,9 +799,21 @@ export function degenerateCriteria(criteria: Record<string, string>): string {
|
|
|
778
799
|
*/
|
|
779
800
|
function cause(status: number, body: string): string {
|
|
780
801
|
if (status === 401 || status === 403) return ""
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
return ": " +
|
|
802
|
+
// ONE policy, now shared with the §8 client path: redact account identifiers, then cap.
|
|
803
|
+
const s = capErrorBody(redactErrorBody(body))
|
|
804
|
+
return s ? ": " + s : ""
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
|
|
809
|
+
* pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
|
|
810
|
+
* with, or `""` when the pairing is fine.
|
|
811
|
+
*/
|
|
812
|
+
export function backendMismatch(baseUrl: string, model: string): string {
|
|
813
|
+
const host = baseUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].toLowerCase()
|
|
814
|
+
if (host !== "openrouter.ai" && !host.endsWith(".openrouter.ai")) return ""
|
|
815
|
+
if (!/^jev-/.test(model)) return ""
|
|
816
|
+
return `classifier: model "${model}" is TypeSafe's spelling; on openrouter.ai use "${CLASSIFIER_BACKENDS.openrouter.model}"`
|
|
784
817
|
}
|
|
785
818
|
|
|
786
819
|
/** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
|
|
@@ -813,20 +846,3 @@ function sleep(ms: number): Promise<void> {
|
|
|
813
846
|
})
|
|
814
847
|
}
|
|
815
848
|
|
|
816
|
-
/**
|
|
817
|
-
* The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
|
|
818
|
-
* `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
|
|
819
|
-
* public "no tools" Toolkit constructor just for this path.
|
|
820
|
-
*/
|
|
821
|
-
const EMPTY_TOOLKIT = {
|
|
822
|
-
skillsPrompt: () => "",
|
|
823
|
-
toOpenAI: () => [],
|
|
824
|
-
toAnthropic: () => [],
|
|
825
|
-
toGemini: () => [],
|
|
826
|
-
get: () => undefined,
|
|
827
|
-
list: () => [],
|
|
828
|
-
execute: async () => {
|
|
829
|
-
throw new Error("classifier: the llm style declares no tools")
|
|
830
|
-
},
|
|
831
|
-
close: async () => {},
|
|
832
|
-
} as unknown as Toolkit
|