/** * pi-aps: Pi Agent extension for the APS Gateway agent API. * * Scope of this PoC: read-only business intents through POST /api/agent/invoke. * The extension never opens a second execution path and never fabricates APS * data. HTTP failures are surfaced as tool errors (isError=true). */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const DEFAULT_APS_BASE_URL = "http://127.0.0.1:8000"; const REQUEST_TIMEOUT_MS = 30_000; const CATALOG_TIMEOUT_MS = 8_000; const BASE_TOOL_NAMES = new Set(["aps_health", "aps_summary", "aps_invoke"]); interface ApsConfigState { baseUrl?: string; token?: string; requestTimeoutMs?: number; catalogTimeoutMs?: number; path: string; loaded: boolean; error?: string; } function apsConfigPath(): string { const explicit = String(process.env.APS_CONFIG_FILE || "").trim(); if (explicit) return explicit; const piHome = String(process.env.PI_CODING_AGENT_DIR || "").trim(); if (piHome) return join(piHome, "aps.json"); return join(homedir(), ".pi", "aps.json"); } function optionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed || undefined; } function optionalPositiveNumber(value: unknown): number | undefined { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } function loadApsConfig(): ApsConfigState { const path = apsConfigPath(); try { if (!existsSync(path)) return { path, loaded: false }; const raw = readFileSync(path, "utf8"); const parsed: any = JSON.parse(raw); if (!parsed || typeof parsed !== "object") throw new Error("config root must be an object"); const section = parsed.aps && typeof parsed.aps === "object" ? parsed.aps : parsed; return { baseUrl: optionalString(section.baseUrl ?? section.serverUrl), token: optionalString(section.token), requestTimeoutMs: optionalPositiveNumber( section.requestTimeoutMs ?? section.request_timeout_ms, ), catalogTimeoutMs: optionalPositiveNumber( section.catalogTimeoutMs ?? section.catalog_timeout_ms, ), path, loaded: true, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { path, loaded: false, error: message }; } } function positiveNumber(raw: string | undefined, configValue: number | undefined, fallback: number): number { const parsed = Number(String(raw || "").trim()); return Number.isFinite(parsed) && parsed > 0 ? parsed : configValue ?? fallback; } const APS_CONFIG = loadApsConfig(); interface IntentEntry { name?: string; power?: string; description?: string; parameters?: any; } interface CatalogState { lastAttemptAt?: string; lastSuccessAt?: string; available: boolean; error?: string; intentCount: number; registeredTools: string[]; } const catalogState: CatalogState = { available: false, intentCount: 0, registeredTools: [], }; const dynamicIntentByTool = new Map(); let lastToolError: string | undefined; class ApsCallError extends Error { code: string; httpStatus?: number; constructor(code: string, message: string, httpStatus?: number) { super(message); this.name = "ApsCallError"; this.code = code; this.httpStatus = httpStatus; } } function apsBaseUrl(): string { const value = String(process.env.APS_BASE_URL || APS_CONFIG.baseUrl || DEFAULT_APS_BASE_URL); return value.trim().replace(/\/+$/, ""); } function apsToken(): string | undefined { const token = String(process.env.APS_AGENT_TOKEN || APS_CONFIG.token || "").trim(); return token || undefined; } function requestTimeoutMs(): number { return positiveNumber(process.env.APS_REQUEST_TIMEOUT_MS, APS_CONFIG.requestTimeoutMs, REQUEST_TIMEOUT_MS); } function catalogTimeoutMs(): number { return positiveNumber(process.env.APS_CATALOG_TIMEOUT_MS, APS_CONFIG.catalogTimeoutMs, CATALOG_TIMEOUT_MS); } function maskToken(token: string | undefined): string { if (!token) return "not configured"; if (token.length <= 4) return "configured (masked)"; return `configured (ends ${token.slice(-4)})`; } function requestId(prefix = "aps"): string { const cryptoObj = (globalThis as any).crypto as Crypto | undefined; if (cryptoObj && typeof cryptoObj.randomUUID === "function") { return `${prefix}-${cryptoObj.randomUUID()}`; } return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } function headers(): Record { const result: Record = { Accept: "application/json", "Content-Type": "application/json", }; const token = apsToken(); if (token) result.Authorization = `Bearer ${token}`; return result; } function serverErrorCode(body: any, fallback: string): string { if (body && typeof body === "object") { if (typeof body.error?.code === "string") return body.error.code; if (typeof body.code === "string") return body.code; } return fallback; } function serverErrorMessage(body: any, fallback: string): string { if (body && typeof body === "object") { if (typeof body.error?.message === "string") return body.error.message; if (typeof body.message === "string") return body.message; } return fallback; } async function readJsonResponse(response: Response): Promise { const text = await response.text(); let body: any = {}; if (text) { try { body = JSON.parse(text); } catch { body = { raw: text.slice(0, 500) }; } } if (!response.ok) { const code = serverErrorCode(body, `HTTP_${response.status}`); const message = serverErrorMessage(body, text.slice(0, 500) || response.statusText); throw new ApsCallError( code, `HTTP ${response.status} (${code}): ${message}`, response.status, ); } if (body && typeof body === "object" && body.ok === false) { const code = serverErrorCode(body, "APS_INVOKE_REJECTED"); const message = serverErrorMessage(body, "APS server returned ok:false"); throw new ApsCallError( code, `HTTP ${response.status} (${code}): ${message}`, response.status, ); } return body; } async function httpJson( path: string, method: string, payload: unknown, signal: AbortSignal | undefined, timeoutMs: number, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => { controller.abort(new ApsCallError("APS_TIMEOUT", `APS request timed out after ${timeoutMs}ms`, undefined)); }, timeoutMs); const onOuterAbort = () => controller.abort(signal?.reason ?? new Error("Aborted by pi")); if (signal) { if (signal.aborted) { clearTimeout(timer); throw new ApsCallError("APS_ABORTED", "APS request aborted by pi", undefined); } signal.addEventListener("abort", onOuterAbort, { once: true }); } try { const url = `${apsBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`; const response = await fetch(url, { method, headers: headers(), body: method === "GET" || method === "HEAD" ? undefined : JSON.stringify(payload ?? {}), signal: controller.signal, }); return await readJsonResponse(response); } catch (err) { if (err instanceof ApsCallError) throw err; const message = err instanceof Error ? err.message : String(err); throw new ApsCallError("APS_NETWORK_FAILED", `APS HTTP request failed: ${message}`, undefined); } finally { clearTimeout(timer); if (signal) signal.removeEventListener("abort", onOuterAbort); } } function responseText(payload: any, label: string): string { if (payload && typeof payload === "object" && typeof payload.data?.text === "string") { const parts: string[] = [payload.data.text]; const refs = Array.isArray(payload.data.evidenceRefs) && payload.data.evidenceRefs.length ? payload.data.evidenceRefs : Array.isArray(payload.evidenceRefs) && payload.evidenceRefs.length ? payload.evidenceRefs : []; if (refs.length) parts.push(`\n\nevidenceRefs: ${refs.map(String).join(", ")}`); return parts.join(""); } if (payload && typeof payload === "object" && typeof payload.text === "string") { return payload.text; } return JSON.stringify(payload ?? null, null, 2); } function toolResult(toolName: string, payload: any) { return { content: [{ type: "text", text: responseText(payload, toolName) }], details: { tool: toolName, receivedAt: new Date().toISOString(), payload, }, }; } function captureToolError(err: unknown): never { lastToolError = err instanceof Error ? `${err.name}: ${err.message}` : String(err); if (err instanceof ApsCallError) throw err; throw err instanceof Error ? err : new Error(String(err)); } async function invokeIntent( intent: string, params: Record, requestIdValue: unknown, signal: AbortSignal | undefined, ): Promise { const rid = typeof requestIdValue === "string" && requestIdValue.trim() ? requestIdValue.trim() : requestId(`aps-${intent.replace(/[^a-zA-Z0-9]+/g, "-")}`); return httpJson("/api/agent/invoke", "POST", { intent, params: params && typeof params === "object" ? params : {}, requestId: rid, }, signal, requestTimeoutMs()); } function registerBaseTools(pi: ExtensionAPI): void { pi.registerTool({ name: "aps_health", label: "APS Health", description: "Call APS read-only business capability 'health'. Returns APS Gateway health/interface metadata verbatim.", parameters: Type.Object({}), async execute(_toolCallId, _params, signal) { try { return toolResult("aps_health", await httpJson("/api/health", "GET", undefined, signal, requestTimeoutMs())); } catch (err) { captureToolError(err); } }, }); pi.registerTool({ name: "aps_summary", label: "APS World Summary", description: "Call APS read-only business capability 'world summary'. Returns APS world/KPI summary data verbatim.", parameters: Type.Object({}), async execute(_toolCallId, _params, signal) { try { return toolResult("aps_summary", await httpJson("/api/world/summary", "GET", undefined, signal, requestTimeoutMs())); } catch (err) { captureToolError(err); } }, }); pi.registerTool({ name: "aps_invoke", label: "APS Invoke", description: "Call an APS read-only business capability by exact intent name through POST /api/agent/invoke. " + "Intent names look like order.pool or plan.buckets. Write or confirmation-gated intents are rejected by the APS server.", parameters: Type.Object({ intent: Type.String({ description: "Exact APS intent name from the /api/agent/intents catalog" }), params: Type.Object({}, { description: "Intent parameters accepted by the catalog schema", additionalProperties: true }), requestId: Type.Optional(Type.String({ description: "Optional client request id for auditing" })), }), async execute(_toolCallId, params: any, signal) { try { return toolResult( "aps_invoke", await invokeIntent(String(params.intent), params.params ?? {}, params.requestId, signal), ); } catch (err) { captureToolError(err); } }, }); } function normalizeToolName(intentName: string): string | undefined { const normalized = String(intentName) .trim() .toLowerCase() .replace(/\./g, "_") .replace(/[^a-z0-9_]+/g, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, ""); if (!normalized) return undefined; const toolName = `aps_${normalized}`; return /^[a-z][a-z0-9_]*$/.test(toolName) ? toolName : undefined; } function typeFromJsonSchema(schemaJson: any, description?: string): any { const type = String(schemaJson?.type || "").toLowerCase(); switch (type) { case "string": return Type.String({ description: description || schemaJson?.description }); case "number": case "integer": return Type.Number({ description: description || schemaJson?.description }); case "boolean": return Type.Boolean({ description: description || schemaJson?.description }); case "array": return Type.Array(typeFromJsonSchema(schemaJson.items), { description: description || schemaJson?.description }); case "object": return schemaFromJsonSchema(schemaJson); case "null": return Type.Unknown({ description: description || schemaJson?.description }); default: return Type.Unknown({ description: description || schemaJson?.description }); } } function schemaFromJsonSchema(schemaJson: any): any { if (!schemaJson || typeof schemaJson !== "object") { return Type.Object({}, { additionalProperties: true }); } const requiredSet = new Set(Array.isArray(schemaJson.required) ? schemaJson.required.map(String) : []); const properties: Record = {}; if (schemaJson.properties && typeof schemaJson.properties === "object") { for (const [key, prop] of Object.entries(schemaJson.properties)) { if (!/^[a-zA-Z0-9_]+$/.test(key)) continue; const base = typeFromJsonSchema(prop); properties[key] = requiredSet.has(key) ? base : Type.Optional(base); } } return Type.Object(properties, { additionalProperties: schemaJson.additionalProperties !== false, description: typeof schemaJson.description === "string" ? schemaJson.description : undefined, }); } function registerCatalogTools(pi: ExtensionAPI): Promise { catalogState.lastAttemptAt = new Date().toISOString(); catalogState.registeredTools = []; return httpJson("/api/agent/intents", "GET", undefined, undefined, catalogTimeoutMs()) .then((payload: any) => { const entries: IntentEntry[] = Array.isArray(payload?.intents) ? payload.intents : []; for (const entry of entries) { if (!entry || typeof entry.name !== "string" || !entry.name.trim()) continue; const toolName = normalizeToolName(entry.name); if (!toolName || BASE_TOOL_NAMES.has(toolName) || dynamicIntentByTool.has(toolName)) continue; const power = String(entry.power || "P0"); const description = entry.description && String(entry.description).trim() ? String(entry.description).trim() : `Call APS intent ${entry.name}`; dynamicIntentByTool.set(toolName, entry.name.trim()); pi.registerTool({ name: toolName, label: `APS ${entry.name}`, description: `Call APS read-only business capability ${entry.name} (power ${power}). ${description}`, parameters: schemaFromJsonSchema(entry.parameters), async execute(_toolCallId, params: any, signal) { try { return toolResult( toolName, await invokeIntent(entry.name.trim(), params ?? {}, params?.requestId, signal), ); } catch (err) { captureToolError(err); } }, }); catalogState.registeredTools.push(toolName); } catalogState.available = true; catalogState.intentCount = entries.length; catalogState.lastSuccessAt = new Date().toISOString(); catalogState.error = undefined; }) .catch((err: unknown) => { catalogState.available = false; catalogState.error = err instanceof Error ? err.message : String(err); // Health/summary/invoke tools remain usable even without the catalog. }); } export default function apsExtension(pi: ExtensionAPI): void { registerBaseTools(pi); pi.on("session_start", () => { return registerCatalogTools(pi); }); pi.registerCommand("aps-status", { description: "Show pi-aps base URL/token state, registered APS tools, and last error", handler: async (_args, ctx) => { const token = apsToken(); const lines = [ `APS config file: ${APS_CONFIG.path}`, `APS config loaded: ${APS_CONFIG.loaded ? "yes" : "no"}`, `APS config error: ${APS_CONFIG.error || "(none)"}`, `APS baseUrl: ${apsBaseUrl()}`, `APS agent token: ${maskToken(token)}`, `Catalog available: ${catalogState.available ? "yes" : "no"}`, `Registered APS tools: ${catalogState.registeredTools.length + BASE_TOOL_NAMES.size}`, `Dynamic tools: ${catalogState.registeredTools.join(", ") || "(none)"}`, `Last catalog attempt: ${catalogState.lastAttemptAt || "(not attempted)"}`, `Last catalog error: ${catalogState.error || "(none)"}`, `Last tool error: ${lastToolError || "(none)"}`, ]; const text = lines.join("\n"); if (ctx.hasUI) { ctx.ui.notify(text, catalogState.error ? "warning" : "info"); } else { process.stderr.write(`[aps-status]\n${text}\n`); } }, }); }