@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.
@@ -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 (your quota window is nearly used up): 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 }, ["summary"]),
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`);
@@ -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, tools, keys or Access headers.
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 = [...new Set([...(options.enableHubAuto ? ["hub/auto"] : []), ...(options.mlx ? [mlxAlias] : []), ...Object.keys(options.allowedDGXmodels)])];
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
+ }
@@ -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) { const reason = `Pi tool step limit ${maxSteps} reached`; forcedFailure = reason; await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason }); runtimeCtx?.abort?.(); return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true }; }
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
- return { content: [{ type: "text", text: String(result.text ?? result) }], details: {} };
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
  }
@@ -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) {