@staix/agent-hub 0.12.14 → 0.12.16
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/CHANGELOG.md +17 -0
- package/README.md +16 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/cooperbench.md +17 -1
- package/docs/operations.md +178 -9
- package/docs/quickstart.md +2 -2
- package/docs/security.md +2 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +104 -0
- package/docs/verified.json +159 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -4
- package/src/adapters/codex-appserver.ts +7 -0
- package/src/adapters/pi.ts +28 -19
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +34 -11
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +5 -2
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +126 -32
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +57 -1
- package/src/models/relay.ts +79 -4
- package/src/pi/ceiling.ts +54 -0
- package/src/pi/extension.ts +20 -2
- package/src/pi/launch.ts +20 -0
- package/src/ui/index.html +6 -0
package/src/hub/hub-tools.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
41
41
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
42
42
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
43
43
|
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
44
|
-
tool("hub_checkpoint", "Answer a checkpoint request from the hub (
|
|
44
|
+
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
45
45
|
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
|
|
46
46
|
];
|
|
47
47
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending";
|
|
4
|
+
export interface SweepRecord {
|
|
5
|
+
kind: SweepKind;
|
|
6
|
+
/** Index of the last real activity, distinguishing even events at the same time. */
|
|
7
|
+
activity: number;
|
|
8
|
+
step: 1 | 2 | 3;
|
|
9
|
+
at: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TaskSweepConfig {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
interval_s: number;
|
|
14
|
+
unaccepted_min: number;
|
|
15
|
+
idle_min: number;
|
|
16
|
+
review_min: number;
|
|
17
|
+
ladder_min: number;
|
|
18
|
+
auto_reassign: boolean;
|
|
19
|
+
}
|
|
20
|
+
export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
|
|
21
|
+
enabled: false, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Config cannot silently enable a sweep or reassignment through a truthy string. */
|
|
25
|
+
export function taskSweepConfig(input: unknown): TaskSweepConfig {
|
|
26
|
+
if (input === undefined) return { ...DEFAULT_TASK_SWEEP };
|
|
27
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("task_sweep must be an object");
|
|
28
|
+
const out = { ...DEFAULT_TASK_SWEEP };
|
|
29
|
+
const values = input as Record<string, unknown>;
|
|
30
|
+
for (const key of ["enabled", "auto_reassign"] as const) {
|
|
31
|
+
if (values[key] === undefined) continue;
|
|
32
|
+
if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
|
|
33
|
+
out[key] = values[key];
|
|
34
|
+
}
|
|
35
|
+
for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ladder_min"] as const) {
|
|
36
|
+
if (values[key] === undefined) continue;
|
|
37
|
+
const value = values[key];
|
|
38
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value * (key === "interval_s" ? 1000 : 60_000) > 2_147_483_647) throw new Error(`task_sweep.${key} must be a bounded number of at least 1`);
|
|
39
|
+
out[key] = value;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pure time/kind ladder; Tasks decides whether the responsible peer is available. */
|
|
45
|
+
export function nextSweep(task: Task, config: TaskSweepConfig, now: number): SweepRecord | undefined {
|
|
46
|
+
if (!config.enabled || !Number.isFinite(now)) return undefined;
|
|
47
|
+
const kind = task.state === "proposed" && task.owner ? "unaccepted-assignment"
|
|
48
|
+
: task.state === "in_progress" && task.owner ? "idle-owner"
|
|
49
|
+
: task.state === "in_review" && task.reviewer ? "review-pending" : undefined;
|
|
50
|
+
if (!kind) return undefined;
|
|
51
|
+
let activity = task.history.length - 1;
|
|
52
|
+
while (activity >= 0 && task.history[activity]?.sweep) activity--;
|
|
53
|
+
const at = task.history[activity]?.at ?? task.created;
|
|
54
|
+
const threshold = kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
|
|
55
|
+
if (now - at < threshold * 60_000) return undefined;
|
|
56
|
+
const previous = task.history.flatMap((h) => h.sweep?.kind === kind && h.sweep.activity === activity ? [h.sweep] : []).at(-1);
|
|
57
|
+
if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;
|
|
58
|
+
return { kind, activity, step: previous ? (previous.step + 1) as 2 | 3 : 1, at: now };
|
|
59
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type R
|
|
|
8
8
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
9
9
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
10
10
|
import { realPath } from "./project.ts";
|
|
11
|
+
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
11
12
|
|
|
12
13
|
export interface TasksDeps {
|
|
13
14
|
board: Board;
|
|
@@ -68,6 +69,9 @@ export interface TasksDeps {
|
|
|
68
69
|
recordSplit?: (task: number, prediction: SplitPrediction, where: "routing" | "cohort") => void;
|
|
69
70
|
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
70
71
|
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
72
|
+
sweep?: TaskSweepConfig;
|
|
73
|
+
/** Shutdown or coordinator work holds task sweeps too. */
|
|
74
|
+
sweepHeld?: () => boolean;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
const ESCALATE_AFTER = 2;
|
|
@@ -80,7 +84,7 @@ const DEMOTE_AT = 1.5;
|
|
|
80
84
|
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
81
85
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
82
86
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
83
|
-
const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
|
|
87
|
+
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
|
|
84
88
|
|
|
85
89
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
86
90
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
@@ -134,8 +138,11 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
|
|
|
134
138
|
export class Tasks {
|
|
135
139
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
136
140
|
readonly cohorts: Cohorts;
|
|
141
|
+
private readonly sweepConfig: TaskSweepConfig;
|
|
142
|
+
private sweeping = false;
|
|
137
143
|
|
|
138
144
|
constructor(private readonly d: TasksDeps) {
|
|
145
|
+
this.sweepConfig = taskSweepConfig(d.sweep);
|
|
139
146
|
this.cohorts = new Cohorts({
|
|
140
147
|
silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
|
|
141
148
|
idle: (peer) => this.d.idle?.(peer) ?? false,
|
|
@@ -157,6 +164,55 @@ export class Tasks {
|
|
|
157
164
|
|
|
158
165
|
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
159
166
|
|
|
167
|
+
/** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
|
|
168
|
+
async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
|
|
169
|
+
const recorded: { task: number; finding: SweepRecord }[] = [];
|
|
170
|
+
if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
|
|
171
|
+
this.sweeping = true;
|
|
172
|
+
try {
|
|
173
|
+
for (const task of this.d.board.list()) {
|
|
174
|
+
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
175
|
+
const finding = nextSweep(task, this.sweepConfig, now);
|
|
176
|
+
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
177
|
+
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
178
|
+
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
179
|
+
const title = this.publicTitle(task);
|
|
180
|
+
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
181
|
+
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
182
|
+
// leaves the notice unpublished or uncertain; the durable-delivery journal owns any delivery retries.
|
|
183
|
+
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
184
|
+
recorded.push({ task: task.id, finding });
|
|
185
|
+
if (finding.step === 1) {
|
|
186
|
+
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
|
|
187
|
+
} else if (finding.step === 2) {
|
|
188
|
+
this.d.notify(message);
|
|
189
|
+
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
|
|
190
|
+
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
const reviewer = finding.kind === "review-pending";
|
|
194
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
195
|
+
const candidate = reviewer ? a.reviewer : a.owner;
|
|
196
|
+
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
197
|
+
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
198
|
+
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally { this.sweeping = false; }
|
|
203
|
+
return recorded;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private sweepAvailable(peer: PeerId): boolean {
|
|
207
|
+
const bus = this.d.bus;
|
|
208
|
+
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()[peer] && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
212
|
+
if (peer === USER) this.d.notify(body);
|
|
213
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
161
217
|
if (!this.d.executionBudget) return undefined;
|
|
162
218
|
if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
|
package/src/models/relay.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
import type { OmniRoute, ChatMessage } from "../omniroute/client.ts";
|
|
4
4
|
import { ensureMlx, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
|
|
@@ -32,7 +32,9 @@ export interface ModelRelayStatus {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Sanitized per-request identity and lifecycle evidence. One record per upstream dispatch attempt
|
|
35
|
-
* (a fallback dispatch is its own record). Records carry no messages,
|
|
35
|
+
* (a fallback dispatch is its own record). Records carry no messages, keys or Access headers; with the
|
|
36
|
+
* opt-in `observeToolSurface` they additionally carry the bounded `toolSurface` projection (allowlist-
|
|
37
|
+
* charset names, a count and an opaque schema hash — never descriptions, arguments or message text).
|
|
36
38
|
* `identified: false` with `outcome: "cancelled"` is the cancelled-before-identification state; an
|
|
37
39
|
* observed `actualModel` that differs from the trusted expected served model sets `mismatch`.
|
|
38
40
|
* Without an explicit expectation, the upstream-configured identifier remains the comparison default.
|
|
@@ -47,6 +49,63 @@ export interface RelayUsageObservation {
|
|
|
47
49
|
totalTokens?: number;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The bounded tool-surface projection of one request's published `tools` array (#183): what the native
|
|
54
|
+
* declared to the model, reduced to allowlist-charset names (sorted, deduplicated), the raw entry count
|
|
55
|
+
* and an opaque full structural JSON-schema fingerprint. Schema annotations are omitted, while types,
|
|
56
|
+
* constraints and literal default/const/enum values are hashed only, never exported.
|
|
57
|
+
*/
|
|
58
|
+
export interface RelayToolSurface {
|
|
59
|
+
count: number;
|
|
60
|
+
names: string[];
|
|
61
|
+
schemaSha256: string;
|
|
62
|
+
invalidEntries: number;
|
|
63
|
+
duplicateNames: number;
|
|
64
|
+
truncated: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SURFACE_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
68
|
+
const SURFACE_CAP = 64;
|
|
69
|
+
const SCHEMA_ANNOTATIONS = new Set(["description", "title", "examples", "$comment"]);
|
|
70
|
+
const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependentRequired"]);
|
|
71
|
+
|
|
72
|
+
/** Structural JSON-schema fingerprint, including types, constraints and nested shapes. Annotation text is
|
|
73
|
+
* omitted; all remaining values enter only the opaque hash. Resource ceilings fail explicitly, never silently
|
|
74
|
+
* qualify a partial schema. */
|
|
75
|
+
export function toolSurfaceProjection(tools: unknown): RelayToolSurface | undefined {
|
|
76
|
+
if (!Array.isArray(tools)) return undefined;
|
|
77
|
+
let invalidEntries = 0, duplicateNames = 0, truncated = tools.length > SURFACE_CAP, nodes = 0;
|
|
78
|
+
const names = new Set<string>();
|
|
79
|
+
const canonical = (value: unknown, depth = 0, keyword = "", schemaMap = false, literal = false): unknown => {
|
|
80
|
+
if (++nodes > 8192 || depth > 20) { truncated = true; return null; }
|
|
81
|
+
if (value === null || typeof value === "boolean") return value;
|
|
82
|
+
if (typeof value === "number") { if (Number.isFinite(value)) return value; invalidEntries++; return null; }
|
|
83
|
+
if (typeof value === "string") { if (value.length > 8192) { truncated = true; return null; } return value; }
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
if (value.length > 256) truncated = true;
|
|
86
|
+
const result = value.slice(0, 256).map((v) => canonical(v, depth + 1, "", false, literal || keyword === "enum"));
|
|
87
|
+
return !literal && (keyword === "required" || keyword === "enum" || keyword === "type") ? result.sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; }) : result;
|
|
88
|
+
}
|
|
89
|
+
if (value && typeof value === "object") {
|
|
90
|
+
const keys = Object.keys(value).filter((k) => literal || schemaMap || !SCHEMA_ANNOTATIONS.has(k)).sort();
|
|
91
|
+
if (keys.length > 256) truncated = true;
|
|
92
|
+
return Object.fromEntries(keys.slice(0, 256).map((k) => [k, canonical((value as Record<string, unknown>)[k], depth + 1, schemaMap ? "" : k, !literal && !schemaMap && SCHEMA_MAPS.has(k), literal || (!schemaMap && ["default", "const", "enum"].includes(k)))]));
|
|
93
|
+
}
|
|
94
|
+
invalidEntries++; return null;
|
|
95
|
+
};
|
|
96
|
+
const schema: { name: string; parameters: unknown }[] = [];
|
|
97
|
+
for (const entry of tools.slice(0, SURFACE_CAP)) {
|
|
98
|
+
const e = entry as { function?: unknown; name?: unknown; parameters?: unknown } | null;
|
|
99
|
+
const fn = (e && typeof e === "object" && e.function && typeof e.function === "object" ? e.function : e) as { name?: unknown; parameters?: unknown } | null;
|
|
100
|
+
const name = typeof fn?.name === "string" && SURFACE_NAME.test(fn.name) ? fn.name : "";
|
|
101
|
+
if (!name || !fn?.parameters || typeof fn.parameters !== "object" || Array.isArray(fn.parameters)) invalidEntries++;
|
|
102
|
+
if (name) { if (names.has(name)) duplicateNames++; names.add(name); }
|
|
103
|
+
schema.push({ name, parameters: canonical(fn?.parameters ?? null) });
|
|
104
|
+
}
|
|
105
|
+
schema.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
106
|
+
return { count: tools.length, names: [...names].sort(), schemaSha256: createHash("sha256").update(JSON.stringify(schema)).digest("hex"), invalidEntries, duplicateNames, truncated };
|
|
107
|
+
}
|
|
108
|
+
|
|
50
109
|
export interface RelayRequestRecord {
|
|
51
110
|
id: string;
|
|
52
111
|
dispatchGroupId?: string;
|
|
@@ -68,6 +127,8 @@ export interface RelayRequestRecord {
|
|
|
68
127
|
usageAvailability?: "known" | "partial" | "missing" | "invalid";
|
|
69
128
|
/** Observed served model; never read back from the backend's mutable last label. */
|
|
70
129
|
actualModel?: string;
|
|
130
|
+
/** Bounded tool-surface projection of the request's published tools; only when the owner opted in. */
|
|
131
|
+
toolSurface?: RelayToolSurface;
|
|
71
132
|
identitySource: "header" | "stream" | "configured" | "none";
|
|
72
133
|
// ponytail: the relay cannot see native turn structure, so role stays "unknown"; a native surface
|
|
73
134
|
// that knows primary vs auxiliary work (benchmark wiring, issue #140) is the upgrade path.
|
|
@@ -106,6 +167,9 @@ export interface ModelRelayOptions {
|
|
|
106
167
|
/** Include explicit missing usage/provider metadata and dispatch groups, independently of observers.
|
|
107
168
|
* Omission preserves the legacy absent-metadata journal schema. */
|
|
108
169
|
observeRequestMetadata?: boolean;
|
|
170
|
+
/** Journal each request's bounded tool-surface projection (#183). Omission preserves the no-tools
|
|
171
|
+
* journal contract. */
|
|
172
|
+
observeToolSurface?: boolean;
|
|
109
173
|
/** Called exactly once per journaled request, at its terminal close, with a sanitized copy. */
|
|
110
174
|
onRequest?: (record: RelayRequestRecord) => void;
|
|
111
175
|
}
|
|
@@ -258,7 +322,12 @@ function sseResponse(response: Response, release: () => void, onModel?: (model:
|
|
|
258
322
|
});
|
|
259
323
|
}
|
|
260
324
|
|
|
261
|
-
const copyRecord = (record: RelayRequestRecord): RelayRequestRecord => ({ ...record, ...(record.requestUsage ? { requestUsage: { ...record.requestUsage } } : {}) });
|
|
325
|
+
const copyRecord = (record: RelayRequestRecord): RelayRequestRecord => ({ ...record, ...(record.requestUsage ? { requestUsage: { ...record.requestUsage } } : {}), ...(record.toolSurface ? { toolSurface: { ...record.toolSurface, names: [...record.toolSurface.names] } } : {}) });
|
|
326
|
+
|
|
327
|
+
/** Pure advertised alias list, also used by no-start launch previews. */
|
|
328
|
+
export function relayModelIds(options: Pick<ModelRelayOptions, "enableHubAuto" | "mlx" | "mlxAlias" | "allowedDGXmodels">): string[] {
|
|
329
|
+
return [...new Set([...(options.enableHubAuto ? ["hub/auto"] : []), ...(options.mlx ? [options.mlxAlias ?? "mlx/fast"] : []), ...Object.keys(options.allowedDGXmodels)])];
|
|
330
|
+
}
|
|
262
331
|
|
|
263
332
|
export async function startModelRelay(options: ModelRelayOptions): Promise<ModelRelay> {
|
|
264
333
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -267,7 +336,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
267
336
|
const mlxAlias = options.mlxAlias ?? "mlx/fast";
|
|
268
337
|
const dgxMaxInputTokens = options.dgxMaxInputTokens ?? 262_144;
|
|
269
338
|
const defaultBackend = options.defaultBackend ?? (options.mlx ? { kind: "mlx", alias: mlxAlias } : { kind: "dgx", alias: "dgx/coding" });
|
|
270
|
-
const models =
|
|
339
|
+
const models = relayModelIds(options);
|
|
271
340
|
const autoRoute = options.enableHubAuto ? new AutoRouteSelector(options, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
|
|
272
341
|
let mlx: MlxHandle | undefined;
|
|
273
342
|
let mlxStarting: Promise<MlxHandle> | undefined;
|
|
@@ -482,6 +551,12 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
482
551
|
let primaryDispatchId: string | undefined;
|
|
483
552
|
const dispatch = async (selected: ModelBackend, body: RelayRequest) => {
|
|
484
553
|
const journalEntry = openRequestRecord(aliasOf(selected, mlxAlias), dispatchGroupId);
|
|
554
|
+
// The surface is a property of the request as admitted: even a failed dispatch keeps what the
|
|
555
|
+
// native published. A request without a tools array carries no observation, never an empty one.
|
|
556
|
+
if (options.observeToolSurface) {
|
|
557
|
+
const surface = toolSurfaceProjection(body.tools);
|
|
558
|
+
if (surface) journalEntry.record.toolSurface = surface;
|
|
559
|
+
}
|
|
485
560
|
if (primaryDispatchId) {
|
|
486
561
|
journalEntry.record.fallbackOfId = primaryDispatchId;
|
|
487
562
|
journalEntry.record.dispatchGroupId = dispatchGroupId;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pi tool-step ceiling signal (#179): the one structured record of the hub extension refusing a
|
|
3
|
+
* managed tool call at its configured step limit. The trusted extension emits it over the bridge at the
|
|
4
|
+
* actual rejection boundary; the adapter validates it here before it can classify anything. Free text
|
|
5
|
+
* that merely resembles a ceiling never parses, and an unparsable or unbound signal leaves the failure
|
|
6
|
+
* unknown, exactly as before #179.
|
|
7
|
+
*
|
|
8
|
+
* Counter semantics (the producer's contract):
|
|
9
|
+
* - unit `tool-step`: one admitted managed-tool execution attempt — the extension's `execute` entry for
|
|
10
|
+
* a hub-registered tool, after execution-budget admission succeeds and before the tool runs.
|
|
11
|
+
* - The rejected pre-effect invocation IS counted: the counter increments at that boundary, before the
|
|
12
|
+
* limit check, so the first rejection reports `count = limit + 1` and the refused call's side effect
|
|
13
|
+
* never executes.
|
|
14
|
+
* - Failed tool calls count: the counter does not observe the outcome. A counted call is an execution
|
|
15
|
+
* attempt, never a proven successful effect.
|
|
16
|
+
* - Calls refused by execution-budget admission (#102) do NOT count: admission precedes the counter.
|
|
17
|
+
* - Reset: `agent_start` zeroes the counter and opens a new turn generation; it never resets mid-turn
|
|
18
|
+
* and never carries across turns.
|
|
19
|
+
*
|
|
20
|
+
* The signal carries fixed enums, counts and identity only — never tool arguments, paths, responses or
|
|
21
|
+
* error text.
|
|
22
|
+
*/
|
|
23
|
+
export const PI_CEILING_KIND = "tool-step-ceiling" as const;
|
|
24
|
+
export const PI_CEILING_UNIT = "tool-step" as const;
|
|
25
|
+
|
|
26
|
+
export interface PiToolStepCeiling {
|
|
27
|
+
kind: typeof PI_CEILING_KIND;
|
|
28
|
+
unit: typeof PI_CEILING_UNIT;
|
|
29
|
+
/** The producer's counter after counting the rejected pre-effect invocation: always > limit. */
|
|
30
|
+
count: number;
|
|
31
|
+
/** The configured ceiling (AGENTHUB_PI_MAX_STEPS). */
|
|
32
|
+
limit: number;
|
|
33
|
+
/** The extension's session id and turn generation the rejection belongs to; the adapter binds both. */
|
|
34
|
+
sessionId: string;
|
|
35
|
+
generation: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse and validate a bridge ceiling event. Shape only — finite nonnegative safe-integer counts with
|
|
40
|
+
* count > limit (a rejection means the counted counter passed the configured limit), the fixed kind and
|
|
41
|
+
* unit, a non-empty bounded session id and a nonnegative turn generation. Session/turn binding to the
|
|
42
|
+
* current adapter state is the caller's check, not this function's.
|
|
43
|
+
*/
|
|
44
|
+
export function piToolStepCeiling(value: unknown): PiToolStepCeiling | undefined {
|
|
45
|
+
if (!value || typeof value !== "object") return undefined;
|
|
46
|
+
const e = value as Record<string, unknown>;
|
|
47
|
+
if (e.kind !== PI_CEILING_KIND || e.unit !== PI_CEILING_UNIT) return undefined;
|
|
48
|
+
if (!Number.isSafeInteger(e.count) || (e.count as number) < 1) return undefined;
|
|
49
|
+
if (!Number.isSafeInteger(e.limit) || (e.limit as number) < 0) return undefined;
|
|
50
|
+
if ((e.count as number) <= (e.limit as number)) return undefined;
|
|
51
|
+
if (typeof e.sessionId !== "string" || !e.sessionId || e.sessionId.length > 200) return undefined;
|
|
52
|
+
if (!Number.isSafeInteger(e.generation) || (e.generation as number) < 0) return undefined;
|
|
53
|
+
return { kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: e.count as number, limit: e.limit as number, sessionId: e.sessionId, generation: e.generation as number };
|
|
54
|
+
}
|
package/src/pi/extension.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assistantTokens } from "./usage.ts";
|
|
2
2
|
import { processSignature } from "./process-signature.ts";
|
|
3
|
+
import { PI_CEILING_KIND, PI_CEILING_UNIT } from "./ceiling.ts";
|
|
3
4
|
type ExtensionAPI = any;
|
|
4
5
|
type BudgetUnit = "model_calls" | "tool_calls";
|
|
5
6
|
|
|
@@ -20,6 +21,7 @@ let lastActivity = 0;
|
|
|
20
21
|
let usageSeq = 0;
|
|
21
22
|
let forcedFailure = "";
|
|
22
23
|
let turnGeneration = 0;
|
|
24
|
+
let sessionId = "";
|
|
23
25
|
const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
|
|
24
26
|
let shutdown: (() => void) | undefined;
|
|
25
27
|
let runtimeCtx: any;
|
|
@@ -103,6 +105,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
103
105
|
modelRegistry = ctx.modelRegistry;
|
|
104
106
|
shutdown = ctx.shutdown;
|
|
105
107
|
const state = ctx.sessionManager.getHeader();
|
|
108
|
+
sessionId = String(state?.id ?? "");
|
|
106
109
|
try {
|
|
107
110
|
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
108
111
|
if (claimed?.ok === false) { ctx.shutdown?.(); return; }
|
|
@@ -163,9 +166,24 @@ export default function(pi: ExtensionAPI): void {
|
|
|
163
166
|
if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
|
|
164
167
|
pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
|
|
165
168
|
if (!(await admitBudget("tool_calls")).allowed) return { content: [{ type: "text", text: `error: ${forcedFailure}` }], details: {}, isError: true };
|
|
166
|
-
if (toolSteps++ >= maxSteps) {
|
|
169
|
+
if (toolSteps++ >= maxSteps) {
|
|
170
|
+
const reason = `Pi tool step limit ${maxSteps} reached`;
|
|
171
|
+
forcedFailure = reason;
|
|
172
|
+
// #179: the structured ceiling signal at the actual rejection boundary, before the failure
|
|
173
|
+
// event, so the adapter binds it while the turn still owns it. The counter already counts the
|
|
174
|
+
// rejected pre-effect invocation (toolSteps++ above); its side effect never executes. The
|
|
175
|
+
// reason text stays the failure record; the signal adds the validated counts.
|
|
176
|
+
try { await post("/event", { type: "ceiling", kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: toolSteps, limit: maxSteps, sessionId, generation: turnGeneration }); } catch { /* the agent_end failure below remains authoritative */ }
|
|
177
|
+
await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason });
|
|
178
|
+
runtimeCtx?.abort?.();
|
|
179
|
+
return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true };
|
|
180
|
+
}
|
|
167
181
|
const result = await post("/tool", { name: raw.name, args: params, toolCallId });
|
|
168
|
-
|
|
182
|
+
const text = String(result.text ?? result);
|
|
183
|
+
// Pi 1.0.1 reads isError === true alone (#181). The bridge computes `failed` with the
|
|
184
|
+
// managed-tool failure contract (toolResultFailed); the extension never re-parses the text.
|
|
185
|
+
// A bridge too old to send `failed` leaves the result unflagged, exactly as before the fix.
|
|
186
|
+
return { content: [{ type: "text", text }], details: {}, isError: result.failed === true };
|
|
169
187
|
} });
|
|
170
188
|
}
|
|
171
189
|
}
|
package/src/pi/launch.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { PiOptions, PiModelDescriptor, PiTuiLaunch } from "../adapters/pi.ts";
|
|
3
|
+
|
|
4
|
+
const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
|
|
5
|
+
const ids = models.map((m) => m.id);
|
|
6
|
+
const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
|
|
7
|
+
if (backend === "mlx") return find("mlx/fast");
|
|
8
|
+
if (backend === "dgx") return find("dgx/coding");
|
|
9
|
+
if (ids.includes("hub/auto")) return "hub/auto";
|
|
10
|
+
return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Pure native launch; bridge/relay setup and session validation remain with the adapter. */
|
|
14
|
+
export function buildPiLaunch(opts: Pick<PiOptions, "stateDir" | "cmd" | "mode" | "backend" | "model" | "relay" | "tools" | "preamble" | "maxSteps" | "sessionFile" | "sessionId">, inherited: NodeJS.ProcessEnv, extension: string, bridgePort: number | string, bridgeToken: string): PiTuiLaunch {
|
|
15
|
+
const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, AGENTHUB_PI_BRIDGE_TOKEN: bridgeToken, AGENTHUB_PI_OWNER_TOKEN: bridgeToken, AGENTHUB_PI_RELAY_URL: opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(opts.tools), AGENTHUB_PI_MAX_STEPS: String(opts.maxSteps ?? 30) };
|
|
16
|
+
const args = [ ...(opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", opts.model ?? modelFor(opts.backend, opts.relay.models), "--models", opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(opts.preamble ? ["--append-system-prompt", opts.preamble] : []), "--session-dir", join(opts.stateDir, "pi-sessions")];
|
|
17
|
+
if (opts.sessionFile) args.push("--session", opts.sessionFile); else if (opts.sessionId) args.push("--session-id", opts.sessionId);
|
|
18
|
+
const command = opts.cmd ?? ["pi"];
|
|
19
|
+
return { cmd: command[0]!, args: [...command.slice(1), ...args], env };
|
|
20
|
+
}
|
package/src/ui/index.html
CHANGED
|
@@ -205,6 +205,12 @@ function render(snapshot) {
|
|
|
205
205
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
206
206
|
for (const [peer, budget] of Object.entries(snapshot.budget)) {
|
|
207
207
|
const row = el('div', undefined, 'row'); row.append(el('div', peer, 'label'));
|
|
208
|
+
const context = budget.context;
|
|
209
|
+
if (context) {
|
|
210
|
+
const pct = context.used === null ? null : Math.round(context.used * 100);
|
|
211
|
+
row.append(el('p', 'Context: ' + (pct === null ? 'unknown' : pct + '% used') + ' (' + context.freshness + ')' + (context.source ? ' / ' + context.source : '') + (context.measuredAt !== null ? ' / measured ' + time(context.measuredAt) : ''), 'meta'));
|
|
212
|
+
if (pct !== null) { const bar = el('progress'); bar.max = 1; bar.value = context.used; bar.setAttribute('aria-label', peer + ' context usage'); row.append(bar); }
|
|
213
|
+
}
|
|
208
214
|
if (budget.paused) { const line = el('p', 'Paused: ' + budget.paused.reason + ' / resets ', 'meta'), stamp = el('span'); stamp.dataset.ts = budget.paused.resetsAt; stamp.title = time(budget.paused.resetsAt); stamp.textContent = rel(budget.paused.resetsAt); line.append(stamp); row.append(line); }
|
|
209
215
|
if (!budget.windows.length) row.append(el('p', 'No usage windows reported.', 'meta'));
|
|
210
216
|
for (const window of budget.windows) {
|