feat: publish Agent Studio integrations and setup guide
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
import { SafeWorkspace } from "../workspace/safe-workspace.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const formats = { json: "application/json", html: "text/html; charset=utf-8", pdf: "application/pdf" } as const;
|
||||
type Format = keyof typeof formats;
|
||||
type QueryRecord = { queryId: string; requestedAt: string; arguments: Record<string, unknown>; result: unknown };
|
||||
type ReportInput = { data: Record<string, unknown>; source_query_ids: string[] };
|
||||
export type MinisReportOptions = { workspace: SafeWorkspace; sessionId: string; skillRoot: string; pythonExecutable?: string; chromeExecutable?: string };
|
||||
|
||||
export class MinisReportService {
|
||||
readonly #queries = new Map<string, QueryRecord>();
|
||||
readonly #reports = new Map<string, { root: string; formats: Set<Format> }>();
|
||||
public constructor(private readonly options: MinisReportOptions) {}
|
||||
|
||||
public recordQuery(args: Record<string, unknown>, result: unknown): string {
|
||||
const queryId = `query_${randomUUID()}`;
|
||||
this.#queries.set(queryId, { queryId, requestedAt: new Date().toISOString(), arguments: structuredClone(args), result: structuredClone(result) });
|
||||
return queryId;
|
||||
}
|
||||
|
||||
public async generate(input: ReportInput, signal?: AbortSignal) {
|
||||
if (!input.source_query_ids.length || input.source_query_ids.some((id) => !this.#queries.has(id))) {
|
||||
throw new HarnessError("REPORT_SOURCE_REQUIRED", "Report requires successful swads query IDs from this Session; never use example data");
|
||||
}
|
||||
const { data } = input;
|
||||
for (const key of ["title", "report_type", "period", "comparison_period", "currency", "timezone", "data_status", "conclusion"]) {
|
||||
if (typeof data[key] !== "string" || !data[key].trim()) throw new HarnessError("INVALID_REPORT", `Report requires ${key}`);
|
||||
}
|
||||
if (!["daily", "weekly"].includes(String(data.report_type)) || !Array.isArray(data.cards) || !Array.isArray(data.issues)) throw new HarnessError("INVALID_REPORT", "Invalid report type, cards or issues");
|
||||
if (data.currency !== "USD") throw new HarnessError("REPORT_CURRENCY_UNSUPPORTED", "This renderer labels charts in USD. Keep other currencies separate and do not generate misleading USD charts.");
|
||||
const payload = JSON.stringify({ ...data, source_queries: input.source_query_ids.map((id) => this.#queries.get(id)) }, null, 2);
|
||||
if (Buffer.byteLength(payload) > 4_194_304) throw new HarnessError("REPORT_TOO_LARGE", "Report source data exceeds 4 MiB");
|
||||
const reportId = `rpt_${randomUUID()}`;
|
||||
// A fresh random directory avoids overwrites and untrusted symlink targets.
|
||||
const root = await mkdtemp(join(this.options.workspace.root, ".report-"));
|
||||
const jsonPath = join(root, "report.json");
|
||||
const htmlPath = join(root, "report.html");
|
||||
await writeFile(jsonPath, payload, { mode: 0o600, flag: "wx" });
|
||||
const source = await SafeWorkspace.create(this.options.skillRoot);
|
||||
const script = await source.resolveReadable("scripts/generate_report.py");
|
||||
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "en_US.UTF-8", PYTHONDONTWRITEBYTECODE: "1", PYTHONNOUSERSITE: "1" };
|
||||
try {
|
||||
await exec(this.options.pythonExecutable ?? "python3", ["-I", script, jsonPath, htmlPath], {
|
||||
cwd: root, env, timeout: 30_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
await chmod(htmlPath, 0o600);
|
||||
} catch {
|
||||
throw new HarnessError("REPORT_RENDER_FAILED", "HTML generation failed. Check report JSON against the Skill schema and the configured Python runtime.");
|
||||
}
|
||||
const available = new Set<Format>(["json", "html"]);
|
||||
let pdfWarning: string | undefined;
|
||||
const pdfPath = join(root, "report.pdf");
|
||||
const profile = await mkdtemp(join(root, ".chrome-"));
|
||||
try {
|
||||
await exec(this.options.chromeExecutable ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "chromium"), [
|
||||
"--headless", "--disable-gpu", "--disable-extensions", "--disable-background-networking", "--no-first-run",
|
||||
"--host-resolver-rules=MAP * ~NOTFOUND", "--no-pdf-header-footer", `--user-data-dir=${profile}`, `--print-to-pdf=${pdfPath}`, pathToFileURL(htmlPath).href,
|
||||
], { cwd: root, env, timeout: 60_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }) });
|
||||
const pdf = await readFile(pdfPath);
|
||||
if (!pdf.subarray(0, 5).equals(Buffer.from("%PDF-")) || pdf.byteLength < 1_000 || !pdf.includes(Buffer.from("%%EOF"))) throw new Error("Invalid PDF output");
|
||||
await chmod(pdfPath, 0o600);
|
||||
available.add("pdf");
|
||||
} catch {
|
||||
pdfWarning = "PDF 生成失败:请检查 Chrome/Chromium 安装与运行权限;HTML 和 JSON 已保留。";
|
||||
} finally {
|
||||
await rm(profile, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (signal?.aborted) throw new HarnessError("REPORT_CANCELLED", "Report generation was cancelled");
|
||||
this.#reports.set(reportId, { root, formats: available });
|
||||
return {
|
||||
reportId, title: data.title, pdfStatus: available.has("pdf") ? "ready" : "failed",
|
||||
...(pdfWarning === undefined ? {} : { warning: pdfWarning }),
|
||||
files: await Promise.all([...available].map(async (format) => ({
|
||||
format, url: `/api/sessions/${this.options.sessionId}/reports/${reportId}/${format}`,
|
||||
byteSize: (await stat(join(root, `report.${format}`))).size,
|
||||
}))),
|
||||
};
|
||||
}
|
||||
|
||||
public async read(reportId: string, format: string): Promise<{ bytes: Buffer; contentType: string }> {
|
||||
const report = this.#reports.get(reportId);
|
||||
if (report === undefined || !Object.hasOwn(formats, format) || !report.formats.has(format as Format)) throw new HarnessError("REPORT_NOT_FOUND", "Report file not found");
|
||||
const file = await this.options.workspace.resolveReadable(join(report.root, `report.${format}`));
|
||||
return { bytes: await readFile(file), contentType: formats[format as Format] };
|
||||
}
|
||||
}
|
||||
|
||||
export function createMinisReportTool(service: MinisReportService) {
|
||||
return defineTool({
|
||||
name: "generate_minis_report", label: "Drama Minis HTML / PDF",
|
||||
description: "Generate HTML + A4 PDF + editable JSON using the installed Drama Minis Skill renderer. Read the Skill, references and example JSON structure first, NEVER reuse example values. Pass source_query_ids returned by successful REAL metrics_semantic_query calls in this Session, and freshly normalized report data (title, report_type daily/weekly, period, comparison_period, currency USD, timezone, data_status, conclusion, cards, issues, funnel, countries, country_revenue, country_funnels; weekly trend). Preserve missing values and source/account coverage in footnote. Requires page approval. Only declare PDF success when pdfStatus is ready. This replaces shell execution for the Skill report script.",
|
||||
parameters: Type.Object({ source_query_ids: Type.Array(Type.String(), { minItems: 1 }), data: Type.Record(Type.String(), Type.Unknown()) }, { additionalProperties: false }),
|
||||
executionMode: "sequential",
|
||||
async execute(_id, params, signal) {
|
||||
const details = await service.generate(params, signal);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
|
||||
const allowed = ["swads_whoami", "metrics_catalog", "metrics_semantic_query"] as const;
|
||||
export const SWADS_MCP_URL = "https://ads.mincode.cn/mcp";
|
||||
|
||||
export class SwadsMcpClient {
|
||||
#connection: Promise<Client> | undefined;
|
||||
public constructor(private readonly options: { token: string; fetch?: typeof fetch }) {}
|
||||
|
||||
async #connect(): Promise<Client> {
|
||||
if (!this.options.token.trim()) throw new HarnessError("SWADS_NOT_CONFIGURED", "SWADS_MCP_TOKEN is not configured on the server");
|
||||
this.#connection ??= (async () => {
|
||||
const client = new Client({ name: "agent-studio-mini", version: "0.1.0" });
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(SWADS_MCP_URL), {
|
||||
requestInit: { redirect: "error", headers: { Authorization: `Bearer ${this.options.token}` } },
|
||||
...(this.options.fetch === undefined ? {} : { fetch: this.options.fetch }),
|
||||
}) as Transport, { timeout: 20_000 });
|
||||
return client;
|
||||
} catch {
|
||||
await client.close().catch(() => undefined);
|
||||
this.#connection = undefined;
|
||||
throw new HarnessError("SWADS_CONNECTION_FAILED", "swads MCP connection failed; check the server credential and network");
|
||||
}
|
||||
})();
|
||||
return this.#connection;
|
||||
}
|
||||
|
||||
public async call(name: string, args: Record<string, unknown>, signal?: AbortSignal) {
|
||||
if (!allowed.some((item) => item === name)) throw new HarnessError("SWADS_TOOL_DENIED", "MCP tool is not allowed");
|
||||
const client = await this.#connect();
|
||||
try {
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
timeout: 60_000, ...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
// Remove the credential even if an upstream error reflects request headers in text.
|
||||
return JSON.parse(JSON.stringify(result).replaceAll(this.options.token, "[REDACTED]")) as typeof result;
|
||||
} catch {
|
||||
throw new HarnessError("SWADS_REQUEST_FAILED", "swads MCP request failed or was cancelled; no metric values were fabricated");
|
||||
}
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const connection = this.#connection;
|
||||
this.#connection = undefined;
|
||||
const client = await connection?.catch(() => undefined);
|
||||
await client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function createSwadsTools(client: SwadsMcpClient, onQueryResult: (args: Record<string, unknown>, result: unknown) => string) {
|
||||
const descriptions = {
|
||||
swads_whoami: "Read the authenticated swads identity and all visible advertising accounts. Do not assume the current account is the whole platform.",
|
||||
metrics_catalog: "Read actual swads metric definitions, dimensions and request_schema before querying. No advertising metrics are queried by this call.",
|
||||
metrics_semantic_query: "Query REAL swads analytics using metrics_catalog request_schema. Confirm analysis period, comparison period and account scope with the user first. Each call requires page approval. Pass date_from/date_to explicitly; external_account_id selects an account (not a campaign). Gateway can fill current tenant/org/platform/account. Never substitute mock data. Query accounts separately, preserve currencies/timezones and distinguish null from zero.",
|
||||
};
|
||||
return allowed.map((name) => defineTool({
|
||||
name, label: name, description: descriptions[name],
|
||||
parameters: Type.Object({}, { additionalProperties: true }),
|
||||
executionMode: "sequential",
|
||||
async execute(_id, params, signal) {
|
||||
const args = params as Record<string, unknown>;
|
||||
const result = await client.call(name, args, signal);
|
||||
if (result.isError) throw new HarnessError("SWADS_TOOL_FAILED", JSON.stringify(result.content).slice(0, 2_000));
|
||||
const sourceQueryId = name === "metrics_semantic_query" ? onQueryResult(args, result) : undefined;
|
||||
const details = { source: "swads MCP", toolName: name, ...(sourceQueryId === undefined ? {} : { sourceQueryId }), result: result.structuredContent ?? result.content };
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
export type ToolPolicyDecision = "allow" | "ask" | "deny";
|
||||
|
||||
const allowedTools = new Set(["read", "grep", "find", "ls", "mock_ads_metrics"]);
|
||||
const allowedTools = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "swads_whoami", "metrics_catalog", "cms_connection_status", "editor_job_status", "submit_drama_analysis", "propose_drama_selection", "submit_editor_plan"]);
|
||||
|
||||
export function decideToolPolicy(toolName: string): ToolPolicyDecision {
|
||||
if (["i2v_create_task", "i2v_status"].includes(toolName)) return "allow";
|
||||
if (["i2v_upload_reference", "i2v_submit"].includes(toolName)) return "ask";
|
||||
if (allowedTools.has(toolName)) return "allow";
|
||||
if (toolName === "save_report_draft") return "ask";
|
||||
if (["save_report_draft", "metrics_semantic_query", "generate_minis_report", "cms_list_dramas", "cms_drama_inventory", "cms_read_subtitle", "prepare_drama_sources", "read_drama_frames", "render_editor_preview"].includes(toolName)) return "ask";
|
||||
return "deny";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user