fix: align runtime startup and model events

This commit is contained in:
Wenxin Duan
2026-09-08 10:47:42 +08:00
parent 89ce7973c3
commit da7558ab49
8 changed files with 79 additions and 18 deletions
+6
View File
@@ -2,8 +2,14 @@ OPENAI_API_KEY=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
AGENT_PROVIDER= AGENT_PROVIDER=
AGENT_MODEL= AGENT_MODEL=
AGENT_MODEL_SUPPORTS_IMAGES=true
AGENT_SKILLS_DIRS=
LLM_BASE_URL= LLM_BASE_URL=
LLM_API=openai-responses LLM_API=openai-responses
HOST=127.0.0.1
PORT=3001
SESSIONS_ROOT=.agent-studio-sessions
AGENT_DIR=.agent-studio-runtime
ALLOW_PRIVATE_LLM_BASE_URLS=false ALLOW_PRIVATE_LLM_BASE_URLS=false
SESSION_RUN_TIMEOUT_MS=120000 SESSION_RUN_TIMEOUT_MS=120000
APPROVAL_TIMEOUT_MS=60000 APPROVAL_TIMEOUT_MS=60000
+3 -3
View File
@@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { import {
FileAttachmentStore, FileAttachmentStore,
HarnessSession, HarnessSession,
@@ -22,7 +22,7 @@ import { loadServerConfig } from "./config.js";
export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.env) { export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.env) {
const config = loadServerConfig(environment); const config = loadServerConfig(environment);
const projectRoot = process.cwd(); const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const sessionsRoot = resolve(projectRoot, config.sessionsRoot); const sessionsRoot = resolve(projectRoot, config.sessionsRoot);
const agentDir = resolve(projectRoot, config.agentDir); const agentDir = resolve(projectRoot, config.agentDir);
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 }); await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
+38 -7
View File
@@ -21,9 +21,32 @@ export function registerModelRoutes(app: FastifyInstance, dependencies: AppDepen
if (session.isBusy === true) { if (session.isBusy === true) {
throw new HarnessError("SESSION_BUSY", "Model configuration cannot change during a run"); throw new HarnessError("SESSION_BUSY", "Model configuration cannot change during a run");
} }
const input = ModelConnectionInputSchema.parse(request.body); try {
await dependencies.modelStore.configure(sessionId, input); const input = ModelConnectionInputSchema.parse(request.body);
return { modelConfig: dependencies.modelStore.getRedacted(sessionId), requestId: request.id }; await dependencies.modelStore.configure(sessionId, input);
const modelConfig = dependencies.modelStore.getRedacted(sessionId);
if (modelConfig !== undefined) {
dependencies.eventStore?.append(sessionId, {
type: "model.configured",
payload: {
providerId: modelConfig.providerId,
modelId: modelConfig.modelId,
supportsImages: modelConfig.supportsImages,
source: modelConfig.source,
},
});
}
return { modelConfig, requestId: request.id };
} catch (error) {
dependencies.eventStore?.append(sessionId, {
type: "model.validation_failed",
payload: {
code: error instanceof HarnessError ? error.code : "MODEL_VALIDATION_FAILED",
message: "Model configuration validation failed",
},
});
throw error;
}
}); });
app.delete("/api/sessions/:sessionId/model-config/credentials", (request) => { app.delete("/api/sessions/:sessionId/model-config/credentials", (request) => {
@@ -37,9 +60,17 @@ export function registerModelRoutes(app: FastifyInstance, dependencies: AppDepen
const { sessionId } = Params.parse(request.params); const { sessionId } = Params.parse(request.params);
requireSession(dependencies, sessionId); requireSession(dependencies, sessionId);
const resolved = dependencies.modelStore.resolveConnection(sessionId); const resolved = dependencies.modelStore.resolveConnection(sessionId);
const result = dependencies.testModelConnection === undefined try {
? { status: "deferred" as const, message: "Saved; validation will occur on first run." } const result = dependencies.testModelConnection === undefined
: await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000)); ? { status: "deferred" as const, message: "Saved; validation will occur on first run." }
return { ...result, requestId: request.id }; : await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000));
return { ...result, requestId: request.id };
} catch {
dependencies.eventStore?.append(sessionId, {
type: "model.validation_failed",
payload: { code: "MODEL_CONNECTION_FAILED", message: "Model connection validation failed" },
});
throw new HarnessError("MODEL_CONNECTION_FAILED", "Model connection validation failed");
}
}); });
} }
+22 -1
View File
@@ -6,7 +6,7 @@ afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())));
describe("model API", () => { describe("model API", () => {
it("configures and returns only redacted model metadata", async () => { it("configures and returns only redacted model metadata", async () => {
const { app, modelStore } = createTestApp(); const { app, modelStore, eventStore } = createTestApp();
apps.push(app); apps.push(app);
const configured = await app.inject({ method: "PUT", url: "/api/sessions/ses_1/model-config", payload: { const configured = await app.inject({ method: "PUT", url: "/api/sessions/ses_1/model-config", payload: {
providerId: "training", api: "openai-responses", apiKey: "sk-super-secret", providerId: "training", api: "openai-responses", apiKey: "sk-super-secret",
@@ -21,6 +21,10 @@ describe("model API", () => {
const body = response.json<{ modelConfig: { keyHint: string }; requestId: string }>(); const body = response.json<{ modelConfig: { keyHint: string }; requestId: string }>();
expect(body.modelConfig.keyHint).toBe("••••cret"); expect(body.modelConfig.keyHint).toBe("••••cret");
expect(body.requestId).toMatch(/^req_/); expect(body.requestId).toMatch(/^req_/);
expect(eventStore.listAfter("ses_1").events.at(-1)).toMatchObject({
type: "model.configured",
payload: { providerId: "training", modelId: "training-model", supportsImages: true, source: "session" },
});
}); });
it("clears credentials idempotently and returns an honest deferred connection test", async () => { it("clears credentials idempotently and returns an honest deferred connection test", async () => {
@@ -36,4 +40,21 @@ describe("model API", () => {
expect(body.requestId).toMatch(/^req_/); expect(body.requestId).toMatch(/^req_/);
expect(tested.body).not.toContain("server-only-secret"); expect(tested.body).not.toContain("server-only-secret");
}); });
it("emits a safe validation event when model configuration fails", async () => {
const { app, modelStore, eventStore } = createTestApp();
apps.push(app);
modelStore.configure.mockRejectedValueOnce(new Error("provider leaked sk-super-secret"));
const response = await app.inject({ method: "PUT", url: "/api/sessions/ses_1/model-config", payload: {
providerId: "training", api: "openai-responses", apiKey: "sk-super-secret",
modelId: "training-model", supportsImages: true,
} });
expect(response.statusCode).toBe(500);
const serialized = JSON.stringify(eventStore.listAfter("ses_1").events);
expect(serialized).toContain("model.validation_failed");
expect(serialized).not.toContain("sk-super-secret");
expect(serialized).not.toContain("provider leaked");
});
}); });
+5 -2
View File
@@ -1,4 +1,4 @@
import { HarnessError } from "@agent-studio/harness"; import { HarnessError, InMemoryEventStore } from "@agent-studio/harness";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { vi } from "vitest"; import { vi } from "vitest";
import { buildApp } from "../src/app.js"; import { buildApp } from "../src/app.js";
@@ -14,6 +14,7 @@ export function createTestApp(): {
}; };
attachmentStore: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> }; attachmentStore: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
approvalBroker: { approve: ReturnType<typeof vi.fn>; deny: ReturnType<typeof vi.fn> }; approvalBroker: { approve: ReturnType<typeof vi.fn>; deny: ReturnType<typeof vi.fn> };
eventStore: InMemoryEventStore;
} { } {
const session = { const session = {
sessionId: "ses_1", sessionId: "ses_1",
@@ -57,15 +58,17 @@ export function createTestApp(): {
approve: vi.fn((approvalId: string) => ({ approvalId, status: "approved" as const })), approve: vi.fn((approvalId: string) => ({ approvalId, status: "approved" as const })),
deny: vi.fn((approvalId: string) => ({ approvalId, status: "denied" as const })), deny: vi.fn((approvalId: string) => ({ approvalId, status: "denied" as const })),
}; };
const eventStore = new InMemoryEventStore();
const app = buildApp({ const app = buildApp({
sessionRegistry: registry, sessionRegistry: registry,
skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] }, skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] },
modelStore, modelStore,
attachmentStore, attachmentStore,
approvalBroker, approvalBroker,
eventStore,
testModelConnection: () => Promise.resolve({ status: "deferred", message: "Saved; validation will occur on first run." }), testModelConnection: () => Promise.resolve({ status: "deferred", message: "Saved; validation will occur on first run." }),
}); });
return { app, session, modelStore, attachmentStore, approvalBroker }; return { app, session, modelStore, attachmentStore, approvalBroker, eventStore };
} }
export function makeSessionBusy(session: { startRun: ReturnType<typeof vi.fn> }): void { export function makeSessionBusy(session: { startRun: ReturnType<typeof vi.fn> }): void {
+2 -2
View File
@@ -9,8 +9,8 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
"/api": "http://127.0.0.1:3000", "/api": "http://127.0.0.1:3001",
"/health": "http://127.0.0.1:3000" "/health": "http://127.0.0.1:3001"
} }
} }
}); });
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"packageManager": "pnpm@11.7.0", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=20" "node": ">=22.19.0"
}, },
"scripts": { "scripts": {
"dev": "pnpm --parallel --filter @agent-studio/server --filter @agent-studio/web dev", "dev": "pnpm --parallel --filter @agent-studio/server --filter @agent-studio/web dev",
@@ -83,14 +83,14 @@ export class HarnessSession {
...(skillInference === undefined ? {} : { skillInference }), ...(skillInference === undefined ? {} : { skillInference }),
}); });
let eventQueue = Promise.resolve(); let eventQueue = Promise.resolve();
let eventError: unknown; let eventError: Error | undefined;
const unsubscribe = port.subscribe((event) => { const unsubscribe = port.subscribe((event) => {
eventQueue = eventQueue.then(async () => { eventQueue = eventQueue.then(async () => {
if (eventError !== undefined) return; if (eventError !== undefined) return;
try { try {
await adapter.observe(event); await adapter.observe(event);
} catch (error) { } catch (error) {
eventError = error; eventError = error instanceof Error ? error : new Error("Agent event mapping failed");
} }
}); });
}); });