diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 5b0e9b0c88..853155aaf8 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -10,6 +10,7 @@ import { hostedChatRequestSchema, } from "./chat-request.ts"; import { RuntimeAgentRunInvocationSchema } from "../runtime/agent-invocation-contract.ts"; +import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; /** Public API contract for hosted chat request principal. */ export type HostedChatRequestPrincipal = { @@ -49,6 +50,7 @@ export type ParsedHostedChatRequest = { runtimeOverrides: ChatRuntimeOverrides | undefined; durableRootRun: DurableRootRunDescriptor | undefined; persistLatestUserMessageBeforeDurableRun: boolean; + agentConfig?: RuntimeAgentMarkdownDefinition; }; /** Options accepted by parse hosted chat request. */ @@ -108,6 +110,7 @@ async function verifyHostedChatProjectAccess(input: { export async function buildParsedHostedChatRequest(input: { chatRequest: HostedChatRequest; agentId?: string; + agentConfig?: RuntimeAgentMarkdownDefinition; authToken: string; userId: string; verifyProjectAccess?: ParseHostedChatRequestOptions["verifyProjectAccess"]; @@ -125,6 +128,13 @@ export async function buildParsedHostedChatRequest(input: { const projectSlug = chatContext.projectSlug; const conversationId = chatContext.conversationId; + if (input.agentConfig && input.agentId && input.agentConfig.id !== input.agentId) { + return createValidationErrorResponse({ + messagePrefix: "Invalid runtime agent invocation", + validationMessage: "agentConfig.id must match the requested agent id", + }); + } + const accessError = await verifyHostedChatProjectAccess({ projectId, authToken: input.authToken, @@ -153,6 +163,7 @@ export async function buildParsedHostedChatRequest(input: { runtimeOverrides, durableRootRun, persistLatestUserMessageBeforeDurableRun: false, + ...(input.agentConfig ? { agentConfig: input.agentConfig } : {}), }; } @@ -215,6 +226,7 @@ export async function parseRuntimeAgentRunInvocationHostedChatRequestFromRequest userId: invocation.data.run.requestedByUserId, chatRequest: chatRequest.data, agentId: invocation.data.run.agentId, + agentConfig: invocation.data.agentConfig, verifyProjectAccess: options.verifyProjectAccess, }); } diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index 2f8f19fea5..ef506e387a 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -1,9 +1,10 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation, buildHostedChatRequestFromRuntimeAgentInvocation, + buildParsedHostedChatRequest, hostedChatRequestSchema, hostedChatRuntimeOverridesSchema, parseHostedChatRequestFromRequest, @@ -269,6 +270,99 @@ describe("agent/hosted-chat-request", () => { assertEquals(parsed.validatedContext.projectSlug, "demo-project"); }); + it("preserves request-scoped project agent config from runtime invocations", async () => { + const parsed = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/runs", { + method: "POST", + body: JSON.stringify({ + ...createRuntimeInvocation(), + agentConfig: { + id: "builder", + name: "Builder", + description: "Builds with project skills.", + instructions: "Use project skills.", + skills: ["support-triage"], + tools: ["search_knowledge", "get_file"], + }, + }), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "token_1" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + }, + ); + + if (parsed instanceof Response) { + throw new Error("Expected parsed request"); + } + + assertEquals(parsed.agentConfig?.skills, ["support-triage"]); + assertEquals(parsed.agentConfig?.tools, ["search_knowledge", "get_file"]); + }); + + it("rejects parsed hosted chat requests when agent config does not match the requested agent", async () => { + const response = await buildParsedHostedChatRequest({ + chatRequest: hostedChatRequestSchema.parse({ + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "Hello" }] }], + context: { + conversationId, + projectId, + branchId, + }, + }), + agentId: "builder", + agentConfig: { + id: "other-agent", + name: "Other Agent", + description: "Does not match the requested agent.", + instructions: "Use another agent.", + }, + authToken: "token_1", + userId, + }); + + if (!(response instanceof Response)) { + throw new Error("Expected error response"); + } + + assertEquals(response.status, 400); + assertEquals(await response.json(), { + errorCode: "VALIDATION_ERROR", + message: "Invalid runtime agent invocation: agentConfig.id must match the requested agent id", + }); + }); + + it("rejects runtime invocation agent config for a different agent", async () => { + const response = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/runs", { + method: "POST", + body: JSON.stringify({ + ...createRuntimeInvocation(), + agentConfig: { + id: "other-agent", + name: "Other Agent", + description: "Does not match the requested agent.", + instructions: "Use another agent.", + }, + }), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "token_1" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + }, + ); + + if (!(response instanceof Response)) { + throw new Error("Expected error response"); + } + + const body = await response.json(); + assertEquals(response.status, 400); + assertEquals(body.errorCode, "VALIDATION_ERROR"); + assertStringIncludes(body.message, "Invalid runtime agent invocation"); + assertStringIncludes(body.message, "agentConfig.id must match run.agentId"); + }); + it("returns hosted chat project-access errors as stable JSON responses", async () => { const response = await parseHostedChatRequestFromRequest( new Request("https://agent.example.com/api/runs", { diff --git a/src/agent/hosted/veryfront-cloud-agent-service.ts b/src/agent/hosted/veryfront-cloud-agent-service.ts index 1fe4580dfc..a09ac8fe1a 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.ts @@ -915,7 +915,9 @@ async function prepareChatExecution( setPrepareChatExecutionStartAttributes(context, { projectId, userId }); - const agentConfig = await resolveAgentConfig(context, req.agentId ?? getDefaultAgentId(context)); + const requestedAgentId = req.agentId ?? getDefaultAgentId(context); + // veryfront-api is the trusted caller for request-scoped project-agent config. + const agentConfig = req.agentConfig ?? await resolveAgentConfig(context, requestedAgentId); const abortController = new AbortController(); const { effectiveMessages, diff --git a/src/agent/runtime/agent-invocation-contract.test.ts b/src/agent/runtime/agent-invocation-contract.test.ts index 742cfb4cc2..c4d1f37c90 100644 --- a/src/agent/runtime/agent-invocation-contract.test.ts +++ b/src/agent/runtime/agent-invocation-contract.test.ts @@ -240,6 +240,62 @@ describe("agent/runtime-agent-invocation-contract", () => { }); }); + it("preserves the selected project agent config on control-plane stream requests", () => { + const parsed = RuntimeAgentRunInvocationSchema.parse(createInvocation({ + agentConfig: { + id: "builder", + name: "Builder", + description: "Builds with project skills.", + instructions: "Use project skills.", + skills: ["support-triage"], + tools: ["search_knowledge", "get_file"], + }, + })); + + const request = buildRuntimeAgentControlPlaneStreamRequestFromInvocation(parsed); + + assertEquals(request.agentConfig, { + id: "builder", + name: "Builder", + description: "Builds with project skills.", + instructions: "Use project skills.", + skills: ["support-triage"], + tools: ["search_knowledge", "get_file"], + }); + }); + + it("rejects request-scoped agent config for a different agent", () => { + assertThrows( + () => + RuntimeAgentRunInvocationSchema.parse(createInvocation({ + agentConfig: { + id: "other-agent", + name: "Other Agent", + description: "Does not match the requested agent.", + instructions: "Use other instructions.", + }, + })), + Error, + "agentConfig.id must match run.agentId", + ); + }); + + it("rejects oversized request-scoped agent config", () => { + assertThrows( + () => + RuntimeAgentRunInvocationSchema.parse(createInvocation({ + agentConfig: { + id: "builder", + name: "Builder", + description: "Builds with project skills.", + instructions: "x".repeat(70_000), + }, + })), + Error, + "agentConfig must be less than 64 KB", + ); + }); + it("parses runtime agent invocation request bodies through the public helper", async () => { const parsed = await parseRuntimeAgentRunInvocation( new Request("http://localhost/api/control-plane/runs/run_1/stream", { diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index 36cf5bbb4c..44f042415a 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -2,12 +2,14 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, RefinementCtx } from "#veryfront/extensions/schema/index.ts"; import { ensureBuiltinSchemaValidator } from "#veryfront/extensions/builtin-extensions.ts"; import { parseAgUiJsonRequestOrError } from "../ag-ui/request-shared.ts"; +import { getRuntimeAgentMarkdownDefinitionSchema } from "./agent-definition.ts"; ensureBuiltinSchemaValidator(); const MAX_TOOL_PARAMETERS_BYTES = 16_384; const MAX_CONTEXT_ITEM_BYTES = 16_384; const MAX_CONTEXT_TOTAL_BYTES = 65_536; +const MAX_AGENT_CONFIG_BYTES = 65_536; const MAX_FORWARDED_PROPS_BYTES = 196_608; const MAX_CREDENTIAL_BYTES = 16_384; const encoder = new TextEncoder(); @@ -313,11 +315,23 @@ export const getRuntimeAgentRunInvocationSchema = defineSchema((v) => { message: "context must be less than 64 KB total" }, ), agentSource: getRuntimeAgentSourceContextSchema().optional(), + agentConfig: getRuntimeAgentMarkdownDefinitionSchema().optional().refine( + (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_AGENT_CONFIG_BYTES), + { message: "agentConfig must be less than 64 KB" }, + ), credentials: getRuntimeAgentCredentialsSchema().optional(), forwardedProps: v.record(v.string(), v.unknown()).optional().refine( (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_FORWARDED_PROPS_BYTES), { message: "forwardedProps must be less than 192 KB" }, ), + }).superRefine((input, ctx) => { + if (input.agentConfig && input.agentConfig.id !== input.run.agentId) { + ctx.addIssue({ + code: "custom", + message: "agentConfig.id must match run.agentId", + path: ["agentConfig", "id"], + }); + } }) ); @@ -368,6 +382,7 @@ export type RuntimeAgentControlPlaneStreamRequest = { context: RuntimeAgentRunInvocation["context"]; credentials?: RuntimeAgentRunInvocation["credentials"]; agentSource?: RuntimeAgentRunInvocation["agentSource"]; + agentConfig?: RuntimeAgentRunInvocation["agentConfig"]; forwardedProps?: RuntimeAgentRunInvocation["forwardedProps"]; }; @@ -385,6 +400,7 @@ export function buildRuntimeAgentControlPlaneStreamRequestFromInvocation( context: input.context, ...(input.credentials ? { credentials: input.credentials } : {}), ...(input.agentSource ? { agentSource: input.agentSource } : {}), + ...(input.agentConfig ? { agentConfig: input.agentConfig } : {}), ...(input.forwardedProps ? { forwardedProps: input.forwardedProps } : {}), }; } diff --git a/src/internal-agents/schema.test.ts b/src/internal-agents/schema.test.ts index 4e7033df2c..d990192141 100644 --- a/src/internal-agents/schema.test.ts +++ b/src/internal-agents/schema.test.ts @@ -249,6 +249,14 @@ describe("internal-agents/schema", () => { }], context: [{ type: "text", text: "Current project context" }], agentSource: { type: "branch", branch: "main" }, + agentConfig: { + id: "incident-responder", + name: "Incident Responder", + description: "Triages incidents.", + instructions: "Use the project incident-response skills.", + skills: ["incident-triage"], + tools: ["search_knowledge", "get_file"], + }, forwardedProps: { runtimeOverrides: { allowedTools: ["studio_search_files"] } }, }); @@ -265,12 +273,40 @@ describe("internal-agents/schema", () => { description: "Search files", inputSchema: { type: "object", properties: { query: { type: "string" } } }, }); + assertEquals(internalRequest.agentConfig, { + id: "incident-responder", + name: "Incident Responder", + description: "Triages incidents.", + instructions: "Use the project incident-response skills.", + skills: ["incident-triage"], + tools: ["search_knowledge", "get_file"], + }); assertEquals( toRuntimeRunAgentInput(internalRequest).threadId, "10000000-1000-4000-8000-100000000001", ); }); + it("rejects mismatched agent config on control-plane stream payloads", () => { + assertThrows( + () => + getInternalAgentStreamRequestSchema().parse({ + agentId: "agent_1", + threadId: "10000000-1000-4000-8000-100000000001", + runId: "run_1", + messages: [], + agentConfig: { + id: "agent_2", + name: "Agent 2", + description: "Wrong agent.", + instructions: "Use another agent.", + }, + }), + Error, + "agentConfig.id must match agentId", + ); + }); + it("normalizes legacy internal stream payloads into the canonical runtime input", () => { const internalRequest = getInternalAgentStreamRequestSchema().parse({ agentId: "agent_1", diff --git a/src/internal-agents/schema.ts b/src/internal-agents/schema.ts index 49157d6e83..e943ef76cf 100644 --- a/src/internal-agents/schema.ts +++ b/src/internal-agents/schema.ts @@ -13,6 +13,7 @@ import { getAgUiRuntimeToolCallSchema, } from "#veryfront/agent/runtime/ag-ui-contract.ts"; import { stripLeadingEmptyObjectPlaceholder } from "#veryfront/agent/streaming/data-stream.ts"; +import { getRuntimeAgentMarkdownDefinitionSchema } from "#veryfront/agent/runtime/agent-definition.ts"; import { buildRuntimeAgentControlPlaneStreamRequestFromInvocation, getRuntimeAgentCredentialsSchema, @@ -22,6 +23,7 @@ import { } from "#veryfront/agent/runtime/agent-invocation-contract.ts"; const AGENT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const MAX_AGENT_CONFIG_BYTES = 65_536; const MAX_FORWARDED_PROPS_BYTES = 196_608; const MAX_TOOL_RESULT_BYTES = 65_536; const MAX_RUNTIME_MESSAGES = 100; @@ -74,12 +76,24 @@ export const getInternalAgentControlPlaneStreamRequestSchema = defineSchema((v) { message: "context must be less than 64 KB total" }, ), agentSource: getRuntimeAgentSourceContextSchema().optional(), + agentConfig: getRuntimeAgentMarkdownDefinitionSchema().optional().refine( + (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_AGENT_CONFIG_BYTES), + { message: "agentConfig must be less than 64 KB" }, + ), credentials: getRuntimeAgentCredentialsSchema().optional(), forwardedProps: v.record(v.string(), v.unknown()).optional().refine( (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_FORWARDED_PROPS_BYTES), { message: "forwardedProps must be less than 192 KB" }, ), - }).strict() + }).strict().superRefine((input, ctx) => { + if (input.agentConfig && input.agentConfig.id !== input.agentId) { + ctx.addIssue({ + code: "custom", + message: "agentConfig.id must match agentId", + path: ["agentConfig", "id"], + }); + } + }) ); export const getInternalAgentStreamRequestSchema = defineSchema((v) => { diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index 5e5ef95b7b..ff0b5eb6b7 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -531,6 +531,140 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(injectedToolSchema, inputSchema); }); + it("runs control-plane streams with request-scoped project agent config", async () => { + let capturedSystem: unknown; + let capturedSkills: unknown; + let capturedTools: unknown; + let capturedAllowedRemoteTools: string[] | undefined; + let platformMcpFetchCalls = 0; + const originalFetch = globalThis.fetch; + const originalApiUrl = Deno.env.get("VERYFRONT_API_URL"); + + Deno.env.set("VERYFRONT_API_URL", "https://api.veryfront.org"); + globalThis.fetch = ((url, init) => { + if (String(url) === "https://api.veryfront.org/mcp") { + platformMcpFetchCalls += 1; + assertEquals( + new Headers(init?.headers).get("authorization"), + "Bearer request-scoped-user-token", + ); + return Promise.resolve( + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "veryfront-platform-mcp:tools:list", + result: { + tools: [ + { + name: "search_knowledge", + description: "Search project knowledge", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "get_file", + description: "Read a project file", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + }), + { headers: { "content-type": "application/json" } }, + ), + ); + } + + if (String(url) === "https://api.veryfront.org/projects/demo-project/environments") { + return Promise.resolve( + new Response(JSON.stringify({ data: [] }), { + headers: { "content-type": "application/json" }, + }), + ); + } + + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }) as typeof fetch; + + try { + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => {}, + getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, + getAllAgentIds: () => ["assistant-1"], + sessionManager: new AgentRunSessionManager(), + createRuntime: (runtimeAgent) => { + const runtimeConfig = runtimeAgent.config as + & typeof runtimeAgent.config + & RuntimeRemoteToolConfig; + capturedSystem = runtimeConfig.system; + capturedSkills = runtimeConfig.skills; + capturedTools = runtimeConfig.tools; + capturedAllowedRemoteTools = runtimeConfig.__vfAllowedRemoteTools; + + return { + stream: async (_messages, _context, callbacks) => { + callbacks?.onFinish?.({ + text: "ok", + messages: [], + toolCalls: [], + status: "completed", + usage: { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + }, + }); + + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + }, + }; + }, + }); + + const body = createAgentStreamRequestBody({ + credentials: { authToken: "request-scoped-user-token" }, + agentConfig: { + id: "assistant-1", + name: "Project Assistant", + description: "Uses project-scoped skills and tools.", + instructions: "Use project-scoped instructions.", + skills: ["support-triage"], + tools: ["search_knowledge", "get_file"], + }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + requestId: "run_1", + }); + + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + createCtx(publicKeyPem), + ); + + assertExists(result.response); + assertEquals(result.response.status, 200); + assertEquals(capturedSystem, "Use project-scoped instructions."); + assertEquals(capturedSkills, ["support-triage"]); + assertEquals((capturedTools as Record).search_knowledge, true); + assertEquals((capturedTools as Record).get_file, true); + assertEquals(capturedAllowedRemoteTools, ["get_file", "search_knowledge"]); + assertEquals(platformMcpFetchCalls, 1); + } finally { + globalThis.fetch = originalFetch; + if (originalApiUrl === undefined) Deno.env.delete("VERYFRONT_API_URL"); + else Deno.env.set("VERYFRONT_API_URL", originalApiUrl); + } + }); + it("does not pass undeclared forwarded remote tool allowlists into the runtime agent config", async () => { let capturedAllowedTools: string[] | undefined; diff --git a/src/server/handlers/request/agent-stream.handler.ts b/src/server/handlers/request/agent-stream.handler.ts index 3e0edb2813..cd4ef4c37e 100644 --- a/src/server/handlers/request/agent-stream.handler.ts +++ b/src/server/handlers/request/agent-stream.handler.ts @@ -11,6 +11,7 @@ import { createRuntimeAgentStreamResponse, type RuntimeAgentStreamExecutionDeps, } from "#veryfront/internal-agents/run-stream.ts"; +import { createRuntimeAgentFromMarkdownDefinition } from "#veryfront/agent/runtime/agent-markdown-adapter.ts"; import type { RuntimeRemoteToolConfig } from "#veryfront/agent/runtime/mcp-server-tool-sources.ts"; import { buildStudioMcpHeaders } from "#veryfront/agent/project/live-studio-mcp-tools.ts"; import { @@ -639,6 +640,7 @@ export class AgentStreamHandler extends BaseHandler { messageCount: payload.messages.length, toolCount: payload.tools.length, hasAgentSource: Boolean(payload.agentSource), + hasAgentConfig: Boolean(payload.agentConfig), }); return await this.withAgentSourceContext( @@ -658,11 +660,16 @@ export class AgentStreamHandler extends BaseHandler { return this.respond(builder.json({ error: "Agent not found" }, 404)); } + // veryfront-api is the trusted control-plane caller; it resolves + // authorization before attaching request-scoped project-agent config. + const runtimeBaseAgent = payload.agentConfig + ? createRuntimeAgentFromMarkdownDefinition(payload.agentConfig) + : agent; const runtimeInput = sanitizeRuntimeRunAgentInput(toRuntimeRunAgentInput(payload)); const apiAuthToken = payload.credentials?.authToken || ctx.proxyToken || getHostEnv("VERYFRONT_API_TOKEN") || ""; const platformRuntimeAgent = await withVeryfrontPlatformRemoteTools({ - agent: agent as Agent, + agent: runtimeBaseAgent as Agent, token: apiAuthToken || null, projectId: ctx.projectId ?? null, availableToolNames: runtimeInput.tools.map((tool) => tool.name),