aps-agent/poc/pi-aps/extensions/aps.ts

479 lines
16 KiB
TypeScript
Raw Normal View History

/**
* 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<string, string>();
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<string, string> {
const result: Record<string, string> = {
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<any> {
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<any> {
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<string, unknown>,
requestIdValue: unknown,
signal: AbortSignal | undefined,
): Promise<any> {
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<string, any> = {};
if (schemaJson.properties && typeof schemaJson.properties === "object") {
for (const [key, prop] of Object.entries<any>(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<void> {
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`);
}
},
});
}