toolnexus 0.13.0 → 0.15.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/a2a.js +8 -0
- package/dist/agents/agent.d.ts +25 -0
- package/dist/agents/agent.js +22 -1
- package/dist/agents/compaction.js +23 -7
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.js +1 -0
- package/dist/agents/loop.d.ts +98 -0
- package/dist/agents/loop.js +179 -0
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +10 -2
- package/dist/client.js +25 -2
- package/package.json +2 -2
- package/src/a2a.ts +8 -0
- package/src/agents/agent.ts +35 -1
- package/src/agents/compaction.ts +26 -7
- package/src/agents/index.ts +1 -0
- package/src/agents/loop.ts +232 -0
- package/src/agents/runtime.ts +18 -2
- package/src/client.ts +25 -2
package/dist/a2a.js
CHANGED
|
@@ -234,6 +234,14 @@ function skillTool(agentName, endpoint, skill, ag) {
|
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
236
|
catch (e) {
|
|
237
|
+
// An abort that lands WHILE an RPC is in flight surfaces as a transport error
|
|
238
|
+
// (AbortError), not through the two checks in the poll loop. Report it as the
|
|
239
|
+
// cancel it is, so a cancelled call is `canceled` regardless of whether the
|
|
240
|
+
// signal fired between polls or mid-request.
|
|
241
|
+
if (ctx?.signal?.aborted) {
|
|
242
|
+
state = "canceled";
|
|
243
|
+
return { output: `A2A task ${taskId} canceled`, isError: true, metadata: meta() };
|
|
244
|
+
}
|
|
237
245
|
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: meta() };
|
|
238
246
|
}
|
|
239
247
|
},
|
package/dist/agents/agent.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Answer, Request, Tool } from "../types.js";
|
|
2
2
|
import { AgentRuntime, type AgentDef, type Budget, type RuntimeOptions, type TaskResult } from "./runtime.js";
|
|
3
|
+
import { Loop, type Completion, type Guardrail } from "./loop.js";
|
|
4
|
+
import type { ClientOptions } from "../client.js";
|
|
5
|
+
import type { Toolkit } from "../toolkit.js";
|
|
3
6
|
/** The declarative agent spec (§7D Level-1 surface). */
|
|
4
7
|
export interface AgentSpec {
|
|
5
8
|
/** Required routing description — what a delegating model sees. */
|
|
@@ -28,6 +31,15 @@ export interface AgentSpec {
|
|
|
28
31
|
/** §8 observability sink for this agent. Set ⇒ replaces the runtime-wide
|
|
29
32
|
* `onMetric` for this agent; resolves independently of `hooks`. */
|
|
30
33
|
onMetric?: AgentDef["onMetric"];
|
|
34
|
+
/** POLICY checks on tool calls — "may it?", never "is it right?". They compile
|
|
35
|
+
* into one `beforeTool` with FIRST-DENY-WINS: a later guardrail can never widen
|
|
36
|
+
* an earlier denial. Composed with `hooks.beforeTool`, which runs only if every
|
|
37
|
+
* guardrail allows. Empty ⇒ byte-identical. */
|
|
38
|
+
guardrails?: Guardrail[];
|
|
39
|
+
/** When set, the gate that stops this agent claiming `done` before its work
|
|
40
|
+
* verifies. It travels WITH the agent, so a caller that delegates via `task`
|
|
41
|
+
* inherits it — which a host-side retry loop cannot do. Omitted ⇒ byte-identical. */
|
|
42
|
+
completion?: Completion;
|
|
31
43
|
}
|
|
32
44
|
/** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
|
|
33
45
|
export type AgentRunOptions = Omit<RuntimeOptions, "registry">;
|
|
@@ -46,6 +58,12 @@ export declare class Agent {
|
|
|
46
58
|
* the registry IS the reachable graph; unreachable agents are not present.
|
|
47
59
|
*/
|
|
48
60
|
registry(acc?: Record<string, AgentDef>): Record<string, AgentDef>;
|
|
61
|
+
/**
|
|
62
|
+
* Open a LIVE EXECUTION of this agent. Takes client OPTIONS rather than a built
|
|
63
|
+
* client, because a per-call `model` override must be able to change the model —
|
|
64
|
+
* which is fixed when a client is constructed.
|
|
65
|
+
*/
|
|
66
|
+
loop(options: ClientOptions, toolkit: Toolkit): Loop;
|
|
49
67
|
/** Build a runtime scoped to this agent's reachable team graph. */
|
|
50
68
|
createRuntime(opts?: AgentRunOptions): AgentRuntime;
|
|
51
69
|
/**
|
|
@@ -64,3 +82,10 @@ export declare class Agent {
|
|
|
64
82
|
}
|
|
65
83
|
/** The one new noun: define an agent. */
|
|
66
84
|
export declare function agent(name: string, spec: AgentSpec): Agent;
|
|
85
|
+
/**
|
|
86
|
+
* `Harness` is a NAME, not a type. An agent spec already IS the harness — tools,
|
|
87
|
+
* soul, team, budget, model, policy, ceilings — so this is the word landing in the
|
|
88
|
+
* API without a second concept to learn or migrate to. A spec built through
|
|
89
|
+
* `harness` and one written inline are indistinguishable.
|
|
90
|
+
*/
|
|
91
|
+
export declare function harness(spec: AgentSpec): AgentSpec;
|
package/dist/agents/agent.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import { defineTool } from "../native.js";
|
|
12
12
|
import { AgentRuntime, isVerbError } from "./runtime.js";
|
|
13
|
+
import { Loop, guardedHooks } from "./loop.js";
|
|
13
14
|
export class Agent {
|
|
14
15
|
name;
|
|
15
16
|
spec;
|
|
@@ -38,13 +39,24 @@ export class Agent {
|
|
|
38
39
|
waitFor: this.spec.waitFor,
|
|
39
40
|
onSpawn: this.spec.onSpawn,
|
|
40
41
|
onClose: this.spec.onClose,
|
|
41
|
-
|
|
42
|
+
// Guardrails are compiled into the hook HERE, at the registry boundary, so a
|
|
43
|
+
// delegated child is governed by them too — not only a directly-driven one.
|
|
44
|
+
hooks: guardedHooks(this.spec.guardrails, this.spec.hooks),
|
|
42
45
|
onMetric: this.spec.onMetric,
|
|
46
|
+
completion: this.spec.completion,
|
|
43
47
|
};
|
|
44
48
|
for (const t of this.spec.team ?? [])
|
|
45
49
|
t.registry(acc);
|
|
46
50
|
return acc;
|
|
47
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Open a LIVE EXECUTION of this agent. Takes client OPTIONS rather than a built
|
|
54
|
+
* client, because a per-call `model` override must be able to change the model —
|
|
55
|
+
* which is fixed when a client is constructed.
|
|
56
|
+
*/
|
|
57
|
+
loop(options, toolkit) {
|
|
58
|
+
return new Loop(this, options, toolkit);
|
|
59
|
+
}
|
|
48
60
|
/** Build a runtime scoped to this agent's reachable team graph. */
|
|
49
61
|
createRuntime(opts = {}) {
|
|
50
62
|
return new AgentRuntime({ ...opts, registry: this.registry() });
|
|
@@ -99,3 +111,12 @@ export class Agent {
|
|
|
99
111
|
export function agent(name, spec) {
|
|
100
112
|
return new Agent(name, spec);
|
|
101
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* `Harness` is a NAME, not a type. An agent spec already IS the harness — tools,
|
|
116
|
+
* soul, team, budget, model, policy, ceilings — so this is the word landing in the
|
|
117
|
+
* API without a second concept to learn or migrate to. A spec built through
|
|
118
|
+
* `harness` and one written inline are indistinguishable.
|
|
119
|
+
*/
|
|
120
|
+
export function harness(spec) {
|
|
121
|
+
return spec;
|
|
122
|
+
}
|
|
@@ -71,25 +71,41 @@ export function compactor(opts) {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* Does this message carry a tool result? Under the anthropic dialect a tool result is a
|
|
75
|
+
* `user` message whose content holds `tool_result` blocks, so such a message is a member
|
|
76
|
+
* of a tool group rather than a conversational boundary.
|
|
77
|
+
*/
|
|
78
|
+
function carriesToolResult(m) {
|
|
79
|
+
const content = m.content;
|
|
80
|
+
if (!Array.isArray(content))
|
|
81
|
+
return false;
|
|
82
|
+
return content.some((b) => typeof b === "object" && b !== null && b.type === "tool_result");
|
|
83
|
+
}
|
|
84
|
+
/** A valid tail boundary: a `user` turn that is not carrying a tool result. */
|
|
85
|
+
function isBoundary(m) {
|
|
86
|
+
return m.role === "user" && !carriesToolResult(m);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Locate the index where the retained tail begins. Tool-pair safety requires the tail to
|
|
90
|
+
* start at a `user` turn that is not a tool-result carrier: the largest such tail that
|
|
91
|
+
* still fits `keepTail`. If none fits, fall back to the most recent valid boundary
|
|
77
92
|
* (favoring safety over size). Returns `msgs.length` (no tail) only when there is no
|
|
78
|
-
*
|
|
93
|
+
* valid boundary after the head at all — the whole body is then summarized, which is
|
|
94
|
+
* bounded and cannot orphan anything since nothing is retained.
|
|
79
95
|
*/
|
|
80
96
|
function findTailStart(msgs, headEnd, keepTail, count) {
|
|
81
97
|
let split = msgs.length;
|
|
82
98
|
for (let i = msgs.length - 1; i > headEnd; i--) {
|
|
83
99
|
if (count(msgs.slice(i)) > keepTail)
|
|
84
100
|
break;
|
|
85
|
-
if (msgs[i]
|
|
101
|
+
if (isBoundary(msgs[i]))
|
|
86
102
|
split = i;
|
|
87
103
|
}
|
|
88
104
|
if (split !== msgs.length)
|
|
89
105
|
return split;
|
|
90
|
-
// No
|
|
106
|
+
// No boundary fit within keepTail — extend back to the most recent valid one.
|
|
91
107
|
for (let i = msgs.length - 1; i > headEnd; i--) {
|
|
92
|
-
if (msgs[i]
|
|
108
|
+
if (isBoundary(msgs[i]))
|
|
93
109
|
return i;
|
|
94
110
|
}
|
|
95
111
|
return msgs.length;
|
package/dist/agents/index.d.ts
CHANGED
package/dist/agents/index.js
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop — a live execution of an Agent, and the completion gate that stops it
|
|
3
|
+
* claiming `done` too early. A layer over the shipped §8 client: nothing here
|
|
4
|
+
* changes existing behaviour.
|
|
5
|
+
*
|
|
6
|
+
* The placement law this encodes:
|
|
7
|
+
*
|
|
8
|
+
* AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
|
|
9
|
+
* RunOptions answers "with WHAT?" — model for this call. Per call.
|
|
10
|
+
* Loop answers "DID it?" — status, turns. Observed.
|
|
11
|
+
* none of them answers "is it RIGHT?"— a tool, skill or agent.
|
|
12
|
+
*
|
|
13
|
+
* So Loop takes no options: it is read, not configured.
|
|
14
|
+
*/
|
|
15
|
+
import { type ClientOptions, type Hooks, type RunResult } from "../client.js";
|
|
16
|
+
import type { Toolkit } from "../toolkit.js";
|
|
17
|
+
import type { Agent } from "./agent.js";
|
|
18
|
+
/** A POLICY check on a tool call — "may it?", never "is it right?".
|
|
19
|
+
* Return `"allow"` (or nothing) to permit; any other string DENIES with that reason. */
|
|
20
|
+
export type Guardrail = (ev: {
|
|
21
|
+
name: string;
|
|
22
|
+
args: Record<string, unknown>;
|
|
23
|
+
id?: string;
|
|
24
|
+
turn: number;
|
|
25
|
+
}) => string | undefined | void;
|
|
26
|
+
/** The verdict a completion verifier returns. */
|
|
27
|
+
export interface Verdict {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
/** The gate that stops an agent claiming `done` before its work verifies. */
|
|
32
|
+
export interface Completion {
|
|
33
|
+
/** Judges the run. Receives the tool calls ACCUMULATED across attempts. */
|
|
34
|
+
verify: (result: RunResult) => Verdict | Promise<Verdict>;
|
|
35
|
+
/** REQUIRED. An unbounded verify loop is a denial-of-service on the caller's bill. */
|
|
36
|
+
maxAttempts: number;
|
|
37
|
+
}
|
|
38
|
+
/** What varies PER CALL. `model` is here — not on the spec's default and not on the
|
|
39
|
+
* Loop — so the same conversation may change model between turns. */
|
|
40
|
+
export interface LoopRunOptions {
|
|
41
|
+
/** Overrides the agent's model for this call only. Omitted ⇒ the agent's. */
|
|
42
|
+
model?: string;
|
|
43
|
+
}
|
|
44
|
+
/** What a run reports. `status` reuses the SHIPPED vocabulary — no new status
|
|
45
|
+
* strings are minted (SPEC.md pins TaskStatus identical across ports). */
|
|
46
|
+
export interface Outcome {
|
|
47
|
+
text: string;
|
|
48
|
+
status: "done" | "incomplete" | "pending" | "error";
|
|
49
|
+
/** Named whenever `status` is not `done` — never a silent stop. */
|
|
50
|
+
stoppedBy?: string;
|
|
51
|
+
attempts: number;
|
|
52
|
+
turns: number;
|
|
53
|
+
result?: RunResult;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
|
|
57
|
+
* composed ahead of any hook the spec already set. No guardrails ⇒ the spec's
|
|
58
|
+
* hooks are returned untouched, so absent is byte-identical.
|
|
59
|
+
*/
|
|
60
|
+
export declare function guardedHooks(guardrails: Guardrail[] | undefined, hooks: Hooks | undefined): Hooks | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The built-in completion verifier. Reads the SHIPPED `todowrite` builtin's
|
|
63
|
+
* result metadata and requires every item to be checked.
|
|
64
|
+
*
|
|
65
|
+
* Structural, not domain: it counts unchecked boxes and never learns what a todo
|
|
66
|
+
* means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒
|
|
67
|
+
* pass, so the gate never punishes an agent that does not use the builtin.
|
|
68
|
+
*/
|
|
69
|
+
export declare function allTodosDone(result: RunResult): Verdict;
|
|
70
|
+
/**
|
|
71
|
+
* Wraps a client run with the completion gate. SHARED by the standalone Loop and
|
|
72
|
+
* the §7D runtime turn, so a delegated child gets exactly the same guarantee as a
|
|
73
|
+
* directly-driven one.
|
|
74
|
+
*
|
|
75
|
+
* Rule 2 in force: a run that is `pending` (suspended on a human) or otherwise
|
|
76
|
+
* non-done already carries its own reason, so the gate never re-judges it. That
|
|
77
|
+
* keeps `pending` and `incomplete` distinct — the caller can always tell whether
|
|
78
|
+
* it owes an Answer or a fix.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runGated(ask: (prompt: string) => Promise<RunResult>, prompt: string, completion: Completion | undefined): Promise<RunResult>;
|
|
81
|
+
/** A live execution of an Agent. Its only verbs are `run` and reading state. */
|
|
82
|
+
export declare class Loop {
|
|
83
|
+
#private;
|
|
84
|
+
private readonly agent;
|
|
85
|
+
/** CLIENT OPTIONS rather than a built client, because a per-call `model`
|
|
86
|
+
* override must be able to change the model — which is fixed at construction. */
|
|
87
|
+
private readonly options;
|
|
88
|
+
private readonly toolkit;
|
|
89
|
+
constructor(agent: Agent,
|
|
90
|
+
/** CLIENT OPTIONS rather than a built client, because a per-call `model`
|
|
91
|
+
* override must be able to change the model — which is fixed at construction. */
|
|
92
|
+
options: ClientOptions, toolkit: Toolkit);
|
|
93
|
+
/** Observed, never set by the caller. */
|
|
94
|
+
get status(): string;
|
|
95
|
+
/** Model round trips this loop has spent. */
|
|
96
|
+
get turns(): number;
|
|
97
|
+
run(prompt: string, opts?: LoopRunOptions): Promise<Outcome>;
|
|
98
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop — a live execution of an Agent, and the completion gate that stops it
|
|
3
|
+
* claiming `done` too early. A layer over the shipped §8 client: nothing here
|
|
4
|
+
* changes existing behaviour.
|
|
5
|
+
*
|
|
6
|
+
* The placement law this encodes:
|
|
7
|
+
*
|
|
8
|
+
* AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
|
|
9
|
+
* RunOptions answers "with WHAT?" — model for this call. Per call.
|
|
10
|
+
* Loop answers "DID it?" — status, turns. Observed.
|
|
11
|
+
* none of them answers "is it RIGHT?"— a tool, skill or agent.
|
|
12
|
+
*
|
|
13
|
+
* So Loop takes no options: it is read, not configured.
|
|
14
|
+
*/
|
|
15
|
+
import { createClient } from "../client.js";
|
|
16
|
+
/**
|
|
17
|
+
* Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
|
|
18
|
+
* composed ahead of any hook the spec already set. No guardrails ⇒ the spec's
|
|
19
|
+
* hooks are returned untouched, so absent is byte-identical.
|
|
20
|
+
*/
|
|
21
|
+
export function guardedHooks(guardrails, hooks) {
|
|
22
|
+
if (!guardrails || guardrails.length === 0)
|
|
23
|
+
return hooks;
|
|
24
|
+
const prior = hooks?.beforeTool;
|
|
25
|
+
return {
|
|
26
|
+
...(hooks ?? {}),
|
|
27
|
+
beforeTool: async (ev) => {
|
|
28
|
+
for (const g of guardrails) {
|
|
29
|
+
const verdict = g(ev);
|
|
30
|
+
if (verdict && verdict !== "allow") {
|
|
31
|
+
return { result: { output: `denied: ${verdict}`, isError: true } };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return prior ? prior(ev) : undefined;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The built-in completion verifier. Reads the SHIPPED `todowrite` builtin's
|
|
40
|
+
* result metadata and requires every item to be checked.
|
|
41
|
+
*
|
|
42
|
+
* Structural, not domain: it counts unchecked boxes and never learns what a todo
|
|
43
|
+
* means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒
|
|
44
|
+
* pass, so the gate never punishes an agent that does not use the builtin.
|
|
45
|
+
*/
|
|
46
|
+
export function allTodosDone(result) {
|
|
47
|
+
for (let i = result.toolCalls.length - 1; i >= 0; i--) {
|
|
48
|
+
if (result.toolCalls[i].name !== "todowrite")
|
|
49
|
+
continue;
|
|
50
|
+
const todos = result.toolCalls[i].metadata?.todos;
|
|
51
|
+
if (!Array.isArray(todos))
|
|
52
|
+
return { ok: true };
|
|
53
|
+
const open = todos
|
|
54
|
+
.filter((t) => !t?.completed)
|
|
55
|
+
.map((t) => String(t?.text ?? ""));
|
|
56
|
+
return open.length > 0
|
|
57
|
+
? { ok: false, reason: `${open.length} item(s) still open: ${open.join("; ")}` }
|
|
58
|
+
: { ok: true };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wraps a client run with the completion gate. SHARED by the standalone Loop and
|
|
64
|
+
* the §7D runtime turn, so a delegated child gets exactly the same guarantee as a
|
|
65
|
+
* directly-driven one.
|
|
66
|
+
*
|
|
67
|
+
* Rule 2 in force: a run that is `pending` (suspended on a human) or otherwise
|
|
68
|
+
* non-done already carries its own reason, so the gate never re-judges it. That
|
|
69
|
+
* keeps `pending` and `incomplete` distinct — the caller can always tell whether
|
|
70
|
+
* it owes an Answer or a fix.
|
|
71
|
+
*/
|
|
72
|
+
export async function runGated(ask, prompt, completion) {
|
|
73
|
+
if (!completion)
|
|
74
|
+
return ask(prompt);
|
|
75
|
+
const { maxAttempts } = completion;
|
|
76
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
77
|
+
throw new Error("toolnexus: completion.maxAttempts must be an integer >= 1");
|
|
78
|
+
}
|
|
79
|
+
if (typeof completion.verify !== "function") {
|
|
80
|
+
throw new Error("toolnexus: completion.verify is required");
|
|
81
|
+
}
|
|
82
|
+
let accumulated = [];
|
|
83
|
+
let last;
|
|
84
|
+
let reason = "";
|
|
85
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
86
|
+
const p = attempt === 1 ? prompt : `Your work did not verify: ${reason}. Fix it and finish.`;
|
|
87
|
+
const r = await ask(p);
|
|
88
|
+
// The gate judges the ACCUMULATED work, so an agent cannot escape it by
|
|
89
|
+
// declining to re-declare its plan on a retry.
|
|
90
|
+
accumulated = [...accumulated, ...r.toolCalls];
|
|
91
|
+
const judged = { ...r, toolCalls: accumulated };
|
|
92
|
+
last = judged;
|
|
93
|
+
if (r.status && r.status !== "done") {
|
|
94
|
+
// The run stopped for its own reason (suspension, budget). If the gate was
|
|
95
|
+
// mid-retry the caller must learn BOTH — otherwise a budget stop masks the
|
|
96
|
+
// verification failure and they never see why it was looping.
|
|
97
|
+
if (reason && r.status !== "pending") {
|
|
98
|
+
judged.text = `${r.text} [while verifying: attempt ${attempt} last failed: ${reason}]`;
|
|
99
|
+
}
|
|
100
|
+
return judged;
|
|
101
|
+
}
|
|
102
|
+
const verdict = await completion.verify(judged);
|
|
103
|
+
if (verdict.ok)
|
|
104
|
+
return judged;
|
|
105
|
+
reason = verdict.reason ?? "unspecified";
|
|
106
|
+
}
|
|
107
|
+
// Structured, not prose: `limit` is how a caller (and the §7D runtime) tells
|
|
108
|
+
// WHICH limit stopped the run. `text` carries the human reason.
|
|
109
|
+
return {
|
|
110
|
+
...last,
|
|
111
|
+
status: "incomplete",
|
|
112
|
+
limit: "completion",
|
|
113
|
+
text: `completion.verify failed ${maxAttempts}×: ${reason}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** A live execution of an Agent. Its only verbs are `run` and reading state. */
|
|
117
|
+
export class Loop {
|
|
118
|
+
agent;
|
|
119
|
+
options;
|
|
120
|
+
toolkit;
|
|
121
|
+
#status = "idle";
|
|
122
|
+
#turns = 0;
|
|
123
|
+
constructor(agent,
|
|
124
|
+
/** CLIENT OPTIONS rather than a built client, because a per-call `model`
|
|
125
|
+
* override must be able to change the model — which is fixed at construction. */
|
|
126
|
+
options, toolkit) {
|
|
127
|
+
this.agent = agent;
|
|
128
|
+
this.options = options;
|
|
129
|
+
this.toolkit = toolkit;
|
|
130
|
+
}
|
|
131
|
+
/** Observed, never set by the caller. */
|
|
132
|
+
get status() {
|
|
133
|
+
return this.#status;
|
|
134
|
+
}
|
|
135
|
+
/** Model round trips this loop has spent. */
|
|
136
|
+
get turns() {
|
|
137
|
+
return this.#turns;
|
|
138
|
+
}
|
|
139
|
+
async run(prompt, opts = {}) {
|
|
140
|
+
const completion = this.agent.spec.completion;
|
|
141
|
+
const client = createClient(this.#clientOptions(opts));
|
|
142
|
+
this.#status = "running";
|
|
143
|
+
let attempts = 0;
|
|
144
|
+
let history = [];
|
|
145
|
+
const ask = async (p) => {
|
|
146
|
+
attempts++;
|
|
147
|
+
const r = await client.run(p, { toolkit: this.toolkit, history });
|
|
148
|
+
this.#turns += r.turns;
|
|
149
|
+
history = r.messages;
|
|
150
|
+
return r;
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
const r = await runGated(ask, prompt, completion);
|
|
154
|
+
if (r.status && r.status !== "done") {
|
|
155
|
+
this.#status = r.status;
|
|
156
|
+
const stoppedBy = r.limit === "completion" ? r.text : `run reported ${r.status}`;
|
|
157
|
+
return { text: r.text, status: r.status, stoppedBy, attempts, turns: this.#turns, result: r };
|
|
158
|
+
}
|
|
159
|
+
this.#status = "idle";
|
|
160
|
+
return { text: r.text, status: "done", attempts, turns: this.#turns, result: r };
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
this.#status = "error";
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Applies a per-call model override via `requestParams` (`model` is not in the
|
|
168
|
+
* forbidden set — the client forbids only messages/tools/stream). */
|
|
169
|
+
#clientOptions(opts) {
|
|
170
|
+
const base = {
|
|
171
|
+
...this.options,
|
|
172
|
+
systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
|
|
173
|
+
hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
|
|
174
|
+
};
|
|
175
|
+
if (!opts.model)
|
|
176
|
+
return base;
|
|
177
|
+
return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } };
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/agents/runtime.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* identical per-handle transition traces against `examples/subagent-*` fixtures.
|
|
16
16
|
*/
|
|
17
17
|
import { type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js";
|
|
18
|
+
import { type Completion } from "./loop.js";
|
|
18
19
|
import type { Answer, Request, Tool } from "../types.js";
|
|
19
20
|
/** Injectable time source. All runtime timers, timeouts, and deadlines use it. */
|
|
20
21
|
export interface Clock {
|
|
@@ -76,6 +77,10 @@ export interface AgentDef {
|
|
|
76
77
|
/** §8 observability sink for THIS agent's client. Set ⇒ REPLACES the runtime-wide
|
|
77
78
|
* `onMetric` for this agent. Resolves independently of `hooks`. Forwarded verbatim. */
|
|
78
79
|
onMetric?: (ev: MetricEvent) => void;
|
|
80
|
+
/** §7D completion gate, projected from the agent spec. Present ⇒ this agent's
|
|
81
|
+
* turns run through the gate even when it is a DELEGATED child — which is what a
|
|
82
|
+
* host-side retry loop cannot do. */
|
|
83
|
+
completion?: Completion;
|
|
79
84
|
}
|
|
80
85
|
/** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
|
|
81
86
|
* drain, with provenance rendered per item (non-ancestor senders are untrusted). */
|
package/dist/agents/runtime.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* identical per-handle transition traces against `examples/subagent-*` fixtures.
|
|
16
16
|
*/
|
|
17
17
|
import { createClient, InMemoryConversationStore } from "../client.js";
|
|
18
|
+
import { runGated } from "./loop.js";
|
|
18
19
|
import { createToolkit } from "../toolkit.js";
|
|
19
20
|
import { defineTool } from "../native.js";
|
|
20
21
|
/** The real clock (default). Timers are unref'd so they never hold the process open. */
|
|
@@ -584,7 +585,9 @@ export class AgentRuntime {
|
|
|
584
585
|
hooks: h.def.hooks ?? this.opts.hooks,
|
|
585
586
|
onMetric: h.def.onMetric ?? this.opts.onMetric,
|
|
586
587
|
});
|
|
587
|
-
|
|
588
|
+
// The gate runs HERE, inside the runtime turn, which is what makes it reach a
|
|
589
|
+
// delegated child: `task` executes the child through this same path.
|
|
590
|
+
const r = await runGated((p) => client.ask(p, { toolkit, id: h.id, signal: h.abort.signal }), input, h.def.completion);
|
|
588
591
|
h.turnsTotal += r.turns;
|
|
589
592
|
this.rollUp(h, r.usage.totalTokens, r.toolCallCount);
|
|
590
593
|
if (r.status === "pending" && r.pending) {
|
|
@@ -604,7 +607,12 @@ export class AgentRuntime {
|
|
|
604
607
|
h.state = "idle";
|
|
605
608
|
h.drained = [];
|
|
606
609
|
this.t(`${h.id}: running→idle (incomplete: ${r.limit ?? "maxTurns"})`);
|
|
607
|
-
|
|
610
|
+
// A completion stop carries its own reason; only a maxTurns stop gets the
|
|
611
|
+
// generic sentence. Reading `limit` is how the runtime tells them apart.
|
|
612
|
+
result = {
|
|
613
|
+
text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
|
|
614
|
+
isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
|
|
615
|
+
};
|
|
608
616
|
}
|
|
609
617
|
else {
|
|
610
618
|
h.state = "idle";
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,28 @@ export class InMemoryConversationStore {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
15
|
+
/** ~68 years; the widest whole-second count all seven ports represent exactly. */
|
|
16
|
+
const RETRY_AFTER_MAX_SECONDS = 2147483647;
|
|
17
|
+
/**
|
|
18
|
+
* Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
|
|
19
|
+
* (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
|
|
20
|
+
*
|
|
21
|
+
* `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
|
|
22
|
+
* and `" "` (as 0), which is how this port came to wait for fractional seconds
|
|
23
|
+
* that five of the other six rejected outright. Fractional, signed, HTTP-date and
|
|
24
|
+
* out-of-range values are not delays we can honour, so the caller falls back to
|
|
25
|
+
* backoff rather than guessing. `0` is a real answer, so it returns `0`, not
|
|
26
|
+
* `null`, and callers must use `??` rather than `||`.
|
|
27
|
+
*/
|
|
28
|
+
function retryAfterMs(raw) {
|
|
29
|
+
if (raw == null)
|
|
30
|
+
return null;
|
|
31
|
+
const s = raw.trim();
|
|
32
|
+
if (!/^[0-9]+$/.test(s))
|
|
33
|
+
return null;
|
|
34
|
+
const secs = Number(s);
|
|
35
|
+
return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null;
|
|
36
|
+
}
|
|
15
37
|
function delay(ms, signal) {
|
|
16
38
|
return new Promise((resolve, reject) => {
|
|
17
39
|
const t = setTimeout(resolve, ms);
|
|
@@ -457,8 +479,9 @@ export class Client {
|
|
|
457
479
|
const tier = classify({ status: res.status, attempt, retryable });
|
|
458
480
|
if (tier === "fail" || attempt === retries)
|
|
459
481
|
return res; // caller surfaces the non-ok status
|
|
460
|
-
const ra =
|
|
461
|
-
|
|
482
|
+
const ra = retryAfterMs(res.headers.get("retry-after"));
|
|
483
|
+
// `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
|
|
484
|
+
await delay(ra ?? base * 2 ** attempt + Math.random() * 100, signal);
|
|
462
485
|
}
|
|
463
486
|
catch (e) {
|
|
464
487
|
if (signal.aborted)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
package/src/a2a.ts
CHANGED
|
@@ -344,6 +344,14 @@ function skillTool(agentName: string, endpoint: string, skill: A2ASkill, ag: Age
|
|
|
344
344
|
metadata: meta(),
|
|
345
345
|
}
|
|
346
346
|
} catch (e) {
|
|
347
|
+
// An abort that lands WHILE an RPC is in flight surfaces as a transport error
|
|
348
|
+
// (AbortError), not through the two checks in the poll loop. Report it as the
|
|
349
|
+
// cancel it is, so a cancelled call is `canceled` regardless of whether the
|
|
350
|
+
// signal fired between polls or mid-request.
|
|
351
|
+
if (ctx?.signal?.aborted) {
|
|
352
|
+
state = "canceled"
|
|
353
|
+
return { output: `A2A task ${taskId} canceled`, isError: true, metadata: meta() }
|
|
354
|
+
}
|
|
347
355
|
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: meta() }
|
|
348
356
|
}
|
|
349
357
|
},
|
package/src/agents/agent.ts
CHANGED
|
@@ -11,6 +11,9 @@ import fs from "node:fs"
|
|
|
11
11
|
import { defineTool } from "../native.js"
|
|
12
12
|
import type { Answer, Request, Tool } from "../types.js"
|
|
13
13
|
import { AgentRuntime, isVerbError, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
|
|
14
|
+
import { Loop, guardedHooks, type Completion, type Guardrail } from "./loop.js"
|
|
15
|
+
import type { ClientOptions } from "../client.js"
|
|
16
|
+
import type { Toolkit } from "../toolkit.js"
|
|
14
17
|
|
|
15
18
|
/** The declarative agent spec (§7D Level-1 surface). */
|
|
16
19
|
export interface AgentSpec {
|
|
@@ -38,6 +41,15 @@ export interface AgentSpec {
|
|
|
38
41
|
/** §8 observability sink for this agent. Set ⇒ replaces the runtime-wide
|
|
39
42
|
* `onMetric` for this agent; resolves independently of `hooks`. */
|
|
40
43
|
onMetric?: AgentDef["onMetric"]
|
|
44
|
+
/** POLICY checks on tool calls — "may it?", never "is it right?". They compile
|
|
45
|
+
* into one `beforeTool` with FIRST-DENY-WINS: a later guardrail can never widen
|
|
46
|
+
* an earlier denial. Composed with `hooks.beforeTool`, which runs only if every
|
|
47
|
+
* guardrail allows. Empty ⇒ byte-identical. */
|
|
48
|
+
guardrails?: Guardrail[]
|
|
49
|
+
/** When set, the gate that stops this agent claiming `done` before its work
|
|
50
|
+
* verifies. It travels WITH the agent, so a caller that delegates via `task`
|
|
51
|
+
* inherits it — which a host-side retry loop cannot do. Omitted ⇒ byte-identical. */
|
|
52
|
+
completion?: Completion
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
/** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
|
|
@@ -72,13 +84,25 @@ export class Agent {
|
|
|
72
84
|
waitFor: this.spec.waitFor,
|
|
73
85
|
onSpawn: this.spec.onSpawn,
|
|
74
86
|
onClose: this.spec.onClose,
|
|
75
|
-
|
|
87
|
+
// Guardrails are compiled into the hook HERE, at the registry boundary, so a
|
|
88
|
+
// delegated child is governed by them too — not only a directly-driven one.
|
|
89
|
+
hooks: guardedHooks(this.spec.guardrails, this.spec.hooks),
|
|
76
90
|
onMetric: this.spec.onMetric,
|
|
91
|
+
completion: this.spec.completion,
|
|
77
92
|
}
|
|
78
93
|
for (const t of this.spec.team ?? []) t.registry(acc)
|
|
79
94
|
return acc
|
|
80
95
|
}
|
|
81
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Open a LIVE EXECUTION of this agent. Takes client OPTIONS rather than a built
|
|
99
|
+
* client, because a per-call `model` override must be able to change the model —
|
|
100
|
+
* which is fixed when a client is constructed.
|
|
101
|
+
*/
|
|
102
|
+
loop(options: ClientOptions, toolkit: Toolkit): Loop {
|
|
103
|
+
return new Loop(this, options, toolkit)
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
/** Build a runtime scoped to this agent's reachable team graph. */
|
|
83
107
|
createRuntime(opts: AgentRunOptions = {}): AgentRuntime {
|
|
84
108
|
return new AgentRuntime({ ...opts, registry: this.registry() })
|
|
@@ -135,3 +159,13 @@ export class Agent {
|
|
|
135
159
|
export function agent(name: string, spec: AgentSpec): Agent {
|
|
136
160
|
return new Agent(name, spec)
|
|
137
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `Harness` is a NAME, not a type. An agent spec already IS the harness — tools,
|
|
165
|
+
* soul, team, budget, model, policy, ceilings — so this is the word landing in the
|
|
166
|
+
* API without a second concept to learn or migrate to. A spec built through
|
|
167
|
+
* `harness` and one written inline are indistinguishable.
|
|
168
|
+
*/
|
|
169
|
+
export function harness(spec: AgentSpec): AgentSpec {
|
|
170
|
+
return spec
|
|
171
|
+
}
|
package/src/agents/compaction.ts
CHANGED
|
@@ -121,11 +121,30 @@ export function compactor(
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
124
|
+
* Does this message carry a tool result? Under the anthropic dialect a tool result is a
|
|
125
|
+
* `user` message whose content holds `tool_result` blocks, so such a message is a member
|
|
126
|
+
* of a tool group rather than a conversational boundary.
|
|
127
|
+
*/
|
|
128
|
+
function carriesToolResult(m: Message): boolean {
|
|
129
|
+
const content = (m as { content?: unknown }).content
|
|
130
|
+
if (!Array.isArray(content)) return false
|
|
131
|
+
return content.some(
|
|
132
|
+
(b) => typeof b === "object" && b !== null && (b as { type?: unknown }).type === "tool_result",
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A valid tail boundary: a `user` turn that is not carrying a tool result. */
|
|
137
|
+
function isBoundary(m: Message): boolean {
|
|
138
|
+
return m.role === "user" && !carriesToolResult(m)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Locate the index where the retained tail begins. Tool-pair safety requires the tail to
|
|
143
|
+
* start at a `user` turn that is not a tool-result carrier: the largest such tail that
|
|
144
|
+
* still fits `keepTail`. If none fits, fall back to the most recent valid boundary
|
|
127
145
|
* (favoring safety over size). Returns `msgs.length` (no tail) only when there is no
|
|
128
|
-
*
|
|
146
|
+
* valid boundary after the head at all — the whole body is then summarized, which is
|
|
147
|
+
* bounded and cannot orphan anything since nothing is retained.
|
|
129
148
|
*/
|
|
130
149
|
function findTailStart(
|
|
131
150
|
msgs: Message[],
|
|
@@ -136,13 +155,13 @@ function findTailStart(
|
|
|
136
155
|
let split = msgs.length
|
|
137
156
|
for (let i = msgs.length - 1; i > headEnd; i--) {
|
|
138
157
|
if (count(msgs.slice(i)) > keepTail) break
|
|
139
|
-
if (msgs[i]
|
|
158
|
+
if (isBoundary(msgs[i])) split = i
|
|
140
159
|
}
|
|
141
160
|
if (split !== msgs.length) return split
|
|
142
161
|
|
|
143
|
-
// No
|
|
162
|
+
// No boundary fit within keepTail — extend back to the most recent valid one.
|
|
144
163
|
for (let i = msgs.length - 1; i > headEnd; i--) {
|
|
145
|
-
if (msgs[i]
|
|
164
|
+
if (isBoundary(msgs[i])) return i
|
|
146
165
|
}
|
|
147
166
|
return msgs.length
|
|
148
167
|
}
|
package/src/agents/index.ts
CHANGED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop — a live execution of an Agent, and the completion gate that stops it
|
|
3
|
+
* claiming `done` too early. A layer over the shipped §8 client: nothing here
|
|
4
|
+
* changes existing behaviour.
|
|
5
|
+
*
|
|
6
|
+
* The placement law this encodes:
|
|
7
|
+
*
|
|
8
|
+
* AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
|
|
9
|
+
* RunOptions answers "with WHAT?" — model for this call. Per call.
|
|
10
|
+
* Loop answers "DID it?" — status, turns. Observed.
|
|
11
|
+
* none of them answers "is it RIGHT?"— a tool, skill or agent.
|
|
12
|
+
*
|
|
13
|
+
* So Loop takes no options: it is read, not configured.
|
|
14
|
+
*/
|
|
15
|
+
import { createClient, type ClientOptions, type Hooks, type RunResult } from "../client.js"
|
|
16
|
+
import type { Toolkit } from "../toolkit.js"
|
|
17
|
+
import type { Agent } from "./agent.js"
|
|
18
|
+
|
|
19
|
+
/** A POLICY check on a tool call — "may it?", never "is it right?".
|
|
20
|
+
* Return `"allow"` (or nothing) to permit; any other string DENIES with that reason. */
|
|
21
|
+
export type Guardrail = (ev: {
|
|
22
|
+
name: string
|
|
23
|
+
args: Record<string, unknown>
|
|
24
|
+
id?: string
|
|
25
|
+
turn: number
|
|
26
|
+
}) => string | undefined | void
|
|
27
|
+
|
|
28
|
+
/** The verdict a completion verifier returns. */
|
|
29
|
+
export interface Verdict {
|
|
30
|
+
ok: boolean
|
|
31
|
+
reason?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The gate that stops an agent claiming `done` before its work verifies. */
|
|
35
|
+
export interface Completion {
|
|
36
|
+
/** Judges the run. Receives the tool calls ACCUMULATED across attempts. */
|
|
37
|
+
verify: (result: RunResult) => Verdict | Promise<Verdict>
|
|
38
|
+
/** REQUIRED. An unbounded verify loop is a denial-of-service on the caller's bill. */
|
|
39
|
+
maxAttempts: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What varies PER CALL. `model` is here — not on the spec's default and not on the
|
|
43
|
+
* Loop — so the same conversation may change model between turns. */
|
|
44
|
+
export interface LoopRunOptions {
|
|
45
|
+
/** Overrides the agent's model for this call only. Omitted ⇒ the agent's. */
|
|
46
|
+
model?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a run reports. `status` reuses the SHIPPED vocabulary — no new status
|
|
50
|
+
* strings are minted (SPEC.md pins TaskStatus identical across ports). */
|
|
51
|
+
export interface Outcome {
|
|
52
|
+
text: string
|
|
53
|
+
status: "done" | "incomplete" | "pending" | "error"
|
|
54
|
+
/** Named whenever `status` is not `done` — never a silent stop. */
|
|
55
|
+
stoppedBy?: string
|
|
56
|
+
attempts: number
|
|
57
|
+
turns: number
|
|
58
|
+
result?: RunResult
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
|
|
63
|
+
* composed ahead of any hook the spec already set. No guardrails ⇒ the spec's
|
|
64
|
+
* hooks are returned untouched, so absent is byte-identical.
|
|
65
|
+
*/
|
|
66
|
+
export function guardedHooks(guardrails: Guardrail[] | undefined, hooks: Hooks | undefined): Hooks | undefined {
|
|
67
|
+
if (!guardrails || guardrails.length === 0) return hooks
|
|
68
|
+
const prior = hooks?.beforeTool
|
|
69
|
+
return {
|
|
70
|
+
...(hooks ?? {}),
|
|
71
|
+
beforeTool: async (ev) => {
|
|
72
|
+
for (const g of guardrails) {
|
|
73
|
+
const verdict = g(ev)
|
|
74
|
+
if (verdict && verdict !== "allow") {
|
|
75
|
+
return { result: { output: `denied: ${verdict}`, isError: true } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return prior ? prior(ev) : undefined
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The built-in completion verifier. Reads the SHIPPED `todowrite` builtin's
|
|
85
|
+
* result metadata and requires every item to be checked.
|
|
86
|
+
*
|
|
87
|
+
* Structural, not domain: it counts unchecked boxes and never learns what a todo
|
|
88
|
+
* means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒
|
|
89
|
+
* pass, so the gate never punishes an agent that does not use the builtin.
|
|
90
|
+
*/
|
|
91
|
+
export function allTodosDone(result: RunResult): Verdict {
|
|
92
|
+
for (let i = result.toolCalls.length - 1; i >= 0; i--) {
|
|
93
|
+
if (result.toolCalls[i].name !== "todowrite") continue
|
|
94
|
+
const todos = result.toolCalls[i].metadata?.todos
|
|
95
|
+
if (!Array.isArray(todos)) return { ok: true }
|
|
96
|
+
const open = todos
|
|
97
|
+
.filter((t: any) => !t?.completed)
|
|
98
|
+
.map((t: any) => String(t?.text ?? ""))
|
|
99
|
+
return open.length > 0
|
|
100
|
+
? { ok: false, reason: `${open.length} item(s) still open: ${open.join("; ")}` }
|
|
101
|
+
: { ok: true }
|
|
102
|
+
}
|
|
103
|
+
return { ok: true }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wraps a client run with the completion gate. SHARED by the standalone Loop and
|
|
108
|
+
* the §7D runtime turn, so a delegated child gets exactly the same guarantee as a
|
|
109
|
+
* directly-driven one.
|
|
110
|
+
*
|
|
111
|
+
* Rule 2 in force: a run that is `pending` (suspended on a human) or otherwise
|
|
112
|
+
* non-done already carries its own reason, so the gate never re-judges it. That
|
|
113
|
+
* keeps `pending` and `incomplete` distinct — the caller can always tell whether
|
|
114
|
+
* it owes an Answer or a fix.
|
|
115
|
+
*/
|
|
116
|
+
export async function runGated(
|
|
117
|
+
ask: (prompt: string) => Promise<RunResult>,
|
|
118
|
+
prompt: string,
|
|
119
|
+
completion: Completion | undefined,
|
|
120
|
+
): Promise<RunResult> {
|
|
121
|
+
if (!completion) return ask(prompt)
|
|
122
|
+
const { maxAttempts } = completion
|
|
123
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
124
|
+
throw new Error("toolnexus: completion.maxAttempts must be an integer >= 1")
|
|
125
|
+
}
|
|
126
|
+
if (typeof completion.verify !== "function") {
|
|
127
|
+
throw new Error("toolnexus: completion.verify is required")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let accumulated: RunResult["toolCalls"] = []
|
|
131
|
+
let last: RunResult | undefined
|
|
132
|
+
let reason = ""
|
|
133
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
134
|
+
const p = attempt === 1 ? prompt : `Your work did not verify: ${reason}. Fix it and finish.`
|
|
135
|
+
const r = await ask(p)
|
|
136
|
+
// The gate judges the ACCUMULATED work, so an agent cannot escape it by
|
|
137
|
+
// declining to re-declare its plan on a retry.
|
|
138
|
+
accumulated = [...accumulated, ...r.toolCalls]
|
|
139
|
+
const judged: RunResult = { ...r, toolCalls: accumulated }
|
|
140
|
+
last = judged
|
|
141
|
+
|
|
142
|
+
if (r.status && r.status !== "done") {
|
|
143
|
+
// The run stopped for its own reason (suspension, budget). If the gate was
|
|
144
|
+
// mid-retry the caller must learn BOTH — otherwise a budget stop masks the
|
|
145
|
+
// verification failure and they never see why it was looping.
|
|
146
|
+
if (reason && r.status !== "pending") {
|
|
147
|
+
judged.text = `${r.text} [while verifying: attempt ${attempt} last failed: ${reason}]`
|
|
148
|
+
}
|
|
149
|
+
return judged
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const verdict = await completion.verify(judged)
|
|
153
|
+
if (verdict.ok) return judged
|
|
154
|
+
reason = verdict.reason ?? "unspecified"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Structured, not prose: `limit` is how a caller (and the §7D runtime) tells
|
|
158
|
+
// WHICH limit stopped the run. `text` carries the human reason.
|
|
159
|
+
return {
|
|
160
|
+
...(last as RunResult),
|
|
161
|
+
status: "incomplete",
|
|
162
|
+
limit: "completion",
|
|
163
|
+
text: `completion.verify failed ${maxAttempts}×: ${reason}`,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A live execution of an Agent. Its only verbs are `run` and reading state. */
|
|
168
|
+
export class Loop {
|
|
169
|
+
#status: Outcome["status"] | "idle" | "running" = "idle"
|
|
170
|
+
#turns = 0
|
|
171
|
+
|
|
172
|
+
constructor(
|
|
173
|
+
private readonly agent: Agent,
|
|
174
|
+
/** CLIENT OPTIONS rather than a built client, because a per-call `model`
|
|
175
|
+
* override must be able to change the model — which is fixed at construction. */
|
|
176
|
+
private readonly options: ClientOptions,
|
|
177
|
+
private readonly toolkit: Toolkit,
|
|
178
|
+
) {}
|
|
179
|
+
|
|
180
|
+
/** Observed, never set by the caller. */
|
|
181
|
+
get status(): string {
|
|
182
|
+
return this.#status
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Model round trips this loop has spent. */
|
|
186
|
+
get turns(): number {
|
|
187
|
+
return this.#turns
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async run(prompt: string, opts: LoopRunOptions = {}): Promise<Outcome> {
|
|
191
|
+
const completion = this.agent.spec.completion
|
|
192
|
+
const client = createClient(this.#clientOptions(opts))
|
|
193
|
+
this.#status = "running"
|
|
194
|
+
|
|
195
|
+
let attempts = 0
|
|
196
|
+
let history: any[] = []
|
|
197
|
+
const ask = async (p: string): Promise<RunResult> => {
|
|
198
|
+
attempts++
|
|
199
|
+
const r = await client.run(p, { toolkit: this.toolkit, history })
|
|
200
|
+
this.#turns += r.turns
|
|
201
|
+
history = r.messages
|
|
202
|
+
return r
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const r = await runGated(ask, prompt, completion)
|
|
207
|
+
if (r.status && r.status !== "done") {
|
|
208
|
+
this.#status = r.status as Outcome["status"]
|
|
209
|
+
const stoppedBy =
|
|
210
|
+
r.limit === "completion" ? r.text : `run reported ${r.status}`
|
|
211
|
+
return { text: r.text, status: r.status as Outcome["status"], stoppedBy, attempts, turns: this.#turns, result: r }
|
|
212
|
+
}
|
|
213
|
+
this.#status = "idle"
|
|
214
|
+
return { text: r.text, status: "done", attempts, turns: this.#turns, result: r }
|
|
215
|
+
} catch (e) {
|
|
216
|
+
this.#status = "error"
|
|
217
|
+
throw e
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Applies a per-call model override via `requestParams` (`model` is not in the
|
|
222
|
+
* forbidden set — the client forbids only messages/tools/stream). */
|
|
223
|
+
#clientOptions(opts: LoopRunOptions): ClientOptions {
|
|
224
|
+
const base: ClientOptions = {
|
|
225
|
+
...this.options,
|
|
226
|
+
systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
|
|
227
|
+
hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
|
|
228
|
+
}
|
|
229
|
+
if (!opts.model) return base
|
|
230
|
+
return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } }
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/agents/runtime.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* identical per-handle transition traces against `examples/subagent-*` fixtures.
|
|
16
16
|
*/
|
|
17
17
|
import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js"
|
|
18
|
+
import { runGated, type Completion } from "./loop.js"
|
|
18
19
|
import { createToolkit, type Toolkit } from "../toolkit.js"
|
|
19
20
|
import { defineTool } from "../native.js"
|
|
20
21
|
import type { Answer, Request, Tool, ToolResult } from "../types.js"
|
|
@@ -98,6 +99,10 @@ export interface AgentDef {
|
|
|
98
99
|
/** §8 observability sink for THIS agent's client. Set ⇒ REPLACES the runtime-wide
|
|
99
100
|
* `onMetric` for this agent. Resolves independently of `hooks`. Forwarded verbatim. */
|
|
100
101
|
onMetric?: (ev: MetricEvent) => void
|
|
102
|
+
/** §7D completion gate, projected from the agent spec. Present ⇒ this agent's
|
|
103
|
+
* turns run through the gate even when it is a DELEGATED child — which is what a
|
|
104
|
+
* host-side retry loop cannot do. */
|
|
105
|
+
completion?: Completion
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
/** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
|
|
@@ -768,7 +773,13 @@ export class AgentRuntime {
|
|
|
768
773
|
hooks: h.def.hooks ?? this.opts.hooks,
|
|
769
774
|
onMetric: h.def.onMetric ?? this.opts.onMetric,
|
|
770
775
|
})
|
|
771
|
-
|
|
776
|
+
// The gate runs HERE, inside the runtime turn, which is what makes it reach a
|
|
777
|
+
// delegated child: `task` executes the child through this same path.
|
|
778
|
+
const r = await runGated(
|
|
779
|
+
(p) => client.ask(p, { toolkit, id: h.id, signal: h.abort!.signal }),
|
|
780
|
+
input,
|
|
781
|
+
h.def.completion,
|
|
782
|
+
)
|
|
772
783
|
h.turnsTotal += r.turns
|
|
773
784
|
this.rollUp(h, r.usage.totalTokens, r.toolCallCount)
|
|
774
785
|
if (r.status === "pending" && r.pending) {
|
|
@@ -787,7 +798,12 @@ export class AgentRuntime {
|
|
|
787
798
|
h.state = "idle"
|
|
788
799
|
h.drained = []
|
|
789
800
|
this.t(`${h.id}: running→idle (incomplete: ${r.limit ?? "maxTurns"})`)
|
|
790
|
-
|
|
801
|
+
// A completion stop carries its own reason; only a maxTurns stop gets the
|
|
802
|
+
// generic sentence. Reading `limit` is how the runtime tells them apart.
|
|
803
|
+
result = {
|
|
804
|
+
text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
|
|
805
|
+
isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
|
|
806
|
+
}
|
|
791
807
|
} else {
|
|
792
808
|
h.state = "idle"
|
|
793
809
|
h.drained = []
|
package/src/client.ts
CHANGED
|
@@ -114,6 +114,28 @@ export class InMemoryConversationStore implements ConversationStore {
|
|
|
114
114
|
|
|
115
115
|
const RETRYABLE = new Set([429, 500, 502, 503, 504])
|
|
116
116
|
|
|
117
|
+
/** ~68 years; the widest whole-second count all seven ports represent exactly. */
|
|
118
|
+
const RETRY_AFTER_MAX_SECONDS = 2147483647
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
|
|
122
|
+
* (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
|
|
123
|
+
*
|
|
124
|
+
* `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
|
|
125
|
+
* and `" "` (as 0), which is how this port came to wait for fractional seconds
|
|
126
|
+
* that five of the other six rejected outright. Fractional, signed, HTTP-date and
|
|
127
|
+
* out-of-range values are not delays we can honour, so the caller falls back to
|
|
128
|
+
* backoff rather than guessing. `0` is a real answer, so it returns `0`, not
|
|
129
|
+
* `null`, and callers must use `??` rather than `||`.
|
|
130
|
+
*/
|
|
131
|
+
function retryAfterMs(raw: string | null | undefined): number | null {
|
|
132
|
+
if (raw == null) return null
|
|
133
|
+
const s = raw.trim()
|
|
134
|
+
if (!/^[0-9]+$/.test(s)) return null
|
|
135
|
+
const secs = Number(s)
|
|
136
|
+
return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
118
140
|
return new Promise((resolve, reject) => {
|
|
119
141
|
const t = setTimeout(resolve, ms)
|
|
@@ -643,8 +665,9 @@ export class Client {
|
|
|
643
665
|
const retryable = RETRYABLE.has(res.status)
|
|
644
666
|
const tier = classify({ status: res.status, attempt, retryable })
|
|
645
667
|
if (tier === "fail" || attempt === retries) return res // caller surfaces the non-ok status
|
|
646
|
-
const ra =
|
|
647
|
-
|
|
668
|
+
const ra = retryAfterMs(res.headers.get("retry-after"))
|
|
669
|
+
// `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
|
|
670
|
+
await delay(ra ?? base * 2 ** attempt + Math.random() * 100, signal)
|
|
648
671
|
} catch (e) {
|
|
649
672
|
if (signal.aborted) throw e // abort/timeout: don't retry
|
|
650
673
|
lastErr = e
|