@staix/agent-hub 0.12.2 → 0.12.3

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.
@@ -14,6 +14,8 @@ export interface ToolContext {
14
14
  sandboxProfile: string;
15
15
  /** Extra environment for sandboxed commands: the egress proxy's variables when network goes through it (#65). */
16
16
  sandboxEnv?: Record<string, string>;
17
+ /** Turn cancellation signal, including the execution-budget wall cap. */
18
+ signal?: AbortSignal;
17
19
  /** Ask the console. Resolves false on deny or timeout. */
18
20
  permit: (title: string) => Promise<boolean>;
19
21
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -106,6 +108,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
106
108
  return "error: tool arguments were not valid JSON";
107
109
  }
108
110
  try {
111
+ if (ctx.signal?.aborted) return "error: turn cancelled before tool execution";
109
112
  switch (name) {
110
113
  case "read": {
111
114
  const file = guardPath(ctx, String(a.path), "read");
@@ -119,6 +122,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
119
122
  let file = guardPath(ctx, String(a.path), "write");
120
123
  const content = String(a.content ?? "");
121
124
  if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`))) return "error: the user did not approve this write";
125
+ if (ctx.signal?.aborted) return "error: turn cancelled before write";
122
126
  file = guardPath(ctx, String(a.path), "write");
123
127
  mkdirSync(dirname(file), { recursive: true });
124
128
  writeFileSync(file, content);
@@ -130,6 +134,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
130
134
  let count = text.split(String(a.old)).length - 1;
131
135
  if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
132
136
  if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`))) return "error: the user did not approve this edit";
137
+ if (ctx.signal?.aborted) return "error: turn cancelled before edit";
133
138
  // Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
134
139
  file = guardPath(ctx, String(a.path), "write");
135
140
  text = readFileSync(file, "utf8");
@@ -144,7 +149,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
144
149
  // The approver sees the whole command, not a prefix: what is hidden cannot be approved.
145
150
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
146
151
  if (!(await ctx.permit(`bash: ${command}`))) return "error: the user did not approve this command";
147
- const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
152
+ const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
148
153
  return `${res.output}\n(exit ${res.code})`;
149
154
  }
150
155
  case "git": {
@@ -155,7 +160,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
155
160
  if (problem) return `error: git ${sub}: ${problem}`;
156
161
  if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`))) return "error: the user did not approve this git command";
157
162
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
158
- const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
163
+ const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
159
164
  return `${res.output}\n(exit ${res.code})`;
160
165
  }
161
166
  case "hub_send":
@@ -32,6 +32,8 @@ export interface ModelRelayStatus {
32
32
 
33
33
  export interface ModelRelayOptions {
34
34
  omni: OmniRoute;
35
+ /** Authoritative admission immediately before each upstream request, including fallbacks. */
36
+ admitRequest?: () => Promise<{ allowed: boolean; reason?: string; remainingMs?: number }>;
35
37
  host?: string;
36
38
  port?: number;
37
39
  token?: string;
@@ -60,6 +62,8 @@ interface ActiveRequest {
60
62
  cleanup: () => void;
61
63
  }
62
64
 
65
+ class ExecutionAdmissionError extends Error {}
66
+
63
67
  const safeHeader = (value: string | null): string | undefined => value && value.length < 256 ? value : undefined;
64
68
 
65
69
  function assertLoopback(host: string): void {
@@ -111,8 +115,9 @@ function sseResponse(response: Response, release: () => void, onModel?: (model:
111
115
  for (const line of lines) {
112
116
  if (!line.startsWith("data:") || line.slice(5).trim() === "[DONE]") continue;
113
117
  try {
114
- const value = JSON.parse(line.slice(5).trim()) as { model?: unknown };
115
- if (typeof value.model === "string" && value.model.length < 256) {
118
+ const value = JSON.parse(line.slice(5).trim()) as { model?: unknown; choices?: unknown[] };
119
+ // Gateway heartbeat events can name a synthetic "keepalive" model with no choices.
120
+ if (Array.isArray(value.choices) && value.choices.length && typeof value.model === "string" && value.model.length < 256) {
116
121
  inspectedModel = true;
117
122
  onModel(value.model);
118
123
  return;
@@ -220,7 +225,10 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
220
225
  const boundedRequest = isOllama
221
226
  ? { ...request, max_tokens: request.max_tokens ?? options.mlx!.maxTokens ?? 2048, reasoning_effort: request.reasoning_effort ?? "none" }
222
227
  : request;
223
- response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", ...(isOllama ? { redirect: "error" as const } : {}), headers, body: JSON.stringify(bodyForUpstream(boundedRequest, model)), signal: AbortSignal.any([signal, AbortSignal.timeout(180_000)]) });
228
+ const decision = await options.admitRequest?.();
229
+ if (decision && !decision.allowed) throw new ExecutionAdmissionError(decision.reason ?? "execution budget exhausted");
230
+ const deadline = decision?.remainingMs === undefined ? 180_000 : Math.max(1, Math.min(180_000, decision.remainingMs));
231
+ response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", ...(isOllama ? { redirect: "error" as const } : {}), headers, body: JSON.stringify(bodyForUpstream(boundedRequest, model)), signal: AbortSignal.any([signal, AbortSignal.timeout(deadline)]) });
224
232
  } catch (error) {
225
233
  releaseOnce();
226
234
  setState(backend, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "upstream request failed" });
@@ -307,7 +315,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
307
315
  record.release = release;
308
316
  return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; });
309
317
  } catch (error) {
310
- if (!fallback || controller.signal.aborted) {
318
+ if (!fallback || controller.signal.aborted || error instanceof ExecutionAdmissionError) {
311
319
  record.cleanup();
312
320
  activeRequests.delete(record);
313
321
  return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 502 });
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "./usage.ts";
2
3
 
3
4
  export interface OmniRouteConfig {
4
5
  /** Candidate base URLs ending in /v1, in order of preference. `AGENTHUB_OMNIROUTE_URL` replaces the list. */
@@ -26,6 +27,10 @@ export interface ChatMessage {
26
27
  }
27
28
  export interface ChatResult {
28
29
  message: ChatMessage;
30
+ /** Usage counters returned by the provider, when valid. No missing counter is inferred as zero. */
31
+ usage?: NormalizedUsage;
32
+ /** Model named by the completed response, when the gateway reports it. */
33
+ servedModel?: string;
29
34
  /** `x-omniroute-provider` (direct path) */
30
35
  provider?: string;
31
36
  /** `x-model-router-selected-model` (through Switchyard) */
@@ -171,16 +176,22 @@ export class OmniRoute {
171
176
  throw e;
172
177
  }
173
178
  if (!res.ok) throw new Error(`model call failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
174
- const json = (await res.json()) as { choices?: { message?: ChatMessage }[] };
179
+ const json = (await res.json()) as { choices?: { message?: ChatMessage }[]; usage?: unknown; model?: unknown };
175
180
  const message = json.choices?.[0]?.message;
176
181
  if (!message) throw new Error("model call returned no message");
177
182
  const provider = res.headers.get("x-omniroute-provider") ?? undefined;
178
183
  const selectedModel = res.headers.get("x-model-router-selected-model") ?? undefined;
184
+ const servedModel = safeModelLabel(json.model) ?? safeModelLabel(selectedModel);
185
+ const safeProvider = safeModelLabel(provider);
186
+ const safeSelectedModel = safeModelLabel(selectedModel);
187
+ const usage = normalizeUsage(json.usage);
179
188
  // reasoning_content and other extras are dropped here: only role, content and tool_calls travel on.
180
189
  return {
181
190
  message: { role: "assistant", content: message.content ?? null, ...(message.tool_calls?.length ? { tool_calls: message.tool_calls } : {}) },
182
- ...(provider ? { provider } : {}),
183
- ...(selectedModel ? { selectedModel } : {}),
191
+ ...(usage ? { usage } : {}),
192
+ ...(safeProvider ? { provider: safeProvider } : {}),
193
+ ...(safeSelectedModel ? { selectedModel: safeSelectedModel } : {}),
194
+ ...(servedModel ? { servedModel } : {}),
184
195
  };
185
196
  }
186
197
  }
@@ -0,0 +1,38 @@
1
+ /** Provider usage is optional. Missing counters stay missing; they are never guessed as zero. */
2
+ export interface NormalizedUsage {
3
+ inputTokens?: number;
4
+ outputTokens?: number;
5
+ cacheReadTokens?: number;
6
+ cacheWriteTokens?: number;
7
+ totalTokens?: number;
8
+ }
9
+
10
+ const count = (value: unknown): number | undefined =>
11
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
12
+
13
+ /** Accept known OpenAI-compatible and Anthropic-compatible counter spellings, dropping malformed fields. */
14
+ export function normalizeUsage(value: unknown): NormalizedUsage | undefined {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
16
+ const raw = value as Record<string, unknown>;
17
+ const inputTokens = count(raw.input_tokens) ?? count(raw.prompt_tokens) ?? count(raw.inputTokens);
18
+ const outputTokens = count(raw.output_tokens) ?? count(raw.completion_tokens) ?? count(raw.outputTokens);
19
+ const cacheReadTokens = count(raw.cache_read_input_tokens) ?? count(raw.cached_read_tokens) ?? count(raw.cacheReadTokens)
20
+ ?? count((raw.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens);
21
+ const cacheWriteTokens = count(raw.cache_creation_input_tokens) ?? count(raw.cache_write_tokens) ?? count(raw.cacheWriteTokens);
22
+ const totalTokens = count(raw.total_tokens) ?? count(raw.totalTokens);
23
+ const result = {
24
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
25
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
26
+ ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
27
+ ...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
28
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
29
+ };
30
+ return Object.keys(result).length ? result : undefined;
31
+ }
32
+
33
+ /** Model/provider labels are metadata, but still untrusted: keep a short printable identifier only. */
34
+ export function safeModelLabel(value: unknown): string | undefined {
35
+ if (typeof value !== "string") return undefined;
36
+ const label = value.trim();
37
+ return label.length > 0 && label.length <= 160 && /^[A-Za-z0-9][A-Za-z0-9._:/+@-]*$/.test(label) ? label : undefined;
38
+ }
@@ -2,6 +2,7 @@ import { assistantTokens } from "./usage.ts";
2
2
  type ExtensionAPI = any;
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
+ type BudgetUnit = "model_calls" | "tool_calls";
5
6
 
6
7
  type BridgeEvent = { type: string; text?: string; sessionId?: string; sessionFile?: string; error?: string };
7
8
 
@@ -14,10 +15,12 @@ const models = (() => {
14
15
  try { return JSON.parse(process.env.AGENTHUB_PI_MODELS ?? "[]") as unknown[]; } catch { return []; }
15
16
  })();
16
17
  let pollStarted = false;
18
+ let pollStopped = false;
17
19
  let toolSteps = 0;
18
20
  let lastActivity = 0;
19
21
  let usageSeq = 0;
20
22
  let forcedFailure = "";
23
+ let turnGeneration = 0;
21
24
  const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
22
25
  let shutdown: (() => void) | undefined;
23
26
  let runtimeCtx: any;
@@ -29,10 +32,27 @@ async function post(path: string, body: unknown): Promise<any> {
29
32
  if (!response.ok) throw new Error(`Pi bridge HTTP ${response.status}`);
30
33
  return response.json();
31
34
  }
35
+ async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ allowed: boolean; reservation?: string }> {
36
+ try {
37
+ const result = await post("/budget", { unit, generation: turnGeneration, ...(idleUserBash ? { idleUserBash: true } : {}) });
38
+ const decisions = Array.isArray(result?.decisions) ? result.decisions : [];
39
+ const denied = decisions.find((item: any) => item?.allowed === false);
40
+ if (!denied) return { allowed: true, ...(idleUserBash && typeof result?.reservation === "string" ? { reservation: result.reservation } : {}) };
41
+ const reason = `execution budget ${denied.reason ?? "exhausted"}: ${denied.scope} ${denied.unit ?? unit} used ${denied.used}${denied.limit === null ? "" : ` of ${denied.limit}`}`;
42
+ if (idleUserBash) return { allowed: false };
43
+ forcedFailure = reason;
44
+ } catch (error) {
45
+ if (idleUserBash) return { allowed: false };
46
+ forcedFailure = `execution budget admission unavailable: ${(error as Error).message}`;
47
+ }
48
+ try { await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: forcedFailure }); } catch { /* abort remains authoritative when the bridge is unavailable */ }
49
+ runtimeCtx?.abort?.();
50
+ return { allowed: false };
51
+ }
32
52
  function processSignature(): string | undefined { try { const text = execFileSync("ps", ["-p", String(process.pid), "-o", "lstart=,comm="], { encoding: "utf8" }).trim(); return text ? createHash("sha256").update(text).digest("hex") : undefined; } catch { return undefined; } }
33
53
 
34
54
  async function poll(pi: ExtensionAPI): Promise<void> {
35
- while (true) {
55
+ while (!pollStopped) {
36
56
  try {
37
57
  const response = await fetch(`${bridgeUrl}/commands`, { headers: { authorization: `Bearer ${bridgeToken}` } });
38
58
  const payload = await response.json() as { command?: any };
@@ -54,7 +74,15 @@ async function poll(pi: ExtensionAPI): Promise<void> {
54
74
  const model = modelRegistry?.find(String(command.provider), String(command.modelId));
55
75
  if (!model || !(await (pi as any).setModel?.(model))) throw new Error("Pi model is not available");
56
76
  } else if (command.type === "shutdown") {
77
+ pollStopped = true;
57
78
  shutdown?.();
79
+ } else if (command.type === "abort_budget") {
80
+ if (Number.isSafeInteger(command.generation) && command.generation === turnGeneration) {
81
+ const reason = "execution budget exhausted: elapsed_ms wall cap reached";
82
+ forcedFailure = reason;
83
+ await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason });
84
+ runtimeCtx?.abort?.();
85
+ }
58
86
  }
59
87
  await post("/ack", { id: command.id, ok: true });
60
88
  } catch (error) { await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
@@ -89,7 +117,7 @@ export default function(pi: ExtensionAPI): void {
89
117
  const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
90
118
  const failed = !!forcedFailure || message?.stopReason === "error";
91
119
  const cancelled = !forcedFailure && message?.stopReason === "aborted";
92
- await post("/event", { type: "agent_end", text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
120
+ await post("/event", { type: "agent_end", generation: turnGeneration, text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
93
121
  });
94
122
  pi.on("message_end", async (event: any) => {
95
123
  const tokens = assistantTokens(event.message);
@@ -104,21 +132,28 @@ export default function(pi: ExtensionAPI): void {
104
132
  lastActivity = now;
105
133
  try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
106
134
  });
107
- pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; await post("/event", { type: "agent_start" }); });
135
+ pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; await post("/event", { type: "agent_start", generation: turnGeneration }); });
108
136
  pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
109
137
  pi.on("before_agent_start", async (event: any, ctx: any) => {
110
138
  if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }
111
139
  return undefined;
112
140
  });
113
- pi.on("before_provider_request", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.abort?.(); return undefined; });
141
+ pi.on("before_provider_request", async (_event: any, ctx: any) => {
142
+ if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return undefined; }
143
+ try { if (!(await admitBudget("model_calls")).allowed) ctx.abort?.(); }
144
+ catch (error) { forcedFailure = `execution budget admission failed: ${(error as Error).message}`; ctx.abort?.(); }
145
+ return undefined;
146
+ });
114
147
  pi.on("agent_settled", async (_event: any, ctx: any) => {
115
148
  const entries = ctx.sessionManager?.getEntries?.() ?? [];
116
149
  const message = entries.slice().reverse().find((entry: any) => entry.type === "message" && entry.message?.role === "assistant")?.message;
117
150
  const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
118
- await post("/event", { type: "agent_settled", ...(text ? { text } : {}) });
151
+ await post("/event", { type: "agent_settled", generation: turnGeneration, ...(text ? { text } : {}) });
119
152
  });
120
153
  pi.on("user_bash", async (event: any) => {
121
- const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}` });
154
+ const admission = await admitBudget("tool_calls", true);
155
+ if (!admission.allowed || !admission.reservation) return { cancel: true };
156
+ const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}`, purpose: "idle_user_bash", generation: turnGeneration, reservation: admission.reservation });
122
157
  return { result: { output: String(result.text ?? result), exitCode: 0, cancelled: false, truncated: false } };
123
158
  });
124
159
  // Managed sessions may only be handed over by PiPeer after it has fenced the
@@ -126,11 +161,12 @@ export default function(pi: ExtensionAPI): void {
126
161
  // otherwise silently detach the hub from its recorded session.
127
162
  pi.on("session_before_switch", async () => ({ cancel: true }));
128
163
  pi.on("session_before_fork", async () => ({ cancel: true }));
129
- pi.on("session_shutdown", async () => { await post("/event", { type: "session_shutdown" }); });
164
+ pi.on("session_shutdown", async () => { pollStopped = true; await post("/event", { type: "session_shutdown" }); });
130
165
  for (const raw of (() => { try { return JSON.parse(process.env.AGENTHUB_PI_TOOLS ?? "[]") as any[]; } catch { return []; } })()) {
131
166
  if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
132
167
  pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
133
- if (toolSteps++ >= maxSteps) { const reason = `Pi tool step limit ${maxSteps} reached`; forcedFailure = reason; await post("/event", { type: "agent_end", failed: true, error: reason }); runtimeCtx?.abort?.(); return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true }; }
168
+ if (!(await admitBudget("tool_calls")).allowed) return { content: [{ type: "text", text: `error: ${forcedFailure}` }], details: {}, isError: true };
169
+ 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 }; }
134
170
  const result = await post("/tool", { name: raw.name, args: params, toolCallId });
135
171
  return { content: [{ type: "text", text: String(result.text ?? result) }], details: {} };
136
172
  } });