fix: align runtime startup and model events
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const input = ModelConnectionInputSchema.parse(request.body);
|
const input = ModelConnectionInputSchema.parse(request.body);
|
||||||
await dependencies.modelStore.configure(sessionId, input);
|
await dependencies.modelStore.configure(sessionId, input);
|
||||||
return { modelConfig: dependencies.modelStore.getRedacted(sessionId), requestId: request.id };
|
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);
|
||||||
|
try {
|
||||||
const result = dependencies.testModelConnection === undefined
|
const result = dependencies.testModelConnection === undefined
|
||||||
? { status: "deferred" as const, message: "Saved; validation will occur on first run." }
|
? { status: "deferred" as const, message: "Saved; validation will occur on first run." }
|
||||||
: await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000));
|
: await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000));
|
||||||
return { ...result, requestId: request.id };
|
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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user