toolnexus 0.18.1 → 0.20.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/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/home.js +4 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +76 -6
- package/dist/agents/runtime.js +92 -22
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +342 -58
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +33 -25
- package/dist/client.d.ts +116 -16
- package/dist/client.js +197 -43
- 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/home.ts +4 -2
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +154 -29
- package/src/builtin.ts +379 -55
- package/src/classifier.ts +45 -27
- package/src/client.ts +220 -51
- 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/home.ts
CHANGED
|
@@ -234,9 +234,11 @@ export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions)
|
|
|
234
234
|
})
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
-
|
|
237
|
+
// keepAlive:false — a heartbeat is housekeeping and must never be the reason
|
|
238
|
+
// a host process stays up.
|
|
239
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }) // self-reschedule → one live timer
|
|
238
240
|
}
|
|
239
|
-
cancel = runtime.clock.setTimeout(beat, hb.everyMs)
|
|
241
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false })
|
|
240
242
|
|
|
241
243
|
return {
|
|
242
244
|
runtime,
|
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"
|
|
@@ -29,16 +39,23 @@ import type { Answer, Request, Tool, ToolResult } from "../types.js"
|
|
|
29
39
|
export interface Clock {
|
|
30
40
|
/** Current time in ms (epoch or virtual). */
|
|
31
41
|
now(): number
|
|
32
|
-
/** Schedule `fn` after `ms`; returns a cancel function.
|
|
33
|
-
|
|
42
|
+
/** Schedule `fn` after `ms`; returns a cancel function.
|
|
43
|
+
*
|
|
44
|
+
* `keepAlive` defaults to TRUE, and the default is the important half: a timer
|
|
45
|
+
* that cannot hold the event loop open never fires when it is the only thing
|
|
46
|
+
* left to do — which is precisely a deadline whose promise nothing else will
|
|
47
|
+
* settle. Background housekeeping that should never hold a process open (a
|
|
48
|
+
* heartbeat) passes `false` explicitly. A host's own Clock may ignore the
|
|
49
|
+
* option; it then behaves as that host's timers always have. */
|
|
50
|
+
setTimeout(fn: () => void, ms: number, opts?: { keepAlive?: boolean }): () => void
|
|
34
51
|
}
|
|
35
52
|
|
|
36
|
-
/** The real clock (default).
|
|
53
|
+
/** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
|
|
37
54
|
export const systemClock: Clock = {
|
|
38
55
|
now: () => Date.now(),
|
|
39
|
-
setTimeout(fn, ms) {
|
|
56
|
+
setTimeout(fn, ms, opts) {
|
|
40
57
|
const t = setTimeout(fn, ms)
|
|
41
|
-
|
|
58
|
+
if (opts?.keepAlive === false) (t as { unref?: () => void }).unref?.()
|
|
42
59
|
return () => clearTimeout(t)
|
|
43
60
|
},
|
|
44
61
|
}
|
|
@@ -118,6 +135,63 @@ export interface InboxItem {
|
|
|
118
135
|
|
|
119
136
|
export type HandleState = "idle" | "running" | "suspended" | "closed"
|
|
120
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
|
|
140
|
+
* §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
|
|
141
|
+
* `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
|
|
142
|
+
* running — never that an LLM call timed out. Two fields are called `status`; they are not
|
|
143
|
+
* interchangeable (ADR 0027 D2.1).
|
|
144
|
+
*/
|
|
145
|
+
export const TASK_STATUSES = [
|
|
146
|
+
"done", "pending", "incomplete", "interrupted", "closed", "timeout", "error",
|
|
147
|
+
] as const
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
|
|
151
|
+
* run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
|
|
152
|
+
* `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
|
|
153
|
+
* stopped me" is only answerable if the answer is the same word everywhere.
|
|
154
|
+
*
|
|
155
|
+
* A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
|
|
156
|
+
* the field; `canonicalLimit` is the boundary that enforces that here.
|
|
157
|
+
*/
|
|
158
|
+
export const TASK_LIMITS = [
|
|
159
|
+
"maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth",
|
|
160
|
+
"completion", "timeout",
|
|
161
|
+
] as const
|
|
162
|
+
|
|
163
|
+
export type TaskLimit = (typeof TASK_LIMITS)[number]
|
|
164
|
+
|
|
165
|
+
/** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
|
|
166
|
+
* leaked internal name. */
|
|
167
|
+
export function canonicalLimit(limit: string | undefined): TaskLimit | undefined {
|
|
168
|
+
return limit && (TASK_LIMITS as readonly string[]).includes(limit) ? (limit as TaskLimit) : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
|
|
173
|
+
* file spells its status and limit through these, never as a string literal, so a rename cannot
|
|
174
|
+
* silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
|
|
175
|
+
* empty `limit` in three of the seven ports (addendum A18).
|
|
176
|
+
*/
|
|
177
|
+
export const S: Readonly<Record<TaskStatus, TaskStatus>> = Object.freeze({
|
|
178
|
+
done: "done", pending: "pending", incomplete: "incomplete", interrupted: "interrupted",
|
|
179
|
+
closed: "closed", timeout: "timeout", error: "error",
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
export const L: Readonly<Record<TaskLimit, TaskLimit>> = Object.freeze({
|
|
183
|
+
maxTurns: "maxTurns", maxTokens: "maxTokens", maxToolCalls: "maxToolCalls", maxWallMs: "maxWallMs",
|
|
184
|
+
maxChildren: "maxChildren", maxConcurrent: "maxConcurrent", maxDepth: "maxDepth",
|
|
185
|
+
completion: "completion", timeout: "timeout",
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* THE INVARIANT `S`/`L` exist to protect (addendum A18): a LIMIT stop (`incomplete`, `timeout`)
|
|
190
|
+
* names its limit; every other status carries none. It is asserted by the suite, not exported —
|
|
191
|
+
* a predicate that exists so our own tests can state a rule is not public API (A19b, ADR 0019).
|
|
192
|
+
* Ports copy the PREDICATE, not an export.
|
|
193
|
+
*/
|
|
194
|
+
|
|
121
195
|
export type TaskStatus =
|
|
122
196
|
| "done"
|
|
123
197
|
| "pending" // §10 durable suspension — resume via runtime.resume(answer)
|
|
@@ -135,8 +209,23 @@ export interface TaskResult {
|
|
|
135
209
|
/** Present iff status === "pending". `data.path` carries the suspended handle's
|
|
136
210
|
* id path (§10 agent-escalation addendum) — never a field grafted on Request. */
|
|
137
211
|
pending?: Request
|
|
212
|
+
/** Cumulative LLM round trips for this handle's SUBTREE — never per-run, never reset. */
|
|
138
213
|
turns: number
|
|
214
|
+
/**
|
|
215
|
+
* Cumulative TREE total: this handle's own spend plus every descendant's, on EVERY status
|
|
216
|
+
* (ADR 0025 D1). It was already the tree total on four of the seven status branches; this
|
|
217
|
+
* gives the field one meaning. `parent.totalTokens >= child.totalTokens` always holds.
|
|
218
|
+
*/
|
|
139
219
|
totalTokens: number
|
|
220
|
+
/** This handle's OWN accumulated spend, excluding children. */
|
|
221
|
+
ownTokens: number
|
|
222
|
+
/**
|
|
223
|
+
* WHICH limit stopped the run, as a value to branch on rather than prose to match. Drawn from
|
|
224
|
+
* the CLOSED, cross-port `TASK_LIMITS` vocabulary (addendum A14) — it names the `Budget` field,
|
|
225
|
+
* or `completion` / `timeout`. Absent otherwise. A stop you cannot explain to a user is a bug,
|
|
226
|
+
* not a state.
|
|
227
|
+
*/
|
|
228
|
+
limit?: TaskLimit
|
|
140
229
|
}
|
|
141
230
|
|
|
142
231
|
/** Uniform verb failure — returned, never thrown (only the root throws to the host). */
|
|
@@ -206,6 +295,8 @@ export class Handle {
|
|
|
206
295
|
readonly deadline?: number
|
|
207
296
|
/** Total tokens attributed to this handle (own turns + descendant roll-up). */
|
|
208
297
|
usageTotal = 0
|
|
298
|
+
/** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
|
|
299
|
+
ownTokens = 0
|
|
209
300
|
/** Total tool calls attributed to this handle (own + descendant roll-up). */
|
|
210
301
|
toolCallsTotal = 0
|
|
211
302
|
/** LLM round trips run by this handle itself (never reset — resume grows it). */
|
|
@@ -270,8 +361,16 @@ export interface RuntimeOptions {
|
|
|
270
361
|
registry: Record<string, AgentDef>
|
|
271
362
|
/** LLM endpoint. Omit for hermetic runs against an injected `fetch` mock. */
|
|
272
363
|
llm?: { baseUrl?: string; style?: ClientStyle; apiKey?: string; model?: string }
|
|
273
|
-
/** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
|
|
364
|
+
/** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
|
|
365
|
+
* Mutually exclusive with `inProcess` — `new AgentRuntime(...)` throws if both are set. */
|
|
274
366
|
fetch?: typeof fetch
|
|
367
|
+
/** A model running IN THIS PROCESS (ADR 0030): the semantic counterpart to
|
|
368
|
+
* `fetch`, so a host whose model is a plain function never has to build its own
|
|
369
|
+
* `fetch`-shaped adapter. Internally this is turned into a `createInProcessFetch`
|
|
370
|
+
* adapter and wrapped by the SAME global turn gate as `fetch` — there is no second
|
|
371
|
+
* code path. Mutually exclusive with `fetch` and with `llm` — `new AgentRuntime(...)`
|
|
372
|
+
* throws if more than one of `fetch`/`inProcess` is set. */
|
|
373
|
+
inProcess?: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
|
|
275
374
|
/** Time source for every timer/timeout/deadline. Default: the system clock. */
|
|
276
375
|
clock?: Clock
|
|
277
376
|
/** The ONE ConversationStore for all handles (conversation id = handle id).
|
|
@@ -324,6 +423,20 @@ export class AgentRuntime {
|
|
|
324
423
|
private readonly turnQueue: GateWaiter[] = []
|
|
325
424
|
|
|
326
425
|
constructor(opts: RuntimeOptions) {
|
|
426
|
+
if (opts.fetch && opts.inProcess) {
|
|
427
|
+
throw new Error("toolnexus/agents: RuntimeOptions.fetch and RuntimeOptions.inProcess are mutually exclusive — set one, not both")
|
|
428
|
+
}
|
|
429
|
+
if (opts.llm && opts.inProcess) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
"toolnexus/agents: RuntimeOptions.llm and RuntimeOptions.inProcess are mutually exclusive — an in-process model has no wire endpoint to point llm at",
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
if (opts.inProcess) {
|
|
435
|
+
// Reuse the SAME adapter createInProcessClient builds on (ADR 0030) — the
|
|
436
|
+
// runtime's in-process wiring is a CALLER of that export, not a second copy
|
|
437
|
+
// of the request/response assembly.
|
|
438
|
+
opts = { ...opts, fetch: createInProcessFetch(opts.inProcess) }
|
|
439
|
+
}
|
|
327
440
|
this.opts = opts
|
|
328
441
|
this.registry = opts.registry
|
|
329
442
|
this.clock = opts.clock ?? systemClock
|
|
@@ -418,11 +531,11 @@ export class AgentRuntime {
|
|
|
418
531
|
if (opts?.by && h.parent !== opts.by) {
|
|
419
532
|
return Promise.resolve({
|
|
420
533
|
text: `wait refused: only the spawner may wait on ${h.id}`,
|
|
421
|
-
isError: true, status:
|
|
534
|
+
isError: true, status: S.error, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
422
535
|
})
|
|
423
536
|
}
|
|
424
537
|
if (h.state === "closed") {
|
|
425
|
-
return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status:
|
|
538
|
+
return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens })
|
|
426
539
|
}
|
|
427
540
|
if (h.state === "suspended") return Promise.resolve(this.pendingResult(h))
|
|
428
541
|
if (h.state === "idle" && h.lastResult && !this.isQueued(h)) return Promise.resolve(h.lastResult)
|
|
@@ -440,7 +553,7 @@ export class AgentRuntime {
|
|
|
440
553
|
cancel = this.clock.setTimeout(() => {
|
|
441
554
|
const i = h.waiters.indexOf(finish)
|
|
442
555
|
if (i >= 0) h.waiters.splice(i, 1)
|
|
443
|
-
finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status:
|
|
556
|
+
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
557
|
}, opts.timeoutMs)
|
|
445
558
|
}
|
|
446
559
|
})
|
|
@@ -496,7 +609,7 @@ export class AgentRuntime {
|
|
|
496
609
|
await h.def.onClose?.(h, reason)
|
|
497
610
|
const prev = h.state
|
|
498
611
|
h.state = "closed"
|
|
499
|
-
const final: TaskResult = { text: "closed", isError: true, status:
|
|
612
|
+
const final: TaskResult = { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
500
613
|
for (const w of h.waiters.splice(0)) w(final)
|
|
501
614
|
this.t(`${h.id}: ${prev}→closed (${reason})`)
|
|
502
615
|
}
|
|
@@ -527,16 +640,19 @@ export class AgentRuntime {
|
|
|
527
640
|
* REATTACHES to the existing child by task key — never spawns a duplicate.
|
|
528
641
|
* The root alone may throw to the host.
|
|
529
642
|
*/
|
|
530
|
-
async resume(answer: Answer): Promise<
|
|
643
|
+
async resume(answer: Answer): Promise<TaskResult> {
|
|
531
644
|
const leaf = this.deepestSuspended(this.root)
|
|
532
645
|
if (!leaf) throw new Error("no suspended handle to resume")
|
|
533
646
|
this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`)
|
|
534
|
-
|
|
647
|
+
// The value is the settled result of the TOPMOST handle the cascade re-ran — what the host
|
|
648
|
+
// would have got from `Agent.run` had the suspension never happened (ADR 0025 D3).
|
|
649
|
+
let topmost = await this.resumeSuspended(leaf, answer, { inline: false })
|
|
535
650
|
for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
|
|
536
651
|
if (p.state !== "suspended") break
|
|
537
652
|
this.t(`${p.id}: cascade resume (reattaching delegated work)`)
|
|
538
|
-
await this.resumeSuspended(p, undefined, { inline: false })
|
|
653
|
+
topmost = await this.resumeSuspended(p, undefined, { inline: false })
|
|
539
654
|
}
|
|
655
|
+
return topmost
|
|
540
656
|
}
|
|
541
657
|
|
|
542
658
|
private deepestSuspended(h: Handle): Handle | null {
|
|
@@ -580,25 +696,28 @@ export class AgentRuntime {
|
|
|
580
696
|
// ---- budgets (live ancestor-chain enforcement + roll-up ledger) --------
|
|
581
697
|
|
|
582
698
|
/** Walk the LIVE ancestor chain; name the first exhausted pool, if any. */
|
|
583
|
-
private poolLimit(h: Handle):
|
|
699
|
+
private poolLimit(h: Handle): TaskLimit | undefined {
|
|
584
700
|
const now = this.clock.now()
|
|
585
701
|
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
|
|
702
|
+
if (a.pool.tokens <= 0) return L.maxTokens
|
|
703
|
+
if (a.pool.toolCalls <= 0) return L.maxToolCalls
|
|
704
|
+
if (a.deadline !== undefined && now >= a.deadline) return L.maxWallMs
|
|
589
705
|
}
|
|
590
706
|
return undefined
|
|
591
707
|
}
|
|
592
708
|
|
|
593
709
|
/** Lifetime turn cap on the handle itself (turns never reset across resumes). */
|
|
594
|
-
private turnCap(h: Handle):
|
|
595
|
-
return h.turnsTotal >= h.eff.maxTurns ?
|
|
710
|
+
private turnCap(h: Handle): TaskLimit | undefined {
|
|
711
|
+
return h.turnsTotal >= h.eff.maxTurns ? L.maxTurns : undefined
|
|
596
712
|
}
|
|
597
713
|
|
|
598
714
|
/** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
|
|
599
|
-
private settleRefused(h: Handle, limit:
|
|
715
|
+
private settleRefused(h: Handle, limit: TaskLimit): string {
|
|
716
|
+
// `limit` here is already a Budget field name (`poolLimit`/`turnCap` return them verbatim);
|
|
717
|
+
// `canonicalLimit` below is the guard that keeps it that way if either ever changes.
|
|
600
718
|
const text = `budget exhausted (${limit}); partial work preserved`
|
|
601
|
-
|
|
719
|
+
// The exhausted pool is COMPUTED here and used to be discarded into prose. `limit` carries it.
|
|
720
|
+
const r: TaskResult = { text, isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens, limit }
|
|
602
721
|
h.lastResult = r
|
|
603
722
|
for (const w of h.waiters.splice(0)) w(r)
|
|
604
723
|
return text
|
|
@@ -606,6 +725,7 @@ export class AgentRuntime {
|
|
|
606
725
|
|
|
607
726
|
/** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
|
|
608
727
|
private rollUp(h: Handle, tokens: number, toolCalls: number): void {
|
|
728
|
+
h.ownTokens += tokens // own figure: outside the ancestor walk, by construction
|
|
609
729
|
for (let a: Handle | null = h; a; a = a.parent) {
|
|
610
730
|
a.usageTotal += tokens
|
|
611
731
|
a.toolCallsTotal += toolCalls
|
|
@@ -728,7 +848,7 @@ export class AgentRuntime {
|
|
|
728
848
|
|
|
729
849
|
private pendingResult(h: Handle): TaskResult {
|
|
730
850
|
const req = h.pendingRequest
|
|
731
|
-
return { text: req?.prompt ?? "", isError: false, status:
|
|
851
|
+
return { text: req?.prompt ?? "", isError: false, status: S.pending, pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
732
852
|
}
|
|
733
853
|
|
|
734
854
|
/** The escalation seam: this handle's §10 interpreter authority, with the
|
|
@@ -801,7 +921,7 @@ export class AgentRuntime {
|
|
|
801
921
|
h.checkpoint = { input }
|
|
802
922
|
h.drained = [] // drained items ride the checkpointed input, not the inbox
|
|
803
923
|
this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`)
|
|
804
|
-
result = { text: r.text, isError: false, status:
|
|
924
|
+
result = { text: r.text, isError: false, status: S.pending, pending: stamped, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
805
925
|
} else if (r.status === "incomplete") {
|
|
806
926
|
h.state = "idle"
|
|
807
927
|
h.drained = []
|
|
@@ -810,13 +930,17 @@ export class AgentRuntime {
|
|
|
810
930
|
// generic sentence. Reading `limit` is how the runtime tells them apart.
|
|
811
931
|
result = {
|
|
812
932
|
text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
|
|
813
|
-
isError: true, status:
|
|
933
|
+
isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
934
|
+
// The LATENT instance golang found: forwarding the client's `limit` straight through
|
|
935
|
+
// would hand a host `incomplete` with an empty limit whenever the client omits it.
|
|
936
|
+
// The `?? L.maxTurns` is what makes the invariant hold by construction, not by luck.
|
|
937
|
+
limit: canonicalLimit(r.limit) ?? L.maxTurns,
|
|
814
938
|
}
|
|
815
939
|
} else {
|
|
816
940
|
h.state = "idle"
|
|
817
941
|
h.drained = []
|
|
818
942
|
this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`)
|
|
819
|
-
result = { text: r.text, isError: false, status:
|
|
943
|
+
result = { text: r.text, isError: false, status: S.done, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
|
|
820
944
|
}
|
|
821
945
|
} catch (e) {
|
|
822
946
|
const aborted = h.abort?.signal.aborted ?? false
|
|
@@ -828,8 +952,9 @@ export class AgentRuntime {
|
|
|
828
952
|
this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`)
|
|
829
953
|
result = {
|
|
830
954
|
text: msg, isError: true,
|
|
831
|
-
|
|
832
|
-
|
|
955
|
+
// None of these three is a limit stop, so none carries a `limit` (A18).
|
|
956
|
+
status: closing ? S.closed : aborted ? S.interrupted : S.error,
|
|
957
|
+
turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
833
958
|
}
|
|
834
959
|
} finally {
|
|
835
960
|
this.releaseChildSlot(h)
|
|
@@ -904,7 +1029,7 @@ export class AgentRuntime {
|
|
|
904
1029
|
} else if (existing.state === "running") {
|
|
905
1030
|
r = await rt.wait(existing, { by: parent })
|
|
906
1031
|
} else {
|
|
907
|
-
r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status:
|
|
1032
|
+
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
1033
|
}
|
|
909
1034
|
} else {
|
|
910
1035
|
const spawned = rt.spawn(parent, name)
|