Files
sw-ads-agent/packages/harness/src/swads-tools.ts
T

135 lines
11 KiB
TypeScript

import { defineTool } from "@earendil-works/pi-coding-agent";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Type } from "typebox";
import { z } from "zod";
import { MAX_GATEWAY_BYTES, SwadsGateway, failure } from "./swads-gateway.js";
import { reportDataSchema } from "./report-schema.js";
import type { ReportStore } from "./report-store.js";
export const commands = {
capabilities: "tools/list", whoami: "swads_whoami", "metrics.catalog": "metrics_catalog", "metrics.query": "metrics_semantic_query",
"runtime.account-overview": "runtime_account_overview", "runtime.proposals": "runtime_list_proposals", "runtime.config": "runtime_get_config", "runtime.findings": "runtime_findings", "campaign.list": "campaign_list",
} as const;
const id = z.string().min(1).max(160);
const scope = { external_account_id: id.optional(), platform: z.literal("tiktok").optional(), tenant_id: id.optional() };
const schemas = {
capabilities: z.object({}).strict(), whoami: z.object({}).strict(),
"metrics.catalog": z.object({ platform: z.literal("tiktok") }).strict(),
"metrics.query": z.object({ ...scope, platform: z.literal("tiktok"), query: z.record(z.string(), z.unknown()) }).strict(),
"runtime.account-overview": z.object({ external_account_id: id.optional(), platform: z.literal("tiktok") }).strict(),
"runtime.proposals": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, status: id.optional() }).strict(),
"runtime.config": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id }).strict(),
"runtime.findings": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, metric: id.optional(), window: id.optional(), min_confidence: z.number().min(0).max(1).optional(), include_entities: z.boolean().optional() }).strict(),
"campaign.list": z.object({ ...scope, platform: z.literal("tiktok"), page: z.number().int().min(1).max(1000).optional(), page_size: z.number().int().min(1).max(100).optional(), cursor: z.string().max(1000).optional(), status: id.optional() }).strict(),
};
export const swadsCliSchema = z.object({ command: z.enum(Object.keys(commands) as [keyof typeof commands, ...Array<keyof typeof commands>]), arguments: z.record(z.string(), z.unknown()) }).strict();
export function validateCli(input: unknown) {
assertBounds(input);
const parsed = swadsCliSchema.safeParse(input);
if (!parsed.success) throw failure("SWADS_INVALID_ARGUMENTS");
const args = schemas[parsed.data.command].safeParse(parsed.data.arguments);
if (!args.success) throw failure("SWADS_INVALID_ARGUMENTS");
if (parsed.data.command === "metrics.query") {
const query = parsed.data.arguments.query as Record<string, unknown>;
if (query.platform !== undefined && query.platform !== "tiktok") throw failure("SWADS_INVALID_ARGUMENTS");
}
return { command: parsed.data.command, arguments: args.data };
}
function assertBounds(input: unknown): void {
let nodes = 0;
const visit = (value: unknown, depth: number) => {
if (++nodes > 4000 || depth > 10) throw failure("SWADS_INVALID_ARGUMENTS");
if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) {
if (/^(?:url|headers?|token|authorization|tool_?name|__proto__|constructor|prototype)$/i.test(key)) throw failure("SWADS_INVALID_ARGUMENTS");
visit(child, depth + 1);
}
};
visit(input, 0);
if (Buffer.byteLength(JSON.stringify(input) ?? "") > 64 * 1024) throw failure("SWADS_INVALID_ARGUMENTS");
}
export function isReadOnly(tool: Tool): boolean { return tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true; }
const required = ["swads_whoami", "metrics_catalog", "metrics_semantic_query"];
function assertCapabilities(tools: Tool[]): void {
for (const name of required) {
const tool = tools.find((item) => item.name === name);
if (!tool) throw failure("SWADS_TOOL_MISSING");
if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY");
}
}
function unwrap(result: unknown): unknown {
if (Buffer.byteLength(JSON.stringify(result)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
const envelope = result as { isError?: boolean; structuredContent?: unknown; content?: Array<{ type: string; text?: string }> };
let data = envelope.structuredContent;
if (data === undefined) {
const content = envelope.content?.filter((part) => part.type === "text");
if (content?.length !== 1 || !content[0]?.text) throw failure("SWADS_INVALID_RESPONSE");
try { data = JSON.parse(content[0].text); } catch { throw failure(envelope.isError ? "SWADS_UPSTREAM_ERROR" : "SWADS_INVALID_RESPONSE"); }
}
const error = data as { error?: unknown; error_code?: string; upstream_status?: number; status?: number } | null;
if (envelope.isError || error?.error || error?.error_code) {
const status = error?.upstream_status ?? error?.status;
throw failure(status === 401 ? "SWADS_AUTH_FAILED" : status === 403 ? "SWADS_FORBIDDEN" : error?.error_code === "platform_not_supported" ? "SWADS_CAPABILITY_MISSING" : "SWADS_UPSTREAM_ERROR");
}
return data;
}
// Per-session, run-scoped evidence prevents rendering after failed/missing preflight.
export class SwadsReportTools {
private evidence: { runId: string; whoami: boolean; catalog: boolean; query: boolean } | undefined;
constructor(private readonly gateway: SwadsGateway, private readonly reports: ReportStore, private readonly getRun: () => { runId: string; signal: AbortSignal } | undefined) {}
async cli(input: unknown, signal: AbortSignal): Promise<unknown> {
let params: ReturnType<typeof validateCli>;
try { params = validateCli(input); } catch (error) { this.evidence = undefined; throw error; }
const run = this.getRun(); if (!run) throw failure("SWADS_NO_ACTIVE_RUN");
if (this.evidence?.runId !== run.runId) this.evidence = { runId: run.runId, whoami: false, catalog: false, query: false };
try {
return await this.gateway.execute(async (connection, requestSignal) => {
const tools = await this.gateway.list(connection, requestSignal, params.command === "capabilities");
assertCapabilities(tools);
if (params.command === "capabilities") return { server_time: new Date().toISOString(), tools: tools.filter((tool) => Object.values(commands).includes(tool.name as typeof commands[keyof typeof commands])) };
if (params.command === "metrics.query" && (!this.evidence?.whoami || !this.evidence.catalog)) throw failure("SWADS_PREFLIGHT_REQUIRED");
const tool = tools.find((item) => item.name === commands[params.command]);
if (!tool) throw failure("SWADS_TOOL_MISSING");
if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY");
try { if (!new AjvJsonSchemaValidator().getValidator(tool.inputSchema as Parameters<AjvJsonSchemaValidator["getValidator"]>[0])(params.arguments).valid) throw failure("SWADS_TOOL_DRIFT"); } catch { throw failure("SWADS_TOOL_DRIFT"); }
const data = unwrap(await connection.call(tool.name, params.arguments, requestSignal));
if (data === null || typeof data !== "object") throw failure("SWADS_INVALID_RESPONSE");
if (params.command === "metrics.query") {
const rows = data as { columns?: Array<{ name?: unknown }>; rows?: unknown[] };
if (!Array.isArray(rows.columns) || !rows.columns.length || !rows.columns.every((column) => typeof column.name === "string") || !Array.isArray(rows.rows)) throw failure("SWADS_INVALID_RESPONSE");
if (!rows.rows.length) throw failure("SWADS_NO_DATA");
}
if (params.command === "metrics.catalog" && Object.keys(data).length === 0) throw failure("SWADS_CAPABILITY_MISSING");
if (params.command === "whoami" && Object.keys(data).length === 0) throw failure("SWADS_INVALID_RESPONSE");
if (params.command === "whoami") this.evidence!.whoami = true;
if (params.command === "metrics.catalog") this.evidence!.catalog = true;
if (params.command === "metrics.query") this.evidence!.query = true;
return data;
}, AbortSignal.any([signal, run.signal]));
} catch (error) {
if (["capabilities", "whoami", "metrics.catalog", "metrics.query"].includes(params.command)) this.evidence = undefined;
throw error;
}
}
async render(input: unknown, signal: AbortSignal) {
const run = this.getRun();
if (!run || this.evidence?.runId !== run.runId || !this.evidence.whoami || !this.evidence.catalog || !this.evidence.query) throw failure("SWADS_PREFLIGHT_REQUIRED");
const parsed = reportDataSchema.safeParse(this.gateway.sanitize(input));
if (!parsed.success) throw failure("SWADS_INVALID_REPORT");
try { return await this.reports.create(parsed.data, AbortSignal.any([signal, run.signal])); } catch { throw failure(signal.aborted || run.signal.aborted ? "SWADS_ABORTED" : "SWADS_REPORT_FAILED"); }
}
definitions() {
return [defineTool({
name: "swads_cli", label: "SW Ads read-only CLI", description: "Read SW Ads via fixed commands. First capabilities then whoami and metrics.catalog. Arguments must follow the remote input schema and the local command schema; only platform tiktok is supported.",
parameters: Type.Unsafe<z.infer<typeof swadsCliSchema>>({ type: "object", properties: { command: { type: "string", enum: Object.keys(commands) }, arguments: { type: "object" } }, required: ["command", "arguments"], additionalProperties: false, oneOf: Object.entries(schemas).map(([command, schema]) => ({ type: "object", properties: { command: { const: command }, arguments: z.toJSONSchema(schema) }, required: ["command", "arguments"], additionalProperties: false })) }),
executionMode: "sequential",
execute: async (_id, params, signal) => { const data = await this.cli(params, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: { status: "success", command: params.command } }; },
}), defineTool({
name: "render_swads_daily_report", label: "SW Ads daily report", description: "After real SW Ads preflight and queries, render normalized data to persistent PNG/HTML/JSON. Output Markdown analysis first, then call this as the final action. No model-selected paths or executable arguments.",
parameters: Type.Object({ data: Type.Unsafe<z.infer<typeof reportDataSchema>>(z.toJSONSchema(reportDataSchema)) }, { additionalProperties: false }), executionMode: "sequential",
execute: async (_id, params, signal) => { const artifact = await this.render(params.data, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(artifact) }], details: artifact }; },
})];
}
}