Initial Daily Campaign Agent release

This commit is contained in:
joliehill294-hue
2026-09-16 21:00:13 +08:00
commit 14fc94b3f0
86 changed files with 10766 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@agent-studio/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": { "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js" },
"dependencies": {
"@agent-studio/harness": "workspace:*", "@agent-studio/shared": "workspace:*",
"@fastify/cors": "latest", "@fastify/multipart": "latest", "fastify": "5.12.1", "zod": "latest"
},
"devDependencies": { "tsx": "latest" }
}
+101
View File
@@ -0,0 +1,101 @@
import path from "node:path";
import { readFile, stat } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import { z } from "zod";
import { accountProfileSchema, messageInputSchema, modelConnectionInputSchema } from "@agent-studio/shared";
import { HarnessError, MemoryApprovalBroker, MemoryAttachmentStore, MemoryEventStore, MemoryModelConnectionStore, MemorySessionRegistry, PersistentAccountProfileStore, SkillCatalog, safeError, type HarnessSession } from "@agent-studio/harness";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const defaultRoot = path.resolve(moduleDir, "../../..");
const idParams = z.object({ sessionId: z.string().min(1) });
const attachmentParams = z.object({ sessionId: z.string().min(1), attachmentId: z.string().min(1) });
const artifactParams = z.object({ sessionId: z.string().min(1), filename: z.string().regex(/^\d{4}-\d{2}-\d{2}-campaign-triage\.docx$/) });
const approvalParams = z.object({ approvalId: z.string().min(1) });
const intEnv = (name: string, fallback: number) => { const value = Number(process.env[name]); return Number.isSafeInteger(value) && value > 0 ? value : fallback; };
export type AppContext = { app: FastifyInstance; events: MemoryEventStore; approvals: MemoryApprovalBroker; models: MemoryModelConnectionStore; attachments: MemoryAttachmentStore; sessions: MemorySessionRegistry; skills: SkillCatalog; accountProfiles: PersistentAccountProfileStore };
function sessionOrThrow(sessions: MemorySessionRegistry, id: string): HarnessSession { const session = sessions.get(id); if (!session) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return session; }
function success(request: FastifyRequest, data: Record<string, unknown> = {}) { return { ...data, requestId: request.id }; }
export async function createApp(projectRoot = defaultRoot, persistentModelPath = process.env.NODE_ENV === "test" ? undefined : process.env.MODEL_CONFIG_PATH ?? path.join(projectRoot, "workspace", ".agent-studio", "model-connection.json"), persistentAccountProfilePath = process.env.NODE_ENV === "test" ? undefined : process.env.ACCOUNT_PROFILE_PATH || path.join(projectRoot, "workspace", ".agent-studio", "account-profile.json")): Promise<AppContext> {
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info", redact: ["req.headers.authorization", "req.headers.cookie", "req.body.apiKey", "*.apiKey", "*.data"] }, genReqId: () => `req_${crypto.randomUUID()}` });
await app.register(cors, { origin: false });
const maxImageBytes = intEnv("MAX_IMAGE_BYTES", 5_242_880); const maxSpreadsheetBytes = intEnv("MAX_SPREADSHEET_BYTES", 20_971_520);
await app.register(multipart, { limits: { files: 1, fileSize: Math.max(maxImageBytes, maxSpreadsheetBytes), fields: 2 } });
const events = new MemoryEventStore(1000);
const registryRef: { current?: MemorySessionRegistry } = {};
const approvals = new MemoryApprovalBroker(intEnv("APPROVAL_TIMEOUT_MS", 60_000), (resolution) => {
events.append(resolution.request.sessionId, { type: "approval.resolved", runId: resolution.request.runId, payload: { approvalId: resolution.request.approvalId, toolCallId: resolution.request.toolCallId, decision: resolution.decision } });
}, (request) => events.append(request.sessionId, { type: "approval.required", runId: request.runId, payload: request }));
const models = new MemoryModelConnectionStore(process.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true", persistentModelPath);
await models.loadPersistent().catch((error) => app.log.warn({ err: error }, "persistent model config could not be loaded"));
const accountProfiles = new PersistentAccountProfileStore(persistentAccountProfilePath);
await accountProfiles.load().catch((error) => app.log.warn({ err: error }, "persistent account profile could not be loaded"));
const skills = await SkillCatalog.create(projectRoot, path.join(projectRoot, "workspace", ".pi-agent"));
const attachments = new MemoryAttachmentStore((id) => sessionOrThrow(registryRef.current!, id).workspace, maxImageBytes, 40_000_000, maxSpreadsheetBytes);
const sessions = new MemorySessionRegistry({ projectRoot, sessionsRoot: path.join(projectRoot, "workspace", "sessions"), agentDir: path.join(projectRoot, "workspace", ".pi-agent"), fixturePath: path.join(projectRoot, "fixtures", "ads-account.json"), eventStore: events, approvals, models, attachments, skills, getAccountProfile: () => accountProfiles.get(), runTimeoutMs: intEnv("SESSION_RUN_TIMEOUT_MS", 600_000), maxImages: intEnv("MAX_IMAGES_PER_MESSAGE", 4) });
registryRef.current = sessions;
app.setErrorHandler((error, request, reply) => {
const harness = error instanceof HarnessError ? error : undefined;
const validation = error instanceof z.ZodError;
const frameworkStatus = (error as { statusCode?: number }).statusCode;
const status = harness?.status ?? (validation ? 400 : frameworkStatus && frameworkStatus >= 400 && frameworkStatus < 600 ? frameworkStatus : 500);
const code = harness?.code ?? (validation ? "VALIDATION_ERROR" : status === 413 ? "PAYLOAD_TOO_LARGE" : "INTERNAL_ERROR");
const message = harness?.message ?? (status === 500 ? "Internal server error" : safeError(error));
const details = validation ? { fields: error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })) } : harness?.details ?? {};
if (status === 500) request.log.error({ err: error }, "request failed");
void reply.status(status).send({ error: { code, message, requestId: request.id, details } });
});
app.get("/health", async (request) => success(request, { status: "ok" }));
app.get("/api/skills", async (request) => success(request, { skills: skills.list() }));
app.get("/api/account-profile", async (request) => success(request, { profile: accountProfiles.get() ?? null }));
app.put("/api/account-profile", async (request) => success(request, { profile: await accountProfiles.save(accountProfileSchema.parse(request.body)) }));
app.post("/api/sessions", async (request, reply) => {
const session = await sessions.create();
const apiKey = process.env.AGENT_PROVIDER === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
if (process.env.AGENT_PROVIDER && process.env.AGENT_MODEL && apiKey) await models.configure(session.sessionId, { providerId: process.env.AGENT_PROVIDER, api: modelConnectionInputSchema.shape.api.parse(process.env.LLM_API ?? "openai-responses"), ...(process.env.LLM_BASE_URL ? { baseUrl: process.env.LLM_BASE_URL } : {}), apiKey, modelId: process.env.AGENT_MODEL, supportsImages: false }, { persist: false, source: "environment" });
else await models.applyPersistent(session.sessionId);
return reply.status(201).send(success(request, { sessionId: session.sessionId }));
});
app.delete("/api/sessions/:sessionId", async (request) => { const { sessionId } = idParams.parse(request.params); await sessions.delete(sessionId); return success(request); });
app.post("/api/sessions/:sessionId/messages", async (request, reply) => {
const { sessionId } = idParams.parse(request.params); const input = messageInputSchema.parse(request.body); const runId = await sessionOrThrow(sessions, sessionId).start(input); return reply.status(202).send(success(request, { runId }));
});
app.post("/api/sessions/:sessionId/cancel", async (request) => { const { sessionId } = idParams.parse(request.params); await sessionOrThrow(sessions, sessionId).cancel("user"); return success(request); });
app.get("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId); return success(request, { model: models.getRedacted(sessionId) ?? { source: "missing", configured: false } }); });
app.put("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = idParams.parse(request.params); const session = sessionOrThrow(sessions, sessionId); if (session.state === "running") throw new HarnessError("SESSION_BUSY", "Cannot change model while a run is active", 409); const input = modelConnectionInputSchema.parse(request.body); const model = await models.configure(sessionId, input); session.resetAgent(); events.append(sessionId, { type: "model.configured", payload: model }); return success(request, { model }); });
app.delete("/api/sessions/:sessionId/model-config/credentials", async (request) => { const { sessionId } = idParams.parse(request.params); const session = sessionOrThrow(sessions, sessionId); if (session.state === "running") throw new HarnessError("SESSION_BUSY", "Cannot clear credentials while a run is active", 409); await models.clearCredentials(sessionId); return success(request, { model: models.getRedacted(sessionId) ?? { source: "missing", configured: false } }); });
app.post("/api/sessions/:sessionId/model-config/test", async (request) => { const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId); await models.createRuntime(sessionId); return success(request, { status: "deferred", message: "配置已通过本地验证,将在首次运行时验证连接" }); });
app.post("/api/sessions/:sessionId/attachments", async (request, reply) => { const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId); const part = await request.file(); if (!part) throw new HarnessError("ATTACHMENT_REQUIRED", "Multipart attachment file is required", 400); try { const attachment = await attachments.add(sessionId, { filename: part.filename, mimetype: part.mimetype, stream: part.file }); const { sessionId: owner, ...payload } = attachment; void owner; events.append(sessionId, { type: "attachment.uploaded", payload }); return reply.status(201).send(success(request, { attachment })); } catch (error) { events.append(sessionId, { type: "attachment.rejected", payload: { name: part.filename, reason: error instanceof HarnessError ? error.code : "UPLOAD_FAILED" } }); throw error; } });
app.delete("/api/sessions/:sessionId/attachments/:attachmentId", async (request) => { const { sessionId, attachmentId } = attachmentParams.parse(request.params); sessionOrThrow(sessions, sessionId); if (!attachments.get(sessionId, attachmentId)) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found", 404); await attachments.remove(sessionId, attachmentId); return success(request); });
app.get("/api/sessions/:sessionId/artifacts/:filename", async (request, reply) => { const { sessionId, filename } = artifactParams.parse(request.params); const session = sessionOrThrow(sessions, sessionId); const file = await session.workspace.resolve(filename, true); try { const info = await stat(file); if (!info.isFile()) throw new Error("not a file"); } catch { throw new HarnessError("ARTIFACT_NOT_FOUND", "Word 报告尚未生成", 404); } const content = await readFile(file); return reply.header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document").header("Content-Disposition", `attachment; filename=${filename}; filename*=UTF-8''${filename}`).send(content); });
const resolveApproval = async (request: FastifyRequest, decision: "approve" | "deny") => { const { approvalId } = approvalParams.parse(request.params); const pending = approvals.get(approvalId); if (!pending) throw new HarnessError("APPROVAL_NOT_FOUND", "Approval not found", 404); const session = sessionOrThrow(sessions, pending.sessionId); if (session.currentRunId !== pending.runId) throw new HarnessError("APPROVAL_RUN_MISMATCH", "Approval does not belong to the active run", 409); const resolution = decision === "approve" ? approvals.approve(approvalId) : approvals.deny(approvalId); return success(request, { resolution }); };
app.post("/api/approvals/:approvalId/approve", async (request) => resolveApproval(request, "approve"));
app.post("/api/approvals/:approvalId/deny", async (request) => resolveApproval(request, "deny"));
app.get("/api/sessions/:sessionId/events", async (request, reply) => {
const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId);
const query = z.object({ afterSequence: z.coerce.number().int().nonnegative().optional() }).parse(request.query);
const lastId = request.headers["last-event-id"]; const history = events.listAfter(sessionId);
let sequence = query.afterSequence ?? 0;
if (query.afterSequence === undefined && typeof lastId === "string") sequence = history.find((event) => event.eventId === lastId)?.sequence ?? 0;
reply.hijack(); reply.raw.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", "X-Request-Id": request.id });
const write = (event: { eventId: string; type: string; sequence: number } & Record<string, unknown>) => reply.raw.write(`id: ${event.eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
const earliest = history.at(0)?.sequence ?? 1; if (sequence > 0 && sequence < earliest - 1) reply.raw.write(`event: harness.reset\ndata: ${JSON.stringify({ reason: "EVENT_HISTORY_GAP", earliestSequence: earliest })}\n\n`);
for (const event of events.listAfter(sessionId, { sequence })) write(event);
const unsubscribe = events.subscribe(sessionId, (event) => { if (!write(event)) { unsubscribe(); clearInterval(heartbeat); reply.raw.end(); } });
const heartbeat = setInterval(() => reply.raw.write(`: heartbeat ${Date.now()}\n\n`), 15_000);
request.raw.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
});
app.addHook("onClose", async () => { await Promise.all(sessions.list().map((session) => session.dispose())); });
return { app, events, approvals, models, attachments, sessions, skills, accountProfiles };
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "./app.js";
const { app } = await createApp();
await app.listen({ port: Number(process.env.PORT ?? 3001), host: process.env.HOST ?? "127.0.0.1" });
+17
View File
@@ -0,0 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import path from "node:path";
import os from "node:os";
import { mkdtemp, writeFile } from "node:fs/promises";
import { createApp, type AppContext } from "../src/app.js";
const root=path.resolve(import.meta.dirname,"../../..");
describe("Fastify API", () => { let context: AppContext; beforeEach(async()=>{context=await createApp(root)}); afterEach(async()=>{await context.app.close()});
it("returns health, requestId and only the built-in skill", async()=>{ const health=await context.app.inject({method:"GET",url:"/health"}); expect(health.statusCode).toBe(200); expect(health.json().requestId).toMatch(/^req_/); const skills=await context.app.inject({method:"GET",url:"/api/skills"}); expect(skills.json().skills.map((skill:{name:string})=>skill.name)).toEqual(["daily-campaign-triage"]); });
it("uses unified 404 and validation errors", async()=>{ const missing=await context.app.inject({method:"DELETE",url:"/api/sessions/nope"}); expect(missing.statusCode).toBe(404); expect(missing.json().error.code).toBe("SESSION_NOT_FOUND"); const created=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const invalid=await context.app.inject({method:"POST",url:`/api/sessions/${created.sessionId}/messages`,payload:{text:"",attachmentIds:[]}}); expect(invalid.statusCode).toBe(400); expect(invalid.json().error.code).toBe("VALIDATION_ERROR"); });
it("preserves framework 4xx status codes",async()=>{ const response=await context.app.inject({method:"POST",url:"/api/sessions",headers:{"content-type":"application/json"},payload:""}); expect(response.statusCode).toBe(400); });
it("does not expose API keys in model config", async()=>{ const {sessionId}=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const key="sk-training-super-secret"; const saved=await context.app.inject({method:"PUT",url:`/api/sessions/${sessionId}/model-config`,payload:{providerId:"openai",api:"openai-responses",apiKey:key,modelId:"gpt-5",supportsImages:true}}); expect(saved.statusCode).toBe(200); expect(saved.body).not.toContain(key); const get=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/model-config`}); expect(get.body).not.toContain(key); expect(get.json().model.keyHint).toBe("••••cret"); });
it("loads a saved model and key after an app restart",async()=>{ const temp=await mkdtemp(path.join(os.tmpdir(),"asm-api-model-")); const file=path.join(temp,"model.json"); const first=await createApp(root,file); const {sessionId}=(await first.app.inject({method:"POST",url:"/api/sessions"})).json(); await first.app.inject({method:"PUT",url:`/api/sessions/${sessionId}/model-config`,payload:{providerId:"openai",api:"openai-responses",apiKey:"sk-persist-across-restart",modelId:"gpt-5",supportsImages:false}}); await first.app.close(); const second=await createApp(root,file); const created=(await second.app.inject({method:"POST",url:"/api/sessions"})).json(); const restored=await second.app.inject({method:"GET",url:`/api/sessions/${created.sessionId}/model-config`}); expect(restored.json().model).toMatchObject({source:"persistent",configured:true,keyHint:"••••tart"}); await second.app.close(); });
it("persists the confirmed account KPI profile across restarts",async()=>{ const temp=await mkdtemp(path.join(os.tmpdir(),"asm-api-profile-")); const file=path.join(temp,"account.json"); const profile={accountName:"蚂蚁",analysisDatePolicy:"yesterday",retentionLagDays:1,kpis:[{metric:"CPI",role:"primary",formula:"spend / installs",direction:"lower",target:"≤ 4",scope:"账户级",maturityRule:"当日完整"}],pacing:{enabled:true,targetValue:"600",currency:"USD",scope:"账户级",period:"每日",targetType:"cap"}}; const first=await createApp(root,undefined,file); const saved=await first.app.inject({method:"PUT",url:"/api/account-profile",payload:profile}); expect(saved.statusCode).toBe(200); await first.app.close(); const second=await createApp(root,undefined,file); const restored=await second.app.inject({method:"GET",url:"/api/account-profile"}); expect(restored.json().profile).toMatchObject({accountName:"蚂蚁",kpis:[{target:"≤ 4"}],pacing:{targetType:"cap"}}); await second.app.close(); });
it("returns 202 semantics only after run acceptance and no model gives 422", async()=>{ const {sessionId}=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const response=await context.app.inject({method:"POST",url:`/api/sessions/${sessionId}/messages`,payload:{text:"hello",attachmentIds:[]}}); expect(response.statusCode).toBe(422); expect(response.json().error.code).toBe("MODEL_NOT_CONFIGURED"); });
it("downloads only a dated Skill Word artifact",async()=>{ const {sessionId}=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const name="2026-09-15-campaign-triage.docx"; const missing=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/artifacts/${name}`}); expect(missing.statusCode).toBe(404); const invalid=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/artifacts/report.docx`}); expect(invalid.statusCode).toBe(400); const session=context.sessions.get(sessionId)!; await writeFile(await session.workspace.resolve(name,true),Buffer.from("PK-test")); const response=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/artifacts/${name}`}); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("wordprocessingml.document"); expect(response.headers["content-disposition"]).toContain(name); });
it("opens an SSE stream with correct headers", async()=>{ const address=await context.app.listen({host:"127.0.0.1",port:0}); const created=await fetch(`${address}/api/sessions`,{method:"POST"}).then((r)=>r.json()) as {sessionId:string}; const controller=new AbortController(); const response=await fetch(`${address}/api/sessions/${created.sessionId}/events`,{signal:controller.signal}); expect(response.headers.get("content-type")).toContain("text/event-stream"); controller.abort(); });
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}