@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.
- package/CHANGELOG.md +8 -0
- package/docs/cooperbench.md +76 -0
- package/docs/events.md +5 -3
- package/docs/smoke.md +16 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -0
- package/docs/verification/2026-10-02-0.12.3.md +44 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +21 -7
- package/src/adapters/claude-channel.ts +17 -4
- package/src/adapters/local-worker.ts +109 -16
- package/src/adapters/pi.ts +106 -21
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +13 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/bus.ts +17 -3
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +77 -7
- package/src/hub/events.ts +3 -1
- package/src/hub/execution-budget.ts +138 -0
- package/src/hub/report.ts +85 -1
- package/src/hub/tasks.ts +22 -0
- package/src/hub/usage.ts +55 -0
- package/src/local/sandbox.ts +6 -2
- package/src/local/tools.ts +7 -2
- package/src/models/relay.ts +12 -4
- package/src/omniroute/client.ts +14 -3
- package/src/omniroute/usage.ts +38 -0
- package/src/pi/extension.ts +44 -8
package/src/local/tools.ts
CHANGED
|
@@ -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":
|
package/src/models/relay.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/omniroute/client.ts
CHANGED
|
@@ -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
|
-
...(
|
|
183
|
-
...(
|
|
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
|
+
}
|
package/src/pi/extension.ts
CHANGED
|
@@ -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 (
|
|
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) => {
|
|
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
|
|
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 (
|
|
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
|
} });
|