|
|
|
@@ -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 };
|
|
|
|
|
}
|