Add Beryl Agent and harness source
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import {randomUUID} from 'node:crypto';
|
||||
export type ApprovalStatus='pending'|'approved'|'denied'|'expired'|'cancelled'; export type Approval={approvalId:string;sessionId:string;runId:string;toolCallId:string;toolName:string;arguments:unknown;riskLevel:'low'|'medium'|'high';createdAt:string;expiresAt:string;status:ApprovalStatus};
|
||||
type Pending={request:Approval;resolve:(s:ApprovalStatus)=>void;timer:ReturnType<typeof setTimeout>;signal:AbortSignal;abort:()=>void};
|
||||
export class ApprovalBroker{private all=new Map<string,Pending|Approval>();constructor(private timeout=60_000,private notify?:(a:Approval)=>void){}request(input:Omit<Approval,'approvalId'|'createdAt'|'expiresAt'|'status'>,signal:AbortSignal){const now=Date.now();const request:Approval={...input,approvalId:`apr_${randomUUID()}`,createdAt:new Date(now).toISOString(),expiresAt:new Date(now+this.timeout).toISOString(),status:'pending'};return new Promise<{request:Approval;decision:ApprovalStatus}>(resolve=>{const finish=(status:ApprovalStatus)=>{request.status=status;const p=this.all.get(request.approvalId);if(p&&'timer'in p){clearTimeout(p.timer);p.signal.removeEventListener('abort',p.abort)}this.all.set(request.approvalId,request);this.notify?.(request);resolve({request,decision:status})};const abort=()=>finish('cancelled');const timer=setTimeout(()=>finish('expired'),this.timeout);signal.addEventListener('abort',abort,{once:true});this.all.set(request.approvalId,{request,resolve:finish,timer,signal,abort});this.notify?.(request)})}resolve(id:string,status:'approved'|'denied'){const item=this.all.get(id);if(!item)throw new Error('APPROVAL_NOT_FOUND');if(!('resolve'in item)){if(item.status===status)return item;throw new Error('APPROVAL_ALREADY_RESOLVED')}item.resolve(status);return item.request}cancelForSession(id:string){for(const item of this.all.values())if('resolve'in item&&item.request.sessionId===id)item.resolve('cancelled')}get(id:string){const x=this.all.get(id);return x&&('request'in x?x.request:x)}}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import type { ImageAttachment } from "@agent-studio/shared";
|
||||
type Stored = ImageAttachment & { path: string };
|
||||
export class AttachmentStore {
|
||||
private all = new Map<string, Stored>();
|
||||
constructor(
|
||||
private maxBytes = 5 * 1024 * 1024,
|
||||
private maxPixels = 40_000_000,
|
||||
) {}
|
||||
async add(
|
||||
sessionId: string,
|
||||
dir: string,
|
||||
name: string,
|
||||
mime: string,
|
||||
data: Buffer,
|
||||
) {
|
||||
if (data.length > this.maxBytes) throw new Error("IMAGE_TOO_LARGE");
|
||||
const magic = data.subarray(0, 12);
|
||||
const detected =
|
||||
magic[0] === 0xff && magic[1] === 0xd8
|
||||
? "image/jpeg"
|
||||
: magic
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
? "image/png"
|
||||
: magic.subarray(0, 4).toString() === "RIFF" &&
|
||||
magic.subarray(8, 12).toString() === "WEBP"
|
||||
? "image/webp"
|
||||
: undefined;
|
||||
if (!detected || detected !== mime) throw new Error("IMAGE_TYPE_MISMATCH");
|
||||
const image = sharp(data, { limitInputPixels: this.maxPixels }).rotate();
|
||||
const meta = await image.metadata();
|
||||
const id = `att_${randomUUID()}`;
|
||||
await mkdir(path.join(dir, "attachments"), { recursive: true });
|
||||
const file = path.join(
|
||||
dir,
|
||||
"attachments",
|
||||
`${id}.${detected.split("/")[1]}`,
|
||||
);
|
||||
const normalized =
|
||||
detected === "image/png"
|
||||
? await image.png().toBuffer()
|
||||
: detected === "image/webp"
|
||||
? await image.webp().toBuffer()
|
||||
: await image.jpeg().toBuffer();
|
||||
await writeFile(file, normalized, { mode: 0o600 });
|
||||
const item: Stored = {
|
||||
attachmentId: id,
|
||||
sessionId,
|
||||
name: path.basename(name),
|
||||
mediaType: detected,
|
||||
byteSize: normalized.length,
|
||||
width: meta.autoOrient.width,
|
||||
height: meta.autoOrient.height,
|
||||
status: "ready",
|
||||
path: file,
|
||||
};
|
||||
this.all.set(`${sessionId}:${id}`, item);
|
||||
return this.public(item);
|
||||
}
|
||||
get(id: string, att: string) {
|
||||
const x = this.all.get(`${id}:${att}`);
|
||||
return x && this.public(x);
|
||||
}
|
||||
async content(id: string, att: string) {
|
||||
const x = this.all.get(`${id}:${att}`);
|
||||
if (!x) throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
return readFile(x.path);
|
||||
}
|
||||
async remove(id: string, att: string) {
|
||||
const x = this.all.get(`${id}:${att}`);
|
||||
if (x) {
|
||||
await rm(x.path, { force: true });
|
||||
this.all.delete(`${id}:${att}`);
|
||||
}
|
||||
}
|
||||
async removeAll(id: string) {
|
||||
for (const x of [...this.all.values()])
|
||||
if (x.sessionId === id) await this.remove(id, x.attachmentId);
|
||||
}
|
||||
private public(value: Stored) {
|
||||
const x: Partial<Stored> = { ...value };
|
||||
delete x.path;
|
||||
return x as ImageAttachment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './store.js'; export * from './approval.js'; export * from './workspace.js'; export * from './skills.js'; export * from './tools.js'; export * from './swads-mcp.js'; export * from './web-tools.js'; export * from './video-tools.js'; export * from './model.js'; export * from './attachments.js'; export * from './session.js';
|
||||
@@ -0,0 +1,5 @@
|
||||
import {lookup} from 'node:dns/promises';import {isIP} from 'node:net';import type {ModelConnectionInput} from '@agent-studio/shared';
|
||||
export type RedactedModel=Omit<ModelConnectionInput,'apiKey'>&{configured:boolean;keyHint?:string;source:'session'|'environment'|'missing'};
|
||||
const blocked=(ip:string)=>ip==='169.254.169.254'||ip==='0.0.0.0'||ip==='::'||ip==='::1'||ip.startsWith('127.')||ip.startsWith('10.')||ip.startsWith('192.168.')||/^172\.(1[6-9]|2\d|3[01])\./.test(ip)||ip.startsWith('169.254.');
|
||||
export async function validateBaseUrl(value:string,allowPrivate=false){const u=new URL(value);if(u.username||u.password||u.hash)throw new Error('BASE_URL_UNSAFE');if(u.protocol!=='https:'&&!(u.protocol==='http:'&&['localhost','127.0.0.1','::1'].includes(u.hostname)))throw new Error('BASE_URL_HTTPS_REQUIRED');if([...u.searchParams.keys()].some(k=>/key|token|secret|auth/i.test(k)))throw new Error('BASE_URL_CREDENTIAL_QUERY');const addresses=isIP(u.hostname)?[{address:u.hostname}]:await lookup(u.hostname,{all:true});if(!allowPrivate&&addresses.some(a=>blocked(a.address)))throw new Error('BASE_URL_PRIVATE_DENIED');return u.toString()}
|
||||
export class ModelConnectionStore{private values=new Map<string,ModelConnectionInput>();async configure(id:string,input:ModelConnectionInput){if(input.baseUrl)await validateBaseUrl(input.baseUrl,process.env.ALLOW_PRIVATE_LLM_BASE_URLS==='true');this.values.set(id,{...input});return this.getRedacted(id)!}get(id:string){return this.values.get(id)}getRedacted(id:string):RedactedModel|undefined{const v=this.values.get(id);if(!v)return;const{apiKey,...safe}=v;return{...safe,configured:Boolean(apiKey),keyHint:apiKey?`••••${apiKey.slice(-4)}`:undefined,source:'session'}}clearCredentials(id:string){const v=this.values.get(id);if(v)this.values.set(id,{...v,apiKey:undefined})}}
|
||||
@@ -0,0 +1,433 @@
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
DefaultResourceLoader,
|
||||
ModelRuntime,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
createAgentSession,
|
||||
type AgentSessionEvent,
|
||||
type ToolDefinition,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai/compat";
|
||||
import type { ModelConnectionInput } from "@agent-studio/shared";
|
||||
import { MemoryEventStore } from "./store.js";
|
||||
import { SafeWorkspace } from "./workspace.js";
|
||||
import { AttachmentStore } from "./attachments.js";
|
||||
import { ModelConnectionStore } from "./model.js";
|
||||
import { ApprovalBroker } from "./approval.js";
|
||||
import { createTools } from "./tools.js";
|
||||
import { createSwadsReadTools } from "./swads-mcp.js";
|
||||
import { createWebReadTools } from "./web-tools.js";
|
||||
import { createVideoTools } from "./video-tools.js";
|
||||
export interface AgentSessionPort {
|
||||
prompt(text: string, options?: { images?: ImageContent[] }): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
dispose(): void;
|
||||
}
|
||||
export class RunIdleWatchdog {
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
constructor(
|
||||
private readonly timeoutMs: number,
|
||||
private readonly onTimeout: () => void,
|
||||
) {}
|
||||
touch() {
|
||||
this.stop();
|
||||
this.timer = setTimeout(this.onTimeout, this.timeoutMs);
|
||||
}
|
||||
pause() {
|
||||
this.stop();
|
||||
}
|
||||
stop() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
export type HarnessSession = {
|
||||
sessionId: string;
|
||||
workspace: SafeWorkspace;
|
||||
agent?: AgentSessionPort;
|
||||
unsubscribe?: () => void;
|
||||
busy: boolean;
|
||||
runId?: string;
|
||||
runError?: string;
|
||||
runTimedOut?: boolean;
|
||||
runIdleWatchdog?: RunIdleWatchdog;
|
||||
assistantTextEmitted?: boolean;
|
||||
};
|
||||
export class SessionRegistry {
|
||||
private sessions = new Map<string, HarnessSession>();
|
||||
readonly events = new MemoryEventStore();
|
||||
readonly attachments = new AttachmentStore(
|
||||
Number(process.env.MAX_IMAGE_BYTES) || 5 * 1024 * 1024,
|
||||
);
|
||||
readonly models = new ModelConnectionStore();
|
||||
readonly approvals = new ApprovalBroker(
|
||||
Number(process.env.APPROVAL_TIMEOUT_MS) || 60_000,
|
||||
(a) => {
|
||||
const session = this.sessions.get(a.sessionId);
|
||||
if (a.status === "pending") session?.runIdleWatchdog?.pause();
|
||||
else session?.runIdleWatchdog?.touch();
|
||||
this.events.append(a.sessionId, {
|
||||
type:
|
||||
a.status === "pending" ? "approval.required" : "approval.resolved",
|
||||
runId: a.runId,
|
||||
payload: { ...a, arguments: redact(a.arguments) },
|
||||
});
|
||||
},
|
||||
);
|
||||
constructor(
|
||||
readonly root: string,
|
||||
readonly sessionsRoot = path.join(root, "workspace/sessions"),
|
||||
) {}
|
||||
async create() {
|
||||
const sessionId = `ses_${randomUUID()}`;
|
||||
const workspace = await SafeWorkspace.create(this.sessionsRoot, sessionId);
|
||||
const s = { sessionId, workspace, busy: false };
|
||||
this.sessions.set(sessionId, s);
|
||||
this.events.append(sessionId, {
|
||||
type: "session.started",
|
||||
payload: { cwd: "workspace/sessions/<session>" },
|
||||
});
|
||||
return s;
|
||||
}
|
||||
get(id: string) {
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
require(id: string) {
|
||||
const s = this.get(id);
|
||||
if (!s) throw new Error("SESSION_NOT_FOUND");
|
||||
return s;
|
||||
}
|
||||
resetAgent(id: string) {
|
||||
const s = this.require(id);
|
||||
s.unsubscribe?.();
|
||||
s.agent?.dispose();
|
||||
s.unsubscribe = undefined;
|
||||
s.agent = undefined;
|
||||
}
|
||||
async ensureAgent(s: HarnessSession, config: ModelConnectionInput) {
|
||||
if (s.agent) return s.agent;
|
||||
const runtime = await ModelRuntime.create({
|
||||
modelsPath: null,
|
||||
refreshOnCreate: false,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
runtime.registerProvider(config.providerId, {
|
||||
name: config.displayName ?? config.providerId,
|
||||
baseUrl: config.baseUrl,
|
||||
api: config.api,
|
||||
models: [
|
||||
{
|
||||
id: config.modelId,
|
||||
name: config.displayName ?? config.modelId,
|
||||
api: config.api,
|
||||
baseUrl: config.baseUrl,
|
||||
reasoning: false,
|
||||
input: config.supportsImages ? ["text", "image"] : ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: config.contextWindow ?? 128000,
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (config.apiKey)
|
||||
await runtime.setRuntimeApiKey(config.providerId, config.apiKey);
|
||||
const model = runtime.getModel(config.providerId, config.modelId);
|
||||
if (!model) throw new Error("MODEL_NOT_CONFIGURED");
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: this.root,
|
||||
agentDir: path.join(this.root, ".pi"),
|
||||
additionalSkillPaths: [
|
||||
path.join(this.root, ".agents/skills"),
|
||||
path.join(this.root, ".pi/skills"),
|
||||
],
|
||||
noExtensions: true,
|
||||
});
|
||||
await loader.reload();
|
||||
let tools: ToolDefinition[] = createTools(
|
||||
path.join(this.root, "fixtures/ads-account.json"),
|
||||
s.workspace,
|
||||
);
|
||||
tools.push(...createSwadsReadTools());
|
||||
tools.push(...createWebReadTools());
|
||||
if (config.supportsVideo)
|
||||
tools.push(...createVideoTools(
|
||||
s.workspace,
|
||||
path.resolve(this.root, "../skills-linke"),
|
||||
));
|
||||
tools = tools.map((t) =>
|
||||
["save_report_draft", "save_report_image", "generate_product_anchor", "h3_render"].includes(t.name)
|
||||
? approvalWrapped(t, this.approvals, s)
|
||||
: t,
|
||||
);
|
||||
const videoToolNames = config.supportsVideo
|
||||
? [
|
||||
"h3_preflight",
|
||||
"h3_workers",
|
||||
"h3_init",
|
||||
"generate_product_anchor",
|
||||
"h3_render",
|
||||
"h3_verify",
|
||||
]
|
||||
: [];
|
||||
const { session } = await createAgentSession({
|
||||
cwd: s.workspace.root,
|
||||
modelRuntime: runtime,
|
||||
model,
|
||||
tools: [
|
||||
"read",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"save_report_draft",
|
||||
"save_report_image",
|
||||
"swads_whoami",
|
||||
"metrics_catalog",
|
||||
"metrics_semantic_query",
|
||||
"commerce_list_products",
|
||||
"browser_search",
|
||||
"browser_open",
|
||||
...videoToolNames,
|
||||
],
|
||||
customTools: tools,
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.inMemory(s.workspace.root),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
defaultTools: [
|
||||
"read",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"swads_whoami",
|
||||
"metrics_catalog",
|
||||
"metrics_semantic_query",
|
||||
"commerce_list_products",
|
||||
"browser_search",
|
||||
"browser_open",
|
||||
...videoToolNames,
|
||||
],
|
||||
}),
|
||||
noTools: "all",
|
||||
});
|
||||
s.agent = session;
|
||||
s.unsubscribe = session.subscribe((e) => this.mapEvent(s, e));
|
||||
return session;
|
||||
}
|
||||
async run(s: HarnessSession, text: string, images: ImageContent[] = []) {
|
||||
if (s.busy) throw new Error("SESSION_BUSY");
|
||||
const config = this.models.get(s.sessionId);
|
||||
if (!config) throw new Error("MODEL_NOT_CONFIGURED");
|
||||
if (images.length && !config.supportsImages)
|
||||
throw new Error("MODEL_DOES_NOT_SUPPORT_IMAGES");
|
||||
s.busy = true;
|
||||
s.runId = `run_${randomUUID()}`;
|
||||
s.runError = undefined;
|
||||
s.runTimedOut = false;
|
||||
s.assistantTextEmitted = false;
|
||||
const runId = s.runId;
|
||||
this.events.append(s.sessionId, {
|
||||
type: "user.message",
|
||||
runId,
|
||||
payload: { text, attachmentCount: images.length },
|
||||
});
|
||||
this.events.append(s.sessionId, {
|
||||
type: "run.started",
|
||||
runId,
|
||||
payload: {},
|
||||
});
|
||||
const agent = await this.ensureAgent(s, config);
|
||||
const idleTimeoutMs =
|
||||
Number(
|
||||
process.env.SESSION_RUN_IDLE_TIMEOUT_MS ??
|
||||
process.env.SESSION_RUN_TIMEOUT_MS,
|
||||
) || 300_000;
|
||||
const watchdog = new RunIdleWatchdog(idleTimeoutMs, () => {
|
||||
s.runTimedOut = true;
|
||||
void agent.abort();
|
||||
this.events.append(s.sessionId, {
|
||||
type: "run.failed",
|
||||
runId,
|
||||
payload: { code: "RUN_IDLE_TIMEOUT", idleTimeoutMs },
|
||||
});
|
||||
});
|
||||
s.runIdleWatchdog = watchdog;
|
||||
watchdog.touch();
|
||||
void agent
|
||||
.prompt(text, { images })
|
||||
.then(() => {
|
||||
if (!s.runTimedOut)
|
||||
this.events.append(s.sessionId, {
|
||||
type: s.runError ? "run.failed" : "run.completed",
|
||||
runId,
|
||||
payload: s.runError ? { message: s.runError } : {},
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!s.runTimedOut)
|
||||
this.events.append(s.sessionId, {
|
||||
type: "run.failed",
|
||||
runId,
|
||||
payload: { message: safeError(e) },
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
watchdog.stop();
|
||||
if (s.runIdleWatchdog === watchdog) s.runIdleWatchdog = undefined;
|
||||
s.busy = false;
|
||||
s.runId = undefined;
|
||||
s.runTimedOut = undefined;
|
||||
});
|
||||
return runId;
|
||||
}
|
||||
async cancel(id: string) {
|
||||
const s = this.require(id);
|
||||
if (s.agent && s.busy) {
|
||||
this.approvals.cancelForSession(id);
|
||||
await s.agent.abort();
|
||||
this.events.append(id, {
|
||||
type: "run.cancelled",
|
||||
runId: s.runId,
|
||||
payload: { reason: "user" },
|
||||
});
|
||||
}
|
||||
}
|
||||
async delete(id: string) {
|
||||
const s = this.require(id);
|
||||
await this.cancel(id);
|
||||
this.approvals.cancelForSession(id);
|
||||
s.unsubscribe?.();
|
||||
s.agent?.dispose();
|
||||
await this.attachments.removeAll(id);
|
||||
this.events.append(id, { type: "session.ended", payload: {} });
|
||||
await s.workspace.remove();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
private mapEvent(s: HarnessSession, e: AgentSessionEvent) {
|
||||
s.runIdleWatchdog?.touch();
|
||||
const runId = s.runId;
|
||||
if (e.type === "message_update") {
|
||||
const x = e.assistantMessageEvent as { type?: string; delta?: string };
|
||||
if (x.type === "text_delta" && x.delta)
|
||||
s.assistantTextEmitted = true,
|
||||
this.events.append(s.sessionId, {
|
||||
type: "assistant.delta",
|
||||
runId,
|
||||
payload: { delta: x.delta },
|
||||
});
|
||||
} else if (e.type === "message_end" && e.message.role === "assistant") {
|
||||
const finalText = e.message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
if (!s.assistantTextEmitted && finalText) {
|
||||
s.assistantTextEmitted = true;
|
||||
this.events.append(s.sessionId, {
|
||||
type: "assistant.delta",
|
||||
runId,
|
||||
payload: { delta: finalText },
|
||||
});
|
||||
}
|
||||
if (
|
||||
!s.runTimedOut &&
|
||||
(e.message.stopReason === "error" || e.message.stopReason === "aborted")
|
||||
) {
|
||||
s.runError = normalizeModelError(
|
||||
e.message.errorMessage ?? `MODEL_${e.message.stopReason.toUpperCase()}`,
|
||||
);
|
||||
if (!finalText)
|
||||
this.events.append(s.sessionId, {
|
||||
type: "assistant.delta",
|
||||
runId,
|
||||
payload: { delta: `模型请求失败:${s.runError}` },
|
||||
});
|
||||
}
|
||||
this.events.append(s.sessionId, {
|
||||
type: "assistant.completed",
|
||||
runId,
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
else if (e.type === "tool_execution_start")
|
||||
this.events.append(s.sessionId, {
|
||||
type: "tool.requested",
|
||||
runId,
|
||||
payload: {
|
||||
toolCallId: e.toolCallId,
|
||||
toolName: e.toolName,
|
||||
arguments: redact(e.args),
|
||||
},
|
||||
});
|
||||
else if (e.type === "tool_execution_end")
|
||||
this.events.append(s.sessionId, {
|
||||
type: e.isError ? "tool.failed" : "tool.completed",
|
||||
runId,
|
||||
payload: {
|
||||
toolCallId: e.toolCallId,
|
||||
toolName: e.toolName,
|
||||
result: summarizeEventResult(e.result),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
function redact(v: unknown): Record<string, unknown> {
|
||||
if (v && typeof v === "object" && !Array.isArray(v))
|
||||
return Object.fromEntries(
|
||||
Object.entries(v).map(([k, x]) => [
|
||||
k,
|
||||
/key|token|secret|authorization/i.test(k)
|
||||
? "[REDACTED]"
|
||||
: typeof x === "string" && x.length > 4000
|
||||
? `${x.slice(0, 4000)}…`
|
||||
: x,
|
||||
]),
|
||||
);
|
||||
return { value: v };
|
||||
}
|
||||
function summarizeEventResult(value: unknown): Record<string, unknown> {
|
||||
const redacted = redact(value);
|
||||
const serialized = JSON.stringify(redacted);
|
||||
return serialized.length <= 8_000
|
||||
? redacted
|
||||
: {
|
||||
omitted: true,
|
||||
originalCharacters: serialized.length,
|
||||
message: "Large tool result omitted from the event timeline.",
|
||||
};
|
||||
}
|
||||
function safeError(e: unknown) {
|
||||
return e instanceof Error
|
||||
? normalizeModelError(e.message.replace(/\/[^\s]+/g, "[path]"))
|
||||
: "Unknown error";
|
||||
}
|
||||
function normalizeModelError(message: string) {
|
||||
return /maximum context length|input_tokens|context window/i.test(message)
|
||||
? "CONTEXT_WINDOW_EXCEEDED:当前 Session 的上下文已满。请重置 Session 后重试;SW Ads 大结果已自动限制为 50 行。"
|
||||
: message;
|
||||
}
|
||||
function approvalWrapped(
|
||||
tool: ToolDefinition,
|
||||
broker: ApprovalBroker,
|
||||
s: HarnessSession,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
...tool,
|
||||
execute: async (toolCallId, args, signal, onUpdate, ctx) => {
|
||||
const decision = await broker.request(
|
||||
{
|
||||
sessionId: s.sessionId,
|
||||
runId: s.runId ?? "",
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
arguments: redact(args),
|
||||
riskLevel: "medium",
|
||||
},
|
||||
signal ?? new AbortController().signal,
|
||||
);
|
||||
if (decision.decision !== "approved")
|
||||
throw new Error(`APPROVAL_${decision.decision.toUpperCase()}`);
|
||||
return tool.execute(toolCallId, args, signal, onUpdate, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import path, { delimiter } from "node:path";
|
||||
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
||||
import type { SkillCatalogItem } from "@agent-studio/shared";
|
||||
export class SkillCatalog {
|
||||
items: SkillCatalogItem[] = [];
|
||||
canonical = new Map<string, SkillCatalogItem>();
|
||||
constructor(private projectRoot: string) {}
|
||||
async reload() {
|
||||
const extra = (process.env.AGENT_SKILLS_DIRS ?? "")
|
||||
.split(delimiter)
|
||||
.filter(Boolean);
|
||||
const dirs = [
|
||||
path.join(this.projectRoot, ".agents/skills"),
|
||||
path.join(this.projectRoot, ".pi/skills"),
|
||||
...extra,
|
||||
];
|
||||
const valid: string[] = [];
|
||||
const diagnostics: SkillCatalogItem[] = [];
|
||||
for (const dir of [...new Set(dirs.map((d) => path.resolve(d)))]) {
|
||||
try {
|
||||
await access(dir);
|
||||
if (!(await stat(dir)).isDirectory()) throw new Error();
|
||||
valid.push(await realpath(dir));
|
||||
} catch {
|
||||
diagnostics.push({
|
||||
name: path.basename(dir),
|
||||
description: "Skill directory unavailable",
|
||||
source: "environment",
|
||||
filePath: dir.startsWith(this.projectRoot)
|
||||
? path.relative(this.projectRoot, dir)
|
||||
: path.basename(dir),
|
||||
diagnostics: [
|
||||
{ level: "error", message: "目录不存在、不可读或不是目录" },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: this.projectRoot,
|
||||
agentDir: path.join(this.projectRoot, ".pi"),
|
||||
additionalSkillPaths: valid,
|
||||
noExtensions: true,
|
||||
});
|
||||
await loader.reload();
|
||||
this.items = [
|
||||
...loader
|
||||
.getSkills()
|
||||
.skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
source: s.sourceInfo.source,
|
||||
filePath: path.relative(this.projectRoot, s.filePath),
|
||||
diagnostics: [],
|
||||
})),
|
||||
...diagnostics,
|
||||
];
|
||||
for (const item of this.items)
|
||||
if (!item.diagnostics.some((d) => d.level === "error"))
|
||||
this.canonical.set(
|
||||
await realpath(path.resolve(this.projectRoot, item.filePath)),
|
||||
item,
|
||||
);
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {randomUUID} from 'node:crypto'; import type {HarnessEvent,HarnessEventType} from '@agent-studio/shared';
|
||||
export interface EventStore{append(sessionId:string,event:{type:HarnessEventType;runId?:string;payload:Record<string,unknown>}):HarnessEvent;listAfter(sessionId:string,cursor?:number):HarnessEvent[];subscribe(sessionId:string,listener:(event:HarnessEvent)=>void):()=>void}
|
||||
export class MemoryEventStore implements EventStore{private data=new Map<string,HarnessEvent[]>();private listeners=new Map<string,Set<(e:HarnessEvent)=>void>>();constructor(private limit=1000){} append(sessionId:string,input:{type:HarnessEventType;runId?:string;payload:Record<string,unknown>}){const list=this.data.get(sessionId)??[];const event={...input,eventId:`evt_${randomUUID()}`,sessionId,timestamp:new Date().toISOString(),sequence:(list.at(-1)?.sequence??0)+1} as HarnessEvent;list.push(event);if(list.length>this.limit)list.shift();this.data.set(sessionId,list);this.listeners.get(sessionId)?.forEach(fn=>fn(event));return event}listAfter(id:string,cursor=0){return (this.data.get(id)??[]).filter(e=>e.sequence>cursor)}subscribe(id:string,fn:(e:HarnessEvent)=>void){const set=this.listeners.get(id)??new Set();set.add(fn);this.listeners.set(id,set);return()=>set.delete(fn)}}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { Type, type TSchema } from "@sinclair/typebox";
|
||||
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/compat";
|
||||
|
||||
type McpToolResult = {
|
||||
content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| Record<string, unknown>
|
||||
>;
|
||||
isError?: boolean;
|
||||
structuredContent?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const endpoint = process.env.SWADS_MCP_URL || "https://ads.mincode.cn/mcp";
|
||||
const maxRows = Math.max(10, Number(process.env.SWADS_MCP_MAX_ROWS) || 50);
|
||||
const maxTextChars = Math.max(
|
||||
10_000,
|
||||
Number(process.env.SWADS_MCP_MAX_TEXT_CHARS) || 60_000,
|
||||
);
|
||||
let clientPromise: Promise<Client> | undefined;
|
||||
|
||||
export function boundSwadsArguments(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) {
|
||||
if (name !== "metrics_semantic_query") return args;
|
||||
const query = args.query;
|
||||
if (!query || typeof query !== "object" || Array.isArray(query)) return args;
|
||||
const requested = Number((query as Record<string, unknown>).limit);
|
||||
return {
|
||||
...args,
|
||||
query: {
|
||||
...query,
|
||||
limit: Number.isFinite(requested)
|
||||
? Math.min(Math.max(1, requested), maxRows)
|
||||
: maxRows,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function compactSwadsText(text: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
let originalRows: number | undefined;
|
||||
let returnedRows: number | undefined;
|
||||
for (const key of ["rows", "products", "items", "data"]) {
|
||||
const value = parsed[key];
|
||||
if (!Array.isArray(value)) continue;
|
||||
originalRows = value.length;
|
||||
parsed[key] = value.slice(0, maxRows);
|
||||
returnedRows = (parsed[key] as unknown[]).length;
|
||||
break;
|
||||
}
|
||||
if (originalRows !== undefined) {
|
||||
parsed.response_compaction = {
|
||||
original_rows: originalRows,
|
||||
returned_rows: returnedRows,
|
||||
max_rows: maxRows,
|
||||
note: "Result was bounded for model context safety. Use aggregate queries or narrower filters for additional detail.",
|
||||
};
|
||||
}
|
||||
const compact = JSON.stringify(parsed);
|
||||
return compact.length <= maxTextChars
|
||||
? compact
|
||||
: `${compact.slice(0, maxTextChars)}\n[SWADS_RESULT_TRUNCATED_AT_${maxTextChars}_CHARS]`;
|
||||
} catch {
|
||||
return text.length <= maxTextChars
|
||||
? text
|
||||
: `${text.slice(0, maxTextChars)}\n[SWADS_RESULT_TRUNCATED_AT_${maxTextChars}_CHARS]`;
|
||||
}
|
||||
}
|
||||
|
||||
async function swadsClient() {
|
||||
const token = process.env.SWADS_MCP_TOKEN?.trim();
|
||||
if (!token)
|
||||
throw new Error(
|
||||
"SWADS_MCP_NOT_CONFIGURED: set SWADS_MCP_TOKEN in the server environment",
|
||||
);
|
||||
clientPromise ??= (async () => {
|
||||
const client = new Client({ name: "agent-studio-mini", version: "0.1.0" });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
||||
});
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
})().catch((error) => {
|
||||
clientPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
function asTool(
|
||||
name: string,
|
||||
description: string,
|
||||
parameters: TSchema,
|
||||
): ToolDefinition {
|
||||
return defineTool({
|
||||
name,
|
||||
label: `SW Ads · ${name}`,
|
||||
description,
|
||||
parameters,
|
||||
execute: async (_id, args, signal) => {
|
||||
const client = await swadsClient();
|
||||
const boundedArgs = boundSwadsArguments(
|
||||
name,
|
||||
args as Record<string, unknown>,
|
||||
);
|
||||
const result = (await client.callTool(
|
||||
{ name, arguments: boundedArgs },
|
||||
undefined,
|
||||
{ signal },
|
||||
)) as McpToolResult;
|
||||
const content: Array<TextContent | ImageContent> = [];
|
||||
for (const item of result.content) {
|
||||
if (item.type === "text")
|
||||
content.push({ type: "text", text: compactSwadsText(String(item.text)) });
|
||||
else if (item.type === "image")
|
||||
content.push({
|
||||
type: "image",
|
||||
data: String(item.data),
|
||||
mimeType: String(item.mimeType),
|
||||
});
|
||||
else content.push({ type: "text", text: JSON.stringify(item) });
|
||||
}
|
||||
if (result.isError)
|
||||
throw new Error(
|
||||
content
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n") || "SWADS_MCP_TOOL_FAILED",
|
||||
);
|
||||
return {
|
||||
content,
|
||||
details: {
|
||||
source: "swads-mcp",
|
||||
contextBounded: true,
|
||||
maxRows,
|
||||
maxTextChars,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const optionalAccount = {
|
||||
external_account_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
tenant_id: Type.Optional(Type.String()),
|
||||
};
|
||||
|
||||
export function createSwadsReadTools(): ToolDefinition[] {
|
||||
return [
|
||||
asTool(
|
||||
"swads_whoami",
|
||||
"Read the current SW Ads identity, visible accounts, capabilities, timezone, and currency.",
|
||||
Type.Object({}, { additionalProperties: false }),
|
||||
),
|
||||
asTool(
|
||||
"metrics_catalog",
|
||||
"Read the supported SW Ads metrics, dimensions, and semantic-query schema before querying data.",
|
||||
Type.Object({}, { additionalProperties: false }),
|
||||
),
|
||||
asTool(
|
||||
"metrics_semantic_query",
|
||||
"Run a read-only SW Ads semantic metrics query for the current or specified account.",
|
||||
Type.Object(
|
||||
{
|
||||
...optionalAccount,
|
||||
query: Type.Object({}, { additionalProperties: true }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
asTool(
|
||||
"commerce_list_products",
|
||||
"Read the TikTok Shop product catalog and current product metadata.",
|
||||
Type.Object(
|
||||
{
|
||||
...optionalAccount,
|
||||
authorized_business_center_id: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()]),
|
||||
),
|
||||
external_shop_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
only_selectable: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import sharp from "sharp";
|
||||
import type { SafeWorkspace } from "./workspace.js";
|
||||
|
||||
export const adsSchema = Type.Object(
|
||||
{ accountId: Type.String({ pattern: "^[a-z0-9-]+$" }), days: Type.Integer({ minimum: 1, maximum: 30 }) },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export async function adsMetrics(fixture: string, accountId: string, days: number) {
|
||||
const raw = JSON.parse(await readFile(fixture, "utf8")) as { accounts: Record<string, { spend: number; impressions: number; clicks: number; conversions: number; revenue: number; campaigns: unknown[] }> };
|
||||
const account = raw.accounts[accountId];
|
||||
if (!account) throw new Error("ACCOUNT_NOT_FOUND");
|
||||
const div = (x: number, y: number) => (y ? Math.round((x / y) * 100) / 100 : 0);
|
||||
return { ...account, days, ctr: div(account.clicks * 100, account.impressions), cpa: div(account.spend, account.conversions), roas: div(account.revenue, account.spend) };
|
||||
}
|
||||
|
||||
const textItem = Type.Object({ label: Type.String({ maxLength: 80 }), value: Type.String({ maxLength: 80 }), note: Type.Optional(Type.String({ maxLength: 120 })) }, { additionalProperties: false });
|
||||
const funnelStage = Type.Object({ label: Type.String({ maxLength: 40 }), value: Type.String({ maxLength: 40 }), rate: Type.Optional(Type.String({ maxLength: 60 })) }, { additionalProperties: false });
|
||||
const imageReportSchema = Type.Object({
|
||||
reportType: Type.Union([Type.Literal("daily"), Type.Literal("weekly")]),
|
||||
accountName: Type.String({ maxLength: 100 }),
|
||||
period: Type.String({ maxLength: 100 }),
|
||||
timezone: Type.String({ maxLength: 60 }),
|
||||
currency: Type.String({ maxLength: 12 }),
|
||||
freshness: Type.String({ maxLength: 100 }),
|
||||
outcome: Type.String({ maxLength: 240 }),
|
||||
kpis: Type.Array(textItem, { minItems: 4, maxItems: 8 }),
|
||||
funnel: Type.Tuple([funnelStage, funnelStage, funnelStage]),
|
||||
funnelCaption: Type.String({ maxLength: 240 }),
|
||||
highlights: Type.Array(Type.String({ maxLength: 180 }), { minItems: 1, maxItems: 8 }),
|
||||
recommendations: Type.Array(Type.String({ maxLength: 220 }), { minItems: 3, maxItems: 3 }),
|
||||
limitations: Type.Array(Type.String({ maxLength: 180 }), { maxItems: 5 }),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
type ImageReport = {
|
||||
reportType: "daily" | "weekly"; accountName: string; period: string; timezone: string; currency: string; freshness: string; outcome: string;
|
||||
kpis: Array<{ label: string; value: string; note?: string }>;
|
||||
funnel: [{ label: string; value: string; rate?: string }, { label: string; value: string; rate?: string }, { label: string; value: string; rate?: string }];
|
||||
funnelCaption: string; highlights: string[]; recommendations: string[]; limitations: string[];
|
||||
};
|
||||
const esc = (value: string) => value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||
function wrap(value: string, limit: number) {
|
||||
const chars = [...value]; const lines: string[] = [];
|
||||
while (chars.length) lines.push(chars.splice(0, limit).join(""));
|
||||
return lines.length ? lines : [""];
|
||||
}
|
||||
function textBlock(value: string, x: number, y: number, width = 48, size = 25, color = "#29463b", weight = 500, line = 38) {
|
||||
return `<text x="${x}" y="${y}" font-size="${size}" fill="${color}" font-weight="${weight}">${wrap(value, width).map((part, i) => `<tspan x="${x}" dy="${i ? line : 0}">${esc(part)}</tspan>`).join("")}</text>`;
|
||||
}
|
||||
function renderReportSvg(report: ImageReport) {
|
||||
const periodWidth = 52;
|
||||
const outcomeWidth = 46;
|
||||
const periodY = 186;
|
||||
const outcomeY = periodY + (wrap(`${report.period} · ${report.timezone} · ${report.currency}`, periodWidth).length - 1) * 38 + 44;
|
||||
const headerHeight = Math.max(235, outcomeY + (wrap(report.outcome, outcomeWidth).length - 1) * 30 + 28 - 45);
|
||||
const kpiStartY = 45 + headerHeight + 50;
|
||||
const kpiRowGap = 18;
|
||||
const visibleKpis = report.kpis.slice(0, 8);
|
||||
const kpiLabelWidth = 12;
|
||||
const kpiValueWidth = 11;
|
||||
const kpiNoteWidth = 13;
|
||||
const kpiHeights = visibleKpis.map((kpi) =>
|
||||
28 + wrap(kpi.label, kpiLabelWidth).length * 22 + 10 + wrap(kpi.value, kpiValueWidth).length * 38 + 8 + wrap(kpi.note ?? "", kpiNoteWidth).length * 21 + 20,
|
||||
);
|
||||
const kpiRowHeights = [0, 1].map((row) =>
|
||||
Math.max(155, ...kpiHeights.slice(row * 4, row * 4 + 4)),
|
||||
);
|
||||
const kpiRowY = [kpiStartY, kpiStartY + kpiRowHeights[0]! + kpiRowGap];
|
||||
const kpis = report.kpis.slice(0, 8).map((kpi, i) => {
|
||||
const col = i % 4, row = Math.floor(i / 4), x = 70 + col * 265, y = kpiRowY[row]!;
|
||||
const labelLines = wrap(kpi.label, kpiLabelWidth).length;
|
||||
const valueLines = wrap(kpi.value, kpiValueWidth).length;
|
||||
const valueY = y + 32 + labelLines * 22 + 12;
|
||||
const noteY = valueY + valueLines * 38 + 2;
|
||||
return `<rect x="${x}" y="${y}" width="240" height="${kpiRowHeights[row]}" rx="18" fill="${i === 3 ? "#18563f" : "#fff"}"/>${textBlock(kpi.label, x + 20, y + 32, kpiLabelWidth, 19, i === 3 ? "#e8f3ee" : "#50635b", 700, 22)}${textBlock(kpi.value, x + 20, valueY, kpiValueWidth, 32, i === 3 ? "#fff" : "#17372b", 800, 38)}${textBlock(kpi.note ?? "", x + 20, noteY, kpiNoteWidth, 15, i === 3 ? "#e8f3ee" : "#53665e", 500, 21)}`;
|
||||
}).join("");
|
||||
const usedKpiRows = report.kpis.length > 4 ? 2 : 1;
|
||||
const fy = kpiRowY[usedKpiRows - 1]! + kpiRowHeights[usedKpiRows - 1]! + 55;
|
||||
const funnel = report.funnel.map((stage, i) => {
|
||||
const widths = [900, 760, 620], x = (1200 - widths[i]!) / 2, y = fy + 75 + i * 92;
|
||||
return `<rect x="${x}" y="${y}" width="${widths[i]}" height="72" rx="16" fill="${i === 2 ? "#e5f3d8" : "#f1f5ef"}"/>${textBlock(stage.label, x + 24, y + 31, 20, 21, "#29463b", 700)}${textBlock(stage.rate ?? "", x + 24, y + 56, 36, 15, "#68766f", 500)}<text x="${x + widths[i]! - 24}" y="${y + 45}" text-anchor="end" font-size="30" fill="#17372b" font-weight="800">${esc(stage.value)}</text>`;
|
||||
}).join("");
|
||||
const funnelCaptionLines = wrap(report.funnelCaption, 54).length;
|
||||
const funnelHeight = 340 + funnelCaptionLines * 25;
|
||||
const hy = fy + funnelHeight + 28;
|
||||
let highlightY = hy + 76;
|
||||
const highlights = report.highlights.slice(0, 6).map((item) => {
|
||||
const value = `• ${item}`;
|
||||
const svg = textBlock(value, 92, highlightY, 46, 21, "#29463b", 500, 31);
|
||||
highlightY += wrap(value, 46).length * 31 + 18;
|
||||
return svg;
|
||||
}).join("");
|
||||
const highlightHeight = Math.max(180, highlightY - hy + 22);
|
||||
const ry = hy + highlightHeight + 28;
|
||||
let recY = ry + 78;
|
||||
const recs = report.recommendations.map((item, i) => {
|
||||
const lines = wrap(item, 44).length;
|
||||
const svg = `<circle cx="108" cy="${recY - 8}" r="22" fill="#c6ef63"/><text x="108" y="${recY}" text-anchor="middle" font-size="22" fill="#18563f" font-weight="800">${i + 1}</text>${textBlock(item, 150, recY - 16, 44, 20, "#29463b", 600, 30)}`;
|
||||
recY += lines * 30 + 28;
|
||||
return svg;
|
||||
}).join("");
|
||||
const recommendationHeight = Math.max(250, recY - ry + 24);
|
||||
const ly = ry + recommendationHeight + 28;
|
||||
let limitationY = ly + 72;
|
||||
const limitations = report.limitations.map((item) => {
|
||||
const value = `• ${item}`;
|
||||
const svg = textBlock(value, 92, limitationY, 54, 17, "#68766f", 500, 27);
|
||||
limitationY += wrap(value, 54).length * 27 + 12;
|
||||
return svg;
|
||||
}).join("");
|
||||
const confirmationY = limitationY + 12;
|
||||
const limitationHeight = confirmationY - ly + 70;
|
||||
const height = ly + limitationHeight + 92;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="${height}" viewBox="0 0 1200 ${height}"><rect width="1200" height="${height}" fill="#f4f1e8"/><rect x="45" y="45" width="1110" height="${headerHeight}" rx="26" fill="#18563f"/>${textBlock("SW ADS · " + (report.reportType === "daily" ? "日报" : "周报"), 78, 88, 30, 18, "#c6ef63", 800)}${textBlock(report.accountName, 78, 145, 34, 40, "#fff", 800)}${textBlock(`${report.period} · ${report.timezone} · ${report.currency}`, 78, periodY, periodWidth, 19, "#d9e9e2", 500)}${textBlock(report.outcome, 78, outcomeY, outcomeWidth, 21, "#fff", 650, 30)}${kpis}<rect x="70" y="${fy}" width="1060" height="${funnelHeight}" rx="20" fill="#fff"/>${textBlock("曝光 → 点击 → 购买漏斗", 92, fy + 45, 40, 28, "#17372b", 800)}${funnel}${textBlock(report.funnelCaption, 150, fy + 345, 54, 16, "#68766f", 500, 25)}<rect x="70" y="${hy}" width="1060" height="${highlightHeight}" rx="20" fill="#fff"/>${textBlock("表现要点", 92, hy + 45, 30, 28, "#17372b", 800)}${highlights}<rect x="70" y="${ry}" width="1060" height="${recommendationHeight}" rx="20" fill="#fff"/>${textBlock("优先建议 · 仅 3 项", 92, ry + 45, 30, 28, "#17372b", 800)}${recs}<rect x="70" y="${ly}" width="1060" height="${limitationHeight}" rx="20" fill="#fff8e9"/>${textBlock("口径、限制与人工确认", 92, ly + 42, 36, 25, "#a96b12", 800)}${limitations}${textBlock("任何预算、状态、目标或素材变更均需人工确认;本图片生成过程只读。", 92, confirmationY, 54, 17, "#68766f", 600)}${textBlock("数据新鲜度:" + report.freshness, 70, height - 45, 60, 16, "#68766f", 500)}</svg>`;
|
||||
}
|
||||
|
||||
export function createTools(fixture: string, workspace: SafeWorkspace) {
|
||||
return [
|
||||
defineTool({ name: "mock_ads_metrics", label: "Mock ads metrics", description: "Get deterministic classroom ad metrics", parameters: adsSchema, execute: async (_id, p) => { const data = await adsMetrics(fixture, p.accountId, p.days); return { content: [{ type: "text", text: JSON.stringify(data) }], details: data }; } }),
|
||||
defineTool({ name: "save_report_draft", label: "Save report draft", description: "Save approved report.md", parameters: Type.Object({ body: Type.String({ minLength: 1 }), title: Type.Optional(Type.String()) }, { additionalProperties: false }), execute: async (_id, p) => { const data = await workspace.writeReport(p.body, p.title); return { content: [{ type: "text", text: `Saved ${data.file}` }], details: data }; } }),
|
||||
defineTool({
|
||||
name: "save_report_image",
|
||||
label: "生成报告图片",
|
||||
description: "Render a complete SW Ads daily or weekly report as PNG. Exactly three funnel stages and three recommendations are required. Use N/A for unavailable funnel values.",
|
||||
parameters: imageReportSchema,
|
||||
execute: async (_id, p) => {
|
||||
const report = p as ImageReport;
|
||||
const png = await sharp(Buffer.from(renderReportSvg(report))).png().toBuffer();
|
||||
const data = await workspace.writeReportImage(report.reportType, png);
|
||||
return { content: [{ type: "text", text: `Saved report image: ${data.path}` }], details: { ...data, reportType: report.reportType, funnelIncluded: true } };
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import type { SafeWorkspace } from "./workspace.js";
|
||||
|
||||
const runFile = promisify(execFile);
|
||||
|
||||
function cleanName(value: string) {
|
||||
const clean = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
if (!clean) throw new Error("INVALID_NAME");
|
||||
return clean.slice(0, 80);
|
||||
}
|
||||
|
||||
async function run(
|
||||
cwd: string,
|
||||
project: string,
|
||||
args: string[],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const result = await runFile("uv", ["--project", project, "run", ...args], {
|
||||
cwd,
|
||||
signal,
|
||||
timeout: 30 * 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: process.env,
|
||||
});
|
||||
return `${result.stdout}${result.stderr}`.trim();
|
||||
}
|
||||
|
||||
export function createVideoTools(
|
||||
workspace: SafeWorkspace,
|
||||
piecesProject: string,
|
||||
): ToolDefinition[] {
|
||||
const preflight = defineTool({
|
||||
name: "h3_preflight",
|
||||
label: "H3 brief preflight",
|
||||
description:
|
||||
"Validate an H3 video brief before writing the shot plan. Read-only and required before rendering.",
|
||||
parameters: Type.Object(
|
||||
{
|
||||
aspectRatio: Type.Union([Type.Literal("9:16"), Type.Literal("16:9")]),
|
||||
duration: Type.Number({ minimum: 5, maximum: 300 }),
|
||||
language: Type.Optional(Type.String()),
|
||||
clipDuration: Type.Optional(Type.Number({ minimum: 5, maximum: 15 })),
|
||||
planningFps: Type.Optional(Type.Integer({ minimum: 1, maximum: 120 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
execute: async (_id, p, signal) => {
|
||||
const args = [
|
||||
"piecesai",
|
||||
"h3",
|
||||
"preflight",
|
||||
"--aspect-ratio",
|
||||
p.aspectRatio,
|
||||
"--duration",
|
||||
String(p.duration),
|
||||
"--json",
|
||||
];
|
||||
if (p.language) args.push("--language", p.language);
|
||||
if (p.clipDuration) args.push("--clip-duration", String(p.clipDuration));
|
||||
if (p.planningFps) args.push("--planning-fps", String(p.planningFps));
|
||||
const output = await run(workspace.root, piecesProject, args, signal);
|
||||
return { content: [{ type: "text" as const, text: output }], details: { output } };
|
||||
},
|
||||
});
|
||||
|
||||
const workers = defineTool({
|
||||
name: "h3_workers",
|
||||
label: "H3 fleet workers",
|
||||
description: "Read H3 GPU worker status, devices, and queue depth.",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
execute: async (_id, _p, signal) => {
|
||||
const output = await run(
|
||||
workspace.root,
|
||||
piecesProject,
|
||||
["piecesai", "h3", "workers"],
|
||||
signal,
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: output }], details: { output } };
|
||||
},
|
||||
});
|
||||
|
||||
const init = defineTool({
|
||||
name: "h3_init",
|
||||
label: "Create H3 project",
|
||||
description: "Create a local H3 production project and return its project ID.",
|
||||
parameters: Type.Object(
|
||||
{ name: Type.String({ minLength: 1, maxLength: 80 }) },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
execute: async (_id, p, signal) => {
|
||||
const output = await run(
|
||||
workspace.root,
|
||||
piecesProject,
|
||||
["piecesai", "h3", "init", "--name", cleanName(p.name)],
|
||||
signal,
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: output }], details: { output } };
|
||||
},
|
||||
});
|
||||
|
||||
const anchor = defineTool({
|
||||
name: "generate_product_anchor",
|
||||
label: "Generate product anchor",
|
||||
description:
|
||||
"Generate one approved product anchor still through PiecesAI. Requires human approval and preserves a receipt.",
|
||||
parameters: Type.Object(
|
||||
{
|
||||
name: Type.String({ minLength: 1, maxLength: 80 }),
|
||||
prompt: Type.String({ minLength: 20, maxLength: 20_000 }),
|
||||
references: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 5 }),
|
||||
aspectRatio: Type.Union([
|
||||
Type.Literal("9:16"),
|
||||
Type.Literal("16:9"),
|
||||
Type.Literal("1:1"),
|
||||
]),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
execute: async (_id, p, signal) => {
|
||||
const name = cleanName(p.name);
|
||||
const dir = workspace.resolve("video/anchors");
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
const promptFile = workspace.resolve(`video/anchors/${name}.prompt.txt`);
|
||||
const out = workspace.resolve(`video/anchors/${name}.png`);
|
||||
const receipt = workspace.resolve(`video/anchors/${name}.receipt.json`);
|
||||
await writeFile(promptFile, p.prompt, { mode: 0o600 });
|
||||
const args = [
|
||||
"python",
|
||||
"skills/pieces-image-generation/scripts/generate_image.py",
|
||||
"--prompt-file",
|
||||
promptFile,
|
||||
"--aspect",
|
||||
p.aspectRatio,
|
||||
"--out",
|
||||
out,
|
||||
"--receipt",
|
||||
receipt,
|
||||
];
|
||||
for (const reference of p.references)
|
||||
args.push("--reference", workspace.resolve(reference));
|
||||
const output = await run(workspace.root, piecesProject, args, signal);
|
||||
const details = {
|
||||
image: path.relative(workspace.root, out),
|
||||
receipt: path.relative(workspace.root, receipt),
|
||||
output,
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const render = defineTool({
|
||||
name: "h3_render",
|
||||
label: "Render H3 video",
|
||||
description:
|
||||
"Render one approved MiniMax H3 Ref2VA clip from a structured prompt and local references. Requires human approval.",
|
||||
parameters: Type.Object(
|
||||
{
|
||||
projectId: Type.String({ minLength: 1, maxLength: 120 }),
|
||||
title: Type.String({ minLength: 1, maxLength: 120 }),
|
||||
callingSkill: Type.String({ minLength: 1, maxLength: 120 }),
|
||||
prompt: Type.String({ minLength: 100, maxLength: 60_000 }),
|
||||
references: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 12 }),
|
||||
aspectRatio: Type.Union([Type.Literal("9:16"), Type.Literal("16:9")]),
|
||||
duration: Type.Number({ minimum: 5, maximum: 15 }),
|
||||
megapixels: Type.Optional(Type.Number({ minimum: 0.1, maximum: 1.03 })),
|
||||
seed: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
execute: async (_id, p, signal) => {
|
||||
const name = cleanName(p.title);
|
||||
const promptDir = workspace.resolve("video/prompts");
|
||||
await mkdir(promptDir, { recursive: true, mode: 0o700 });
|
||||
const promptFile = workspace.resolve(`video/prompts/${name}.txt`);
|
||||
await writeFile(promptFile, p.prompt, { mode: 0o600 });
|
||||
const args = [
|
||||
"piecesai",
|
||||
"h3",
|
||||
"render",
|
||||
"--project-id",
|
||||
p.projectId,
|
||||
"--title",
|
||||
p.title,
|
||||
"--skill",
|
||||
p.callingSkill,
|
||||
"--prompt-file",
|
||||
promptFile,
|
||||
"--aspect-ratio",
|
||||
p.aspectRatio,
|
||||
"--duration",
|
||||
String(p.duration),
|
||||
"--resolution",
|
||||
"768p",
|
||||
"--h3-mode",
|
||||
"ref2va",
|
||||
"--megapixels",
|
||||
String(p.megapixels ?? 1.03),
|
||||
];
|
||||
for (const reference of p.references)
|
||||
args.push("--reference", workspace.resolve(reference));
|
||||
if (p.seed !== undefined) args.push("--seed", String(p.seed));
|
||||
const output = await run(workspace.root, piecesProject, args, signal);
|
||||
return { content: [{ type: "text" as const, text: output }], details: { output } };
|
||||
},
|
||||
});
|
||||
|
||||
const verify = defineTool({
|
||||
name: "h3_verify",
|
||||
label: "Verify H3 delivery",
|
||||
description: "Verify the rendered video's aspect ratio, duration, and optional frame rate.",
|
||||
parameters: Type.Object(
|
||||
{
|
||||
input: Type.String({ minLength: 1 }),
|
||||
aspectRatio: Type.Union([Type.Literal("9:16"), Type.Literal("16:9")]),
|
||||
duration: Type.Optional(Type.Number({ minimum: 0.1, maximum: 3600 })),
|
||||
fps: Type.Optional(Type.Number({ minimum: 1, maximum: 240 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
execute: async (_id, p, signal) => {
|
||||
const args = [
|
||||
"piecesai",
|
||||
"h3",
|
||||
"post",
|
||||
"verify",
|
||||
"--input",
|
||||
workspace.resolve(p.input),
|
||||
"--expect-aspect",
|
||||
p.aspectRatio,
|
||||
"--json",
|
||||
];
|
||||
if (p.duration !== undefined) args.push("--expect-duration", String(p.duration));
|
||||
if (p.fps !== undefined) args.push("--expect-fps", String(p.fps));
|
||||
const output = await run(workspace.root, piecesProject, args, signal);
|
||||
return { content: [{ type: "text" as const, text: output }], details: { output } };
|
||||
},
|
||||
});
|
||||
|
||||
return [preflight, workers, init, anchor, render, verify];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const userAgent =
|
||||
"Mozilla/5.0 (compatible; AgentStudioMini/0.1; +http://localhost)";
|
||||
|
||||
function decodeHtml(value: string) {
|
||||
return value
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function privateAddress(address: string) {
|
||||
if (address === "::1" || address.startsWith("fe80:") || address.startsWith("fc") || address.startsWith("fd")) return true;
|
||||
if (!address.includes(".")) return false;
|
||||
const [a = 0, b = 0] = address.split(".").map(Number);
|
||||
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||
}
|
||||
|
||||
async function publicUrl(raw: string) {
|
||||
const url = new URL(raw);
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error("URL_PROTOCOL_NOT_ALLOWED");
|
||||
if (url.username || url.password) throw new Error("URL_CREDENTIALS_NOT_ALLOWED");
|
||||
const addresses = isIP(url.hostname)
|
||||
? [{ address: url.hostname }]
|
||||
: await lookup(url.hostname, { all: true });
|
||||
if (!addresses.length || addresses.some((item) => privateAddress(item.address)))
|
||||
throw new Error("PRIVATE_NETWORK_URL_NOT_ALLOWED");
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchText(url: URL, maxChars: number) {
|
||||
const response = await fetch(url, {
|
||||
headers: { "user-agent": userAgent, accept: "text/html,text/plain" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||
const text = await response.text();
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
export function createWebReadTools(): ToolDefinition[] {
|
||||
return [
|
||||
defineTool({
|
||||
name: "browser_search",
|
||||
label: "Search the public web",
|
||||
description: "Search public web pages read-only. Returns titles, URLs, and snippets; never use it to claim engagement metrics that are not visible in the results.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ minLength: 2, maxLength: 300 }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
|
||||
}, { additionalProperties: false }),
|
||||
execute: async (_id, params) => {
|
||||
const limit = params.limit ?? 8;
|
||||
const url = new URL("https://html.duckduckgo.com/html/");
|
||||
url.searchParams.set("q", params.query);
|
||||
const html = await fetchText(url, 500_000);
|
||||
const results: Array<{ title: string; url: string; snippet: string }> = [];
|
||||
const blocks = html.split(/class="result results_links/).slice(1);
|
||||
for (const block of blocks) {
|
||||
const link = block.match(/class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/);
|
||||
if (!link) continue;
|
||||
const target = new URL(link[1]!.replace(/&/g, "&"), url);
|
||||
const redirected = target.searchParams.get("uddg");
|
||||
const snippet = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>|class="result__snippet"[^>]*>([\s\S]*?)<\/div>/);
|
||||
results.push({
|
||||
title: decodeHtml(link[2]!),
|
||||
url: redirected ? decodeURIComponent(redirected) : target.toString(),
|
||||
snippet: decodeHtml(snippet?.[1] ?? snippet?.[2] ?? ""),
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify({ query: params.query, results }) }], details: { source: "public-web-search", resultCount: results.length } };
|
||||
},
|
||||
}),
|
||||
defineTool({
|
||||
name: "browser_open",
|
||||
label: "Read a public web page",
|
||||
description: "Open one public HTTP(S) page read-only and return bounded visible text. Localhost, private networks, credentials, scripts, and browser actions are blocked.",
|
||||
parameters: Type.Object({ url: Type.String({ minLength: 8, maxLength: 2048 }) }, { additionalProperties: false }),
|
||||
execute: async (_id, params) => {
|
||||
const url = await publicUrl(params.url);
|
||||
const html = await fetchText(url, 250_000);
|
||||
const title = decodeHtml(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "");
|
||||
const text = decodeHtml(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ")).slice(0, 30_000);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ url: url.toString(), title, text }) }], details: { source: "public-web-page", truncated: text.length >= 30_000 } };
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export class SafeWorkspace {
|
||||
constructor(readonly root: string, private sessionsRoot: string) {}
|
||||
static async create(root: string, sessionId: string) {
|
||||
const sessionsRoot = path.resolve(root);
|
||||
const dir = path.join(sessionsRoot, sessionId);
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
return new SafeWorkspace(await realpath(dir), await realpath(sessionsRoot));
|
||||
}
|
||||
resolve(relative: string) {
|
||||
const target = path.resolve(this.root, relative);
|
||||
const rel = path.relative(this.root, target);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("PATH_ESCAPE");
|
||||
return target;
|
||||
}
|
||||
async writeReport(body: string, title?: string) {
|
||||
return this.writeAtomic("report.md", Buffer.from(`${title ? `# ${title}\n\n` : ""}${body}`));
|
||||
}
|
||||
async writeReportImage(kind: "daily" | "weekly", png: Buffer) {
|
||||
return this.writeAtomic(`swads-${kind}-report.png`, png);
|
||||
}
|
||||
private async writeAtomic(file: string, body: Buffer) {
|
||||
const target = this.resolve(file);
|
||||
try {
|
||||
if ((await lstat(target)).isSymbolicLink()) throw new Error("SYMLINK_DENIED");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const temp = this.resolve(`.${file}-${crypto.randomUUID()}.tmp`);
|
||||
const handle = await open(temp, "wx", 0o600);
|
||||
try { await handle.writeFile(body); } finally { await handle.close(); }
|
||||
await rename(temp, target);
|
||||
return { file, path: target, bytes: body.byteLength };
|
||||
}
|
||||
async remove() {
|
||||
const rel = path.relative(this.sessionsRoot, this.root);
|
||||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("UNSAFE_DELETE");
|
||||
await rm(this.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user