Add Beryl Agent and harness source
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import Fastify from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import cors from "@fastify/cors";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MessageInputSchema,
|
||||
ModelConnectionInputSchema,
|
||||
} from "@agent-studio/shared";
|
||||
import { SessionRegistry, SkillCatalog } from "@agent-studio/harness";
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../",
|
||||
);
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: { redact: ["req.headers.authorization", "req.body.apiKey"] },
|
||||
bodyLimit: 6 * 1024 * 1024,
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
});
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
fileSize: Number(process.env.MAX_IMAGE_BYTES) || 5 * 1024 * 1024,
|
||||
files: 1,
|
||||
},
|
||||
});
|
||||
const registry = new SessionRegistry(root);
|
||||
const skills = new SkillCatalog(root);
|
||||
await skills.reload();
|
||||
app.decorate("registry", registry);
|
||||
app.get("/health", async (req) => ({ ok: true, requestId: req.id }));
|
||||
app.get("/api/skills", async (req) => ({
|
||||
skills: skills.items,
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.post("/api/sessions", async (req, reply) => {
|
||||
const s = await registry.create();
|
||||
return reply.code(201).send({ sessionId: s.sessionId, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id", async (req, reply) => {
|
||||
await registry.delete((req.params as { id: string }).id);
|
||||
return reply.send({ ok: true, requestId: req.id });
|
||||
});
|
||||
app.get("/api/sessions/:id/model-config", async (req) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
registry.require(id);
|
||||
return {
|
||||
config: registry.models.getRedacted(id) ?? {
|
||||
configured: false,
|
||||
source: "missing",
|
||||
},
|
||||
requestId: req.id,
|
||||
};
|
||||
});
|
||||
app.put("/api/sessions/:id/model-config", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
const s = registry.require(id);
|
||||
if (s.busy)
|
||||
throw Object.assign(new Error("SESSION_BUSY"), { statusCode: 409 });
|
||||
const parsed = ModelConnectionInputSchema.parse(req.body);
|
||||
const environmentKey =
|
||||
parsed.api === "anthropic-messages"
|
||||
? process.env.ANTHROPIC_API_KEY
|
||||
: process.env.OPENAI_API_KEY;
|
||||
const apiKey = parsed.apiKey?.trim() || environmentKey?.trim();
|
||||
if (!apiKey)
|
||||
return reply
|
||||
.code(400)
|
||||
.send(
|
||||
error(
|
||||
"API_KEY_REQUIRED",
|
||||
`请填写 ${parsed.api === "anthropic-messages" ? "Anthropic" : "OpenAI"} API Key`,
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
const input = { ...parsed, apiKey };
|
||||
registry.resetAgent(id);
|
||||
const config = await registry.models.configure(id, input);
|
||||
registry.events.append(id, {
|
||||
type: "model.configured",
|
||||
payload: { ...config },
|
||||
});
|
||||
return reply.send({ config, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id/model-config/credentials", async (req) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
registry.require(id);
|
||||
registry.models.clearCredentials(id);
|
||||
return { ok: true, requestId: req.id };
|
||||
});
|
||||
app.post("/api/sessions/:id/model-config/test", async (req) => {
|
||||
registry.require((req.params as { id: string }).id);
|
||||
return {
|
||||
status: "deferred",
|
||||
message: "配置已保存,将在首次运行时验证",
|
||||
requestId: req.id,
|
||||
};
|
||||
});
|
||||
app.post("/api/sessions/:id/attachments", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
const s = registry.require(id);
|
||||
const part = await req.file();
|
||||
if (!part) throw new Error("UPLOAD_REQUIRED");
|
||||
const data = await part.toBuffer();
|
||||
const item = await registry.attachments.add(
|
||||
id,
|
||||
s.workspace.root,
|
||||
part.filename,
|
||||
part.mimetype,
|
||||
data,
|
||||
);
|
||||
registry.events.append(id, {
|
||||
type: "attachment.uploaded",
|
||||
payload: { ...item },
|
||||
});
|
||||
return reply.code(201).send({ ...item, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id/attachments/:att", async (req) => {
|
||||
const p = req.params as { id: string; att: string };
|
||||
registry.require(p.id);
|
||||
await registry.attachments.remove(p.id, p.att);
|
||||
return { ok: true, requestId: req.id };
|
||||
});
|
||||
app.get("/api/sessions/:id/reports/:kind", async (req, reply) => {
|
||||
const params = req.params as { id: string; kind: string };
|
||||
const session = registry.require(params.id);
|
||||
if (params.kind !== "daily" && params.kind !== "weekly")
|
||||
throw Object.assign(new Error("REPORT_KIND_NOT_FOUND"), { statusCode: 404 });
|
||||
const file = session.workspace.resolve(`swads-${params.kind}-report.png`);
|
||||
const png = await readFile(file);
|
||||
return reply.header("cache-control", "no-store").type("image/png").send(png);
|
||||
});
|
||||
app.post("/api/sessions/:id/messages", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
const s = registry.require(id);
|
||||
if (s.busy)
|
||||
return reply
|
||||
.code(409)
|
||||
.send(error("SESSION_BUSY", "Session is busy", req.id));
|
||||
const input = MessageInputSchema.parse(req.body);
|
||||
const config = registry.models.get(id);
|
||||
if (!config)
|
||||
return reply
|
||||
.code(400)
|
||||
.send(error("MODEL_NOT_CONFIGURED", "Model is not configured", req.id));
|
||||
if (input.attachmentIds.length && !config.supportsImages)
|
||||
return reply
|
||||
.code(422)
|
||||
.send(
|
||||
error(
|
||||
"MODEL_DOES_NOT_SUPPORT_IMAGES",
|
||||
"Selected model does not support images",
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
const attachmentMeta = input.attachmentIds.map((attachmentId) => {
|
||||
const meta = registry.attachments.get(id, attachmentId);
|
||||
if (!meta) throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
return meta;
|
||||
});
|
||||
const images = await Promise.all(
|
||||
attachmentMeta.map(async (meta) => {
|
||||
return {
|
||||
type: "image" as const,
|
||||
mimeType: meta.mediaType,
|
||||
data: (await registry.attachments.content(id, meta.attachmentId)).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const localFiles = attachmentMeta.map(
|
||||
(meta) =>
|
||||
`attachments/${meta.attachmentId}.${meta.mediaType.split("/")[1]}`,
|
||||
);
|
||||
const promptText = localFiles.length
|
||||
? `${input.text}\n\nLocal attachment paths in this Session workspace:\n${localFiles.map((file) => `- ${file}`).join("\n")}`
|
||||
: input.text;
|
||||
const runId = await registry.run(s, promptText, images);
|
||||
return reply.code(202).send({ runId, requestId: req.id });
|
||||
});
|
||||
app.post("/api/sessions/:id/cancel", async (req) => {
|
||||
await registry.cancel((req.params as { id: string }).id);
|
||||
return { ok: true, requestId: req.id };
|
||||
});
|
||||
app.post("/api/approvals/:id/approve", async (req) => ({
|
||||
approval: registry.approvals.resolve(
|
||||
(req.params as { id: string }).id,
|
||||
"approved",
|
||||
),
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.post("/api/approvals/:id/deny", async (req) => ({
|
||||
approval: registry.approvals.resolve(
|
||||
(req.params as { id: string }).id,
|
||||
"denied",
|
||||
),
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.get("/api/sessions/:id/events", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
registry.require(id);
|
||||
const query = req.query as { afterSequence?: string };
|
||||
const last =
|
||||
typeof req.headers["last-event-id"] === "string"
|
||||
? req.headers["last-event-id"]
|
||||
: undefined;
|
||||
const previous = registry.events.listAfter(id, 0);
|
||||
const byId = last
|
||||
? previous.find((e) => e.eventId === last)?.sequence
|
||||
: undefined;
|
||||
const cursor = Number(query.afterSequence ?? byId ?? 0);
|
||||
reply.raw.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
});
|
||||
const send = (e: ReturnType<typeof registry.events.append>) =>
|
||||
reply.raw.write(
|
||||
`id: ${e.eventId}\nevent: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`,
|
||||
);
|
||||
registry.events.listAfter(id, cursor).forEach(send);
|
||||
const unsub = registry.events.subscribe(id, send);
|
||||
const heartbeat = setInterval(
|
||||
() => reply.raw.write(": heartbeat\n\n"),
|
||||
15000,
|
||||
);
|
||||
req.raw.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
unsub();
|
||||
});
|
||||
return reply;
|
||||
});
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
const failure = err as Error & { statusCode?: number; issues?: unknown };
|
||||
const code =
|
||||
failure.name === "ZodError"
|
||||
? "VALIDATION_ERROR"
|
||||
: failure.message || "INTERNAL_ERROR";
|
||||
const status =
|
||||
failure.statusCode ??
|
||||
(code.includes("NOT_FOUND")
|
||||
? 404
|
||||
: code.includes("TOO_LARGE")
|
||||
? 413
|
||||
: code.includes("PRIVATE") || code.includes("UNSAFE")
|
||||
? 400
|
||||
: 500);
|
||||
reply
|
||||
.code(status)
|
||||
.send(
|
||||
error(
|
||||
code,
|
||||
status === 500 ? "Internal server error" : failure.message,
|
||||
req.id,
|
||||
failure.name === "ZodError" ? { issues: failure.issues } : {},
|
||||
),
|
||||
);
|
||||
});
|
||||
app.addHook("onClose", async () => {
|
||||
for (const id of [] as string[]) await registry.delete(id);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
function error(
|
||||
code: string,
|
||||
message: string,
|
||||
requestId: string,
|
||||
details: Record<string, unknown> = {},
|
||||
) {
|
||||
return { error: { code, message, requestId, details } };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../",
|
||||
);
|
||||
try {
|
||||
process.loadEnvFile(path.join(projectRoot, ".env"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const app = await buildApp();
|
||||
await app.listen({ host: "0.0.0.0", port: Number(process.env.PORT) || 3001 });
|
||||
Reference in New Issue
Block a user