Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/agent/hosted/chat-request-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -49,6 +50,7 @@ export type ParsedHostedChatRequest = {
runtimeOverrides: ChatRuntimeOverrides | undefined;
durableRootRun: DurableRootRunDescriptor | undefined;
persistLatestUserMessageBeforeDurableRun: boolean;
agentConfig?: RuntimeAgentMarkdownDefinition;
};

/** Options accepted by parse hosted chat request. */
Expand Down Expand Up @@ -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"];
Expand All @@ -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,
Expand Down Expand Up @@ -153,6 +163,7 @@ export async function buildParsedHostedChatRequest(input: {
runtimeOverrides,
durableRootRun,
persistLatestUserMessageBeforeDurableRun: false,
...(input.agentConfig ? { agentConfig: input.agentConfig } : {}),
};
}

Expand Down Expand Up @@ -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,
});
}
96 changes: 95 additions & 1 deletion src/agent/hosted/chat-request.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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", {
Expand Down
4 changes: 3 additions & 1 deletion src/agent/hosted/veryfront-cloud-agent-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/agent/runtime/agent-invocation-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
16 changes: 16 additions & 0 deletions src/agent/runtime/agent-invocation-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"],
});
}
})
);

Expand Down Expand Up @@ -368,6 +382,7 @@ export type RuntimeAgentControlPlaneStreamRequest = {
context: RuntimeAgentRunInvocation["context"];
credentials?: RuntimeAgentRunInvocation["credentials"];
agentSource?: RuntimeAgentRunInvocation["agentSource"];
agentConfig?: RuntimeAgentRunInvocation["agentConfig"];
forwardedProps?: RuntimeAgentRunInvocation["forwardedProps"];
};

Expand All @@ -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 } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add agentConfig to the internal stream schema

When an invocation includes this new field, buildRuntimeAgentControlPlaneStreamRequestFromInvocation now emits agentConfig, but AgentStreamHandler parses that transformed object with getInternalAgentStreamRequestSchema(), whose control-plane schema in src/internal-agents/schema.ts is .strict() and has no agentConfig field. In that environment, any runtime invocation carrying a request-scoped project agent config is accepted by RuntimeAgentRunInvocationSchema and then rejected as an invalid internal agent stream request before streaming, so the new project-agent-config path cannot work through /api/control-plane/runs/.../stream until the strict schema accepts the field.

Useful? React with 👍 / 👎.

...(input.forwardedProps ? { forwardedProps: input.forwardedProps } : {}),
};
}
Expand Down
36 changes: 36 additions & 0 deletions src/internal-agents/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] } },
});

Expand All @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/internal-agents/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
Loading