Add Beryl Agent and harness source

This commit is contained in:
2026-09-08 20:10:03 +08:00
parent 06d72d001c
commit 47cfbcd66a
70 changed files with 12558 additions and 0 deletions
@@ -0,0 +1 @@
{"name":"@agent-studio/server","version":"0.1.0","type":"module","scripts":{"dev":"tsx src/index.ts","lint":"eslint src tests","typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run","build":"tsc -p tsconfig.json"},"dependencies":{"@agent-studio/harness":"workspace:*","@agent-studio/shared":"workspace:*","@fastify/cors":"^11.1.0","@fastify/multipart":"^9.2.1","fastify":"^5.5.0","zod":"^4.1.5"},"devDependencies":{"tsx":"^4.20.5"}}
+276
View File
@@ -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 });
@@ -0,0 +1,39 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/app.js";
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => app.close());
describe("server", () => {
it("is healthy", async () => {
const r = await app.inject({ url: "/health" });
expect(r.statusCode).toBe(200);
expect(r.json().requestId).toMatch(/^req_/);
});
it("creates a session and redacts model key", async () => {
const c = await app.inject({ method: "POST", url: "/api/sessions" });
const id = c.json().sessionId;
const r = await app.inject({
method: "PUT",
url: `/api/sessions/${id}/model-config`,
payload: {
providerId: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
apiKey: "secret-value",
modelId: "demo",
supportsImages: true,
},
});
expect(r.statusCode).toBe(200);
expect(r.body).not.toContain("secret-value");
expect(r.json().config.keyHint).toBe("••••alue");
});
it("returns unified 404", async () => {
const r = await app.inject({ url: "/api/sessions/nope/model-config" });
expect(r.statusCode).toBe(404);
expect(r.json().error.code).toBe("SESSION_NOT_FOUND");
});
});
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"outDir":"dist","rootDir":"."},"include":["src","tests"]}