diff --git a/deno.json b/deno.json index ad89db52cd..9fa040f288 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "veryfront", - "version": "0.1.1105", + "version": "0.1.1106", "license": "Apache-2.0", "nodeModulesDir": "auto", "minimumDependencyAge": { diff --git a/docs/guides/agents.md b/docs/guides/agents.md index 41579a3486..b3b457a476 100644 --- a/docs/guides/agents.md +++ b/docs/guides/agents.md @@ -158,6 +158,27 @@ on the server that owns the tools. When `tools` is an explicit object, include the remote MCP tool name in `tools` and authorize it with the server `toolPolicy`. +Explicitly named tools that are not local are resolved from the Veryfront API +MCP server when `mcpServers` is omitted and the server bootstrap is available. +This lets a project pulled from Studio run locally without repeating transport +configuration. `VERYFRONT_API_URL` selects the API endpoint; +`VERYFRONT_API_TOKEN` and `VERYFRONT_PROJECT_SLUG` provide server-side identity. +These environment variables do not grant tools by themselves. + +```ts +export default agent({ + id: "project-reader", + system: "Read project files when needed.", + tools: { get_file: true, list_files: true }, +}); +``` + +Only the explicitly named unresolved tools are requested from the remote MCP +catalog. Remote `tools/list` remains authoritative, and browser AG-UI context +cannot replace server identity. Set `mcpServers: []` to opt out. An explicit +`mcpServers` list overrides the default; use `{ kind: "veryfront-api" }` with a +`toolPolicy` when the connection policy should travel with the agent. + ```ts // agents/docs.ts import { agent } from "veryfront/agent"; @@ -327,6 +348,7 @@ export default agent({ | `system` | `string \| () => string \| Promise` | System prompt | | `resolveRuntimeState` | `(request: RuntimeStateRequest) => ResolvedRuntimeState \| Promise` | Refresh system/context before later model steps in the same run | | `tools` | `Record` | Tools the agent can use | +| `delegates` | `string[]` | Exact agent ids exposed as scoped `agent_` tools | | `providerTools` | `string[]` | Provider-executed tools such as `web_search` | | `mcpServers` | `AgentMcpServerConfig[]` | Remote MCP-compatible tool servers | | `skills` | `true \| string[]` | Advertise all visible skills (`true` or omitted), selected IDs, or none (`[]`) | diff --git a/docs/guides/multi-agent.md b/docs/guides/multi-agent.md index 3edc84c70b..dfbfc0df02 100644 --- a/docs/guides/multi-agent.md +++ b/docs/guides/multi-agent.md @@ -101,11 +101,23 @@ const researchTool = agentAsTool(researcher, "Research a topic using web search" ## Declarative delegation with `delegates` -A markdown agent can opt into orchestration by listing the specialists it may -call in its `delegates` frontmatter. The runtime gives the agent one -`agent_{id}` tool per delegate; each delegate runs with its own settings, -skills, and tools - capability ownership does not cross the delegation -boundary in either direction. +Code and markdown agents can opt into orchestration by listing the exact +specialists they may call. The runtime gives the agent one `agent_{id}` tool +per delegate. Each scoped tool accepts `{ input: string }` and runs the actual +delegate definition with its own model, skills, MCP servers, and tools. + +```ts +// agents/orchestrator.ts +import { agent } from "veryfront/agent"; + +export default agent({ + id: "orchestrator", + system: "Use agent_researcher, then agent_writer.", + delegates: ["researcher", "writer"], +}); +``` + +The same configuration is available in markdown frontmatter: ```md --- @@ -118,9 +130,18 @@ Break the task down. Use agent_researcher to gather facts, then agent_writer to produce the final copy. ``` -With several agents and no `delegates`, the agents are independent: a caller -selects one by id. Self-delegation and delegate ids that cannot form a valid -provider tool name are rejected at discovery with explicit diagnostics. +Set `delegates: []` when an agent must not delegate. Hosted runtimes retain the +legacy generic `invoke_agent` tool only for older definitions where +`delegates` is absent; direct runtimes do not add it automatically. +Self-delegation and delegate ids that cannot form a valid provider tool name +are rejected with explicit diagnostics. Declare direct tools by name when +using `delegates`; `tools: true` is intentionally rejected because it would +hide the agent's capability boundary. + +Hosted nested delegation carries trusted invocation lineage from parent to +child runs. The root conversation and run stay stable, the immediate parent is +updated for each handoff, and hosted runtimes stop delegation after eight +nested levels. ## Workflow-based composition diff --git a/docs/guides/tools.md b/docs/guides/tools.md index b78927963d..c1484e2b44 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -120,6 +120,27 @@ Use `mcpServers` for remote MCP tools. Put remote visibility policy on the MCP server. When `tools` is an explicit object, also list the remote tool name in `tools` so the model can use it. +When `mcpServers` is omitted, explicitly named tools that are not local are +resolved from the Veryfront API MCP server if server bootstrap credentials are +available. This makes a project pulled from Studio runnable locally without +duplicating transport configuration. + +```ts +export default agent({ + id: "project-reader", + system: "Use project evidence when answering.", + tools: { get_file: true, list_files: true }, +}); +``` + +`VERYFRONT_API_URL` selects the endpoint, while `VERYFRONT_API_TOKEN` and +`VERYFRONT_PROJECT_SLUG` provide server-side identity. Environment variables +never grant tools: only explicitly named unresolved tools are requested, and +the remote `tools/list` response defines their schemas. Use `mcpServers: []` +to opt out, or declare `{ kind: "veryfront-api", toolPolicy: ... }` to make the +connection policy explicit. Direct application routes and hosted runtimes do +not accept browser-supplied credentials or project identity for this server. + ```ts // agents/docs.ts import { agent } from "veryfront/agent"; diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 449dd64ca6..8ff5c7aac7 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -20,7 +20,6 @@ "src/agent/conversation/run-mirror.test.ts", "src/agent/hosted/child-fork-step-message-preparation.test.ts", "src/agent/hosted/child-stream-watchdog.test.ts", - "src/agent/hosted/cloud-runtime-system-messages.test.ts", "src/agent/hosted/form-input-tool.test.ts", "src/agent/hosted/response-stream.test.ts", "src/agent/hosted/root-sandbox-tool-source.test.ts", diff --git a/src/agent/factory.test.ts b/src/agent/factory.test.ts index 7d7eac6be9..7f30aae7d0 100644 --- a/src/agent/factory.test.ts +++ b/src/agent/factory.test.ts @@ -147,6 +147,35 @@ describe("agent factory", () => { }); }); + it("binds one scoped tool for each declared delegate", () => { + const assistant = agent({ + id: "orchestrator", + system: "Delegate specialist work.", + delegates: ["ingestion-agent"], + }); + + if (!assistant.config.tools || assistant.config.tools === true) { + throw new Error("Expected an agent tool map"); + } + assertEquals(typeof assistant.config.tools["agent_ingestion-agent"], "object"); + assertEquals(assistant.config.delegates, ["ingestion-agent"]); + assertEquals(toolRegistry.has("agent_ingestion-agent"), false); + }); + + it("rejects delegates combined with the implicit all-tools selector", () => { + assertThrows( + () => + agent({ + id: "broad-orchestrator", + system: "Delegate specialist work.", + delegates: ["ingestion-agent"], + tools: true, + }), + Error, + "cannot combine delegates with tools: true", + ); + }); + it("uses the default system prompt before an available skill catalog", async () => { registerSkill("support-triage", { id: "support-triage", diff --git a/src/agent/factory.ts b/src/agent/factory.ts index 487329f0e7..b63de1c233 100644 --- a/src/agent/factory.ts +++ b/src/agent/factory.ts @@ -24,7 +24,7 @@ import { } from "#veryfront/skill/tools.ts"; import { agentRegistry } from "./composition/index.ts"; import { agentLogger } from "#veryfront/utils"; -import { createError, toError } from "#veryfront/errors"; +import { createError, INVALID_ARGUMENT, toError } from "#veryfront/errors"; import { COMMON_BLOCKED_PATTERNS, securityMiddleware } from "./middleware/security/validator.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { resolveConfiguredAgentModel } from "./runtime/model-resolution.ts"; @@ -37,6 +37,8 @@ import { } from "#veryfront/security/input-validation/limits.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; import { ensureBuiltinSchemaValidator } from "#veryfront/extensions/builtin-schema-validator.ts"; +import { buildAgentDelegateTools } from "./runtime/agent-delegation.ts"; +import { normalizeAgentDelegateIds } from "./runtime/agent-delegation-names.ts"; const STREAMING_HEADERS: Record = { "Content-Type": "text/event-stream", @@ -105,9 +107,11 @@ export function agent(config: AgentConfig): Agent { } const id = config.id ?? generateAgentId(); + const delegates = normalizeAgentDelegateIds(id, config.delegates); const publicConfig: ResolvedAgentConfig = { ...config, + ...(delegates === undefined ? {} : { delegates }), model: resolveConfiguredAgentModel(config.model), }; @@ -148,6 +152,19 @@ export function agent(config: AgentConfig): Agent { mergedToolsConfig = configuredTools; } + if (delegates?.length) { + if (mergedToolsConfig === true) { + throw INVALID_ARGUMENT.create({ + detail: `Agent "${id}" cannot combine delegates with tools: true. ` + + "Declare the required tools by name so delegate capabilities remain explicit.", + }); + } + mergedToolsConfig = { + ...(mergedToolsConfig ?? {}), + ...buildAgentDelegateTools({ delegates, selfId: id }), + }; + } + // System prompt augmentation with skill manifest. // Re-resolve registry-backed entries at invocation time so HMR changes are picked up. const originalSystem = config.system; diff --git a/src/agent/hosted/chat-execution-runtime.ts b/src/agent/hosted/chat-execution-runtime.ts index 5f7959e2a8..362b4b9777 100644 --- a/src/agent/hosted/chat-execution-runtime.ts +++ b/src/agent/hosted/chat-execution-runtime.ts @@ -57,6 +57,7 @@ import { } from "../../chat/stream-watchdog.ts"; import { unrefTimer } from "../../platform/compat/process.ts"; import type { HostedChatExecutionLifecycleAdapter } from "./chat-execution-lifecycle-types.ts"; +import { AGENT_DELEGATE_TOOL_PREFIX } from "../runtime/agent-delegation-names.ts"; export type { HostedChatExecutionLifecycleAdapter } from "./chat-execution-lifecycle-types.ts"; const INCOMPLETE_TOOL_CALLS_PART_ERROR_TEXT = "Assistant ended before tool execution completed"; @@ -230,7 +231,10 @@ function createHostedChatExecutionCleanup(cleanup: () => Promise): () => P const HOSTED_LONG_RUNNING_TOOL_NAMES = ["invoke_agent"] as const; function createDefaultHostedChatExecutionRootStreamWatchdog(): HostedChatExecutionRootStreamWatchdog { - return createChatStreamWatchdog({ longRunningToolNames: HOSTED_LONG_RUNNING_TOOL_NAMES }); + return createChatStreamWatchdog({ + longRunningToolNames: HOSTED_LONG_RUNNING_TOOL_NAMES, + longRunningToolPrefixes: [AGENT_DELEGATE_TOOL_PREFIX], + }); } function resolveStreamBootstrapKeepaliveIntervalMs(intervalMs: number | undefined): number { diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index 6526f823ab..68a3b409df 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -259,7 +259,7 @@ Deno.test("prepareHostedChatRuntimeCreationOptions builds runtime options from r maxSteps: 7, maxOutputTokens: 1200, allowedTools: ["load_skill"], - allowedProviderTools: ["load_skill"], + allowedProviderTools: [], includeRuntimeEssentialToolsWhenEmpty: false, allowDelegation: false, conversationId: "conversation-1", @@ -381,7 +381,7 @@ Deno.test("prepareHostedChatExecution prepares root run, runtime, and final mess ]); }); -Deno.test("prepareHostedChatExecution strips provider history enabled by a runtime override", async () => { +Deno.test("prepareHostedChatExecution strips configured provider history selected by a runtime override", async () => { const messages: ChatUiMessage[] = [ { id: "user-1", @@ -438,6 +438,7 @@ Deno.test("prepareHostedChatExecution strips provider history enabled by a runti agentConfig: { id: "agent-1", model: "anthropic/claude-sonnet-4-6", + providerTools: ["web_search"], }, apiUrl: "https://api.example.com", abortSignal: new AbortController().signal, diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index 9f3c7d3929..7e76cf2d54 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -85,6 +85,7 @@ export type HostedChatRuntimeInstructionsInput = { environmentContext?: string; instructions: string; skills: RuntimeSkillDefinition[]; + availableToolNames?: readonly string[]; }; /** Input payload for hosted chat runtime creation preparation. */ diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index 4acef598b2..c466e7d37f 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -315,6 +315,43 @@ Deno.test("prepareHostedChatRuntimeToolAssembly removes source-denied integratio assertEquals(toolAssembly.systemInstructions.includes("gmail__list_emails"), false); }); +Deno.test("prepareHostedChatRuntimeToolAssembly honors explicit API-only MCP without granting Studio tools", async () => { + const taskContext: HostedChatRuntimeToolAssemblyContext = { + authToken: "token", + projectId: "project-1", + model: "openai/gpt-4.1", + clientProfile: { + id: "veryfront-studio", + type: "web", + trusted: true, + capabilities: ["ui_panels"], + }, + }; + const createdSourceIds: string[] = []; + + const toolAssembly = await prepareHostedChatRuntimeToolAssembly({ + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + taskContext, + instructions: "Base instructions", + localTools: {}, + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + studioMcpUrl: "https://studio.example.com/mcp", + mcpServers: [{ kind: "veryfront-api" }], + allowedToolNames: ["studio_open_project"], + createRemoteToolSource: (config) => { + createdSourceIds.push(config.id ?? "source"); + return remoteSourceFromConfig(config); + }, + preloadLatestConversationUserText: false, + }); + + assertEquals(createdSourceIds, ["veryfront-mcp"]); + assertEquals(toolAssembly.remoteToolNames, []); + assertEquals(toolAssembly.compatibleRemoteToolNames, []); + assertEquals(taskContext.availableToolNames, []); +}); + Deno.test("prepareHostedChatRuntimeToolAssembly applies configured tools before the OpenAI cap", async () => { const availableConfiguredToolNames = ["get_agent", "get_agent_source", "update_agent"]; const configuredToolNames = ["bash", ...availableConfiguredToolNames]; diff --git a/src/agent/hosted/child-fork-execution-runner.test.ts b/src/agent/hosted/child-fork-execution-runner.test.ts index 2373109b93..493e209f74 100644 --- a/src/agent/hosted/child-fork-execution-runner.test.ts +++ b/src/agent/hosted/child-fork-execution-runner.test.ts @@ -92,6 +92,7 @@ Deno.test("executeHostedChildForkWithPreparedTools executes a prepared child for kind: "invoke_agent", provider: "anthropic", forkModel: "anthropic/claude-sonnet-4", + temperature: 0.2, maxSteps: 4, effectivePrompt: "Do the work.", forkContext: { @@ -130,6 +131,7 @@ Deno.test("executeHostedChildForkWithPreparedTools executes a prepared child for }, runStep: async (input) => { assertEquals(input.model, "anthropic/claude-sonnet-4"); + assertEquals(input.temperature, 0.2); assertEquals(input.forkToolNames, ["noop"]); assertEquals(input.providerOptions, undefined); assertEquals(input.system.includes('project_reference: "project-1"'), true); @@ -289,6 +291,7 @@ Deno.test("executeHostedChildForkToolInput resolves runtime config and prepares project_id: "project-2", tools: ["noop"], model: "sonnet", + temperature: 0.4, thinking: 256, max_steps: 120, }, @@ -314,6 +317,7 @@ Deno.test("executeHostedChildForkToolInput resolves runtime config and prepares assertEquals(runtimeConfig.description, "Review checkout"); assertEquals(runtimeConfig.forkModel, "resolved-sonnet"); assertEquals(runtimeConfig.provider, "provider-resolved-sonnet"); + assertEquals(runtimeConfig.temperature, 0.4); assertEquals(runtimeConfig.maxSteps, 120); assertEquals(runtimeConfig.thinkingConfig, { enabled: true, budgetTokens: 256 }); assertEquals(runtimeConfig.effectivePrompt.includes("Review the checkout flow."), true); @@ -346,6 +350,7 @@ Deno.test("executeHostedChildForkToolInput resolves runtime config and prepares startRuntime: (input) => { callbacks.push(`start:${input.forkModel}`); assertEquals(input.provider, "provider-resolved-sonnet"); + assertEquals(input.temperature, 0.4); assertEquals(input.maxSteps, 120); assertEquals(input.providerOptions, { forkModel: "resolved-sonnet", @@ -453,8 +458,19 @@ Deno.test("executeHostedChildForkToolInput preserves root invocation context for authToken: "token", apiUrl: "https://api.example.com", projectId: "project-1", + parentConversationId: "conversation-parent-2", conversationId: "conversation-parent-2", parentRunId: "run-parent-2", + parentMessageId: "message-parent-2", + trustedInvocationContext: { + root_conversation_id: "conversation-root-1", + parent_conversation_id: "conversation-parent-1", + root_run_id: "run-root-1", + parent_run_id: "run-parent-1", + parent_message_id: "message-parent-1", + tool_call_id: "tool-call-parent", + delegation_depth: 1, + }, kind: "invoke_agent", forkInput: { description: "Review nested handoff", @@ -491,6 +507,11 @@ Deno.test("executeHostedChildForkToolInput preserves root invocation context for runtimeConfig.effectivePrompt.includes('"parent_run_id":"run-parent-2"'), true, ); + assertEquals( + runtimeConfig.effectivePrompt.includes('"parent_message_id":"message-parent-2"'), + true, + ); + assertEquals(runtimeConfig.effectivePrompt.includes('"delegation_depth":2'), true); assertEquals(runtimeConfig.effectivePrompt.includes('"tool_call_id":"tool-call-2"'), true); assertEquals(runtimeConfig.effectivePrompt.includes('"tool-call-parent"'), false); return { diff --git a/src/agent/hosted/child-fork-execution-runner.ts b/src/agent/hosted/child-fork-execution-runner.ts index fb5a7a3348..1ed2405322 100644 --- a/src/agent/hosted/child-fork-execution-runner.ts +++ b/src/agent/hosted/child-fork-execution-runner.ts @@ -49,6 +49,7 @@ import { throwIfChildRunAborted } from "../child-run/execution-support.ts"; import { type HostedChildForkRuntimeConfig, type HostedChildForkToolInput, + type HostedChildInvocationContext, resolveHostedChildForkRuntimeConfig, type ResolveHostedChildForkRuntimeConfigInput, withHostedChildInvocationContext, @@ -108,14 +109,18 @@ export type ExecuteHostedChildForkWithPreparedToolsInput< kind: string; provider: string; forkModel: string; + temperature?: number; maxSteps: number; effectivePrompt: string; forkContext?: HostedChildForkInstructionsContext; toolAssembly: DefaultHostedChildForkToolAssemblyResult; abortSignal?: AbortSignal; durableChildRun?: HostedChildRunIdentifiers; + parentConversationId?: string; conversationId?: string; parentRunId?: string; + parentMessageId?: string; + trustedInvocationContext?: HostedChildInvocationContext; pendingToolLogWriter?: { warn: (message: string, metadata?: Record) => void }; logger?: HostedChildForkStreamLogger; instrumentation?: HostedChildForkExecutionInstrumentation; @@ -233,6 +238,7 @@ export type ExecuteHostedChildForkToolInputOptions< ) => RuntimeReasoningOption | undefined; resolveModelThinking?: ResolveHostedChildForkRuntimeConfigInput["resolveModelThinking"]; onRuntimeConfig?: (runtimeConfig: HostedChildForkRuntimeConfig) => void | Promise; + inputAlreadyHasInvocationContext?: boolean; }; /** Input payload for execute hosted child fork tool. */ @@ -245,11 +251,16 @@ export async function executeHostedChildForkToolInput< await input.onRequestedProjectId?.(input.forkInput.project_id); } - const forkInput = withHostedChildInvocationContext(input.forkInput, { - conversationId: input.conversationId, - parentRunId: input.parentRunId, - toolCallId: input.toolCallId, - }); + const forkInput = input.inputAlreadyHasInvocationContext + ? input.forkInput + : withHostedChildInvocationContext(input.forkInput, { + parentConversationId: input.parentConversationId, + conversationId: input.conversationId, + parentRunId: input.parentRunId, + parentMessageId: input.parentMessageId, + toolCallId: input.toolCallId, + trustedInvocationContext: input.trustedInvocationContext, + }); const runtimeConfig = resolveHostedChildForkRuntimeConfig({ forkInput, contextModel: input.contextModel, @@ -274,6 +285,7 @@ export async function executeHostedChildForkToolInput< description: runtimeConfig.description, provider: runtimeConfig.provider, forkModel: runtimeConfig.forkModel, + temperature: runtimeConfig.temperature, maxSteps: runtimeConfig.maxSteps, effectivePrompt: runtimeConfig.effectivePrompt, toolAssembly, @@ -349,6 +361,7 @@ export async function executeHostedChildForkWithPreparedTools< projectId: input.projectId ?? null, provider: input.provider, forkModel: input.forkModel, + temperature: input.temperature, maxSteps: input.maxSteps, prompt: input.effectivePrompt, maxContinuationSteps: input.maxContinuationSteps ?? 0, diff --git a/src/agent/hosted/child-fork-tool-sources.test.ts b/src/agent/hosted/child-fork-tool-sources.test.ts index 3e13ac6bc5..bbdad2f772 100644 --- a/src/agent/hosted/child-fork-tool-sources.test.ts +++ b/src/agent/hosted/child-fork-tool-sources.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "@std/assert"; +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import type { AgentServiceSandboxToolsOptions, AgentServiceSandboxToolsResult, @@ -13,6 +13,8 @@ import type { ToolDefinition, ToolExecutionContext, } from "#veryfront/tool"; +import { dynamicTool } from "#veryfront/tool"; +import { defineSchema } from "../../schemas/define.ts"; import { prepareDefaultHostedChildForkSandboxToolSources, prepareDefaultHostedChildForkToolSources, @@ -26,6 +28,8 @@ const trustedStudioProfile: RuntimeClientProfile = { capabilities: ["ui_panels"], }; +const passthroughToolSchema = defineSchema((v) => v.object({}).passthrough())(); + function remoteTool(name: string): ToolDefinition { return { name, @@ -34,6 +38,12 @@ function remoteTool(name: string): ToolDefinition { }; } +function toToolInputRecord(input: unknown): Record { + return typeof input === "object" && input !== null && !Array.isArray(input) + ? input as Record + : {}; +} + function createRemoteSourceFixtures() { const createdConfigs: RemoteMCPToolSourceConfig[] = []; const executeCalls: Array<{ @@ -229,6 +239,205 @@ Deno.test("prepareDefaultHostedChildForkToolSources filters API MCP tools with t assertEquals(Object.keys(result.forkTools), ["delete_server", "update_file"]); }); +Deno.test("prepareDefaultHostedChildForkToolSources enforces API MCP tool policy at listing and execution", async () => { + const executed: string[] = []; + let listedDefinitions: string[] = []; + const result = await prepareDefaultHostedChildForkToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + mcpServers: [{ + kind: "veryfront-api", + toolPolicy: { allow: ["update_file"], deny: ["delete_file"] }, + }], + getProjectId: () => "project-1", + createRemoteToolSource: (config) => ({ + id: config.id ?? "source", + listTools: () => Promise.resolve([remoteTool("update_file"), remoteTool("delete_file")]), + executeTool: (toolName) => { + executed.push(toolName); + return Promise.resolve({ ok: true }); + }, + }), + createToolsFromRemoteDefinitions: (source, definitions) => { + listedDefinitions = definitions.map((definition) => definition.name); + return { + ...Object.fromEntries( + definitions.map((definition) => [ + definition.name, + dynamicTool({ + id: definition.name, + description: definition.description, + inputSchema: passthroughToolSchema, + execute: (input: unknown, context?: ToolExecutionContext) => + source.executeTool(definition.name, toToolInputRecord(input), context), + }), + ]), + ), + delete_file: dynamicTool({ + id: "delete_file", + description: "hostile materialized denied tool", + inputSchema: passthroughToolSchema, + execute: (input: unknown, context?: ToolExecutionContext) => + source.executeTool("delete_file", toToolInputRecord(input), context), + }), + }; + }, + }); + + assertEquals(result.ok, true); + if (!result.ok) { + return; + } + + assertEquals(listedDefinitions, ["update_file"]); + assertEquals(Object.keys(result.forkTools), ["delete_file", "update_file"]); + await result.forkTools.update_file?.execute?.({}); + await assertRejects( + async () => await result.forkTools.delete_file!.execute!({}), + Error, + 'Tool "delete_file" is not allowed for this MCP server', + ); + assertEquals(executed, ["get_tool_access_profile", "update_file"]); +}); + +Deno.test("prepareDefaultHostedChildForkToolSources enforces generic MCP tool policy at listing and execution", async () => { + const executed: string[] = []; + let listedDefinitions: string[] = []; + const result = await prepareDefaultHostedChildForkToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + mcpServers: [{ + id: "docs", + endpoint: "https://docs.example/mcp", + toolPolicy: { allow: ["search_docs"], deny: ["delete_docs"] }, + }], + getProjectId: () => "project-1", + createRemoteToolSource: (config) => ({ + id: config.id ?? "source", + listTools: () => Promise.resolve([remoteTool("search_docs"), remoteTool("delete_docs")]), + executeTool: (toolName) => { + executed.push(toolName); + return Promise.resolve({ ok: true }); + }, + }), + createToolsFromRemoteDefinitions: (source, definitions) => { + listedDefinitions = definitions.map((definition) => definition.name); + return { + ...Object.fromEntries( + definitions.map((definition) => [ + definition.name, + dynamicTool({ + id: definition.name, + description: definition.description, + inputSchema: passthroughToolSchema, + execute: (input: unknown, context?: ToolExecutionContext) => + source.executeTool(definition.name, toToolInputRecord(input), context), + }), + ]), + ), + delete_docs: dynamicTool({ + id: "delete_docs", + description: "hostile materialized denied tool", + inputSchema: passthroughToolSchema, + execute: (input: unknown, context?: ToolExecutionContext) => + source.executeTool("delete_docs", toToolInputRecord(input), context), + }), + }; + }, + }); + + assertEquals(result.ok, true); + if (!result.ok) { + return; + } + + assertEquals(listedDefinitions, ["search_docs"]); + assertEquals(Object.keys(result.forkTools), ["delete_docs", "search_docs"]); + await result.forkTools.search_docs?.execute?.({}); + await assertRejects( + async () => await result.forkTools.delete_docs!.execute!({}), + Error, + 'Tool "delete_docs" is not allowed for this MCP server', + ); + assertEquals(executed, ["search_docs"]); +}); + +Deno.test("prepareDefaultHostedChildForkToolSources enforces Studio MCP tool policy at listing and execution", async () => { + const executed: string[] = []; + const studioPolicy = { + allow: ["studio_open_project"], + deny: ["studio_delete_project"], + }; + const result = await prepareDefaultHostedChildForkToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + mcpServers: [{ + kind: "veryfront-studio", + toolPolicy: studioPolicy, + }], + studioMcpUrl: "https://studio.example/mcp", + clientProfile: trustedStudioProfile, + getProjectId: () => "project-1", + createLiveStudioTools: () => + Promise.resolve({ + tools: { + studio_open_project: { + description: "Open project", + execute: () => { + executed.push("studio_open_project"); + return { ok: true }; + }, + }, + studio_delete_project: { + description: "Delete project", + execute: () => { + executed.push("studio_delete_project"); + return { ok: true }; + }, + }, + }, + close: () => Promise.resolve(), + }), + }); + + assertEquals(result.ok, true); + if (!result.ok) { + return; + } + + assertEquals(Object.keys(result.forkTools), ["studio_open_project"]); + assertEquals(result.forkTools.studio_delete_project, undefined); + studioPolicy.allow = []; + assertThrows( + () => result.forkTools.studio_open_project?.execute?.({}), + Error, + 'Tool "studio_open_project" is not allowed for this MCP server', + ); + assertEquals(executed, []); +}); + +Deno.test("prepareDefaultHostedChildForkToolSources preserves explicit MCP opt-out", async () => { + const result = await prepareDefaultHostedChildForkToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + mcpServers: [], + getProjectId: () => "project-1", + createRemoteToolSource: () => { + throw new Error("remote MCP source must not be created"); + }, + createLiveStudioTools: () => { + throw new Error("Studio tools must not be created"); + }, + }); + + assertEquals(result.ok, true); + if (!result.ok) { + return; + } + + assertEquals(result.forkTools, {}); +}); + Deno.test("prepareDefaultHostedChildForkToolSources reports Studio setup failures", async () => { const logged: Array<{ message: string; metadata?: Record }> = []; diff --git a/src/agent/hosted/child-fork-tool-sources.ts b/src/agent/hosted/child-fork-tool-sources.ts index cb7cea8e27..14a7d9221b 100644 --- a/src/agent/hosted/child-fork-tool-sources.ts +++ b/src/agent/hosted/child-fork-tool-sources.ts @@ -4,8 +4,10 @@ import { type HostToolSet, type RemoteMCPToolSourceConfig, type RemoteToolSource, + type ToolDefinition, + type ToolExecutionContext, } from "#veryfront/tool"; -import { AGENT_ERROR } from "#veryfront/errors"; +import { AGENT_ERROR, PERMISSION_DENIED } from "#veryfront/errors"; import { type AgentServiceMcpServerConfig, createAgentServiceRemoteMcpConfig, @@ -30,6 +32,8 @@ import { type DefaultHostedChildForkToolAssemblySourceResult, } from "./child-requested-tools.ts"; import { filterVeryfrontApiToolDefinitionsWithAccessProfile } from "./veryfront-api-tool-access.ts"; +import { createHostedMcpToolPolicySource } from "./project-remote-tool-source.ts"; +import type { AgentMcpToolPolicy } from "../types.ts"; /** Public API contract for hosted child fork tool sources logger. */ export type HostedChildForkToolSourcesLogger = { @@ -81,6 +85,56 @@ export type PrepareDefaultHostedChildForkSandboxToolSourcesInput = ) => Promise; }; +function isMcpToolAllowed(toolName: string, policy: AgentMcpToolPolicy | undefined): boolean { + if (policy?.deny?.includes(toolName)) { + return false; + } + + return policy?.allow ? policy.allow.includes(toolName) : true; +} + +function filterHostToolsByMcpPolicy( + tools: HostToolSet, + policy: AgentMcpToolPolicy | undefined, +): HostToolSet { + if (!policy?.allow && !policy?.deny) { + return tools; + } + + return Object.fromEntries( + Object.entries(tools) + .filter(([toolName]) => isMcpToolAllowed(toolName, policy)) + .map(([toolName, toolDefinition]) => [ + toolName, + { + ...toolDefinition, + execute: toolDefinition.execute + ? (toolInput: unknown, execOptions?: ToolExecutionContext) => { + if (!isMcpToolAllowed(toolName, policy)) { + throw PERMISSION_DENIED.create({ + detail: `Tool "${toolName}" is not allowed for this MCP server`, + }); + } + + return toolDefinition.execute?.(toolInput, execOptions); + } + : toolDefinition.execute, + }, + ]), + ); +} + +function filterToolDefinitionsByMcpPolicy( + definitions: readonly ToolDefinition[], + policy: AgentMcpToolPolicy | undefined, +): ToolDefinition[] { + if (!policy?.allow && !policy?.deny) { + return [...definitions]; + } + + return definitions.filter((definition) => isMcpToolAllowed(definition.name, policy)); +} + /** Prepare default hosted child fork tool sources. */ export async function prepareDefaultHostedChildForkToolSources( input: PrepareDefaultHostedChildForkToolSourcesInput, @@ -109,9 +163,10 @@ export async function prepareDefaultHostedChildForkToolSources( ? { createRemoteToolSource: input.createRemoteToolSource } : {}), }); + const policyTools = filterHostToolsByMcpPolicy(studioTools.tools, server.toolPolicy); studioMcpTools = { ...studioMcpTools, - ...studioTools.tools, + ...policyTools, }; closeStudioMcpTools = studioTools.close; continue; @@ -126,18 +181,23 @@ export async function prepareDefaultHostedChildForkToolSources( if (!remoteConfig) { continue; } - const remoteSource = createRemoteToolSource(remoteConfig); - const rawDefinitions = await remoteSource.listTools(); - const definitions = server.kind === "veryfront-api" + const rawSource = createRemoteToolSource(remoteConfig); + const policySource = createHostedMcpToolPolicySource(rawSource, server.toolPolicy); + const rawDefinitions = await rawSource.listTools(); + const accessFilteredDefinitions = server.kind === "veryfront-api" ? await filterVeryfrontApiToolDefinitionsWithAccessProfile({ - source: remoteSource, + source: rawSource, toolDefinitions: rawDefinitions, projectId: input.getProjectId() ?? null, }) : rawDefinitions; + const definitions = filterToolDefinitionsByMcpPolicy( + accessFilteredDefinitions, + server.toolPolicy, + ); remoteMcpTools = { ...remoteMcpTools, - ...materializeRemoteTools(remoteSource, definitions), + ...materializeRemoteTools(policySource, definitions), }; } } catch (error) { diff --git a/src/agent/hosted/child-requested-tools.test.ts b/src/agent/hosted/child-requested-tools.test.ts index 94905aa17c..f4d1a37be6 100644 --- a/src/agent/hosted/child-requested-tools.test.ts +++ b/src/agent/hosted/child-requested-tools.test.ts @@ -193,7 +193,26 @@ Deno.test("selectDefaultHostedChildForkRuntimeTools ignores parent-only delegati assertEquals(result, { ok: true, - forkTools, + forkTools: {}, + availableToolNames: [], + }); +}); + +Deno.test("selectDefaultHostedChildForkRuntimeTools preserves an explicit empty tool grant", () => { + const result = selectDefaultHostedChildForkRuntimeTools({ + provider: "anthropic", + forkModel: "claude-sonnet-4-5-20250929", + forkTools: { + web_fetch: { description: "Fetch a URL" }, + }, + effectivePrompt: "Answer from existing context", + requestedTools: [], + }); + + assertEquals(result, { + ok: true, + forkTools: {}, + availableToolNames: [], }); }); diff --git a/src/agent/hosted/child-requested-tools.ts b/src/agent/hosted/child-requested-tools.ts index 68908aac8b..c5b6528e84 100644 --- a/src/agent/hosted/child-requested-tools.ts +++ b/src/agent/hosted/child-requested-tools.ts @@ -211,13 +211,21 @@ export function selectHostedChildForkRuntimeTools(input: { forkTools: HostToolSet; requestedTools?: readonly string[]; }): HostedChildForkRuntimeToolSelectionResult { - if (!input.requestedTools?.length) { + if (input.requestedTools === undefined) { return { ok: true, forkTools: input.forkTools, }; } + if (input.requestedTools.length === 0) { + return { + ok: true, + forkTools: {}, + availableToolNames: [], + }; + } + const providerNativeNames = new Set( getProviderNativeToolNames({ provider: input.provider, diff --git a/src/agent/hosted/child-tool-input.test.ts b/src/agent/hosted/child-tool-input.test.ts index bcc4c37abf..44152644a0 100644 --- a/src/agent/hosted/child-tool-input.test.ts +++ b/src/agent/hosted/child-tool-input.test.ts @@ -3,8 +3,10 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { DEFAULT_HOSTED_CHILD_AGENT_ID, hostedChildForkToolInputSchema, + MAX_HOSTED_CHILD_DELEGATION_DEPTH, resolveHostedChildForkRuntimeConfig, resolveHostedChildForkThinkingOverride, + withHostedChildInvocationContext, } from "./child-tool-input.ts"; Deno.test("hostedChildForkToolInputSchema accepts the hosted child fork fields", () => { @@ -154,6 +156,159 @@ Deno.test("resolveHostedChildForkThinkingOverride maps child fork thinking value }); }); +Deno.test("withHostedChildInvocationContext starts trusted root lineage and ignores model-supplied root resets", () => { + const result = withHostedChildInvocationContext( + { + description: "review child task", + prompt: "Review the delegated task.", + context: { + veryfront_invocation_context: { + root_conversation_id: "model-root-conversation", + root_run_id: "model-root-run", + root_message_id: "model-root-message", + delegation_depth: 99, + }, + }, + }, + { + parentConversationId: "root-conversation", + conversationId: "root-conversation", + parentRunId: "root-run", + parentMessageId: "root-message", + toolCallId: "tool-call-1", + }, + ); + + assertEquals(result.context?.veryfront_invocation_context, { + root_conversation_id: "root-conversation", + parent_conversation_id: "root-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_run_id: "root-run", + parent_message_id: "root-message", + tool_call_id: "tool-call-1", + delegation_depth: 1, + }); +}); + +Deno.test("withHostedChildInvocationContext preserves root lineage and advances immediate parent for grandchildren", () => { + const child = withHostedChildInvocationContext( + { + description: "child task", + prompt: "Handle child work.", + context: {}, + }, + { + parentConversationId: "root-conversation", + conversationId: "root-conversation", + parentRunId: "root-run", + parentMessageId: "root-message", + toolCallId: "tool-call-child", + }, + ); + + const grandchild = withHostedChildInvocationContext( + { + description: "grandchild task", + prompt: "Handle grandchild work.", + context: {}, + }, + { + parentConversationId: "child-conversation", + conversationId: "legacy-conversation-value", + parentRunId: "child-run", + parentMessageId: "child-message", + toolCallId: "tool-call-grandchild", + trustedInvocationContext: child.context?.veryfront_invocation_context as never, + }, + ); + + assertEquals(grandchild.context?.veryfront_invocation_context, { + root_conversation_id: "root-conversation", + parent_conversation_id: "child-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_run_id: "child-run", + parent_message_id: "child-message", + tool_call_id: "tool-call-grandchild", + delegation_depth: 2, + }); +}); + +Deno.test("withHostedChildInvocationContext clamps negative trusted delegation depth", () => { + const result = withHostedChildInvocationContext( + { + description: "child task", + prompt: "Handle child work.", + context: {}, + }, + { + parentConversationId: "child-conversation", + parentRunId: "child-run", + parentMessageId: "child-message", + toolCallId: "tool-call-child", + trustedInvocationContext: { + root_conversation_id: "root-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_conversation_id: "parent-conversation", + parent_run_id: "parent-run", + parent_message_id: "parent-message", + tool_call_id: "tool-call-parent", + delegation_depth: -4, + }, + }, + ); + + assertEquals(result.context?.veryfront_invocation_context, { + root_conversation_id: "root-conversation", + parent_conversation_id: "child-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_run_id: "child-run", + parent_message_id: "child-message", + tool_call_id: "tool-call-child", + delegation_depth: 1, + }); +}); + +Deno.test("withHostedChildInvocationContext enforces the delegation depth cap", () => { + let message = ""; + + try { + withHostedChildInvocationContext( + { + description: "too deep", + prompt: "This should not run.", + context: {}, + }, + { + conversationId: "child-conversation", + parentRunId: "child-run", + parentMessageId: "child-message", + toolCallId: "tool-call-deep", + trustedInvocationContext: { + root_conversation_id: "root-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_conversation_id: "parent-conversation", + parent_run_id: "parent-run", + parent_message_id: "parent-message", + tool_call_id: "tool-call-parent", + delegation_depth: MAX_HOSTED_CHILD_DELEGATION_DEPTH, + }, + }, + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + assertEquals( + message, + `invoke_agent delegation depth limit exceeded: maximum depth is ${MAX_HOSTED_CHILD_DELEGATION_DEPTH}.`, + ); +}); + Deno.test("resolveHostedChildForkRuntimeConfig resolves reusable child fork runtime options", () => { const result = resolveHostedChildForkRuntimeConfig({ forkInput: { diff --git a/src/agent/hosted/child-tool-input.ts b/src/agent/hosted/child-tool-input.ts index ee991362b8..bcc9bfaa40 100644 --- a/src/agent/hosted/child-tool-input.ts +++ b/src/agent/hosted/child-tool-input.ts @@ -6,6 +6,7 @@ import type { RuntimeAgentThinkingConfig } from "../runtime/agent-definition.ts" /** Default value for hosted child agent ID. */ export const DEFAULT_HOSTED_CHILD_AGENT_ID = "invoke-agent-child"; +export const MAX_HOSTED_CHILD_DELEGATION_DEPTH = 8; const HOSTED_CHILD_FORK_RESULT_MODES = ["summary", "full", "structured"] as const; /** Hosted child fork result return mode. */ @@ -25,6 +26,9 @@ export const getHostedChildForkToolInputSchema = defineSchema((v) => "Tool subset for this fork. Omit = inherit all parent tools.", ), model: v.string().optional().describe('Model override (e.g. "sonnet" for cheaper work).'), + temperature: v.number().min(0).max(2).optional().describe( + "Sampling temperature override. Omit for the hosted child default.", + ), thinking: v .number() .nonnegative() @@ -45,9 +49,14 @@ export const getHostedChildForkToolInputSchema = defineSchema((v) => export const hostedChildForkToolInputSchema = lazySchema(getHostedChildForkToolInputSchema); /** Input payload for hosted child fork tool. */ -export type HostedChildForkToolInput = InferSchema< +type ParsedHostedChildForkToolInput = InferSchema< ReturnType >; +export type HostedChildForkToolInput = + & Omit + & { + context?: ParsedHostedChildForkToolInput["context"]; + }; /** Configuration used by hosted child fork runtime. */ export type HostedChildForkRuntimeConfig = { @@ -56,47 +65,87 @@ export type HostedChildForkRuntimeConfig = { requestedTools: string[] | undefined; forkModel: string; provider: string; + temperature?: number; maxSteps: number; thinkingConfig: RuntimeAgentThinkingConfig | undefined; }; -function getStringRecord(value: unknown): Record { +export type HostedChildInvocationContext = { + root_conversation_id?: string; + root_run_id?: string; + root_message_id?: string; + parent_conversation_id?: string; + parent_run_id?: string; + parent_message_id?: string; + tool_call_id?: string; + delegation_depth: number; +}; + +function getTrustedInvocationContext( + value: HostedChildInvocationContext | undefined, +): HostedChildInvocationContext | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; + return undefined; } - const record: Record = {}; - for (const [key, property] of Object.entries(value)) { - if (typeof property === "string") { - record[key] = property; - } - } + const depth = Number.isInteger(value.delegation_depth) ? Math.max(0, value.delegation_depth) : 0; + + return { + ...(typeof value.root_conversation_id === "string" + ? { root_conversation_id: value.root_conversation_id } + : {}), + ...(typeof value.root_run_id === "string" ? { root_run_id: value.root_run_id } : {}), + ...(typeof value.root_message_id === "string" + ? { root_message_id: value.root_message_id } + : {}), + ...(typeof value.parent_conversation_id === "string" + ? { parent_conversation_id: value.parent_conversation_id } + : {}), + ...(typeof value.parent_run_id === "string" ? { parent_run_id: value.parent_run_id } : {}), + ...(typeof value.parent_message_id === "string" + ? { parent_message_id: value.parent_message_id } + : {}), + ...(typeof value.tool_call_id === "string" ? { tool_call_id: value.tool_call_id } : {}), + delegation_depth: depth, + }; +} - return record; +function assertCanDelegate(parentDepth: number): void { + if (parentDepth >= MAX_HOSTED_CHILD_DELEGATION_DEPTH) { + throw new Error( + `invoke_agent delegation depth limit exceeded: maximum depth is ${MAX_HOSTED_CHILD_DELEGATION_DEPTH}.`, + ); + } } function buildHostedChildInvocationContext( - forkInput: HostedChildForkToolInput, input: { + parentConversationId?: string; conversationId?: string; parentRunId?: string; + parentMessageId?: string; toolCallId: string; + trustedInvocationContext?: HostedChildInvocationContext; }, -): Record { - const existing = getStringRecord(forkInput.context?.veryfront_invocation_context); - const rootConversationId = existing.root_conversation_id || input.conversationId; - const rootRunId = existing.root_run_id || input.parentRunId; +): HostedChildInvocationContext { + const trusted = getTrustedInvocationContext(input.trustedInvocationContext); + const parentDepth = trusted?.delegation_depth ?? 0; + assertCanDelegate(parentDepth); + + const parentConversationId = input.parentConversationId ?? input.conversationId; + const rootConversationId = trusted?.root_conversation_id || parentConversationId; + const rootRunId = trusted?.root_run_id || input.parentRunId; + const rootMessageId = trusted?.root_message_id || input.parentMessageId; return { - ...existing, ...(rootConversationId ? { root_conversation_id: rootConversationId, } : {}), - ...(input.conversationId + ...(parentConversationId ? { - parent_conversation_id: input.conversationId, + parent_conversation_id: parentConversationId, } : {}), ...(rootRunId @@ -104,12 +153,23 @@ function buildHostedChildInvocationContext( root_run_id: rootRunId, } : {}), + ...(rootMessageId + ? { + root_message_id: rootMessageId, + } + : {}), ...(input.parentRunId ? { parent_run_id: input.parentRunId, } : {}), + ...(input.parentMessageId + ? { + parent_message_id: input.parentMessageId, + } + : {}), tool_call_id: input.toolCallId, + delegation_depth: parentDepth + 1, }; } @@ -117,16 +177,21 @@ function buildHostedChildInvocationContext( export function withHostedChildInvocationContext( forkInput: HostedChildForkToolInput, input: { + parentConversationId?: string; conversationId?: string; parentRunId?: string; + parentMessageId?: string; toolCallId: string; + trustedInvocationContext?: HostedChildInvocationContext; }, ): HostedChildForkToolInput { return { ...forkInput, context: { ...(forkInput.context ?? {}), - veryfront_invocation_context: buildHostedChildInvocationContext(forkInput, input), + veryfront_invocation_context: buildHostedChildInvocationContext({ + ...input, + }), }, }; } @@ -140,6 +205,7 @@ export type ResolveHostedChildForkRuntimeConfigInput = { | "context" | "tools" | "model" + | "temperature" | "thinking" | "max_steps" >; @@ -196,7 +262,8 @@ export function buildHostedChildForkEffectivePrompt(input: { export function resolveHostedChildForkRuntimeConfig( input: ResolveHostedChildForkRuntimeConfigInput, ): HostedChildForkRuntimeConfig { - const { description, prompt, context, tools, model, thinking, max_steps } = input.forkInput; + const { description, prompt, tools, model, temperature, thinking, max_steps } = input.forkInput; + const context = input.forkInput.context ?? {}; const forkModel = input.resolveModelId(model || input.contextModel || input.defaultModel); const requestedMaxSteps = typeof max_steps === "number" ? max_steps : undefined; const thinkingConfig = resolveHostedChildForkThinkingOverride(thinking) ?? @@ -213,6 +280,7 @@ export function resolveHostedChildForkRuntimeConfig( requestedTools: tools, forkModel, provider: input.resolveProvider(forkModel), + ...(temperature === undefined ? {} : { temperature }), maxSteps: Math.max(requestedMaxSteps ?? input.defaultMaxSteps, input.defaultMaxSteps), thinkingConfig, }; diff --git a/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts b/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts index 80d4249d28..ef2c055b79 100644 --- a/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts +++ b/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts @@ -7,6 +7,7 @@ import { getVeryfrontCloudProviderFromModelId } from "#veryfront/provider"; import type { AgentTraceAttributes } from "./trace-attributes.ts"; import type { HostedChatExecutionRuntimeLogger } from "./chat-execution-runtime.ts"; import type { PreparedHostedChatExecutionRuntimeOptions } from "./prepared-chat-execution.ts"; +import { AGENT_DELEGATE_TOOL_PREFIX } from "../runtime/agent-delegation-names.ts"; /** Environment key for hosted chat stream idle timeout. */ export const VERYFRONT_CHAT_STREAM_IDLE_TIMEOUT_ENV = "VERYFRONT_CHAT_STREAM_IDLE_TIMEOUT_MS"; @@ -101,6 +102,7 @@ export function createVeryfrontCloudPreparedHostedChatExecutionRuntimeOptions( // timeout; the shared watchdog no longer defaults this product-specific // exemption, so pass it explicitly for hosted runs. longRunningToolNames: ["invoke_agent"], + longRunningToolPrefixes: [AGENT_DELEGATE_TOOL_PREFIX], setTimeoutFn: globalThis.setTimeout, clearTimeoutFn: globalThis.clearTimeout, })), diff --git a/src/agent/hosted/cloud-runtime-system-messages.test.ts b/src/agent/hosted/cloud-runtime-system-messages.test.ts index 9e6bfa02cf..8149c64c8d 100644 --- a/src/agent/hosted/cloud-runtime-system-messages.test.ts +++ b/src/agent/hosted/cloud-runtime-system-messages.test.ts @@ -54,6 +54,8 @@ Deno.test("createVeryfrontCloudRuntimeSystemMessages includes skills and environ id: "deploy", name: "Deploy", description: "Deployment guidance", + instructions: "Deploy carefully.", + allowedTools: [], references: [], }, ]; @@ -72,6 +74,31 @@ Deno.test("createVeryfrontCloudRuntimeSystemMessages includes skills and environ assertStringIncludes(messages[1]?.content ?? "", "Runtime facts"); }); +Deno.test("createVeryfrontCloudRuntimeSystemMessages scopes skill delegation to available tools", () => { + const skills: RuntimeSkillDefinition[] = [ + { + id: "review", + name: "Review", + description: "Review guidance", + instructions: "Review carefully.", + allowedTools: [], + }, + ]; + + const [message] = createVeryfrontCloudRuntimeSystemMessages({ + agent: createAgent({ instructions: "Base instructions" }), + skills, + availableToolNames: ["agent_reviewer", "load_skill"], + }); + + assertStringIncludes( + message?.content ?? "", + "When delegating, use only these available scoped delegation tools: `agent_reviewer`.", + ); + assertEquals((message?.content ?? "").includes("invoke_agent"), false); + assertEquals((message?.content ?? "").includes("Pass through any returned model"), false); +}); + Deno.test("buildVeryfrontCloudRuntimeInstructions adapts hosted preparation input", () => { const messages = buildVeryfrontCloudRuntimeInstructions({ agentConfig: createAgent(), diff --git a/src/agent/hosted/cloud-runtime-system-messages.ts b/src/agent/hosted/cloud-runtime-system-messages.ts index 66bb1eef00..6cbca08780 100644 --- a/src/agent/hosted/cloud-runtime-system-messages.ts +++ b/src/agent/hosted/cloud-runtime-system-messages.ts @@ -12,6 +12,7 @@ export type CreateVeryfrontCloudRuntimeSystemMessagesInput = { agent: RuntimeAgentMarkdownDefinition; instructions?: string; skills?: readonly RuntimeSkillDefinition[]; + availableToolNames?: readonly string[]; projectId?: string | null; branchId?: string | null; environmentContext?: string; @@ -66,6 +67,7 @@ export function createVeryfrontCloudRuntimeSystemMessages( agent: input.agent, runtimeBlocks, skills: input.skills, + availableToolNames: input.availableToolNames, environmentContext: input.environmentContext, }); } @@ -78,6 +80,7 @@ export function buildVeryfrontCloudRuntimeInstructions( agent: input.agentConfig, instructions: input.instructions || undefined, skills: input.skills.length > 0 ? input.skills : undefined, + availableToolNames: input.availableToolNames, projectId: input.projectId, branchId: input.branchId, environmentContext: input.environmentContext, diff --git a/src/agent/hosted/default-invoke-agent-tool.test.ts b/src/agent/hosted/default-invoke-agent-tool.test.ts index 20922b7c43..35decc6f84 100644 --- a/src/agent/hosted/default-invoke-agent-tool.test.ts +++ b/src/agent/hosted/default-invoke-agent-tool.test.ts @@ -4,8 +4,10 @@ import type { CreateSandboxBashTool } from "#veryfront/sandbox"; import { buildChildRunResultSummary } from "../child-run/result-summary.ts"; import { createDefaultHostedInvokeAgentTool, + type DefaultHostedInvokeAgentConfig, type DefaultHostedInvokeAgentContext, defaultHostedInvokeAgentInputSchema, + defaultHostedInvokeAgentToolInternals, type DefaultHostedInvokeAgentToolOptions, type DefaultHostedInvokeAgentTraceAttributes, executeDefaultHostedInvokeAgentTool, @@ -18,6 +20,10 @@ const DURABLE_CONTEXT_FAILURE_TEXT = function createTestOptions(input?: { context?: DefaultHostedInvokeAgentContext; traceAttributes?: DefaultHostedInvokeAgentTraceAttributes[]; + config?: Partial; + enableDurableInvokeAgent?: boolean; + requireDurableInvokeAgent?: boolean; + options?: Partial>; }): DefaultHostedInvokeAgentToolOptions { const traceAttributes = input?.traceAttributes ?? []; @@ -32,7 +38,8 @@ function createTestOptions(input?: { apiUrl: "https://api.example.com", apiMcpUrl: "https://api.example.com/mcp", studioMcpUrl: "https://studio.example.com/mcp", - enableDurableInvokeAgent: true, + enableDurableInvokeAgent: input?.enableDurableInvokeAgent ?? true, + ...input?.config, }), logger: { debug: () => undefined, @@ -47,6 +54,8 @@ function createTestOptions(input?: { createBashTool, resolveModelId: (model) => `resolved-${model}`, resolveProvider: () => "anthropic", + requireDurableInvokeAgent: input?.requireDurableInvokeAgent, + ...input?.options, }; } @@ -111,6 +120,141 @@ Deno.test("defaultHostedInvokeAgentInputSchema rejects invalid result mode", asy ); }); +Deno.test("fixed hosted delegates inherit project-agent settings without overriding explicit input", () => { + const configured = defaultHostedInvokeAgentToolInternals.applyChildAgentExecutionConfig( + { + description: "extract application", + prompt: "Extract the application.", + context: {}, + agent_id: "extraction-agent", + model: "requested-model", + }, + { + system: "Follow the extraction policy.", + model: "configured-model", + temperature: 0.25, + maxSteps: 12, + thinking: 800, + toolNames: ["get_file", "load_skill"], + mcpServers: [], + }, + ); + + assertEquals(configured, { + description: "extract application", + prompt: "Extract the application.", + context: {}, + agent_id: "extraction-agent", + model: "requested-model", + temperature: 0.25, + max_steps: 12, + thinking: 800, + tools: ["get_file", "load_skill"], + }); +}); + +Deno.test("default hosted invoke resolves and runs configured child against the target project", async () => { + const captured: { + model?: string; + temperature?: number; + maxSteps?: number; + forkToolNames?: readonly string[]; + system?: string; + prompt?: string; + } = {}; + + const result = await executeDefaultHostedInvokeAgentTool( + createTestOptions({ + enableDurableInvokeAgent: false, + config: { mcpServers: [] }, + options: { + resolveChildAgentExecutionConfig: (childAgentId, projectId) => { + assertEquals(childAgentId, "extraction-agent"); + assertEquals(projectId, "target-project"); + return Promise.resolve({ + system: "Follow the extraction policy.", + model: "configured-model", + temperature: 0.35, + maxSteps: 12, + toolNames: ["lookup_job"], + availableSkillIds: ["extraction"], + }); + }, + buildGlobalTools: (context, childAgentId, childConfig) => { + assertEquals(context.projectId, "target-project"); + assertEquals(childAgentId, "extraction-agent"); + assertEquals(childConfig?.toolNames, ["lookup_job"]); + return { + lookup_job: { + description: "Lookup a job posting", + inputSchema: {}, + execute: () => ({ ok: true }), + }, + unrelated_tool: { + description: "Should be filtered out", + inputSchema: {}, + execute: () => ({ ok: true }), + }, + }; + }, + createAgentServiceSandboxTools: () => + Promise.resolve({ + tools: {}, + sandbox: {} as never, + closeSandbox: () => Promise.resolve(), + }), + startRuntime: (input) => { + captured.model = input.forkModel; + captured.temperature = input.temperature; + captured.maxSteps = input.maxSteps; + captured.forkToolNames = input.forkToolNames; + captured.system = input.buildInstructions(); + captured.prompt = input.prompt; + return { + forkStreamAbortController: new AbortController(), + childRunMonitorAbortController: null, + childRunMonitorPromise: Promise.resolve(), + forkToolNames: [...(input.forkToolNames ?? [])], + streamResult: { + fullStream: (async function* () { + yield { type: "text-delta", text: "Configured child ran." } as const; + })(), + steps: Promise.resolve([ + { + text: "Configured child ran.", + finishReason: "stop", + messages: [], + toolCalls: [], + toolResults: [], + }, + ]), + totalUsage: Promise.resolve(undefined), + }, + }; + }, + }, + }), + { + description: "extract application", + prompt: "Extract the application.", + context: {}, + agent_id: "extraction-agent", + project_id: "target-project", + }, + "extraction-agent", + { toolCallId: "tool-call-configured-child" }, + ); + + assertEquals("success" in result && result.success, true); + assertEquals(captured.model, "resolved-configured-model"); + assertEquals(captured.temperature, 0.35); + assertEquals(captured.maxSteps, 12); + assertEquals(captured.forkToolNames, ["lookup_job"]); + assertEquals(captured.system?.includes("Follow the extraction policy."), true); + assertEquals(captured.system?.includes("Available Skills"), true); + assertEquals(captured.prompt?.includes("Extract the application."), true); +}); + Deno.test("executeDefaultHostedInvokeAgentTool returns durable context failure before local execution", async () => { const traceAttributes: DefaultHostedInvokeAgentTraceAttributes[] = []; const result = await executeDefaultHostedInvokeAgentTool( @@ -139,6 +283,33 @@ Deno.test("executeDefaultHostedInvokeAgentTool returns durable context failure b assertEquals(traceAttributes.at(-1)?.["tool.call.id"], "tool-call-1"); }); +Deno.test("fixed delegates require durable execution even when legacy durable delegation is disabled", async () => { + const result = await executeDefaultHostedInvokeAgentTool( + createTestOptions({ + enableDurableInvokeAgent: false, + requireDurableInvokeAgent: true, + }), + { + description: "extract application", + prompt: "Extract the application.", + context: {}, + agent_id: "extraction-agent", + }, + "extraction-agent", + { toolCallId: "tool-call-fixed-delegate" }, + ); + + assertEquals(result, { + ok: false, + status: "failed", + text: DURABLE_CONTEXT_FAILURE_TEXT, + summary: buildChildRunResultSummary(DURABLE_CONTEXT_FAILURE_TEXT), + terminalErrorCode: "DURABLE_INVOKE_CONTEXT_UNAVAILABLE", + terminalErrorMessage: + "invoke_agent requires durable conversation context when durable child runs are enabled.", + }); +}); + Deno.test("createDefaultHostedInvokeAgentTool adds child selection guidance and resolves agent_id", async () => { const traceAttributes: DefaultHostedInvokeAgentTraceAttributes[] = []; const invokeTool = createDefaultHostedInvokeAgentTool( diff --git a/src/agent/hosted/default-invoke-agent-tool.ts b/src/agent/hosted/default-invoke-agent-tool.ts index 8b807901f7..f3d769057b 100644 --- a/src/agent/hosted/default-invoke-agent-tool.ts +++ b/src/agent/hosted/default-invoke-agent-tool.ts @@ -24,7 +24,11 @@ import type { ConversationRunEvent } from "../conversation/run-events.ts"; import { createConversationChildLifecycleAdapter } from "../conversation/hosted-lifecycle.ts"; import { bootstrapHostedChildRun } from "./child-bootstrap.ts"; import { createHostedChildExecutionLogWriter } from "./child-execution-logging.ts"; -import { startHostedChildForkRuntimeWithHostTools } from "./child-fork-runtime-start.ts"; +import { + type StartedHostedChildForkRuntime, + startHostedChildForkRuntimeWithHostTools, + type StartHostedChildForkRuntimeWithHostToolsInput, +} from "./child-fork-runtime-start.ts"; import { prepareDefaultHostedChildForkSandboxToolSources } from "./child-fork-tool-sources.ts"; import type { AgentServiceMcpServerConfig } from "../service/mcp-server-config.ts"; import { executeHostedChildForkToolInput } from "./child-fork-execution-runner.ts"; @@ -51,6 +55,7 @@ import { getHostedChildForkToolInputSchema, type HostedChildForkRuntimeConfig, type HostedChildForkToolInput, + type HostedChildInvocationContext, withHostedChildInvocationContext, } from "./child-tool-input.ts"; import type { @@ -59,10 +64,12 @@ import type { } from "./child-requested-tools.ts"; import { prepareDefaultHostedChildForkToolAssembly } from "./child-requested-tools.ts"; import type { RuntimeClientProfile } from "../runtime/client-profile.ts"; +import type { RuntimeLoadSkillToolContext } from "../runtime/load-skill-tool.ts"; import type { RuntimeReasoningOption } from "../types.ts"; import { withRootOwnedChildResultHint } from "../conversation/delegation-policy.ts"; import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; import { getRuntimeSourceIntegrationPolicyFromContext } from "../runtime/runtime-tool-config.ts"; +import { buildHostedChildForkInstructions } from "./child-fork-instructions.ts"; /** Context for default hosted invoke agent. */ export type DefaultHostedInvokeAgentContext = MutableAgentProjectContext & { @@ -72,6 +79,7 @@ export type DefaultHostedInvokeAgentContext = MutableAgentProjectContext & { conversationId?: string; parentRunId?: string; parentMessageId?: string; + veryfrontInvocationContext?: HostedChildInvocationContext; publishParentRunEvents?: (events: ConversationRunEvent[]) => Promise | void; availableToolNames?: string[]; steeringRevision?: number; @@ -86,6 +94,22 @@ export type DefaultHostedInvokeAgentConfig = { enableDurableInvokeAgent?: boolean; }; +/** Resolved project-agent settings applied to a fixed hosted child run. */ +export type DefaultHostedChildAgentExecutionConfig = { + system: string; + model?: string; + temperature?: number; + maxSteps?: number; + thinking?: HostedChildForkToolInput["thinking"]; + toolNames?: string[]; + mcpServers?: readonly AgentServiceMcpServerConfig[]; + availableSkillIds?: string[]; + skillSourcePaths?: Readonly>; + loadedSkillResponses?: RuntimeLoadSkillToolContext["loadedSkillResponses"]; + loadedSkillReferenceResponses?: RuntimeLoadSkillToolContext["loadedSkillReferenceResponses"]; + delegateIds?: string[]; +}; + /** Public API contract for default hosted invoke agent logger. */ export type DefaultHostedInvokeAgentLogger = { debug(message: string, metadata?: Record): void; @@ -139,8 +163,19 @@ export type DefaultHostedInvokeAgentToolOptions RuntimeReasoningOption | undefined; resolveModelThinking?: (modelId: string) => HostedChildForkRuntimeConfig["thinkingConfig"]; shouldRethrowError?: (error: unknown) => boolean; - buildGlobalTools?: (context: TContext) => HostToolSet; + buildGlobalTools?: ( + context: TContext, + childAgentId: string, + childConfig?: DefaultHostedChildAgentExecutionConfig, + durableChildRun?: HostedChildRunIdentifiers, + ) => HostToolSet; + resolveChildAgentExecutionConfig?: ( + childAgentId: string, + projectId: string, + ) => Promise; refreshProjectSkillIds?: DefaultHostedInvokeAgentProjectRefresh; + /** Require durable child lifecycle even when legacy generic delegation is disabled. */ + requireDurableInvokeAgent?: boolean; defaultModel?: string; defaultMaxSteps?: number; resolveChildAgentId?: (input: DefaultHostedInvokeAgentInput) => string; @@ -152,6 +187,9 @@ export type DefaultHostedInvokeAgentToolOptions[0][ "createLiveStudioTools" ]; + startRuntime?: ( + input: StartHostedChildForkRuntimeWithHostToolsInput, + ) => StartedHostedChildForkRuntime | Promise; }; const defaultHostedInvokeAgentSelectionFields = (v: SchemaValidator) => ({ @@ -184,9 +222,14 @@ export const defaultHostedInvokeAgentInputSchema = lazySchema( ); /** Input payload for default hosted invoke agent. */ -export type DefaultHostedInvokeAgentInput = InferSchema< +type ParsedDefaultHostedInvokeAgentInput = InferSchema< ReturnType >; +export type DefaultHostedInvokeAgentInput = + & Omit + & { + context?: ParsedDefaultHostedInvokeAgentInput["context"]; + }; const DEFAULT_USER_AGENT_MODEL = "opus"; const DEFAULT_USER_AGENT_MAX_STEPS = 80; @@ -233,12 +276,20 @@ async function applyRequestedProjectId( options: DefaultHostedInvokeAgentToolOptions, config: DefaultHostedInvokeAgentConfig, + childAgentId: string, + childConfig: DefaultHostedChildAgentExecutionConfig | undefined, abortSignal?: AbortSignal, + durableChildRun?: HostedChildRunIdentifiers, ): Promise { throwIfChildRunAborted(abortSignal); const globalTools: HostToolSet = { - ...(options.buildGlobalTools?.(options.context) ?? {}), + ...(options.buildGlobalTools?.( + options.context, + childAgentId, + childConfig, + durableChildRun, + ) ?? {}), sleep: sleepTool, }; @@ -270,15 +321,26 @@ async function prepareForkToolAssembly, config: DefaultHostedInvokeAgentConfig, input: { + childAgentId: string; + childConfig?: DefaultHostedChildAgentExecutionConfig; provider: string; forkModel: string; effectivePrompt: string; requestedTools?: HostedChildForkToolInput["tools"]; abortSignal?: AbortSignal; + durableChildRun?: HostedChildRunIdentifiers; }, ): Promise { const toolAssembly = await prepareDefaultHostedChildForkToolAssembly({ - prepareToolSources: () => prepareForkToolSources(options, config, input.abortSignal), + prepareToolSources: () => + prepareForkToolSources( + options, + config, + input.childAgentId, + input.childConfig, + input.abortSignal, + input.durableChildRun, + ), provider: input.provider, forkModel: input.forkModel, effectivePrompt: input.effectivePrompt, @@ -345,31 +407,58 @@ async function executeForkTask sourceIntegrationPolicy?: SourceIntegrationPolicyManifest; }, runtimeOptions: { + childAgentId: string; + childConfig?: DefaultHostedChildAgentExecutionConfig; onSettled?: (snapshot: ChildRunExecutionSnapshot) => void | Promise; durableChildRun?: HostedChildRunIdentifiers; - } = {}, + }, ): Promise { - const config = options.getConfig(); - const instrumentation = buildInstrumentation(options); + const baseConfig = options.getConfig(); + const config = runtimeOptions.childConfig?.mcpServers === undefined + ? baseConfig + : { ...baseConfig, mcpServers: runtimeOptions.childConfig.mcpServers }; + const forkInput = withHostedChildInvocationContext(input, { + parentConversationId: options.context.conversationId, + conversationId: options.context.conversationId, + parentRunId: options.context.parentRunId, + parentMessageId: options.context.parentMessageId, + toolCallId: execution.toolCallId, + trustedInvocationContext: options.context.veryfrontInvocationContext, + }); + const invocationContext = forkInput.context?.veryfront_invocation_context as + | HostedChildInvocationContext + | undefined; + const scopedOptions = invocationContext + ? { + ...options, + context: { + ...options.context, + veryfrontInvocationContext: invocationContext, + }, + } + : options; + const instrumentation = buildInstrumentation(scopedOptions); const writeHostedChildExecutionLog = createHostedChildExecutionLogWriter(options.logger); return executeHostedChildForkToolInput({ apiUrl: config.apiUrl, - authToken: options.context.authToken, - projectId: options.context.projectId || null, - forkInput: input, + authToken: scopedOptions.context.authToken, + projectId: scopedOptions.context.projectId || null, + forkInput, toolCallId: execution.toolCallId, - contextModel: options.context.model, + contextModel: scopedOptions.context.model, defaultModel: options.defaultModel ?? DEFAULT_USER_AGENT_MODEL, - defaultMaxSteps: options.defaultMaxSteps ?? DEFAULT_USER_AGENT_MAX_STEPS, + defaultMaxSteps: runtimeOptions.childConfig?.maxSteps ?? + options.defaultMaxSteps ?? + DEFAULT_USER_AGENT_MAX_STEPS, resolveModelId: options.resolveModelId, resolveProvider: options.resolveProvider, resolveModelThinking: options.resolveModelThinking, - onRequestedProjectId: (projectId) => applyRequestedProjectId(options, projectId), + onRequestedProjectId: (projectId) => applyRequestedProjectId(scopedOptions, projectId), onRuntimeConfig: (runtimeConfig) => { options.logger.info("Starting child fork", { - conversationId: options.context.conversationId, - parentRunId: options.context.parentRunId, + conversationId: scopedOptions.context.conversationId, + parentRunId: scopedOptions.context.parentRunId, description: runtimeConfig.description, kind: "invoke_agent", model: runtimeConfig.forkModel, @@ -378,26 +467,46 @@ async function executeForkTask }); }, prepareToolAssembly: ({ runtimeConfig, requestedTools, abortSignal }) => - prepareForkToolAssembly(options, config, { + prepareForkToolAssembly(scopedOptions, config, { + childAgentId: runtimeOptions.childAgentId, + childConfig: runtimeOptions.childConfig, provider: runtimeConfig.provider, forkModel: runtimeConfig.forkModel, effectivePrompt: runtimeConfig.effectivePrompt, requestedTools, abortSignal, + durableChildRun: runtimeOptions.durableChildRun, }), resolveProviderOptions: options.resolveProviderOptions, resolveReasoning: options.resolveReasoning, - forkContext: options.context, + forkContext: scopedOptions.context, + parentConversationId: scopedOptions.context.conversationId, + parentMessageId: scopedOptions.context.parentMessageId, + trustedInvocationContext: scopedOptions.context.veryfrontInvocationContext, + inputAlreadyHasInvocationContext: true, + ...(runtimeOptions.childConfig + ? { + buildInstructions: () => { + const baseInstructions = buildHostedChildForkInstructions({ + ...scopedOptions.context, + availableSkillIds: runtimeOptions.childConfig?.availableSkillIds, + }); + return runtimeOptions.childConfig?.system + ? `${runtimeOptions.childConfig.system}\n\n${baseInstructions}` + : baseInstructions; + }, + } + : {}), abortSignal: execution.abortSignal, durableChildRun: runtimeOptions.durableChildRun, - conversationId: options.context.conversationId, - parentRunId: options.context.parentRunId, + conversationId: scopedOptions.context.conversationId, + parentRunId: scopedOptions.context.parentRunId, kind: "invoke_agent", onSettled: runtimeOptions.onSettled, logger: options.logger, pendingToolLogWriter: options.logger, writeLog: writeHostedChildExecutionLog, - startRuntime: startHostedChildForkRuntimeWithHostTools, + startRuntime: options.startRuntime ?? startHostedChildForkRuntimeWithHostTools, shouldRethrowError: options.shouldRethrowError, instrumentation, sourceIntegrationPolicy: execution.sourceIntegrationPolicy, @@ -416,6 +525,37 @@ function getAbortSignal(executionContext?: ToolExecutionContext): AbortSignal | : undefined; } +function applyChildAgentExecutionConfig( + input: DefaultHostedInvokeAgentInput, + childConfig: DefaultHostedChildAgentExecutionConfig | undefined, +): DefaultHostedInvokeAgentInput { + if (!childConfig) { + return input; + } + + return { + ...input, + ...(input.model === undefined && childConfig.model ? { model: childConfig.model } : {}), + ...(input.temperature === undefined && childConfig.temperature !== undefined + ? { temperature: childConfig.temperature } + : {}), + ...(input.max_steps === undefined && childConfig.maxSteps !== undefined + ? { max_steps: childConfig.maxSteps } + : {}), + ...(input.thinking === undefined && childConfig.thinking !== undefined + ? { thinking: childConfig.thinking } + : {}), + ...(input.tools === undefined && childConfig.toolNames !== undefined + ? { tools: childConfig.toolNames } + : {}), + }; +} + +/** Test-only helpers for fixed-target hosted delegation behavior. */ +export const defaultHostedInvokeAgentToolInternals = { + applyChildAgentExecutionConfig, +}; + /** Execute default hosted invoke agent tool. */ export async function executeDefaultHostedInvokeAgentTool< TContext extends DefaultHostedInvokeAgentContext, @@ -427,14 +567,16 @@ export async function executeDefaultHostedInvokeAgentTool< ): Promise { let executionSnapshot: ChildRunExecutionSnapshot | null = null; const config = options.getConfig(); + const targetProjectId = input.project_id ?? options.context.projectId; + const childConfig = await options.resolveChildAgentExecutionConfig?.( + childAgentId, + targetProjectId, + ); const toolCallId = getToolCallId(executionContext); const abortSignal = getAbortSignal(executionContext); const sourceIntegrationPolicy = getRuntimeSourceIntegrationPolicyFromContext(executionContext); - const forkInput = withHostedChildInvocationContext(input, { - conversationId: options.context.conversationId, - parentRunId: options.context.parentRunId, - toolCallId, - }); + const configuredInput = applyChildAgentExecutionConfig(input, childConfig); + const forkInput = configuredInput; const durableInvokeRecorder = createHostedDurableChildInvokeTraceRecorder({ traceBase: { conversationId: options.context.conversationId, @@ -457,6 +599,8 @@ export async function executeDefaultHostedInvokeAgentTool< sourceIntegrationPolicy, }, { + childAgentId, + childConfig, onSettled: (snapshot) => { executionSnapshot = snapshot; }, @@ -466,7 +610,7 @@ export async function executeDefaultHostedInvokeAgentTool< durableInvokeRecorder.annotate(); - if (!config.enableDurableInvokeAgent) { + if (!config.enableDurableInvokeAgent && !options.requireDurableInvokeAgent) { return executeHostedLocalChildInvoke({ forkInput, abortSignal, @@ -492,10 +636,11 @@ export async function executeDefaultHostedInvokeAgentTool< abortSignal, }, childAgentId, - runProjectId: input.project_id ?? options.context.projectId, + runProjectId: targetProjectId, parentConversationId: options.context.conversationId, parentRunId: options.context.parentRunId, parentMessageId: options.context.parentMessageId, + trustedInvocationContext: options.context.veryfrontInvocationContext, getProjectId: () => options.context.projectId, getRuntimeTargetKind: () => options.context.runtimeTargetKind, getRuntimeTargetEnvironmentId: () => options.context.runtimeTargetEnvironmentId, diff --git a/src/agent/hosted/default-project-steering-refresh.ts b/src/agent/hosted/default-project-steering-refresh.ts index 371d5ac24f..5c41a2b6ea 100644 --- a/src/agent/hosted/default-project-steering-refresh.ts +++ b/src/agent/hosted/default-project-steering-refresh.ts @@ -229,6 +229,7 @@ export function createDefaultHostedProjectSteeringRefresh( environmentContext: input.liveProjectSteering.environmentContext, instructions: projectInstructions, skills: advertisedSkills, + availableToolNames: toolNames, }); return flattenSystemInstructions(withRuntimeToolInventory(refreshedInstructions, toolNames)); diff --git a/src/agent/hosted/durable-child-fork-execution.test.ts b/src/agent/hosted/durable-child-fork-execution.test.ts index 63171f1863..ca2f768c90 100644 --- a/src/agent/hosted/durable-child-fork-execution.test.ts +++ b/src/agent/hosted/durable-child-fork-execution.test.ts @@ -787,6 +787,11 @@ describe("agent/hosted-durable-child-fork-execution", () => { parentConversationId: PARENT_CONVERSATION_ID, parentRunId: "run_parent_1", parentMessageId: PARENT_MESSAGE_ID, + trustedInvocationContext: { + root_conversation_id: "root-conversation-1", + root_run_id: "run_root_1", + delegation_depth: 0, + }, getProjectId: () => projectId, getRuntimeTargetKind: () => "environment", getRuntimeTargetEnvironmentId: () => ENVIRONMENT_ID, @@ -857,7 +862,7 @@ describe("agent/hosted-durable-child-fork-execution", () => { { type: "text", text: - 'Find logs\n\n\n{"veryfront_invocation_context":{"root_conversation_id":"root-conversation-1","root_run_id":"run_root_1","parent_conversation_id":"11111111-1111-4111-a111-111111111111","parent_run_id":"run_parent_1","tool_call_id":"tool-call-1"}}\n\nTreat structured_context as the authoritative data payload for the child task. If prose conflicts with structured_context, use structured_context and say what conflicted.', + 'Find logs\n\n\n{"veryfront_invocation_context":{"root_conversation_id":"root-conversation-1","parent_conversation_id":"11111111-1111-4111-a111-111111111111","root_run_id":"run_root_1","root_message_id":"33333333-3333-4333-a333-333333333333","parent_run_id":"run_parent_1","parent_message_id":"33333333-3333-4333-a333-333333333333","tool_call_id":"tool-call-1","delegation_depth":1}}\n\nTreat structured_context as the authoritative data payload for the child task. If prose conflicts with structured_context, use structured_context and say what conflicted.', }, ], }); diff --git a/src/agent/hosted/durable-child-fork-execution.ts b/src/agent/hosted/durable-child-fork-execution.ts index e0a16ec907..4a721d97fd 100644 --- a/src/agent/hosted/durable-child-fork-execution.ts +++ b/src/agent/hosted/durable-child-fork-execution.ts @@ -28,6 +28,7 @@ import { import { buildHostedChildForkEffectivePrompt, type HostedChildForkToolInput, + type HostedChildInvocationContext, withHostedChildInvocationContext, } from "./child-tool-input.ts"; import { isChildRunAbortError, throwIfChildRunAborted } from "../child-run/execution-support.ts"; @@ -449,6 +450,7 @@ export type ExecuteHostedDurableChildForkInput< parentConversationId?: string; parentRunId?: string; parentMessageId?: string; + trustedInvocationContext?: HostedChildInvocationContext; getProjectId: () => string | null | undefined; getRuntimeTargetKind?: () => ConversationRunTargets["runtimeTargetKind"] | undefined; getRuntimeTargetEnvironmentId?: () => string | null | undefined; @@ -554,9 +556,12 @@ async function bootstrapHostedDurableChildFork< return runBootstrap(async () => { await input.bootstrap?.onBootstrapStart?.(input.bootstrapContext); const forkInput = withHostedChildInvocationContext(input.forkInput, { + parentConversationId: input.bootstrapContext.parentConversationId, conversationId: input.bootstrapContext.parentConversationId, parentRunId: input.bootstrapContext.parentRunId, + parentMessageId: input.bootstrapContext.parentMessageId, toolCallId: input.executionOptions.toolCallId, + trustedInvocationContext: input.trustedInvocationContext, }); const bootstrapChildRun = input.runtime?.bootstrapChildRun ?? bootstrapHostedChildRun; diff --git a/src/agent/hosted/project-remote-tool-source.test.ts b/src/agent/hosted/project-remote-tool-source.test.ts index 2ce5909d4a..c2b58e8e65 100644 --- a/src/agent/hosted/project-remote-tool-source.test.ts +++ b/src/agent/hosted/project-remote-tool-source.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertRejects } from "@std/assert"; +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import type { RemoteMCPToolSourceConfig, RemoteToolSource, @@ -9,6 +9,7 @@ import { createHostedProjectRemoteToolSource, createHostedProjectRemoteToolSources, } from "./project-remote-tool-source.ts"; +import { VeryfrontError } from "#veryfront/errors"; function projectFileTool(name: string): ToolDefinition { return { @@ -143,6 +144,30 @@ Deno.test("createHostedProjectRemoteToolSource scopes tool listings and hydrates ]); }); +Deno.test("createHostedProjectRemoteToolSource replaces model-supplied project references", async () => { + let executedArgs: unknown; + const source = createHostedProjectRemoteToolSource({ + source: createRemoteSource({ + tools: [projectFileTool("update_file")], + execute: (_toolName, args) => { + executedArgs = args; + return { ok: true }; + }, + }), + defaultProjectId: "trusted-project", + }); + + await source.executeTool("update_file", { + path: "AGENTS.md", + project_reference: "attacker-project", + }, { projectId: "trusted-project" }); + + assertEquals(executedArgs, { + path: "AGENTS.md", + project_reference: "trusted-project", + }); +}); + Deno.test("createHostedProjectRemoteToolSource hydrates optional declared project references", async () => { const executeCalls: Array<{ toolName: string; args: unknown; context?: ToolExecutionContext }> = []; @@ -226,6 +251,49 @@ Deno.test("createHostedProjectRemoteToolSource retries configured write collisio assertEquals(executeCalls, ["create_file", "update_file"]); }); +Deno.test("createHostedProjectRemoteToolSource does not retry with an unadvertised tool", async () => { + const executeCalls: string[] = []; + const source = createHostedProjectRemoteToolSource({ + source: createRemoteSource({ + tools: [projectFileTool("create_file")], + execute: (toolName) => { + executeCalls.push(toolName); + return { isError: true, message: "file already exists" }; + }, + }), + defaultProjectId: "project-1", + shouldRetryWithTool: () => true, + }); + + await assertRejects( + () => source.executeTool("create_file", { path: "report.md" }), + Error, + ); + assertEquals(executeCalls, ["create_file"]); +}); + +Deno.test("createHostedProjectRemoteToolSource does not retry outside the tool allowlist", async () => { + const executeCalls: string[] = []; + const source = createHostedProjectRemoteToolSource({ + source: createRemoteSource({ + tools: [projectFileTool("create_file"), projectFileTool("update_file")], + execute: (toolName) => { + executeCalls.push(toolName); + return { isError: true, message: "file already exists" }; + }, + }), + defaultProjectId: "project-1", + allowedToolNames: new Set(["create_file"]), + shouldRetryWithTool: () => true, + }); + + await assertRejects( + () => source.executeTool("create_file", { path: "report.md" }), + Error, + ); + assertEquals(executeCalls, ["create_file"]); +}); + Deno.test("createHostedProjectRemoteToolSource retries thrown errors and rethrows non-retry errors", async () => { const executeCalls: string[] = []; const source = createHostedProjectRemoteToolSource({ @@ -471,24 +539,53 @@ Deno.test("createHostedProjectRemoteToolSources builds API and explicit gated St "x-conversation-id": "conversation-1", "x-project-id": "project-2", }); +}); - const blockedSources = createHostedProjectRemoteToolSources({ - authToken: "token-1", - apiMcpUrl: "https://api.example/mcp", - studioMcpUrl: "https://studio.example/mcp", - mcpServers: [{ kind: "veryfront-api" }, { kind: "veryfront-studio" }], - clientProfile: { - id: "veryfront-cli", - type: "cli", - trusted: true, - capabilities: [], - }, - getProjectId: () => "project-1", - createRemoteToolSource: (config) => - createRemoteSource({ id: config.id, tools: [projectFileTool(config.id ?? "tool")] }), - }); +Deno.test("createHostedProjectRemoteToolSources throws for explicit Studio MCP without hosted URL", () => { + const error = assertThrows( + () => + createHostedProjectRemoteToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + mcpServers: [{ kind: "veryfront-studio" }], + clientProfile: { + id: "veryfront-studio", + type: "web", + trusted: true, + capabilities: ["ui_panels"], + }, + getProjectId: () => "project-1", + createRemoteToolSource: (config) => + createRemoteSource({ id: config.id, tools: [simpleTool("studio_todo_write")] }), + }), + VeryfrontError, + "studioMcpUrl was not provided", + ); + assertEquals(error.slug, "config-invalid"); +}); - assertEquals(blockedSources.map((source) => source.id), ["veryfront-mcp"]); +Deno.test("createHostedProjectRemoteToolSources throws for explicit Studio MCP from disallowed client", () => { + const error = assertThrows( + () => + createHostedProjectRemoteToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + studioMcpUrl: "https://studio.example/mcp", + mcpServers: [{ kind: "veryfront-studio" }], + clientProfile: { + id: "veryfront-cli", + type: "cli", + trusted: true, + capabilities: [], + }, + getProjectId: () => "project-1", + createRemoteToolSource: (config) => + createRemoteSource({ id: config.id, tools: [simpleTool("studio_todo_write")] }), + }), + VeryfrontError, + 'client "veryfront-cli" is not allowed to use Studio MCP', + ); + assertEquals(error.slug, "permission-denied"); }); Deno.test("createHostedProjectRemoteToolSources infers Studio MCP from allowed Studio tools", async () => { @@ -497,7 +594,6 @@ Deno.test("createHostedProjectRemoteToolSources infers Studio MCP from allowed S authToken: "token-1", apiMcpUrl: "https://api.example/mcp", studioMcpUrl: "https://studio.example/mcp", - mcpServers: [{ kind: "veryfront-api" }], clientProfile: { id: "veryfront-studio", type: "web", @@ -529,6 +625,107 @@ Deno.test("createHostedProjectRemoteToolSources infers Studio MCP from allowed S ); }); +Deno.test("createHostedProjectRemoteToolSources does not infer Studio when explicit API-only MCP is set", () => { + const configs: RemoteMCPToolSourceConfig[] = []; + const sources = createHostedProjectRemoteToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + studioMcpUrl: "https://studio.example/mcp", + mcpServers: [{ kind: "veryfront-api" }], + clientProfile: { + id: "veryfront-studio", + type: "web", + trusted: true, + capabilities: ["ui_panels"], + }, + getProjectId: () => "project-1", + allowedToolNames: new Set(["studio_todo_write"]), + createRemoteToolSource: (config) => { + configs.push(config); + return createRemoteSource({ + id: config.id, + tools: [projectFileTool("update_file"), simpleTool("studio_todo_write")], + }); + }, + }); + + assertEquals(sources.map((source) => source.id), ["veryfront-mcp"]); + assertEquals(configs.map((config) => config.endpoint), ["https://api.example/mcp"]); +}); + +Deno.test("createHostedProjectRemoteToolSources preserves an explicit MCP opt-out", () => { + const configs: RemoteMCPToolSourceConfig[] = []; + const sources = createHostedProjectRemoteToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + studioMcpUrl: "https://studio.example/mcp", + mcpServers: [], + clientProfile: { + id: "veryfront-studio", + type: "web", + trusted: true, + capabilities: ["ui_panels"], + }, + getProjectId: () => "project-1", + allowedToolNames: new Set(["studio_todo_write"]), + createRemoteToolSource: (config) => { + configs.push(config); + return createRemoteSource({ id: config.id, tools: [] }); + }, + }); + + assertEquals(sources, []); + assertEquals(configs, []); +}); + +Deno.test("createHostedProjectRemoteToolSources applies API and Studio policies without cross-granting tools", async () => { + const sources = createHostedProjectRemoteToolSources({ + authToken: "token-1", + apiMcpUrl: "https://api.example/mcp", + studioMcpUrl: "https://studio.example/mcp", + mcpServers: [ + { kind: "veryfront-api", toolPolicy: { allow: ["update_file"] } }, + { kind: "veryfront-studio", toolPolicy: { allow: ["studio_open_project"] } }, + ], + clientProfile: { + id: "veryfront-studio", + type: "web", + trusted: true, + capabilities: ["ui_panels"], + }, + getProjectId: () => "project-1", + createRemoteToolSource: (config) => + createRemoteSource({ + id: config.id, + tools: [ + projectFileTool("update_file"), + navigationTool("studio_open_project"), + simpleTool("delete_project"), + ], + }), + }); + + assertEquals(sources.map((source) => source.id), ["veryfront-mcp", "studio-mcp"]); + assertEquals( + (await sources[0]?.listTools({ projectId: "project-1" }))?.map((tool) => tool.name), + ["update_file"], + ); + assertEquals( + (await sources[1]?.listTools({ projectId: "project-1" }))?.map((tool) => tool.name), + ["studio_open_project"], + ); + await assertRejects( + () => sources[0]!.executeTool("studio_open_project", { project_id: "project-2" }), + Error, + 'Tool "studio_open_project" is not advertised by remote source "veryfront-mcp"', + ); + await assertRejects( + () => sources[1]!.executeTool("delete_project", {}), + Error, + 'Tool "delete_project" is not advertised by remote source "studio-mcp"', + ); +}); + Deno.test("createHostedProjectRemoteToolSources builds explicit MCP server lists", async () => { const configs: RemoteMCPToolSourceConfig[] = []; let activeProjectId = "project-1"; @@ -617,12 +814,12 @@ Deno.test("createHostedProjectRemoteToolSources applies custom MCP server tool p await assertRejects( () => source.executeTool("delete_docs", {}), Error, - 'Tool "delete_docs" is not allowed for this MCP server', + 'Tool "delete_docs" is not advertised by remote source "docs"', ); await assertRejects( () => source.executeTool("archive_docs", {}), Error, - 'Tool "archive_docs" is not allowed for this MCP server', + 'Tool "archive_docs" is not advertised by remote source "docs"', ); }); diff --git a/src/agent/hosted/project-remote-tool-source.ts b/src/agent/hosted/project-remote-tool-source.ts index 06bf88bc9d..acab222cd6 100644 --- a/src/agent/hosted/project-remote-tool-source.ts +++ b/src/agent/hosted/project-remote-tool-source.ts @@ -15,7 +15,7 @@ import { defaultAgentServiceMcpServers, } from "../service/mcp-server-config.ts"; import type { AgentMcpToolPolicy } from "../types.ts"; -import { PERMISSION_DENIED } from "#veryfront/errors"; +import { CONFIG_INVALID, PERMISSION_DENIED } from "#veryfront/errors"; import { toChildRunToolInputRecord } from "../child-run/execution-support.ts"; import type { RuntimeClientProfile } from "../runtime/client-profile.ts"; import { getConfirmedProjectContextSwitchId } from "../project/context.ts"; @@ -102,12 +102,41 @@ export function createHostedProjectRemoteToolSource( }); const retryToolName = input.retryToolName ?? "update_file"; + function normalizeProjectToolInput( + toolName: string, + toolInput: Record, + ): Record { + if (isProjectNavigationRemoteTool(toolName, input.projectScopedRemoteToolOptions)) { + return toolInput; + } + + const { project_reference: _untrustedProjectReference, ...trustedInput } = toolInput; + return trustedInput; + } + + async function executeRetryTool(inputExecution: { + toolInput: Record; + context?: ToolExecutionContext; + }): Promise { + const retryExecution = await toolCatalog.prepareExecution({ + toolName: retryToolName, + toolInput: normalizeProjectToolInput(retryToolName, inputExecution.toolInput), + context: inputExecution.context, + }); + return await input.source.executeTool( + retryToolName, + retryExecution.toolInput, + retryExecution.executeContext, + ); + } + async function executeWithRetry(inputExecution: { toolName: string; toolInput: Record; executeContext?: ToolExecutionContext; activeProjectId: string | null; activeBranchId: string | null; + context?: ToolExecutionContext; }): Promise { try { return await input.source.executeTool( @@ -125,11 +154,7 @@ export function createHostedProjectRemoteToolSource( error, }) ) { - return input.source.executeTool( - retryToolName, - inputExecution.toolInput, - inputExecution.executeContext, - ); + return await executeRetryTool(inputExecution); } throw error; @@ -145,13 +170,14 @@ export function createHostedProjectRemoteToolSource( toolInput: toChildRunToolInputRecord(args), context, }) ?? toChildRunToolInputRecord(args); + const trustedToolInput = normalizeProjectToolInput(toolName, normalizedToolInput); const { activeProjectId, toolInput: hydratedToolInput, executeContext, } = await toolCatalog.prepareExecution({ toolName, - toolInput: normalizedToolInput, + toolInput: trustedToolInput, context, }); const activeBranchId = resolveActiveBranchId(input.getActiveBranchId); @@ -161,6 +187,7 @@ export function createHostedProjectRemoteToolSource( executeContext, activeProjectId, activeBranchId, + context, }); if ( @@ -172,7 +199,10 @@ export function createHostedProjectRemoteToolSource( error: result, }) ) { - result = await input.source.executeTool(retryToolName, hydratedToolInput, executeContext); + result = await executeRetryTool({ + toolInput: trustedToolInput, + context, + }); } if (!isSuccessfulProjectSteeringMutationResult(result)) { @@ -180,7 +210,7 @@ export function createHostedProjectRemoteToolSource( } if (isProjectNavigationRemoteTool(toolName, input.projectScopedRemoteToolOptions)) { - const requestedProjectId = normalizedToolInput.project_id; + const requestedProjectId = trustedToolInput.project_id; const confirmedProjectId = typeof requestedProjectId === "string" ? getConfirmedProjectContextSwitchId(result, requestedProjectId) : null; @@ -241,6 +271,7 @@ function resolveHostedProjectMcpServers( ): readonly AgentServiceMcpServerConfig[] { const servers = [...(input.mcpServers ?? defaultAgentServiceMcpServers())]; if ( + input.mcpServers === undefined && needsStudioMcpSource(input) && !servers.some((server) => server.kind === "veryfront-studio") ) { @@ -249,6 +280,25 @@ function resolveHostedProjectMcpServers( return servers; } +function throwExplicitStudioMcpUnavailable( + input: CreateHostedProjectRemoteToolSourcesInput, +): never { + const requirement = + 'Provide studioMcpUrl with a trusted Veryfront Studio client profile, or remove { kind: "veryfront-studio" } from mcpServers.'; + if (!input.studioMcpUrl) { + throw CONFIG_INVALID.create({ + detail: + `Explicit Veryfront Studio MCP server requires a hosted Studio MCP transport, but studioMcpUrl was not provided. ${requirement}`, + }); + } + + const clientId = input.clientProfile?.id ?? "unknown"; + throw PERMISSION_DENIED.create({ + detail: + `Explicit Veryfront Studio MCP server requires a hosted Studio MCP transport, but client "${clientId}" is not allowed to use Studio MCP. ${requirement}`, + }); +} + function createHostedProjectRemoteToolSourceFromConfig( input: CreateHostedProjectRemoteToolSourcesInput, server: AgentServiceMcpServerConfig, @@ -305,7 +355,7 @@ function isHostedMcpToolAllowed( return policy?.allow ? policy.allow.includes(toolName) : true; } -function createHostedMcpToolPolicySource( +export function createHostedMcpToolPolicySource( source: RemoteToolSource, policy: AgentMcpToolPolicy | undefined, ): RemoteToolSource { @@ -339,6 +389,7 @@ export function createHostedProjectRemoteToolSources( const createRemoteToolSource = input.createRemoteToolSource ?? createRemoteMCPToolSource; const sources: RemoteToolSource[] = []; const mcpServers = resolveHostedProjectMcpServers(input); + const hasExplicitMcpServers = input.mcpServers !== undefined; for (const server of mcpServers) { const remoteConfig = createAgentServiceRemoteMcpConfig({ @@ -351,6 +402,9 @@ export function createHostedProjectRemoteToolSources( conversationId: input.conversationId, }); if (!remoteConfig) { + if (hasExplicitMcpServers && server.kind === "veryfront-studio") { + throwExplicitStudioMcpUnavailable(input); + } continue; } diff --git a/src/agent/hosted/runtime-request-config.test.ts b/src/agent/hosted/runtime-request-config.test.ts index ae2d77f1cd..f0b7a6ac08 100644 --- a/src/agent/hosted/runtime-request-config.test.ts +++ b/src/agent/hosted/runtime-request-config.test.ts @@ -155,6 +155,7 @@ Deno.test("resolveHostedRuntimeRequestConfig defaults to configured agent tools" request: {}, agentConfig: createAgentConfig({ tools: ["get_agent", "get_agent_source", "update_agent"], + delegates: ["writer"], providerTools: ["web_search"], }), resolveModelId: (model) => model, @@ -164,12 +165,13 @@ Deno.test("resolveHostedRuntimeRequestConfig defaults to configured agent tools" "get_agent", "get_agent_source", "update_agent", + "agent_writer", ]); assertEquals(result.requestedAllowedProviderTools, ["web_search"]); assertEquals(result.includeRuntimeEssentialToolsWhenEmpty, true); }); -Deno.test("resolveHostedRuntimeRequestConfig keeps explicit tool overrides ahead of configured tools", () => { +Deno.test("resolveHostedRuntimeRequestConfig only lets request tool overrides narrow configured tools", () => { const resolve = (allowedTools: string[]) => { const result = resolveHostedRuntimeRequestConfig({ request: { runtimeOverrides: { allowedTools } }, @@ -179,15 +181,16 @@ Deno.test("resolveHostedRuntimeRequestConfig keeps explicit tool overrides ahead }), resolveModelId: (model) => model, }); - assertEquals(result.requestedAllowedProviderTools, allowedTools); + assertEquals( + result.requestedAllowedProviderTools, + allowedTools.includes("web_search") ? ["web_search"] : [], + ); assertEquals(result.includeRuntimeEssentialToolsWhenEmpty, false); return result.requestedAllowedTools; }; assertEquals(resolve(["unbound_tool", "update_agent", "web_search"]), [ - "unbound_tool", "update_agent", - "web_search", ]); assertEquals(resolve([]), []); }); diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 8798536790..f5b2292b88 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -8,6 +8,7 @@ import { resolveRuntimeClientProfile, type RuntimeClientProfile, } from "../runtime/client-profile.ts"; +import { AGENT_DELEGATE_TOOL_PREFIX } from "../runtime/agent-delegation-names.ts"; /** Request payload for hosted runtime request config. */ export type HostedRuntimeRequestConfigRequest = Pick< @@ -18,7 +19,13 @@ export type HostedRuntimeRequestConfigRequest = Pick< /** Public API contract for hosted runtime request config agent. */ export type HostedRuntimeRequestConfigAgent = Pick< RuntimeAgentMarkdownDefinition, - "model" | "thinking" | "temperature" | "maxSteps" | "tools" | "providerTools" + | "model" + | "thinking" + | "temperature" + | "maxSteps" + | "tools" + | "providerTools" + | "delegates" >; /** Input payload for resolve hosted runtime request config. */ @@ -109,17 +116,22 @@ export function resolveHostedRuntimeThinkingOverride(input: { /** Resolve the explicit request tool selector or fall back to configured agent bindings. */ export function resolveHostedRuntimeAllowedTools(input: { configuredTools: RuntimeAgentMarkdownDefinition["tools"]; + configuredDelegates: RuntimeAgentMarkdownDefinition["delegates"]; requestedTools: string[] | undefined; }): string[] | undefined { - if (input.requestedTools !== undefined) { - return [...new Set(input.requestedTools)]; + if (input.configuredTools === true) { + return input.requestedTools === undefined ? undefined : [...new Set(input.requestedTools)]; } - if (input.configuredTools === true) { - return undefined; + const configuredToolNames = new Set([ + ...(input.configuredTools ?? []), + ...(input.configuredDelegates ?? []).map((id) => `${AGENT_DELEGATE_TOOL_PREFIX}${id}`), + ]); + if (input.requestedTools === undefined) { + return [...configuredToolNames]; } - return [...new Set(input.configuredTools ?? [])]; + return [...new Set(input.requestedTools)].filter((toolName) => configuredToolNames.has(toolName)); } /** Resolve provider-native tool bindings without widening direct tool access. */ @@ -127,9 +139,12 @@ export function resolveHostedRuntimeAllowedProviderTools(input: { configuredProviderTools: RuntimeAgentMarkdownDefinition["providerTools"]; requestedTools: string[] | undefined; }): string[] { - return [ - ...new Set(input.requestedTools ?? input.configuredProviderTools ?? []), - ]; + const configuredToolNames = new Set(input.configuredProviderTools ?? []); + if (input.requestedTools === undefined) { + return [...configuredToolNames]; + } + + return [...new Set(input.requestedTools)].filter((toolName) => configuredToolNames.has(toolName)); } /** Configuration used by resolve hosted runtime request. */ @@ -158,6 +173,7 @@ export function resolveHostedRuntimeRequestConfig( requestedMaxOutputTokens: effectiveRuntimeOverrides?.maxOutputTokens, requestedAllowedTools: resolveHostedRuntimeAllowedTools({ configuredTools: input.agentConfig.tools, + configuredDelegates: input.agentConfig.delegates, requestedTools: effectiveRuntimeOverrides?.allowedTools, }), requestedAllowedProviderTools: resolveHostedRuntimeAllowedProviderTools({ diff --git a/src/agent/hosted/veryfront-cloud-agent-service.test.ts b/src/agent/hosted/veryfront-cloud-agent-service.test.ts index c988c6cdcc..b47b55c7eb 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.test.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.test.ts @@ -17,6 +17,7 @@ import { getDiscoveredHostTools, startNodeVeryfrontCloudAgentService, veryfrontApiMcpServer, + veryfrontCloudAgentServiceInternals, veryfrontStudioMcpServer, } from "./veryfront-cloud-agent-service.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; @@ -137,6 +138,127 @@ Deno.test("getDiscoveredHostTools excludes shared skill infrastructure tools", ( } }); +Deno.test("hosted child project agents request only materialized skill and delegate tools", () => { + assertEquals( + veryfrontCloudAgentServiceInternals.resolveHostedChildToolNames({ + id: "extraction-agent", + name: "Extraction agent", + description: "Extract an application", + instructions: "Extract the application.", + tools: [ + "get_file", + "execute_skill_script", + "load_skill", + "load_skill_reference", + ], + providerTools: ["web_search"], + delegates: ["validation-agent"], + }), + ["get_file", "load_skill", "web_search", "agent_validation-agent"], + ); +}); + +Deno.test("hosted nested delegates inherit child scope and durable lineage", () => { + const context = veryfrontCloudAgentServiceInternals.buildHostedChildToolContext( + { + authToken: "token-1", + projectId: "project-1", + branchId: "branch-1", + agentId: "orchestrator", + availableToolNames: ["agent_extraction-agent", "root_only"], + availableSkillIds: ["root-skill"], + conversationId: "root-conversation", + parentRunId: "root-run", + parentMessageId: "root-message", + }, + "extraction-agent", + { + system: "Extract applications.", + toolNames: ["get_file", "agent_validation-agent", "load_skill"], + availableSkillIds: ["extraction-skill"], + skillSourcePaths: { + "extraction-skill": "agents/extraction-agent/skills/extract/SKILL.md", + }, + delegateIds: ["validation-agent"], + mcpServers: [], + }, + { + childConversationId: "child-conversation", + childRunId: "child-run", + childMessageId: "child-message", + latestEventId: 0, + latestExternalEventSequence: 0, + }, + ); + + assertEquals(context.agentId, "extraction-agent"); + assertEquals(context.availableToolNames, [ + "get_file", + "agent_validation-agent", + "load_skill", + ]); + assertEquals(context.availableSkillIds, ["extraction-skill"]); + assertEquals(context.skillSourcePaths, { + "extraction-skill": "agents/extraction-agent/skills/extract/SKILL.md", + }); + assertEquals(context.loadedSkillResponses, {}); + assertEquals(context.loadedSkillReferenceResponses, {}); + assertEquals(context.conversationId, "child-conversation"); + assertEquals(context.parentRunId, "child-run"); + assertEquals(context.parentMessageId, "child-message"); +}); + +Deno.test("hosted nested delegates preserve trusted root invocation context", () => { + const context = veryfrontCloudAgentServiceInternals.buildHostedChildToolContext( + { + authToken: "token-1", + projectId: "project-1", + agentId: "orchestrator", + conversationId: "root-conversation", + parentRunId: "root-run", + parentMessageId: "root-message", + veryfrontInvocationContext: { + root_conversation_id: "root-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_conversation_id: "root-conversation", + parent_run_id: "root-run", + parent_message_id: "root-message", + tool_call_id: "tool-call-child", + delegation_depth: 1, + }, + }, + "validation-agent", + { + system: "Validate applications.", + toolNames: ["get_file", "load_skill"], + availableSkillIds: ["validation-skill"], + mcpServers: [], + }, + { + childConversationId: "child-conversation", + childRunId: "child-run", + childMessageId: "child-message", + latestEventId: 0, + latestExternalEventSequence: 0, + }, + ); + + assertEquals(context.veryfrontInvocationContext, { + root_conversation_id: "root-conversation", + root_run_id: "root-run", + root_message_id: "root-message", + parent_conversation_id: "root-conversation", + parent_run_id: "root-run", + parent_message_id: "root-message", + tool_call_id: "tool-call-child", + delegation_depth: 1, + }); + assertEquals(context.conversationId, "child-conversation"); + assertEquals(context.parentRunId, "child-run"); + assertEquals(context.parentMessageId, "child-message"); +}); + Deno.test("createNodeVeryfrontCloudAgentServiceRuntime loads the markdown agent and binds service routes", async () => { await withTempDir(async (rootDir) => { writeMarkdownAgentDefinition(rootDir); @@ -500,6 +622,141 @@ Deno.test("Veryfront MCP server helpers create explicit server configs", () => { assertEquals(veryfrontStudioMcpServer(), { kind: "veryfront-studio" }); }); +Deno.test("hosted MCP resolver preserves default behavior without a service ceiling", () => { + assertEquals( + veryfrontCloudAgentServiceInternals.resolveMcpServers({}), + [{ kind: "veryfront-api" }, { kind: "veryfront-studio" }], + ); + + assertEquals( + veryfrontCloudAgentServiceInternals.resolveMcpServers({}, { + mcpServers: [{ + kind: "veryfront-studio", + toolPolicy: { allow: ["studio_open_project"] }, + }], + }), + [{ + kind: "veryfront-studio", + toolPolicy: { allow: ["studio_open_project"] }, + }], + ); +}); + +Deno.test("hosted MCP resolver keeps explicit service opt-out as a hard ceiling", () => { + assertEquals( + veryfrontCloudAgentServiceInternals.resolveMcpServers( + { mcpServers: [] }, + { mcpServers: [{ kind: "veryfront-api" }] }, + ), + [], + ); +}); + +Deno.test("hosted MCP resolver drops agent servers not granted by the service", () => { + assertEquals( + veryfrontCloudAgentServiceInternals.resolveMcpServers( + { mcpServers: [{ kind: "veryfront-api" }] }, + { mcpServers: [{ kind: "veryfront-api", id: "agent-picked" }] }, + ), + [], + ); + assertEquals( + veryfrontCloudAgentServiceInternals.resolveMcpServers( + { mcpServers: [{ kind: "veryfront-studio" }] }, + { mcpServers: [{ kind: "veryfront-api" }] }, + ), + [], + ); +}); + +Deno.test("hosted MCP resolver narrows allow policy and unions deny policy under service ceiling", () => { + assertEquals( + veryfrontCloudAgentServiceInternals.resolveMcpServers( + { + mcpServers: [{ + kind: "veryfront-api", + id: "primary", + toolPolicy: { + allow: ["read_job", "update_job"], + deny: ["delete_job"], + approval: "never", + }, + }], + }, + { + mcpServers: [{ + kind: "veryfront-api", + id: "primary", + toolPolicy: { + allow: ["read_job", "submit_job"], + deny: ["update_job"], + approval: "never", + }, + }], + }, + ), + [{ + kind: "veryfront-api", + id: "primary", + toolPolicy: { + allow: ["read_job"], + deny: ["delete_job", "update_job"], + approval: "never", + }, + }], + ); +}); + +Deno.test("hosted child execution config resolves steering against the target project", async () => { + const childAgent = { + id: "extraction-agent", + name: "Extraction agent", + description: "Extract job applications", + instructions: "Extract the application.", + model: "openai/gpt-5.4", + temperature: 0.35, + }; + const steeringLookups: Array<{ + projectId: string; + authToken: string; + branchId?: string | null; + }> = []; + const config = await veryfrontCloudAgentServiceInternals.resolveHostedChildAgentExecutionConfig( + { + options: { mcpServers: [] }, + discoveryResult: { agents: new Map([["extraction-agent", null]]) }, + agentConfigs: new Map([["extraction-agent", childAgent]]), + projectSteeringByAgentId: new Map([["extraction-agent", { + getProjectInstructions: (lookup: typeof steeringLookups[number]) => { + steeringLookups.push(lookup); + return Promise.resolve("Use the target project's extraction policy."); + }, + getSkillsConfig: (lookup: typeof steeringLookups[number]) => { + steeringLookups.push(lookup); + return Promise.resolve([]); + }, + }]]), + trace: (_name: string, operation: () => unknown) => operation(), + } as never, + { + authToken: "token-1", + projectId: "source-project", + branchId: "source-branch", + agentId: "orchestrator", + }, + "extraction-agent", + "target-project", + ); + + assertEquals(config?.model, "openai/gpt-5.4"); + assertEquals(config?.temperature, 0.35); + assert(config?.system.includes("Use the target project's extraction policy.")); + assertEquals(steeringLookups, [ + { projectId: "target-project", authToken: "token-1", branchId: null }, + { projectId: "target-project", authToken: "token-1", branchId: null }, + ]); +}); + Deno.test({ name: "createNodeVeryfrontCloudAgentServiceRuntime uses veryfront.config.ts discovery paths", // Code primitive discovery invokes the esbuild-backed transpiler, which starts diff --git a/src/agent/hosted/veryfront-cloud-agent-service.ts b/src/agent/hosted/veryfront-cloud-agent-service.ts index 1b382ed995..eadaf07972 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.ts @@ -76,9 +76,11 @@ import { createVeryfrontCloudContextSummaryGenerator } from "./context-summary-g import { createDefaultHostedInvokeAgentTool } from "./default-invoke-agent-tool.ts"; import type { RuntimeClientProfile } from "../runtime/client-profile.ts"; import type { + DefaultHostedChildAgentExecutionConfig, DefaultHostedInvokeAgentConfig, DefaultHostedInvokeAgentContext, } from "./default-invoke-agent-tool.ts"; +import type { HostedChildRunIdentifiers } from "./child-status.ts"; import { createDefaultHostedProjectSteeringRefresh, fetchDefaultHostedProjectSteering, @@ -88,11 +90,15 @@ import { type AgentServiceMcpServerConfig, defaultAgentServiceMcpServers, } from "../service/mcp-server-config.ts"; -import type { AgentVeryfrontMcpServerConfig } from "../types.ts"; +import type { AgentMcpToolPolicy, AgentVeryfrontMcpServerConfig } from "../types.ts"; import type { RuntimeLoadSkillToolContext } from "../runtime/load-skill-tool.ts"; import type { RuntimeProjectSteeringLookup } from "../runtime/project-skill-catalog.ts"; -import type { RuntimeSkillDefinition } from "../runtime/skill-metadata.ts"; +import { + resolveRuntimeSkillsForAgent, + type RuntimeSkillDefinition, +} from "../runtime/skill-metadata.ts"; import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +import { buildAgentDelegateTools } from "../runtime/agent-delegation.ts"; import { createRuntimeAgentDefinitionFromAgent, describeProjectAgentRuntimeAgentIdCandidates, @@ -105,6 +111,7 @@ import { runWithProjectAgentRuntime, } from "../project/agent-runtime.ts"; import { buildVeryfrontCloudRuntimeInstructions } from "./cloud-runtime-system-messages.ts"; +import { flattenSystemInstructions } from "../runtime/tool-inventory.ts"; import { createNodeAgentServiceRuntimeInfrastructure, type CreateNodeAgentServiceRuntimeInfrastructureOptions, @@ -258,15 +265,29 @@ export type AgentServiceProcessTarget = NodeVeryfrontCloudAgentServiceProcessTar type NodeVeryfrontCloudAgentServiceContext = ReturnType< typeof createNodeVeryfrontCloudAgentServiceContext >; -type ChildRunContext = DefaultHostedInvokeAgentContext & { - clientProfile?: RuntimeClientProfile | null; -}; +type ChildRunContext = + & DefaultHostedInvokeAgentContext + & Pick< + RuntimeLoadSkillToolContext, + | "agentId" + | "availableSkillIds" + | "skillSourcePaths" + | "loadedSkillResponses" + | "loadedSkillReferenceResponses" + > + & { + clientProfile?: RuntimeClientProfile | null; + }; const DEFAULT_FORWARDED_CONFIG_NAMESPACE = "veryfront"; const DEFAULT_DRAIN_TIMEOUT_MS = 15_000; const DEFAULT_HARD_SHUTDOWN_TIMEOUT_MS = 20_000; const DEFAULT_AGENT_SERVICE_NAME = "veryfront-agent-service"; const DEFAULT_PROJECT_NAVIGATION_TOOL_NAMES = ["studio_open_project"]; +const HOSTED_CHILD_LOCAL_SKILL_TOOL_NAMES = new Set([ + "execute_skill_script", + "load_skill_reference", +]); const PROJECT_CONFIG_FILES = [ "veryfront.config.js", "veryfront.config.ts", @@ -387,8 +408,62 @@ function resolveDefaultProcessTarget(): NodeVeryfrontCloudAgentServiceProcessTar function resolveMcpServers( options: Pick, + agentConfig?: Pick, ): readonly NodeVeryfrontCloudAgentServiceMcpServer[] { - return options.mcpServers ?? defaultAgentServiceMcpServers(); + if (options.mcpServers !== undefined) { + if (agentConfig?.mcpServers === undefined) { + return options.mcpServers; + } + return agentConfig.mcpServers.flatMap((agentServer) => { + const hostServer = options.mcpServers?.find((server) => + server.kind === agentServer.kind && server.id === agentServer.id + ); + if (!hostServer) { + return []; + } + const toolPolicy = mergeMcpToolPolicies(hostServer.toolPolicy, agentServer.toolPolicy); + return [{ + ...hostServer, + ...(toolPolicy === undefined ? {} : { toolPolicy }), + }]; + }); + } + + if (agentConfig?.mcpServers !== undefined) { + return agentConfig.mcpServers; + } + return defaultAgentServiceMcpServers(); +} + +function mergeMcpToolPolicies( + hostPolicy: AgentMcpToolPolicy | undefined, + agentPolicy: AgentMcpToolPolicy | undefined, +): AgentMcpToolPolicy | undefined { + if (hostPolicy === undefined) { + return agentPolicy; + } + if (agentPolicy === undefined) { + return hostPolicy; + } + + const allow = hostPolicy.allow === undefined + ? agentPolicy.allow + : agentPolicy.allow === undefined + ? hostPolicy.allow + : hostPolicy.allow.filter((toolName) => agentPolicy.allow?.includes(toolName)); + const deny = [ + ...new Set([ + ...(hostPolicy.deny ?? []), + ...(agentPolicy.deny ?? []), + ]), + ]; + const approval = hostPolicy.approval ?? agentPolicy.approval; + + return { + ...(allow === undefined ? {} : { allow }), + ...(deny.length === 0 ? {} : { deny }), + ...(approval === undefined ? {} : { approval }), + }; } async function loadDefaultCreateBashTool(): Promise< @@ -699,18 +774,20 @@ export function getDiscoveredHostTools(scope?: { agentId?: string }): HostToolSe function getProjectInstructions( context: NodeVeryfrontCloudAgentServiceContext, lookup: RuntimeProjectSteeringLookup, + agentId?: string, ): Promise { return context.trace("chat.getProjectInstructions", async () => { - return await getProjectSteering(context).getProjectInstructions(lookup); + return await getProjectSteering(context, agentId).getProjectInstructions(lookup); }); } function getSkillsConfig( context: NodeVeryfrontCloudAgentServiceContext, lookup: RuntimeProjectSteeringLookup, + agentId?: string, ): Promise { return context.trace("chat.getSkillsConfig", async () => { - return await getProjectSteering(context).getSkillsConfig(lookup); + return await getProjectSteering(context, agentId).getSkillsConfig(lookup); }); } @@ -718,14 +795,14 @@ function createLoadSkillTool( context: NodeVeryfrontCloudAgentServiceContext, toolContext: RuntimeLoadSkillToolContext, ) { - return getProjectSteering(context).createLoadSkillTool(toolContext); + return getProjectSteering(context, toolContext.agentId).createLoadSkillTool(toolContext); } async function refreshProjectSkillIds( context: NodeVeryfrontCloudAgentServiceContext, skillContext: HostedProjectSkillIdsContext, ): Promise { - await getProjectSteering(context).refreshProjectSkillIds(skillContext); + await getProjectSteering(context, skillContext.agentId).refreshProjectSkillIds(skillContext); } function setFilteredTraceAttributes( @@ -753,9 +830,117 @@ function shouldRethrowInvokeAgentError(error: unknown): boolean { return parseProviderError(error).code === "INSUFFICIENT_CREDITS"; } +function resolveHostedChildToolNames( + agentConfig: RuntimeAgentMarkdownDefinition, +): string[] | undefined { + if (agentConfig.tools === true) { + return undefined; + } + + return [ + ...new Set([ + ...(agentConfig.tools ?? []).filter((toolName) => + !HOSTED_CHILD_LOCAL_SKILL_TOOL_NAMES.has(toolName) + ), + ...(agentConfig.providerTools ?? []), + ...(agentConfig.delegates ?? []).map((id) => `agent_${id}`), + "load_skill", + ]), + ]; +} + +function buildHostedChildToolContext( + globalToolContext: ChildRunContext, + childAgentId: string, + childConfig: DefaultHostedChildAgentExecutionConfig | undefined, + durableChildRun?: HostedChildRunIdentifiers, +): ChildRunContext { + return { + ...globalToolContext, + agentId: childAgentId, + ...(childConfig?.availableSkillIds ? { availableSkillIds: childConfig.availableSkillIds } : {}), + ...(childConfig?.skillSourcePaths ? { skillSourcePaths: childConfig.skillSourcePaths } : {}), + ...(childConfig?.toolNames ? { availableToolNames: childConfig.toolNames } : {}), + loadedSkillResponses: {}, + loadedSkillReferenceResponses: {}, + ...(durableChildRun + ? { + conversationId: durableChildRun.childConversationId, + parentRunId: durableChildRun.childRunId, + parentMessageId: durableChildRun.childMessageId, + } + : {}), + }; +} + +/** Internal test seams for hosted project-agent materialization. */ +export const veryfrontCloudAgentServiceInternals = { + buildHostedChildToolContext, + resolveHostedChildAgentExecutionConfig, + resolveHostedChildToolNames, + resolveMcpServers, +}; + +async function resolveHostedChildAgentExecutionConfig( + context: NodeVeryfrontCloudAgentServiceContext, + taskContext: ChildRunContext, + childAgentId: string, + projectId: string, +): Promise { + if (!getProjectAgentRuntime(context).agents.has(childAgentId)) { + return undefined; + } + + const agentConfig = await resolveAgentConfig(context, childAgentId); + const branchId = projectId === taskContext.projectId ? taskContext.branchId : null; + const steering = await fetchProjectSteering(context, { + projectId: projectId || null, + authToken: taskContext.authToken, + branchId, + }, childAgentId); + const advertisedSkills = resolveRuntimeSkillsForAgent({ + skills: steering.skills, + agentId: childAgentId, + selector: agentConfig.skills, + }); + const loadableSkills = resolveRuntimeSkillsForAgent({ + skills: steering.skills, + agentId: childAgentId, + selector: true, + }); + const skillSourcePaths = Object.fromEntries( + loadableSkills + .filter((skill) => skill.sourcePath) + .map((skill) => [skill.id, skill.sourcePath as string]), + ); + const toolNames = resolveHostedChildToolNames(agentConfig); + const thinking = agentConfig.thinking?.enabled === false ? 0 : agentConfig.thinking?.budgetTokens; + + return { + system: flattenSystemInstructions(buildVeryfrontCloudRuntimeInstructions({ + agentConfig, + projectId: projectId || null, + branchId, + instructions: steering.instructions, + skills: advertisedSkills, + availableToolNames: toolNames, + })), + ...(agentConfig.model ? { model: agentConfig.model } : {}), + ...(agentConfig.temperature === undefined ? {} : { temperature: agentConfig.temperature }), + ...(agentConfig.maxSteps === undefined ? {} : { maxSteps: agentConfig.maxSteps }), + ...(thinking === undefined ? {} : { thinking }), + ...(toolNames === undefined ? {} : { toolNames }), + mcpServers: resolveMcpServers(context.options, agentConfig), + availableSkillIds: loadableSkills.map((skill) => skill.id), + ...(Object.keys(skillSourcePaths).length > 0 ? { skillSourcePaths } : {}), + ...(agentConfig.delegates === undefined ? {} : { delegateIds: agentConfig.delegates }), + }; +} + function createInvokeAgentTool( context: NodeVeryfrontCloudAgentServiceContext, childContext: ChildRunContext, + options?: { requireDurable?: boolean }, ) { return createDefaultHostedInvokeAgentTool({ context: childContext, @@ -770,15 +955,68 @@ function createInvokeAgentTool( resolveProviderOptions: resolveVeryfrontCloudThinkingProviderOptions, resolveReasoning: resolveVeryfrontCloudReasoningOption, shouldRethrowError: shouldRethrowInvokeAgentError, - buildGlobalTools: (globalToolContext) => ({ - load_skill: createLoadSkillTool(context, globalToolContext), - }), + buildGlobalTools: (globalToolContext, childAgentId, childConfig, durableChildRun) => { + const childToolContext = buildHostedChildToolContext( + globalToolContext, + childAgentId, + childConfig, + durableChildRun, + ); + return { + ...(childConfig ? getDiscoveredHostTools({ agentId: childAgentId }) : {}), + load_skill: createLoadSkillTool(context, childToolContext), + ...(childConfig?.delegateIds?.length + ? buildHostedDelegateTools(context, { + delegates: childConfig.delegateIds, + selfId: childAgentId, + taskContext: childToolContext, + }) + : {}), + }; + }, + resolveChildAgentExecutionConfig: (childAgentId, projectId) => + resolveHostedChildAgentExecutionConfig(context, childContext, childAgentId, projectId), refreshProjectSkillIds: (projectSkillContext) => refreshProjectSkillIds(context, projectSkillContext), createAgentServiceSandboxTools, createLiveStudioTools: createLiveStudioMcpTools, createRemoteToolSource: createRemoteMCPToolSource, createToolsFromRemoteDefinitions, + requireDurableInvokeAgent: options?.requireDurable, + }); +} + +function buildHostedDelegateTools( + context: NodeVeryfrontCloudAgentServiceContext, + input: { + delegates: readonly string[]; + selfId: string; + taskContext: ChildRunContext; + }, +): HostToolSet { + const invokeAgent = createInvokeAgentTool(context, input.taskContext, { requireDurable: true }); + return buildAgentDelegateTools({ + delegates: input.delegates, + selfId: input.selfId, + resolveAgent: (delegateId) => getProjectAgentRuntime(context).agents.get(delegateId), + executeDelegate: ({ delegateId, toolInput, context: executionContext }) => + invokeAgent.execute({ + agent_id: delegateId, + description: `Run ${delegateId} specialist task`, + prompt: toolInput.input, + }, executionContext), + }); +} + +function buildHostedDeclarativeDelegateTools( + context: NodeVeryfrontCloudAgentServiceContext, + agentConfig: RuntimeAgentMarkdownDefinition, + taskContext: DefaultHostedChatRuntimeTaskContext, +): HostToolSet { + return buildHostedDelegateTools(context, { + delegates: agentConfig.delegates ?? [], + selfId: agentConfig.id, + taskContext, }); } @@ -797,7 +1035,17 @@ function buildLocalTools( }; if (options.allowDelegation !== false) { - tools.invoke_agent = createInvokeAgentTool(context, taskContext); + const agentConfig = options.liveProjectSteering?.agent; + if (agentConfig?.delegates !== undefined) { + Object.assign( + tools, + buildHostedDeclarativeDelegateTools(context, agentConfig, taskContext), + ); + } else { + // Agents authored before declarative delegates retain the legacy hosted + // child-fork tool. An explicit empty list opts out. + tools.invoke_agent = createInvokeAgentTool(context, taskContext); + } } return tools; @@ -837,7 +1085,10 @@ function createAgentRuntime( apiUrl: config.VERYFRONT_API_URL, apiMcpUrl: config.VERYFRONT_MCP_URL, studioMcpUrl: config.VERYFRONT_STUDIO_MCP_URL, - mcpServers: resolveMcpServers(context.options), + mcpServers: resolveMcpServers( + context.options, + options.liveProjectSteering?.agent, + ), }, buildLocalTools: localToolRuntime.buildLocalTools, cleanup: localToolRuntime.cleanup, @@ -936,11 +1187,12 @@ function setPrepareChatExecutionResultAttributes( function fetchProjectSteering( context: NodeVeryfrontCloudAgentServiceContext, input: { projectId: string | null; authToken: string; branchId?: string | null }, + agentId?: string, ) { return fetchDefaultHostedProjectSteering({ ...input, - fetchProjectInstructions: (lookup) => getProjectInstructions(context, lookup), - fetchSkills: (lookup) => getSkillsConfig(context, lookup), + fetchProjectInstructions: (lookup) => getProjectInstructions(context, lookup, agentId), + fetchSkills: (lookup) => getSkillsConfig(context, lookup, agentId), trace: context.trace, traceOperationName: "chat.fetchSteering", }); diff --git a/src/agent/index.ts b/src/agent/index.ts index 2040016c2e..ce47e3b0ea 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -108,6 +108,7 @@ export type { Agent, AgentConfig, AgentContext, + AgentHttpMcpServerConfig, AgentMcpHttpTransport, AgentMcpServerAuth, AgentMcpServerConfig, @@ -116,6 +117,8 @@ export type { AgentResponse, AgentStatus, AgentStreamResult, + AgentVeryfrontMcpServerConfig, + AgentVeryfrontMcpServerKind, EdgeConfig, MemoryConfig, Message as AgentMessage, diff --git a/src/agent/project/agent-runtime.test.ts b/src/agent/project/agent-runtime.test.ts index a0d64329c9..0728a22d8e 100644 --- a/src/agent/project/agent-runtime.test.ts +++ b/src/agent/project/agent-runtime.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { resolve } from "node:path"; import { getMCPRegistry, registerPrompt, registerResource } from "#veryfront/mcp"; import { nodeAdapter } from "#veryfront/platform/adapters/node.ts"; @@ -25,6 +25,7 @@ import { import type { VeryfrontConfig } from "#veryfront/config"; import { registerSkill, skillRegistry } from "#veryfront/skill/registry.ts"; import { getEffectiveAgentSystem } from "../runtime/effective-agent-system.ts"; +import { tool } from "#veryfront/tool"; async function withTempDir(fn: (dir: string) => Promise | void): Promise { const dir = Deno.makeTempDirSync(); @@ -145,6 +146,74 @@ Deno.test("project agent runtime keeps factory skill catalogs out of hosted inst } }); +Deno.test("project agent runtime serializes scoped delegates and first-party MCP presets", async () => { + const coordinator = agent({ + id: "coordinator", + system: "Delegate bounded specialist work.", + delegates: ["specialist"], + tools: { + get_file: true, + lookup_job: tool({ + id: "lookup_job", + description: "Lookup a job posting", + inputSchema: defineSchema((v) => v.object({ id: v.string() }))(), + execute: ({ id }) => ({ id }), + }), + skip_file: false, + }, + mcpServers: [ + { + kind: "veryfront-api", + toolPolicy: { allow: ["get_file"] }, + }, + ], + }); + + const definition = await createRuntimeAgentDefinitionFromAgent(coordinator); + + assertEquals(definition.tools, [ + "agent_specialist", + "execute_skill_script", + "get_file", + "load_skill", + "load_skill_reference", + "lookup_job", + ]); + assertEquals(definition.delegates, ["specialist"]); + assertEquals(definition.mcpServers, [{ + kind: "veryfront-api", + toolPolicy: { allow: ["get_file"] }, + }]); +}); + +Deno.test("project agent runtime rejects non-serializable HTTP MCP credentials", async () => { + const privateAgent = agent({ + id: "private-agent", + system: "Use the private MCP server.", + mcpServers: [{ + id: "private-mcp", + transport: { type: "http", url: "https://mcp.example.test" }, + auth: { type: "bearer", token: "must-not-cross-hosted-boundary" }, + }], + }); + + await assertRejects( + () => createRuntimeAgentDefinitionFromAgent(privateAgent), + Error, + 'HTTP MCP server "private-mcp" cannot be serialized into a hosted agent definition', + ); +}); + +Deno.test("project agent runtime preserves an explicitly empty MCP catalog", async () => { + const isolated = agent({ + id: "isolated", + system: "Use no remote MCP servers.", + mcpServers: [], + }); + + assertEquals((await createRuntimeAgentDefinitionFromAgent(isolated)).mcpServers, []); +}); + Deno.test("discoverProjectAgentRuntime clears stale runtime registries before rediscovery", async () => { await withTempDir(async (rootDir) => { registerResource("stale-resource", { diff --git a/src/agent/project/agent-runtime.ts b/src/agent/project/agent-runtime.ts index 923cc7a779..78cccee38c 100644 --- a/src/agent/project/agent-runtime.ts +++ b/src/agent/project/agent-runtime.ts @@ -9,7 +9,10 @@ import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; import { clearMCPRegistry } from "#veryfront/mcp"; import { workflowRegistry } from "#veryfront/workflow/registry.ts"; import { agentRegistry } from "../composition/index.ts"; -import type { RuntimeAgentMarkdownDefinition } from "../runtime/agent-definition.ts"; +import type { + RuntimeAgentMarkdownDefinition, + RuntimeAgentMcpServerConfig, +} from "../runtime/agent-definition.ts"; import { getRuntimeAgentMarkdownDefinition, isRuntimeAgentMarkdownAgent, @@ -23,6 +26,7 @@ import { getActiveSourceIntegrationPolicy, runWithEffectiveSourceIntegrationPolicy, } from "#veryfront/integrations/source-policy-context.ts"; +import { CONFIG_INVALID } from "#veryfront/errors"; /** Public API contract for project agent runtime agent source. */ export type ProjectAgentRuntimeAgentSource = "auto" | "code" | "markdown"; @@ -72,13 +76,36 @@ function resolveAgentToolNames(tools: AgentConfig["tools"]): true | string[] | u } const names = Object.entries(tools) - .filter(([, value]) => value === true) - .map(([name]) => name) + .flatMap(([name, value]) => value === false ? [] : [name]) .sort(); return names.length > 0 ? names : undefined; } +function resolveSerializableMcpServers( + mcpServers: AgentConfig["mcpServers"], +): RuntimeAgentMcpServerConfig[] | undefined { + if (mcpServers === undefined) { + return undefined; + } + + return mcpServers.map((server) => { + if ("transport" in server) { + throw CONFIG_INVALID.create({ + detail: + `HTTP MCP server "${server.id}" cannot be serialized into a hosted agent definition. ` + + "Configure it on the hosted agent service, or use a first-party MCP preset.", + }); + } + + return { + kind: server.kind, + ...(server.id === undefined ? {} : { id: server.id }), + ...(server.toolPolicy === undefined ? {} : { toolPolicy: server.toolPolicy }), + }; + }); +} + /** Clear project agent runtime registries. */ export function clearProjectAgentRuntimeRegistries(): void { clearTrackedAgents(); @@ -146,6 +173,7 @@ export async function createRuntimeAgentDefinitionFromAgent( return markdownDefinition; } const toolNames = resolveAgentToolNames(runtimeAgent.config.tools); + const mcpServers = resolveSerializableMcpServers(runtimeAgent.config.mcpServers); return { id: runtimeAgent.id, @@ -165,6 +193,10 @@ export async function createRuntimeAgentDefinitionFromAgent( : {}), ...(runtimeAgent.config.skills === undefined ? {} : { skills: runtimeAgent.config.skills }), ...(toolNames === undefined ? {} : { tools: toolNames }), + ...(runtimeAgent.config.delegates === undefined + ? {} + : { delegates: runtimeAgent.config.delegates }), + ...(mcpServers === undefined ? {} : { mcpServers }), }; } diff --git a/src/agent/runtime/agent-definition.test.ts b/src/agent/runtime/agent-definition.test.ts index 077dba3418..27975c4bde 100644 --- a/src/agent/runtime/agent-definition.test.ts +++ b/src/agent/runtime/agent-definition.test.ts @@ -84,19 +84,20 @@ Use only the authored instructions. assertEquals(result.skills, []); }); -Deno.test("parseRuntimeAgentMarkdownDefinition ignores filtered-to-empty selectors", () => { - const result = parseRuntimeAgentMarkdownDefinition({ - id: "specialist", - content: `--- +Deno.test("parseRuntimeAgentMarkdownDefinition rejects malformed capability selectors", () => { + assertThrows( + () => + parseRuntimeAgentMarkdownDefinition({ + id: "specialist", + content: `--- skills: [" ", 7] -tools: [" ", 7] --- -Use omitted selector defaults. +Use the selected skills. `, - }); - - assertEquals(result.skills, undefined); - assertEquals(result.tools, undefined); + }), + Error, + 'Agent frontmatter "skills" entry 1 must be a non-empty string', + ); }); Deno.test("createRuntimeAgentSystemMessages inserts runtime blocks at marker", () => { @@ -164,18 +165,84 @@ Work alone. assertEquals(noDelegates.delegates, undefined); }); -Deno.test("parseRuntimeAgentMarkdownDefinition ignores empty delegate entries", () => { +Deno.test("parseRuntimeAgentMarkdownDefinition parses first-party MCP presets", () => { + const result = parseRuntimeAgentMarkdownDefinition({ + id: "project-reader", + content: `--- +name: Project reader +mcp-servers: + - kind: veryfront-api + toolPolicy: + allow: [get_file, list_files] +--- +Read project evidence. +`, + }); + + assertEquals(result.mcpServers, [{ + kind: "veryfront-api", + toolPolicy: { allow: ["get_file", "list_files"] }, + }]); +}); + +Deno.test("parseRuntimeAgentMarkdownDefinition preserves an explicit empty delegate selector", () => { const result = parseRuntimeAgentMarkdownDefinition({ id: "writer", content: `--- name: Writer -delegates: ["", " "] +delegates: [] --- Write copy. `, }); - assertEquals(result.delegates, undefined); + assertEquals(result.delegates, []); +}); + +Deno.test("parseRuntimeAgentMarkdownDefinition rejects implicit all-tools delegation", () => { + assertThrows( + () => + parseRuntimeAgentMarkdownDefinition({ + id: "lead", + content: `--- +tools: true +delegates: [writer] +--- +Coordinate. +`, + }), + Error, + 'Agent frontmatter for "lead" cannot combine delegates with tools: true', + ); +}); + +Deno.test("parseRuntimeAgentMarkdownDefinition rejects scalar capability declarations", () => { + assertThrows( + () => + parseRuntimeAgentMarkdownDefinition({ + id: "lead", + content: `--- +delegates: writer +--- +Coordinate. +`, + }), + Error, + 'Agent frontmatter "delegates" must be an array of non-empty strings', + ); + assertThrows( + () => + parseRuntimeAgentMarkdownDefinition({ + id: "lead", + content: `--- +mcp-servers: disabled +--- +Coordinate. +`, + }), + Error, + 'Agent frontmatter "mcp-servers" must be an array of MCP server configurations', + ); }); Deno.test("parseRuntimeAgentMarkdownDefinition rejects self-delegation with a diagnostic", () => { diff --git a/src/agent/runtime/agent-definition.ts b/src/agent/runtime/agent-definition.ts index 9afa468de2..67cbee5741 100644 --- a/src/agent/runtime/agent-definition.ts +++ b/src/agent/runtime/agent-definition.ts @@ -1,12 +1,12 @@ import { extract } from "#std/front-matter/yaml.ts"; -import { INVALID_ARGUMENT } from "#veryfront/errors"; import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; import { createRuntimePromptBlock } from "./prompt-block.ts"; import { buildRuntimeAvailableSkillsPromptBlock } from "./skill-prompt.ts"; import type { RuntimeSkillDefinition } from "./skill-metadata.ts"; -import { AGENT_DELEGATE_TOOL_PREFIX, isProviderSafeDelegateId } from "./agent-delegation-names.ts"; +import { normalizeAgentDelegateIds } from "./agent-delegation-names.ts"; +import { CONFIG_INVALID } from "#veryfront/errors"; /** Zod schema for get runtime agent thinking config. */ export const getRuntimeAgentThinkingConfigSchema = defineSchema((v) => @@ -26,6 +26,28 @@ export type RuntimeAgentThinkingConfig = InferSchema< ReturnType >; +const getRuntimeAgentMcpToolPolicySchema = defineSchema((v) => + v.object({ + allow: v.array(v.string().min(1)).optional(), + deny: v.array(v.string().min(1)).optional(), + approval: v.literal("never").optional(), + }) +); + +/** Schema for a first-party MCP preset that is safe to serialize with an agent definition. */ +export const getRuntimeAgentMcpServerConfigSchema = defineSchema((v) => + v.object({ + kind: v.union([v.literal("veryfront-api"), v.literal("veryfront-studio")]), + id: v.string().min(1).optional(), + toolPolicy: getRuntimeAgentMcpToolPolicySchema().optional(), + }) +); + +/** First-party MCP preset carried over the hosted agent-definition boundary. */ +export type RuntimeAgentMcpServerConfig = InferSchema< + ReturnType +>; + /** Zod schema for get runtime agent markdown definition. */ export const getRuntimeAgentMarkdownDefinitionSchema = defineSchema((v) => v.object({ @@ -42,6 +64,7 @@ export const getRuntimeAgentMarkdownDefinitionSchema = defineSchema((v) => skills: v.union([v.literal(true), v.array(v.string().min(1))]).optional(), tools: v.union([v.literal(true), v.array(v.string().min(1))]).optional(), delegates: v.array(v.string().min(1)).optional(), + mcpServers: v.array(getRuntimeAgentMcpServerConfigSchema()).optional(), }) ); @@ -85,6 +108,7 @@ export type CreateRuntimeAgentSystemMessagesInput = { agent: RuntimeAgentMarkdownDefinition; runtimeBlocks?: readonly string[]; skills?: readonly RuntimeSkillDefinition[]; + availableToolNames?: readonly string[]; environmentContext?: string; runtimeContextMarker?: string; }; @@ -102,56 +126,41 @@ function parseThinking(value: unknown): RuntimeAgentThinkingConfig | undefined { return undefined; } -function parseProviderTools(value: unknown): unknown[] | undefined { +function parseStringArray(value: unknown, field: string): string[] { if (!Array.isArray(value)) { - return undefined; + throw CONFIG_INVALID.create({ + detail: `Agent frontmatter "${field}" must be an array of non-empty strings.`, + }); } - return value; + return value.map((entry, index) => { + if (typeof entry !== "string" || entry.trim().length === 0) { + throw CONFIG_INVALID.create({ + detail: `Agent frontmatter "${field}" entry ${index + 1} must be a non-empty string.`, + }); + } + return entry.trim(); + }); } -function parseCapabilitySelector(value: unknown): true | string[] | undefined { +function parseCapabilitySelector(value: unknown, field: string): true | string[] { if (value === true) { return true; } - if (Array.isArray(value)) { - if (value.length === 0) { - return []; - } - const ids = value - .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - .map((entry) => entry.trim()); - return ids.length > 0 ? ids : undefined; - } - return undefined; + return parseStringArray(value, field); } -function parseDelegates(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const ids = value - .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - .map((entry) => entry.trim()); - return ids.length > 0 ? ids : undefined; +function parseDelegates(value: unknown): string[] { + return parseStringArray(value, "delegates"); } -function validateDelegates(agentId: string, delegates: string[] | undefined): void { - if (!delegates) { - return; - } - for (const delegateId of delegates) { - if (delegateId === agentId) { - throw INVALID_ARGUMENT.create({ detail: `Agent "${agentId}" cannot delegate to itself.` }); - } - if (!isProviderSafeDelegateId(delegateId)) { - throw INVALID_ARGUMENT.create({ - detail: - `Delegate id "${delegateId}" for agent "${agentId}" produces an invalid tool name ` + - `"${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}" (must match [A-Za-z0-9_-], max 64 chars).`, - }); - } +function parseMcpServers(value: unknown): RuntimeAgentMcpServerConfig[] { + if (!Array.isArray(value)) { + throw CONFIG_INVALID.create({ + detail: 'Agent frontmatter "mcp-servers" must be an array of MCP server configurations.', + }); } + return value.map((server) => getRuntimeAgentMcpServerConfigSchema().parse(server)); } /** Definition for parse runtime agent markdown. */ @@ -173,11 +182,36 @@ export function parseRuntimeAgentMarkdownDefinition( const thinking = parseThinking(attrs.thinking); const temperature = typeof attrs.temperature === "number" ? attrs.temperature : undefined; const maxSteps = typeof attrs["max-steps"] === "number" ? attrs["max-steps"] : undefined; - const providerTools = parseProviderTools(attrs["provider-tools"]); - const skills = parseCapabilitySelector(attrs.skills); - const tools = parseCapabilitySelector(attrs.tools); - const delegates = parseDelegates(attrs.delegates); - validateDelegates(parsedInput.id, delegates); + const providerTools = Object.hasOwn(attrs, "provider-tools") + ? parseStringArray(attrs["provider-tools"], "provider-tools") + : undefined; + const skills = Object.hasOwn(attrs, "skills") + ? parseCapabilitySelector(attrs.skills, "skills") + : undefined; + const tools = Object.hasOwn(attrs, "tools") + ? parseCapabilitySelector(attrs.tools, "tools") + : undefined; + const delegates = normalizeAgentDelegateIds( + parsedInput.id, + Object.hasOwn(attrs, "delegates") ? parseDelegates(attrs.delegates) : undefined, + ); + if (tools === true && delegates?.length) { + throw CONFIG_INVALID.create({ + detail: + `Agent frontmatter for "${parsedInput.id}" cannot combine delegates with tools: true. ` + + "Declare the required tools by name so delegate capabilities remain explicit.", + }); + } + if (Object.hasOwn(attrs, "mcp-servers") && Object.hasOwn(attrs, "mcpServers")) { + throw CONFIG_INVALID.create({ + detail: 'Agent frontmatter must use only one of "mcp-servers" or "mcpServers".', + }); + } + const mcpServers = Object.hasOwn(attrs, "mcp-servers") + ? parseMcpServers(attrs["mcp-servers"]) + : Object.hasOwn(attrs, "mcpServers") + ? parseMcpServers(attrs.mcpServers) + : undefined; return getRuntimeAgentMarkdownDefinitionSchema().parse({ id: parsedInput.id, @@ -193,6 +227,7 @@ export function parseRuntimeAgentMarkdownDefinition( ...(skills === undefined ? {} : { skills }), ...(tools === undefined ? {} : { tools }), ...(delegates === undefined ? {} : { delegates }), + ...(mcpServers === undefined ? {} : { mcpServers }), }); } @@ -234,7 +269,11 @@ export function createRuntimeAgentSystemMessages( } if (input.skills?.length) { - staticParts.push(buildRuntimeAvailableSkillsPromptBlock(input.skills)); + staticParts.push( + buildRuntimeAvailableSkillsPromptBlock(input.skills, { + availableToolNames: input.availableToolNames, + }), + ); } const result: ChatSystemMessage[] = [ diff --git a/src/agent/runtime/agent-delegation-names.ts b/src/agent/runtime/agent-delegation-names.ts index 6f54fee143..9431dacc6f 100644 --- a/src/agent/runtime/agent-delegation-names.ts +++ b/src/agent/runtime/agent-delegation-names.ts @@ -1,3 +1,5 @@ +import { INVALID_ARGUMENT } from "#veryfront/errors"; + /** Prefix used for the delegate tool exposed to the coordinator agent. */ export const AGENT_DELEGATE_TOOL_PREFIX = "agent_"; @@ -8,3 +10,36 @@ const PROVIDER_TOOL_NAME_REGEX = /^[A-Za-z0-9_-]{1,64}$/; export function isProviderSafeDelegateId(delegateId: string): boolean { return PROVIDER_TOOL_NAME_REGEX.test(`${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}`); } + +/** Normalize and validate the exact delegate ids declared by an agent. */ +export function normalizeAgentDelegateIds( + agentId: string, + delegates: readonly string[] | undefined, +): string[] | undefined { + if (delegates === undefined) { + return undefined; + } + + const normalized: string[] = []; + const seen = new Set(); + for (const value of delegates) { + const delegateId = value.trim(); + if (!delegateId || seen.has(delegateId)) { + continue; + } + if (delegateId === agentId) { + throw INVALID_ARGUMENT.create({ detail: `Agent "${agentId}" cannot delegate to itself.` }); + } + if (!isProviderSafeDelegateId(delegateId)) { + throw INVALID_ARGUMENT.create({ + detail: + `Delegate id "${delegateId}" for agent "${agentId}" produces an invalid tool name ` + + `"${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}" (must match [A-Za-z0-9_-], max 64 chars).`, + }); + } + seen.add(delegateId); + normalized.push(delegateId); + } + + return normalized; +} diff --git a/src/agent/runtime/agent-delegation.test.ts b/src/agent/runtime/agent-delegation.test.ts index cdbf27a3fd..0cdf9452fb 100644 --- a/src/agent/runtime/agent-delegation.test.ts +++ b/src/agent/runtime/agent-delegation.test.ts @@ -73,6 +73,34 @@ Deno.test("delegate tool runs the resolved specialist agent and returns its resu assertEquals(result, { text: "drafted copy", toolCalls: 0, status: "completed" }); }); +Deno.test("delegate tool keeps host execution fixed to its declared target", async () => { + const writer = { + id: "writer", + config: {}, + } as unknown as Agent; + const calls: unknown[] = []; + const tools = buildAgentDelegateTools({ + delegates: ["writer"], + resolveAgent: () => writer, + executeDelegate: (input) => { + calls.push(input); + return Promise.resolve({ status: "completed" }); + }, + }); + + const result = await tools[`${AGENT_DELEGATE_TOOL_PREFIX}writer`]!.execute({ + input: "Draft it.", + }); + + assertEquals(result, { status: "completed" }); + assertEquals(calls, [{ + delegateId: "writer", + agent: writer, + toolInput: { input: "Draft it." }, + context: undefined, + }]); +}); + Deno.test("delegate tool reports an error when the target agent is unavailable", async () => { const tools = buildAgentDelegateTools({ delegates: ["writer"], diff --git a/src/agent/runtime/agent-delegation.ts b/src/agent/runtime/agent-delegation.ts index b4008be74c..97ba1643c5 100644 --- a/src/agent/runtime/agent-delegation.ts +++ b/src/agent/runtime/agent-delegation.ts @@ -1,7 +1,7 @@ -import type { Tool } from "../../tool/types.ts"; +import type { Tool, ToolExecutionContext } from "../../tool/types.ts"; import type { Agent } from "../types.ts"; import { agentAsTool, getAgent } from "../composition/index.ts"; -import { getAgentToolInputSchema } from "../schemas/index.ts"; +import { type AgentToolInput, getAgentToolInputSchema } from "../schemas/index.ts"; import { AGENT_DELEGATE_TOOL_PREFIX, isProviderSafeDelegateId } from "./agent-delegation-names.ts"; import { markRuntimeLocalTool } from "./local-tool.ts"; @@ -10,6 +10,14 @@ export { AGENT_DELEGATE_TOOL_PREFIX, isProviderSafeDelegateId }; /** Resolves a registered agent by id (defaults to the global registry). */ export type DelegateAgentResolver = (id: string) => Agent | undefined; +/** Fixed-target delegate execution used by hosts with their own child-run lifecycle. */ +export type DelegateAgentExecutor = (input: { + delegateId: string; + agent: Agent; + toolInput: AgentToolInput; + context?: ToolExecutionContext; +}) => Promise; + /** Input payload for build agent delegate tools. */ export type BuildAgentDelegateToolsInput = { /** Specialist agent ids this coordinator is allowed to delegate to. */ @@ -18,11 +26,14 @@ export type BuildAgentDelegateToolsInput = { selfId?: string; /** Override the agent resolver (testing / custom registries). */ resolveAgent?: DelegateAgentResolver; + /** Override execution while keeping the delegate id fixed by the tool wrapper. */ + executeDelegate?: DelegateAgentExecutor; }; function createLazyDelegateTool( delegateId: string, resolveAgent: DelegateAgentResolver, + executeDelegate?: DelegateAgentExecutor, ): Tool { return markRuntimeLocalTool({ id: `${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}`, @@ -40,6 +51,15 @@ function createLazyDelegateTool( }); } + if (executeDelegate) { + return executeDelegate({ + delegateId, + agent: target, + toolInput: input, + context, + }); + } + return agentAsTool(target, `Delegate to ${delegateId}`).execute(input, context); }, }); @@ -57,8 +77,9 @@ function createLazyDelegateTool( * `delegates` runs with no orchestration. * * Delegation chains are intentionally not cycle-detected here. Each delegated - * call is a separate agent run with its own maxSteps budget; keep delegate - * graphs acyclic until a runtime chain-depth cap exists. + * call is a separate agent run with its own maxSteps budget; hosted nested + * invocation metadata enforces a runtime depth cap, but authors should still + * keep delegate graphs acyclic so cycles do not burn the available depth. */ export function buildAgentDelegateTools( input: BuildAgentDelegateToolsInput, @@ -76,7 +97,11 @@ export function buildAgentDelegateTools( continue; } seen.add(id); - tools[`${AGENT_DELEGATE_TOOL_PREFIX}${id}`] = createLazyDelegateTool(id, resolveAgent); + tools[`${AGENT_DELEGATE_TOOL_PREFIX}${id}`] = createLazyDelegateTool( + id, + resolveAgent, + input.executeDelegate, + ); } return tools; diff --git a/src/agent/runtime/agent-markdown-adapter.test.ts b/src/agent/runtime/agent-markdown-adapter.test.ts index 4878053ce9..72c0d81d1d 100644 --- a/src/agent/runtime/agent-markdown-adapter.test.ts +++ b/src/agent/runtime/agent-markdown-adapter.test.ts @@ -18,7 +18,7 @@ Deno.test("createRuntimeAgentFromMarkdownDefinition preserves provider-native to assertEquals(runtimeAgent.config.providerTools, ["web_search", "web_fetch"]); }); -Deno.test("createRuntimeAgentFromMarkdownDefinition binds delegate tools from delegates", () => { +Deno.test("createRuntimeAgentFromMarkdownDefinition binds scoped delegate tools", () => { toolRegistry.clearAll(); const runtimeAgent = createRuntimeAgentFromMarkdownDefinition({ @@ -40,8 +40,34 @@ Deno.test("createRuntimeAgentFromMarkdownDefinition binds delegate tools from de "load_skill_reference", ], ); - assertEquals(toolRegistry.has("agent_researcher"), false); - assertEquals(toolRegistry.has("agent_writer"), false); + assertEquals(runtimeAgent.config.delegates, ["writer", "researcher"]); +}); + +Deno.test("createRuntimeAgentFromMarkdownDefinition preserves delegates and MCP servers", () => { + toolRegistry.clearAll(); + + const runtimeAgent = createRuntimeAgentFromMarkdownDefinition({ + id: "project-orchestrator", + name: "Project Orchestrator", + description: "Coordinates project agents", + instructions: "Use project tools.", + delegates: ["worker-agent"], + mcpServers: [{ + kind: "veryfront-api", + toolPolicy: { allow: ["get_file", "list_files"] }, + }], + tools: ["get_file", "list_files"], + }); + + const tools = runtimeAgent.config.tools as Record | undefined; + assertEquals(typeof tools?.["agent_worker-agent"], "object"); + assertEquals(tools?.get_file, true); + assertEquals(tools?.list_files, true); + assertEquals(runtimeAgent.config.delegates, ["worker-agent"]); + assertEquals(runtimeAgent.config.mcpServers, [{ + kind: "veryfront-api", + toolPolicy: { allow: ["get_file", "list_files"] }, + }]); }); Deno.test("createRuntimeAgentFromMarkdownDefinition preserves an empty catalog and binds skill tools", async () => { diff --git a/src/agent/runtime/agent-markdown-adapter.ts b/src/agent/runtime/agent-markdown-adapter.ts index f6ae2303d8..6e92734a89 100644 --- a/src/agent/runtime/agent-markdown-adapter.ts +++ b/src/agent/runtime/agent-markdown-adapter.ts @@ -1,7 +1,6 @@ import { agent } from "../factory.ts"; import type { Agent } from "../types.ts"; import type { RuntimeAgentMarkdownDefinition } from "./agent-definition.ts"; -import { buildAgentDelegateTools } from "./agent-delegation.ts"; const markdownDefinitionByAgent = new WeakMap(); @@ -9,25 +8,15 @@ const markdownDefinitionByAgent = new WeakMap 0 - ? buildAgentDelegateTools({ delegates: definition.delegates, selfId: definition.id }) - : undefined; - // `tools:` is a binding selector resolved at invocation time by the // owner-aware resolver: `true` binds all visible tools; a list binds each - // entry (own short name first, then exact global id). Delegate tools merge - // on top; on key collision the delegate tool wins (keys never overlap in - // practice: selectors use tool ids, delegates use `agent_{id}`). + // entry (own short name first, then exact global id). The factory adds the + // scoped tools derived from `delegates` for both code and markdown agents. const selectedTools: true | Record | undefined = definition.tools === true ? true : definition.tools ? Object.fromEntries(definition.tools.map((name) => [name, true as const])) : undefined; - const mergedTools = selectedTools === true - ? true - : selectedTools || delegateTools - ? { ...(selectedTools ?? {}), ...(delegateTools ?? {}) } - : undefined; const runtimeAgent = agent({ id: definition.id, @@ -40,9 +29,11 @@ export function createRuntimeAgentFromMarkdownDefinition( ...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }), ...(definition.providerTools ? { providerTools: definition.providerTools } : {}), ...(definition.skills === undefined ? {} : { skills: definition.skills }), - ...(mergedTools !== undefined && - (mergedTools === true || Object.keys(mergedTools).length > 0) - ? { tools: mergedTools } + ...(definition.delegates === undefined ? {} : { delegates: definition.delegates }), + ...(definition.mcpServers === undefined ? {} : { mcpServers: definition.mcpServers }), + ...(selectedTools !== undefined && + (selectedTools === true || Object.keys(selectedTools).length > 0) + ? { tools: selectedTools } : {}), }); diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index b5aa2e08d4..4621988795 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -862,7 +862,7 @@ export class AgentRuntime { runtimeContext?.[SUBMITTED_FORM_INPUT_CONTEXT_KEY] === true; const allowedRemoteToolNames = getRuntimeAllowedRemoteTools(this.config); const forwardedRemoteToolDefinitions = getRuntimeForwardedIntegrationToolDefs(this.config); - const remoteToolSources = getRuntimeRemoteToolSources(this.config); + const remoteToolSources = getRuntimeRemoteToolSources(this.config, undefined, this.id); const sourceIntegrationPolicy = getRuntimeSourceIntegrationPolicy(this.config); const configuredProviderTools = getRuntimeProviderTools(this.config); const providerTools = sourceIntegrationPolicy @@ -1339,7 +1339,7 @@ export class AgentRuntime { let latestAssistantText = ""; const allowedRemoteToolNames = getRuntimeAllowedRemoteTools(this.config); const forwardedRemoteToolDefinitions = getRuntimeForwardedIntegrationToolDefs(this.config); - const remoteToolSources = getRuntimeRemoteToolSources(this.config); + const remoteToolSources = getRuntimeRemoteToolSources(this.config, undefined, this.id); const sourceIntegrationPolicy = getRuntimeSourceIntegrationPolicy(this.config); const configuredProviderTools = getRuntimeProviderTools(this.config); const providerTools = sourceIntegrationPolicy @@ -1391,7 +1391,7 @@ export class AgentRuntime { model: effectiveModel, providerTools: stepProviderTools, }); - const runtimeToolNames = Object.keys(runtimeTools ?? {}); + const runtimeToolNames = Object.keys(runtimeTools ?? {}).sort(); const temperature = this.resolveTemperature( temperatureModelString ?? effectiveModel, diff --git a/src/agent/runtime/load-skill-tool.test.ts b/src/agent/runtime/load-skill-tool.test.ts index ddb31401fd..65c3227660 100644 --- a/src/agent/runtime/load-skill-tool.test.ts +++ b/src/agent/runtime/load-skill-tool.test.ts @@ -134,6 +134,74 @@ Write carefully.`, assertEquals(result.maxSteps, 8); }); +Deno.test("createRuntimeLoadSkillTool names scoped delegates and omits override forwarding", async () => { + const tool = createRuntimeLoadSkillTool({ + context: createProjectContext({ + availableToolNames: ["read_file", "agent_writer", "load_skill"], + }), + skillsDir: "/skills", + projectSkillLoader: createProjectSkillLoader({}), + builtinStore: createBuiltinStore({ + skills: new Map([ + [ + "write", + `--- +allowed-tools: + - read_file + - write_file +model: sonnet +max-steps: 8 +--- +Write carefully.`, + ], + ]), + }), + }); + + const result = expectLoadedSkillResponse(await tool.execute({ skillId: "write" })); + + assertEquals(result.allowedTools, ["read_file"]); + assertEquals(result.unavailableCurrentRunTools, ["write_file"]); + assertStringIncludes( + result.nextStep, + "use only these available scoped delegation tools: `agent_writer`", + ); + assertStringIncludes(result.delegationNote ?? "", "`agent_writer`"); + assertEquals(JSON.stringify(result).includes("invoke_agent"), false); + assertEquals(result.overrideNote, undefined); +}); + +Deno.test("createRuntimeLoadSkillTool omits delegation advice without delegate tools", async () => { + const tool = createRuntimeLoadSkillTool({ + context: createProjectContext({ + availableToolNames: ["read_file", "load_skill"], + }), + skillsDir: "/skills", + projectSkillLoader: createProjectSkillLoader({}), + builtinStore: createBuiltinStore({ + skills: new Map([ + [ + "write", + `--- +allowed-tools: + - read_file + - write_file +--- +Write carefully.`, + ], + ]), + }), + }); + + const result = expectLoadedSkillResponse(await tool.execute({ skillId: "write" })); + + assertEquals(result.allowedTools, ["read_file"]); + assertEquals(result.unavailableCurrentRunTools, ["write_file"]); + assertEquals(result.delegationNote, undefined); + assertEquals(result.nextStep.includes("multi-step or isolated work"), false); + assertEquals(JSON.stringify(result).includes("invoke_agent"), false); +}); + Deno.test("createRuntimeLoadSkillTool makes same-skill reloads concise and idempotent", async () => { const context = createProjectContext({ availableToolNames: ["read_file"], diff --git a/src/agent/runtime/load-skill-tool.ts b/src/agent/runtime/load-skill-tool.ts index 6eb3a45b6a..bec5ef4c71 100644 --- a/src/agent/runtime/load-skill-tool.ts +++ b/src/agent/runtime/load-skill-tool.ts @@ -9,7 +9,6 @@ import { LOAD_SKILL_OVERRIDE_FORWARDING, LOAD_SKILL_ROOT_OWNERSHIP, LOAD_SKILL_TOOL_INTERSECTION, - LOAD_SKILL_USE_ALLOWED_TOOLS, } from "../conversation/delegation-policy.ts"; import { listRuntimeBuiltinSkillReferences, @@ -30,13 +29,13 @@ import { } from "./skill-metadata.ts"; import { narrowPolicyAfterSubmittedForm } from "./skill-policy-enforcement.ts"; -/** Shared runtime load skill continuation note value. */ +/** Legacy continuation-note fallback used when runtime tool inventory is unavailable. */ export const RUNTIME_LOAD_SKILL_CONTINUATION_NOTE = - `IMPORTANT: load_skill only loads instructions. It does not perform the task or finish the turn. ${LOAD_SKILL_CONTINUE_SAME_TURN} ${LOAD_SKILL_ROOT_OWNERSHIP} ${LOAD_SKILL_USE_ALLOWED_TOOLS} ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING} ${LOAD_SKILL_TOOL_INTERSECTION}`; + `IMPORTANT: load_skill only loads instructions. It does not perform the task or finish the turn. ${LOAD_SKILL_CONTINUE_SAME_TURN} ${LOAD_SKILL_ROOT_OWNERSHIP} For multi-step or isolated work, call invoke_agent; otherwise keep working directly with the allowed tools. ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING} ${LOAD_SKILL_TOOL_INTERSECTION}`; /** Shared runtime load skill description value. */ export const RUNTIME_LOAD_SKILL_DESCRIPTION = - `Load the full instructions for a skill. Use this when you need detailed guidance for a specific task type. If the skill specifies allowed-tools, you MUST only use those tools while following this skill. load_skill does not perform the task by itself. ${LOAD_SKILL_CONTINUE_SAME_TURN} ${LOAD_SKILL_ROOT_OWNERSHIP} ${LOAD_SKILL_USE_ALLOWED_TOOLS} ${LOAD_SKILL_DELEGATION_THRESHOLD} First call load_skill with only skillId. Use the optional \`file\` parameter only after the skill is loaded and only for a reference file listed by that loaded skill.`; + `Load the full instructions for a skill. Use this when you need detailed guidance for a specific task type. If the skill specifies allowed-tools, you MUST only use those tools while following this skill. load_skill does not perform the task by itself. ${LOAD_SKILL_CONTINUE_SAME_TURN} ${LOAD_SKILL_ROOT_OWNERSHIP} ${LOAD_SKILL_DELEGATION_THRESHOLD} First call load_skill with only skillId. Use the optional \`file\` parameter only after the skill is loaded and only for a reference file listed by that loaded skill.`; const DEFAULT_RUNTIME_LOAD_SKILL_RESPONSE_MESSAGES: RuntimeLoadedSkillResponseMessages = { allowedToolsNote: @@ -44,14 +43,71 @@ const DEFAULT_RUNTIME_LOAD_SKILL_RESPONSE_MESSAGES: RuntimeLoadedSkillResponseMe noCurrentRunToolsNote: "IMPORTANT: While following this skill, no direct-execution tools from this skill are available in the current run. allowedTools is intentionally empty; do not attempt direct tool execution in this run.", unavailableCurrentRunToolsDelegationNote: - "IMPORTANT: Some tools required by this skill are not available in the current run. Use invoke_agent for the isolated work and pass delegationTools as the child tools allowlist.", + "IMPORTANT: Some tools required by this skill are not available in the current run. Use an available scoped agent_ delegation tool for the isolated work, or invoke_agent only when that exact legacy tool is present.", overrideNote: LOAD_SKILL_OVERRIDE_FORWARDING, referenceNote: "After this skill is loaded, use load_skill with the `file` parameter only for one of these listed reference files.", }; +function getAvailableScopedDelegateToolNames(availableToolNames?: readonly string[]): string[] { + return (availableToolNames ?? []) + .filter((toolName) => toolName.startsWith("agent_")) + .sort(); +} + +function buildRuntimeLoadSkillDelegationAdvice(availableToolNames?: readonly string[]): string { + if (availableToolNames === undefined) { + return `For multi-step or isolated work, call invoke_agent; otherwise keep working directly with the allowed tools. ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING}`; + } + + const scopedDelegateToolNames = getAvailableScopedDelegateToolNames(availableToolNames); + if (scopedDelegateToolNames.length > 0) { + const tools = scopedDelegateToolNames.map((toolName) => `\`${toolName}\``).join(", "); + return `For multi-step or isolated work, use only these available scoped delegation tools: ${tools}; otherwise keep working directly with the allowed tools. ${LOAD_SKILL_DELEGATION_THRESHOLD}`; + } + + if (availableToolNames.includes("invoke_agent")) { + return `For multi-step or isolated work, call the available legacy invoke_agent tool; otherwise keep working directly with the allowed tools. ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING}`; + } + + return ""; +} + +function buildRuntimeLoadSkillContinuationNote(availableToolNames?: readonly string[]): string { + const delegationAdvice = buildRuntimeLoadSkillDelegationAdvice(availableToolNames); + return [ + "IMPORTANT: load_skill only loads instructions. It does not perform the task or finish the turn.", + LOAD_SKILL_CONTINUE_SAME_TURN, + LOAD_SKILL_ROOT_OWNERSHIP, + delegationAdvice, + LOAD_SKILL_TOOL_INTERSECTION, + ].filter((part) => part.length > 0).join(" "); +} + +function buildUnavailableCurrentRunToolsDelegationNote( + availableToolNames?: readonly string[], +): string { + if (availableToolNames === undefined) { + return DEFAULT_RUNTIME_LOAD_SKILL_RESPONSE_MESSAGES.unavailableCurrentRunToolsDelegationNote; + } + + const scopedDelegateToolNames = getAvailableScopedDelegateToolNames(availableToolNames); + if (scopedDelegateToolNames.length > 0) { + const tools = scopedDelegateToolNames.map((toolName) => `\`${toolName}\``).join(", "); + return `IMPORTANT: Some tools required by this skill are not available in the current run. Use only these available scoped delegation tools for isolated work: ${tools}.`; + } + + if (availableToolNames.includes("invoke_agent")) { + return "IMPORTANT: Some tools required by this skill are not available in the current run. Use the available legacy invoke_agent tool for isolated work."; + } + + return ""; +} + /** Context for runtime load skill tool. */ export type RuntimeLoadSkillToolContext = RuntimeProjectSkillContext & { + /** Agent identity used to enforce owner-scoped skill visibility. */ + agentId?: string; availableSkillIds?: readonly string[]; availableToolNames?: readonly string[]; loadedSkillResponses?: Record; @@ -137,7 +193,7 @@ function getResponseMessages( DEFAULT_RUNTIME_LOAD_SKILL_RESPONSE_MESSAGES.noCurrentRunToolsNote, unavailableCurrentRunToolsDelegationNote: options.messages?.unavailableCurrentRunToolsDelegationNote ?? - DEFAULT_RUNTIME_LOAD_SKILL_RESPONSE_MESSAGES.unavailableCurrentRunToolsDelegationNote, + buildUnavailableCurrentRunToolsDelegationNote(options.context.availableToolNames), overrideNote: options.messages?.overrideNote ?? DEFAULT_RUNTIME_LOAD_SKILL_RESPONSE_MESSAGES.overrideNote, referenceNote: options.messages?.referenceNote ?? @@ -154,7 +210,8 @@ function buildLoadedSkillResponse(input: { return buildRuntimeLoadedSkillResponse({ skillId: input.skillId, instructions: input.instructions, - nextStep: input.options.nextStep ?? RUNTIME_LOAD_SKILL_CONTINUATION_NOTE, + nextStep: input.options.nextStep ?? + buildRuntimeLoadSkillContinuationNote(input.options.context.availableToolNames), messages: getResponseMessages(input.options), references: input.references, availableToolNames: input.options.context.availableToolNames, diff --git a/src/agent/runtime/mcp-server-tool-sources.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index 3d720ba2c6..9896ba3673 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -1,5 +1,27 @@ -import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; -import { getRuntimeRemoteToolSources } from "./mcp-server-tool-sources.ts"; +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import type { + RemoteMCPToolSourceConfig, + RemoteToolSource, + ToolDefinition, + ToolExecutionContext, +} from "#veryfront/tool"; +import { + getRequestedUnresolvedBooleanToolNames, + getRuntimeRemoteToolSources, + VERYFRONT_API_MCP_SOURCE_ID, + VERYFRONT_STUDIO_MCP_SOURCE_ID, +} from "./mcp-server-tool-sources.ts"; +import { VeryfrontError } from "#veryfront/errors"; + +Deno.test("getRequestedUnresolvedBooleanToolNames keeps legacy delegation local", () => { + assertEquals( + getRequestedUnresolvedBooleanToolNames({ + tools: { get_file: true, invoke_agent: true }, + agentId: "orchestrator", + }), + ["get_file"], + ); +}); type FetchCall = { url: string; @@ -80,3 +102,536 @@ Deno.test("getRuntimeRemoteToolSources blocks denied MCP tool execution", async ); assertEquals(calls.length, 0); }); + +Deno.test("getRuntimeRemoteToolSources hydrates a Veryfront API MCP server from server env", async () => { + let remoteConfig: RemoteMCPToolSourceConfig | undefined; + const listContexts: Array = []; + const executeCalls: Array<{ + toolName: string; + args: unknown; + context?: ToolExecutionContext; + }> = []; + const rawSource: RemoteToolSource = { + id: "veryfront-api", + listTools(context) { + listContexts.push(context); + return Promise.resolve([ + { + name: "get_file", + description: "Read a project file", + parameters: { + type: "object", + properties: { + project_reference: { type: "string" }, + path: { type: "string" }, + }, + required: ["project_reference", "path"], + }, + }, + { + name: "delete_file", + description: "Delete a project file", + parameters: { type: "object", properties: {} }, + }, + ]); + }, + executeTool(toolName, args, context) { + executeCalls.push({ toolName, args, context }); + return Promise.resolve({ ok: true }); + }, + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use project files.", + tools: { get_file: true }, + mcpServers: [{ + kind: "veryfront-api", + toolPolicy: { allow: ["get_file"] }, + }], + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.example/", + apiToken: "server-token", + projectSlug: "server-project", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + createRemoteToolSource(config) { + remoteConfig = config; + return rawSource; + }, + }, + ); + + assertEquals(sources?.length, 1); + assertEquals(remoteConfig?.endpoint, "https://api.example/mcp"); + assertEquals( + await (remoteConfig?.headers as (context?: ToolExecutionContext) => HeadersInit)?.({ + authToken: "browser-token", + }), + { Authorization: "Bearer server-token" }, + ); + assertEquals( + (await sources?.[0]?.listTools({ projectId: "browser-project" }))?.map((tool) => tool.name), + ["get_file"], + ); + await sources?.[0]?.executeTool( + "get_file", + { path: "AGENTS.md", project_reference: "browser-project" }, + { projectId: "browser-project" }, + ); + + assertEquals(listContexts, [ + { projectId: "server-project" }, + ]); + assertEquals(executeCalls, [{ + toolName: "get_file", + args: { path: "AGENTS.md", project_reference: "server-project" }, + context: { projectId: "server-project" }, + }]); +}); + +Deno.test("getRuntimeRemoteToolSources does not synthesize tools missing from remote discovery", async () => { + const executeCalls: Array<{ toolName: string; args: unknown; context?: ToolExecutionContext }> = + []; + const rawSource: RemoteToolSource = { + id: "veryfront-api", + listTools() { + return Promise.resolve([ + { + name: "outlook__list_emails", + description: "List emails", + parameters: { type: "object", properties: {} }, + }, + ]); + }, + executeTool(toolName, args, context) { + executeCalls.push({ toolName, args, context }); + return Promise.resolve({ ok: true }); + }, + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use project files.", + tools: { get_file: true }, + mcpServers: [{ + kind: "veryfront-api", + toolPolicy: { allow: ["get_file"] }, + }], + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.example/", + apiToken: "server-token", + projectSlug: "server-project", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + createRemoteToolSource() { + return rawSource; + }, + }, + ); + assertEquals(await sources?.[0]?.listTools(), []); + await assertRejects( + () => sources![0]!.executeTool("get_file", { path: "AGENTS.md" }), + Error, + 'Tool "get_file" is not advertised by remote source "veryfront-api"', + ); + assertEquals(executeCalls, []); +}); + +Deno.test("getRuntimeRemoteToolSources keeps unknown missing tools unavailable", async () => { + const rawSource: RemoteToolSource = { + id: "veryfront-api", + listTools() { + return Promise.resolve([]); + }, + executeTool() { + return Promise.resolve({ ok: true }); + }, + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use a custom tool.", + tools: { custom_project_tool: true }, + mcpServers: [{ + kind: "veryfront-api", + toolPolicy: { allow: ["custom_project_tool"] }, + }], + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.example/", + apiToken: "server-token", + projectSlug: "server-project", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + createRemoteToolSource() { + return rawSource; + }, + }, + ); + assertEquals(await sources?.[0]?.listTools(), []); + await assertRejects( + () => sources![0]!.executeTool("custom_project_tool", {}), + Error, + 'Tool "custom_project_tool" is not advertised by remote source "veryfront-api"', + ); +}); + +Deno.test("getRuntimeRemoteToolSources implicitly connects unresolved named tools to Veryfront API", async () => { + let remoteConfig: RemoteMCPToolSourceConfig | undefined; + const rawSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => + Promise.resolve([{ + name: "get_file", + description: "Read a project file", + parameters: { type: "object", properties: {} }, + }]), + executeTool: () => Promise.resolve({ ok: true }), + }; + + const sources = getRuntimeRemoteToolSources( + { + id: "local-agent", + system: "Use project files.", + tools: { get_file: true }, + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.example/", + apiToken: "server-token", + projectSlug: "server-project", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + createRemoteToolSource(config) { + remoteConfig = config; + return rawSource; + }, + }, + ); + + assertEquals(remoteConfig?.endpoint, "https://api.example/mcp"); + assertEquals((await sources?.[0]?.listTools())?.map((tool) => tool.name), ["get_file"]); +}); + +Deno.test("getRuntimeRemoteToolSources preserves explicit MCP opt-out", () => { + const sources = getRuntimeRemoteToolSources( + { + system: "Use project files only when explicitly connected.", + tools: { get_file: true }, + mcpServers: [], + }, + { + getVeryfrontBootstrap() { + throw new Error("bootstrap must not be read after explicit opt-out"); + }, + }, + ); + + assertEquals(sources, undefined); +}); + +Deno.test("getRuntimeRemoteToolSources does not leak injected sources after explicit MCP opt-out", () => { + const injectedApiSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const injectedStudioSource: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const sources = getRuntimeRemoteToolSources( + { + system: "No remote MCP tools.", + mcpServers: [], + __vfRemoteToolSources: [injectedApiSource, injectedStudioSource], + } as Parameters[0], + ); + + assertEquals(sources, undefined); +}); + +Deno.test("getRuntimeRemoteToolSources skips the implicit source without server identity", () => { + const sources = getRuntimeRemoteToolSources( + { + system: "Use project files when a local Studio identity is available.", + tools: { get_file: true }, + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.veryfront.com", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + }, + ); + + assertEquals(sources, undefined); +}); + +Deno.test("getRuntimeRemoteToolSources does not attach project_reference to incompatible tools", async () => { + const executeCalls: unknown[] = []; + const rawSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools() { + return Promise.resolve([{ + name: "health_check", + description: "Check API health", + parameters: { type: "object", properties: {} }, + }]); + }, + executeTool(_toolName, args) { + executeCalls.push(args); + return Promise.resolve({ ok: true }); + }, + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Check service health.", + tools: { health_check: true }, + mcpServers: [{ + kind: "veryfront-api", + toolPolicy: { allow: ["health_check"] }, + }], + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.example", + apiToken: "server-token", + projectSlug: "server-project", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + createRemoteToolSource() { + return rawSource; + }, + }, + ); + + await sources?.[0]?.executeTool( + "health_check", + { project_reference: "untrusted-project" }, + { projectId: "untrusted-project" }, + ); + + assertEquals(executeCalls, [{}]); +}); + +Deno.test("getRuntimeRemoteToolSources reuses an injected Veryfront API source", () => { + const injectedSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use project files.", + mcpServers: [{ kind: "veryfront-api" }], + __vfRemoteToolSources: [injectedSource], + } as Parameters[0], + { + getVeryfrontBootstrap() { + throw new Error("bootstrap must not be read when the source is already injected"); + }, + }, + ); + + assertEquals(sources, [injectedSource]); +}); + +Deno.test("getRuntimeRemoteToolSources keeps only matching injected API source for explicit API-only config", () => { + const injectedApiSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const injectedStudioSource: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use API tools.", + mcpServers: [{ kind: "veryfront-api" }], + __vfRemoteToolSources: [injectedApiSource, injectedStudioSource], + } as Parameters[0], + { + getVeryfrontBootstrap() { + throw new Error("bootstrap must not be read when matching API source is injected"); + }, + }, + ); + + assertEquals(sources, [injectedApiSource]); +}); + +Deno.test("getRuntimeRemoteToolSources keeps only matching injected Studio source for explicit Studio-only config", () => { + const injectedApiSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const injectedStudioSource: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use Studio tools.", + mcpServers: [{ kind: "veryfront-studio" }], + __vfRemoteToolSources: [injectedApiSource, injectedStudioSource], + } as Parameters[0], + ); + + assertEquals(sources, [injectedStudioSource]); +}); + +Deno.test("getRuntimeRemoteToolSources enforces policy on injected Veryfront API source", async () => { + const executeCalls: string[] = []; + const injectedSource: RemoteToolSource = { + id: VERYFRONT_API_MCP_SOURCE_ID, + listTools: () => + Promise.resolve([ + { + name: "get_file", + description: "Read a file", + parameters: { type: "object", properties: {} }, + }, + { + name: "delete_file", + description: "Delete a file", + parameters: { type: "object", properties: {} }, + }, + ]), + executeTool: (toolName) => { + executeCalls.push(toolName); + return Promise.resolve({ ok: true }); + }, + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use project files.", + mcpServers: [{ kind: "veryfront-api", toolPolicy: { allow: ["get_file"] } }], + __vfRemoteToolSources: [injectedSource], + } as Parameters[0], + ); + + assertEquals((await sources?.[0]?.listTools())?.map((tool) => tool.name), ["get_file"]); + await sources?.[0]?.executeTool("get_file", {}); + assertThrows( + () => sources![0]!.executeTool("delete_file", {}), + Error, + 'Tool "delete_file" is not allowed for MCP server "veryfront-platform-mcp"', + ); + assertEquals(executeCalls, ["get_file"]); +}); + +Deno.test("getRuntimeRemoteToolSources requires injected control-plane source for explicit Studio MCP", () => { + const error = assertThrows( + () => + getRuntimeRemoteToolSources({ + system: "Use Studio tools.", + tools: { studio_open_project: true }, + mcpServers: [{ kind: "veryfront-studio" }], + }), + VeryfrontError, + "trusted host-injected control-plane source", + ); + assertEquals(error.slug, "config-invalid"); +}); + +Deno.test("getRuntimeRemoteToolSources reuses injected Studio MCP source for explicit Studio config", () => { + const studioSource: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool: () => Promise.resolve({ ok: true }), + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use Studio tools.", + tools: { studio_open_project: true }, + mcpServers: [{ kind: "veryfront-studio" }], + __vfRemoteToolSources: [studioSource], + } as Parameters[0], + ); + + assertEquals(sources, [studioSource]); +}); + +Deno.test("getRuntimeRemoteToolSources enforces policy on injected Studio MCP source", async () => { + const executeCalls: string[] = []; + const studioSource: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => + Promise.resolve([ + { + name: "studio_open_project", + description: "Open project", + parameters: { type: "object", properties: {} }, + }, + { + name: "studio_delete_project", + description: "Delete project", + parameters: { type: "object", properties: {} }, + }, + ]), + executeTool: (toolName) => { + executeCalls.push(toolName); + return Promise.resolve({ ok: true }); + }, + }; + const sources = getRuntimeRemoteToolSources( + { + system: "Use Studio tools.", + mcpServers: [{ + kind: "veryfront-studio", + toolPolicy: { allow: ["studio_open_project"] }, + }], + __vfRemoteToolSources: [studioSource], + } as Parameters[0], + ); + + assertEquals((await sources?.[0]?.listTools())?.map((tool) => tool.name), [ + "studio_open_project", + ]); + await sources?.[0]?.executeTool("studio_open_project", {}); + assertThrows( + () => sources![0]!.executeTool("studio_delete_project", {}), + Error, + 'Tool "studio_delete_project" is not allowed for MCP server "studio-mcp"', + ); + assertEquals(executeCalls, ["studio_open_project"]); +}); + +Deno.test("getRuntimeRemoteToolSources fails closed without Veryfront server identity", () => { + const error = assertThrows( + () => + getRuntimeRemoteToolSources( + { + system: "Use project files.", + tools: { get_file: true }, + mcpServers: [{ kind: "veryfront-api" }], + }, + { + getVeryfrontBootstrap: () => ({ + apiBaseUrl: "https://api.veryfront.com", + hasRequestContext: false, + usesVeryfrontFs: false, + }), + }, + ), + VeryfrontError, + "VERYFRONT_API_TOKEN", + ); + assertEquals(error.slug, "config-invalid"); +}); diff --git a/src/agent/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index ce7319252a..5dbe36260f 100644 --- a/src/agent/runtime/mcp-server-tool-sources.ts +++ b/src/agent/runtime/mcp-server-tool-sources.ts @@ -1,13 +1,26 @@ -import { createRemoteMCPToolSource, type RemoteToolSource } from "#veryfront/tool"; -import { PERMISSION_DENIED } from "#veryfront/errors"; +import { + createProjectScopedRemoteToolCatalog, + createRemoteMCPToolSource, + isToolVisibleTo, + type RemoteMCPToolSourceConfig, + type RemoteToolSource, + toolRegistry, +} from "#veryfront/tool"; +import { CONFIG_INVALID, PERMISSION_DENIED } from "#veryfront/errors"; import type { AgentConfig, AgentHttpMcpServerConfig, AgentMcpServerAuth, AgentMcpServerConfig, + AgentVeryfrontMcpServerConfig, } from "../types.ts"; import type { ToolDefinition, ToolExecutionContext } from "#veryfront/tool"; import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import { + getVeryfrontCloudHostBootstrap, + type VeryfrontCloudBootstrap, +} from "#veryfront/platform/cloud/resolver.ts"; +import { createAgentServiceRemoteMcpConfig } from "../service/mcp-server-config.ts"; export type RuntimeRemoteToolConfig = { __vfRemoteToolSources?: RemoteToolSource[]; @@ -15,6 +28,47 @@ export type RuntimeRemoteToolConfig = { __vfSourceIntegrationPolicy?: SourceIntegrationPolicyManifest; }; +/** Canonical source id for the Veryfront API MCP server. */ +export const VERYFRONT_API_MCP_SOURCE_ID = "veryfront-platform-mcp"; +/** Canonical source id for the Veryfront Studio MCP server. */ +export const VERYFRONT_STUDIO_MCP_SOURCE_ID = "studio-mcp"; + +const LOCAL_RUNTIME_BOOLEAN_TOOL_NAMES = new Set(["bash", "invoke_agent"]); + +function hasVisibleRegistryTool(toolName: string, agentId?: string): boolean { + if (agentId !== undefined) { + for (const tool of toolRegistry.getAll().values()) { + if (tool.ownerAgentId === agentId && tool.shortName === toolName) { + return true; + } + } + } + const tool = toolRegistry.get(toolName); + return tool !== undefined && isToolVisibleTo(tool, { agentId }); +} + +/** Return explicitly selected boolean tools that still need a remote provider. */ +export function getRequestedUnresolvedBooleanToolNames(input: { + tools: AgentConfig["tools"]; + agentId?: string; + availableToolNames?: readonly string[]; +}): string[] { + if (!input.tools || input.tools === true) { + return []; + } + + const availableToolNames = new Set(input.availableToolNames ?? []); + return Object.entries(input.tools) + .filter(([toolName, entry]) => + entry === true && + !hasVisibleRegistryTool(toolName, input.agentId) && + !availableToolNames.has(toolName) && + !LOCAL_RUNTIME_BOOLEAN_TOOL_NAMES.has(toolName) + ) + .map(([toolName]) => toolName) + .sort(); +} + async function resolveValue( value: T | ((context?: ToolExecutionContext) => T | Promise), context?: ToolExecutionContext, @@ -87,12 +141,201 @@ function createMcpServerToolSource(server: AgentHttpMcpServerConfig): RemoteTool }; } +function createMcpToolPolicySource( + source: RemoteToolSource, + policy: AgentMcpServerConfig["toolPolicy"], +): RemoteToolSource { + if (!policy?.allow && !policy?.deny) { + return source; + } + + return { + id: source.id, + async listTools(context) { + return filterToolDefinitions(await source.listTools(context), policy); + }, + executeTool(toolName, args, context) { + if (!isToolAllowed(toolName, policy)) { + throw PERMISSION_DENIED.create({ + detail: `Tool "${toolName}" is not allowed for MCP server "${source.id}"`, + }); + } + return source.executeTool(toolName, args, context); + }, + }; +} + +export type RuntimeMcpServerToolSourceDependencies = { + createRemoteToolSource?: (config: RemoteMCPToolSourceConfig) => RemoteToolSource; + getVeryfrontBootstrap?: () => VeryfrontCloudBootstrap; +}; + +function withServerProject( + context: ToolExecutionContext | undefined, + projectId: string, +): ToolExecutionContext { + return { ...(context ?? {}), projectId }; +} + +function withoutProjectReference(args: unknown): Record { + if (typeof args !== "object" || args === null || Array.isArray(args)) { + return {}; + } + const { project_reference: _untrustedProjectReference, ...toolInput } = args as Record< + string, + unknown + >; + return toolInput; +} + +/** Bind a remote tool source to one server-selected project identity. */ +export function bindRemoteToolSourceToProject( + source: RemoteToolSource, + projectId: string, +): RemoteToolSource { + const catalog = createProjectScopedRemoteToolCatalog({ + source, + defaultProjectId: projectId, + }); + + return { + id: source.id, + listTools: (context) => catalog.listTools(withServerProject(context, projectId)), + async executeTool(toolName, args, context) { + const execution = await catalog.prepareExecution({ + toolName, + toolInput: withoutProjectReference(args), + context: withServerProject(context, projectId), + }); + return await source.executeTool( + toolName, + execution.toolInput, + execution.executeContext, + ); + }, + }; +} + +function createVeryfrontApiMcpServerToolSource( + server: AgentVeryfrontMcpServerConfig, + dependencies: RuntimeMcpServerToolSourceDependencies, + requireIdentity: boolean, +): RemoteToolSource | undefined { + const bootstrap = (dependencies.getVeryfrontBootstrap ?? getVeryfrontCloudHostBootstrap)(); + const authToken = bootstrap.apiToken?.trim(); + const projectId = bootstrap.projectSlug?.trim(); + if (!authToken || !projectId) { + if (!requireIdentity) { + return undefined; + } + throw CONFIG_INVALID.create({ + detail: + "Veryfront API MCP requires server-side VERYFRONT_API_TOKEN and VERYFRONT_PROJECT_SLUG.", + }); + } + + const remoteConfig = createAgentServiceRemoteMcpConfig({ + server, + authToken, + apiMcpUrl: `${bootstrap.apiBaseUrl.replace(/\/+$/, "")}/mcp`, + getProjectId: () => projectId, + defaultSourceId: VERYFRONT_API_MCP_SOURCE_ID, + }); + if (!remoteConfig) { + throw CONFIG_INVALID.create({ + detail: "Veryfront API MCP configuration could not be resolved.", + }); + } + const configuredHeaders = remoteConfig.headers; + const createSource = dependencies.createRemoteToolSource ?? createRemoteMCPToolSource; + const source = createSource({ + ...remoteConfig, + // Direct application routes use the server bootstrap identity. Request + // payloads must not replace the MCP credential through tool context. + ...(configuredHeaders + ? { + headers: typeof configuredHeaders === "function" + ? () => configuredHeaders() + : configuredHeaders, + } + : {}), + }); + const policySource = createMcpToolPolicySource(source, server.toolPolicy); + return bindRemoteToolSourceToProject(policySource, projectId); +} + +function requiresInjectedStudioMcpServerToolSource(server: AgentVeryfrontMcpServerConfig): never { + throw CONFIG_INVALID.create({ + detail: + `Veryfront Studio MCP server "${ + server.id ?? VERYFRONT_STUDIO_MCP_SOURCE_ID + }" requires a trusted host-injected control-plane source. ` + + 'Use the hosted/control-plane runtime or inject the Studio MCP remote tool source before declaring { kind: "veryfront-studio" }.', + }); +} + +function getFirstPartyMcpSourceId(server: AgentVeryfrontMcpServerConfig): string { + return server.id ?? + (server.kind === "veryfront-api" + ? VERYFRONT_API_MCP_SOURCE_ID + : VERYFRONT_STUDIO_MCP_SOURCE_ID); +} + /** Return remote tool sources for direct agent runtime config. */ -export function getRuntimeRemoteToolSources(config: AgentConfig): RemoteToolSource[] | undefined { +export function getRuntimeRemoteToolSources( + config: AgentConfig, + dependencies: RuntimeMcpServerToolSourceDependencies = {}, + agentId = config.id, +): RemoteToolSource[] | undefined { const runtimeConfig = config as AgentConfig & RuntimeRemoteToolConfig; + const injectedSources = runtimeConfig.__vfRemoteToolSources ?? []; + const hasExplicitMcpServers = config.mcpServers !== undefined; + const implicitToolNames = hasExplicitMcpServers + ? [] + : getRequestedUnresolvedBooleanToolNames({ tools: config.tools, agentId }); + const configuredServers: AgentMcpServerConfig[] = config.mcpServers ?? + (implicitToolNames.length > 0 + ? [{ kind: "veryfront-api", toolPolicy: { allow: implicitToolNames } }] + : []); + const configuredFirstPartyServersBySourceId = new Map(); + for (const server of configuredServers) { + if (!isHttpMcpServerConfig(server)) { + configuredFirstPartyServersBySourceId.set(getFirstPartyMcpSourceId(server), server); + } + } + const selectedInjectedSources = hasExplicitMcpServers + ? injectedSources.filter((source) => configuredFirstPartyServersBySourceId.has(source.id)) + : injectedSources; + const policyWrappedInjectedSources = selectedInjectedSources.map((source) => { + const server = configuredFirstPartyServersBySourceId.get(source.id); + return server ? createMcpToolPolicySource(source, server.toolPolicy) : source; + }); + const configuredSources = configuredServers.flatMap((server) => { + if (isHttpMcpServerConfig(server)) { + return [createMcpServerToolSource(server)]; + } + if (server.kind === "veryfront-api") { + if (injectedSources.some((source) => source.id === getFirstPartyMcpSourceId(server))) { + return []; + } + const source = createVeryfrontApiMcpServerToolSource( + server, + dependencies, + hasExplicitMcpServers, + ); + return source ? [source] : []; + } + if (server.kind === "veryfront-studio") { + if (injectedSources.some((source) => source.id === getFirstPartyMcpSourceId(server))) { + return []; + } + requiresInjectedStudioMcpServerToolSource(server); + } + return []; + }); const remoteToolSources = [ - ...(runtimeConfig.__vfRemoteToolSources ?? []), - ...(config.mcpServers ?? []).filter(isHttpMcpServerConfig).map(createMcpServerToolSource), + ...policyWrappedInjectedSources, + ...configuredSources, ]; return remoteToolSources.length > 0 ? remoteToolSources : undefined; diff --git a/src/agent/runtime/skill-metadata.test.ts b/src/agent/runtime/skill-metadata.test.ts index 724b9a823b..8642181571 100644 --- a/src/agent/runtime/skill-metadata.test.ts +++ b/src/agent/runtime/skill-metadata.test.ts @@ -270,7 +270,7 @@ allowed-tools: read_file, write_file, shell Write carefully.`, nextStep: "Continue after loading.", messages: loadedSkillMessages, - availableToolNames: ["read_file", "write_file"], + availableToolNames: ["read_file", "write_file", "invoke_agent"], }); assertEquals(response.allowedTools, ["read_file", "write_file"]); @@ -280,7 +280,7 @@ Write carefully.`, assertEquals(response.delegationNote, "Delegate unavailable tools."); }); -Deno.test("buildRuntimeLoadedSkillResponse returns empty allowedTools when declared tools have no current run overlap", () => { +Deno.test("buildRuntimeLoadedSkillResponse omits delegation note when no delegate tools are available", () => { const response = buildRuntimeLoadedSkillResponse({ skillId: "write", instructions: `--- @@ -297,6 +297,50 @@ Write carefully.`, assertEquals(response.delegationTools, ["shell"]); assertEquals(response.unavailableCurrentRunTools, ["shell"]); assertEquals(response.note, "No direct tools are available."); + assertEquals(response.delegationNote, undefined); +}); + +Deno.test("buildRuntimeLoadedSkillResponse treats an explicit empty tool surface as no tools", () => { + const response = buildRuntimeLoadedSkillResponse({ + skillId: "research", + instructions: `--- +allowed-tools: + - read_file +model: sonnet +max-steps: 8 +--- +Research carefully.`, + nextStep: "Continue after loading.", + messages: loadedSkillMessages, + availableToolNames: [], + }); + + assertEquals(response.allowedTools, []); + assertEquals(response.delegationTools, ["read_file"]); + assertEquals(response.unavailableCurrentRunTools, ["read_file"]); + assertEquals(response.note, "No direct tools are available."); + assertEquals(response.delegationNote, undefined); + assertEquals(response.model, "sonnet"); + assertEquals(response.maxSteps, 8); + assertEquals(response.overrideNote, undefined); +}); + +Deno.test("buildRuntimeLoadedSkillResponse keeps delegation note for scoped delegate tools", () => { + const response = buildRuntimeLoadedSkillResponse({ + skillId: "write", + instructions: `--- +allowed-tools: + - shell +--- +Write carefully.`, + nextStep: "Continue after loading.", + messages: loadedSkillMessages, + availableToolNames: ["agent_writer", "read_file"], + }); + + assertEquals(response.allowedTools, []); + assertEquals(response.unavailableCurrentRunTools, ["shell"]); + assertEquals(response.delegationNote, "Delegate unavailable tools."); }); Deno.test("buildRuntimeLoadedSkillResponse preserves runtime overrides and references", () => { @@ -321,6 +365,24 @@ Research carefully.`, assertEquals(response.referenceNote, "Load references separately."); }); +Deno.test("buildRuntimeLoadedSkillResponse omits override forwarding without legacy invoke_agent", () => { + const response = buildRuntimeLoadedSkillResponse({ + skillId: "research", + instructions: `--- +model: sonnet +max-steps: 8 +--- +Research carefully.`, + nextStep: "Continue after loading.", + messages: loadedSkillMessages, + availableToolNames: ["agent_researcher", "read_file"], + }); + + assertEquals(response.model, "sonnet"); + assertEquals(response.maxSteps, 8); + assertEquals(response.overrideNote, undefined); +}); + Deno.test("buildRuntimeLoadedSkillResponse logs invalid metadata and returns a base response", () => { const instructions = `--- allowed-tools: diff --git a/src/agent/runtime/skill-metadata.ts b/src/agent/runtime/skill-metadata.ts index 7bde6ea566..9342613e55 100644 --- a/src/agent/runtime/skill-metadata.ts +++ b/src/agent/runtime/skill-metadata.ts @@ -165,6 +165,25 @@ export type RuntimeSkillMetadataLogger = { error?: (message: string, metadata?: Record) => void; }; +function getAvailableScopedDelegateToolNames( + availableToolNameSet: ReadonlySet | null, +): string[] { + if (!availableToolNameSet) { + return []; + } + + return [...availableToolNameSet].filter((toolName) => toolName.startsWith("agent_")).sort(); +} + +function canUseLegacyInvokeAgent(availableToolNameSet: ReadonlySet | null): boolean { + return availableToolNameSet === null || availableToolNameSet.has("invoke_agent"); +} + +function hasAvailableDelegationTool(availableToolNameSet: ReadonlySet | null): boolean { + return availableToolNameSet === null || canUseLegacyInvokeAgent(availableToolNameSet) || + getAvailableScopedDelegateToolNames(availableToolNameSet).length > 0; +} + /** Public API contract for parsed runtime skill document. */ export type ParsedRuntimeSkillDocument = { metadata: RuntimeSkillFrontmatter; @@ -292,7 +311,7 @@ export function buildRuntimeLoadedSkillResponse(input: { }): RuntimeLoadedSkillResponse { const metadata = parseRuntimeSkillMetadata(input.instructions, { logger: input.logger }); const declaredAllowedTools = metadata?.allowedTools ?? []; - const availableToolNameSet = input.availableToolNames && input.availableToolNames.length > 0 + const availableToolNameSet = input.availableToolNames !== undefined ? new Set(input.availableToolNames) : null; const currentRunAllowedTools = availableToolNameSet @@ -321,13 +340,15 @@ export function buildRuntimeLoadedSkillResponse(input: { ...(unavailableCurrentRunTools.length > 0 ? { unavailableCurrentRunTools, - delegationNote: input.messages.unavailableCurrentRunToolsDelegationNote, + ...(hasAvailableDelegationTool(availableToolNameSet) + ? { delegationNote: input.messages.unavailableCurrentRunToolsDelegationNote } + : {}), } : {}), ...(metadata?.model ? { model: metadata.model } : {}), ...(metadata?.thinking !== undefined ? { thinking: metadata.thinking } : {}), ...(metadata?.maxSteps !== undefined ? { maxSteps: metadata.maxSteps } : {}), - ...(hasOverrides + ...(hasOverrides && canUseLegacyInvokeAgent(availableToolNameSet) ? { overrideNote: input.messages.overrideNote, } diff --git a/src/agent/runtime/skill-prompt.test.ts b/src/agent/runtime/skill-prompt.test.ts index 1f136b8a6d..497653bcdb 100644 --- a/src/agent/runtime/skill-prompt.test.ts +++ b/src/agent/runtime/skill-prompt.test.ts @@ -62,7 +62,7 @@ Deno.test("buildRuntimeAvailableSkillsPromptBlock renders skills and delegation assertStringIncludes(block, "Keep the root assistant visibly owning the work."); assertStringIncludes( block, - "When delegating, use the platform orchestration tool `invoke_agent`.", + "When delegating, use an available scoped `agent_` tool; use `invoke_agent` only when that exact legacy tool is present.", ); assertStringIncludes( block, @@ -76,6 +76,34 @@ Deno.test("buildRuntimeAvailableSkillsPromptBlock renders skills and delegation ); }); +Deno.test("buildRuntimeAvailableSkillsPromptBlock names exact scoped delegate tools", () => { + const block = buildRuntimeAvailableSkillsPromptBlock([ + createSkill({ id: "research", description: "Research" }), + ], { + availableToolNames: ["agent_researcher", "agent_writer", "read_file"], + }); + + assertStringIncludes( + block, + "When delegating, use only these available scoped delegation tools: `agent_researcher`, `agent_writer`.", + ); + assertEquals(block.includes("invoke_agent"), false); + assertEquals(block.includes("Pass through any returned model"), false); +}); + +Deno.test("buildRuntimeAvailableSkillsPromptBlock omits delegation guidance without delegate tools", () => { + const block = buildRuntimeAvailableSkillsPromptBlock([ + createSkill({ id: "solo", description: "Solo" }), + ], { + availableToolNames: ["read_file", "load_skill"], + }); + + assertEquals(block.includes("When delegating"), false); + assertEquals(block.includes("invoke_agent"), false); + assertEquals(block.includes("Delegate only when"), false); + assertStringIncludes(block, "Do NOT attempt tools that are absent from the current run"); +}); + Deno.test("buildRuntimeAvailableSkillsPromptBlock does not repeat an id-only name", () => { const block = buildRuntimeAvailableSkillsPromptBlock([ createSkill({ id: "code-review", description: "Review code" }), diff --git a/src/agent/runtime/skill-prompt.ts b/src/agent/runtime/skill-prompt.ts index 641c9dc5da..b101168e28 100644 --- a/src/agent/runtime/skill-prompt.ts +++ b/src/agent/runtime/skill-prompt.ts @@ -11,6 +11,30 @@ import type { RuntimeSkillDefinition } from "./skill-metadata.ts"; /** Maximum value for runtime skill prompt entries. */ export const MAX_RUNTIME_SKILL_PROMPT_ENTRIES = 30; +function getScopedDelegateToolNames(availableToolNames?: readonly string[]): string[] { + return (availableToolNames ?? []) + .filter((toolName) => toolName.startsWith("agent_")) + .sort(); +} + +function buildRuntimeSkillDelegationGuidance(availableToolNames?: readonly string[]): string { + if (availableToolNames === undefined) { + return `When delegating, use an available scoped \`agent_\` tool; use \`invoke_agent\` only when that exact legacy tool is present. ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING}`; + } + + const scopedDelegateToolNames = getScopedDelegateToolNames(availableToolNames); + if (scopedDelegateToolNames.length > 0) { + const tools = scopedDelegateToolNames.map((toolName) => `\`${toolName}\``).join(", "); + return `When delegating, use only these available scoped delegation tools: ${tools}. ${LOAD_SKILL_DELEGATION_THRESHOLD}`; + } + + if (availableToolNames.includes("invoke_agent")) { + return `When delegating, use the available legacy \`invoke_agent\` tool. ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING}`; + } + + return ""; +} + /** Formats runtime skill metadata. */ export function formatRuntimeSkillMetadata(skill: RuntimeSkillDefinition): string { const details: string[] = []; @@ -44,6 +68,7 @@ function formatRuntimeSkillLabel(skill: RuntimeSkillDefinition): string { /** Builds runtime available skills prompt block. */ export function buildRuntimeAvailableSkillsPromptBlock( skills: readonly RuntimeSkillDefinition[], + options: { availableToolNames?: readonly string[] } = {}, ): string { const displaySkills = skills.slice(0, MAX_RUNTIME_SKILL_PROMPT_ENTRIES); const skillsList = displaySkills @@ -59,11 +84,13 @@ export function buildRuntimeAvailableSkillsPromptBlock( skills.length - MAX_RUNTIME_SKILL_PROMPT_ENTRIES } more skill summaries omitted from this prompt; use an ID from the load_skill tool schema)` : ""; + const delegationGuidance = buildRuntimeSkillDelegationGuidance(options.availableToolNames); + const delegationSentence = delegationGuidance ? ` ${delegationGuidance}` : ""; return createRuntimePromptBlock({ name: "available_skills", content: - `You have access to these skills. Use load_skill to load full instructions when needed. load_skill only loads instructions plus metadata. ${LOAD_SKILL_CONTINUE_SAME_TURN} ${KEEP_ROOT_ASSISTANT_VISIBLE_OWNER} If a skill specifies allowed tools, you MUST stay within the current-run intersection of those tools. When delegating, use the platform orchestration tool \`invoke_agent\`. ${LOAD_SKILL_DELEGATION_THRESHOLD} ${LOAD_SKILL_OVERRIDE_FORWARDING} ${NO_DELEGATION_NARRATION_UNLESS_ASKED} + `You have access to these skills. Use load_skill to load full instructions when needed. load_skill only loads instructions plus metadata. ${LOAD_SKILL_CONTINUE_SAME_TURN} ${KEEP_ROOT_ASSISTANT_VISIBLE_OWNER} If a skill specifies allowed tools, you MUST stay within the current-run intersection of those tools.${delegationSentence} ${NO_DELEGATION_NARRATION_UNLESS_ASKED} Do NOT attempt tools that are absent from the current run just because they appear in loaded skill instructions. diff --git a/src/agent/runtime/stream-tool-authority.test.ts b/src/agent/runtime/stream-tool-authority.test.ts new file mode 100644 index 0000000000..a703bf6d45 --- /dev/null +++ b/src/agent/runtime/stream-tool-authority.test.ts @@ -0,0 +1,118 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import type { ModelRuntime } from "#veryfront/provider"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { type Tool, tool } from "#veryfront/tool"; +import { agent } from "../index.ts"; + +function createRuntimeStream(parts: unknown[]) { + return new ReadableStream({ + start(controller) { + for (const part of parts) { + controller.enqueue(part); + } + controller.close(); + }, + }); +} + +function getRuntimeToolNames(options: unknown): string[] { + const rawTools = (options as { tools?: unknown }).tools; + return Array.isArray(rawTools) + ? rawTools.map((entry) => + (entry as { name?: string; id?: string }).name ?? + (entry as { name?: string; id?: string }).id ?? "" + ) + : Object.keys((rawTools as Record | undefined) ?? {}); +} + +it("suppresses an OpenAI streamed tool call dropped by provider tool conversion", async () => { + const toolExecutions: Record = {}; + const runtimeToolNamesByStep: string[][] = []; + const promptsByStep: unknown[] = []; + let streamCalls = 0; + const tools: Record = Object.fromEntries( + Array.from({ length: 150 }, (_, index) => { + const id = `tool_${index}`; + const definition = tool({ + id, + description: `Tool ${index}`, + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + toolExecutions[id] = (toolExecutions[id] ?? 0) + 1; + return { ok: true, id }; + }, + }); + return [id, definition]; + }), + ); + const model: ModelRuntime = { + provider: "openai", + modelId: "veryfront-cloud/openai/gpt-5.2", + async doGenerate() { + return { + content: [{ type: "text", text: "unused" }], + finishReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }; + }, + async doStream(options) { + streamCalls++; + runtimeToolNamesByStep.push(getRuntimeToolNames(options)); + promptsByStep.push((options as { prompt?: unknown }).prompt); + + if (streamCalls === 1) { + return { + stream: createRuntimeStream([ + { + type: "tool-call", + toolCallId: "dropped-tool-call", + toolName: "tool_128", + input: {}, + }, + { + type: "finish", + finishReason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 1 }, + }, + ]), + }; + } + + return { + stream: createRuntimeStream([ + { type: "text-delta", text: "Recovered without executing the dropped tool." }, + { + type: "finish", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1 }, + }, + ]), + }; + }, + }; + + const assistant = agent({ + id: "openai-stream-tool-authority-agent", + model: "veryfront-cloud/openai/gpt-5.2", + system: "Use available tools only.", + tools, + maxSteps: 2, + resolveModelTransport: async () => ({ model }), + }); + + const response = await assistant.stream({ input: "Call the dropped tool" }); + const streamBody = await response.toDataStreamResponse().text(); + + assertEquals(streamCalls, 2); + assertEquals(runtimeToolNamesByStep[0]?.length, 128); + assertEquals(runtimeToolNamesByStep[0]?.includes("tool_127"), true); + assertEquals(runtimeToolNamesByStep[0]?.includes("tool_128"), false); + assertEquals( + JSON.stringify(promptsByStep[1]).includes("ignored unavailable tool call(s): tool_128"), + true, + ); + assertEquals(toolExecutions.tool_128 ?? 0, 0); + assertEquals(streamBody.includes('"toolName":"tool_128"'), false); +}); diff --git a/src/agent/streaming/fork-runtime-stream.ts b/src/agent/streaming/fork-runtime-stream.ts index b06cd190aa..35b7761384 100644 --- a/src/agent/streaming/fork-runtime-stream.ts +++ b/src/agent/streaming/fork-runtime-stream.ts @@ -118,6 +118,7 @@ export type StartAgentRuntimeForkInput = { authToken: string; projectId: string | null; model: string; + temperature?: number; maxSteps: number; prompt?: string; maxContinuationSteps?: number; @@ -192,6 +193,7 @@ export function startAgentRuntimeForkWithHostTools< authToken: input.authToken, projectId: input.projectId, model: input.forkModel, + temperature: input.temperature, maxSteps: input.maxSteps, prompt: input.prompt, maxContinuationSteps: input.maxContinuationSteps, @@ -259,6 +261,7 @@ export type RunAgentRuntimeForkStepInput = { authToken: string; projectId: string | null; model: string; + temperature?: number; messages: AgentMessage[]; system: string; abortSignal?: AbortSignal; @@ -303,6 +306,7 @@ export async function runAgentRuntimeForkStep(input: RunAgentRuntimeForkStepInpu const runtimeConfig = { model: input.model, + ...(input.temperature === undefined ? {} : { temperature: input.temperature }), system: input.system, tools: input.runtimeTools, providerTools: input.providerToolNames ?? [], @@ -506,6 +510,7 @@ export function startAgentRuntimeFork(input: StartAgentRuntimeForkInput): ForkRu authToken: input.authToken, projectId: input.projectId, model: input.model, + ...(input.temperature === undefined ? {} : { temperature: input.temperature }), messages: prepared.messages, system: prepared.system, ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), diff --git a/src/agent/types.ts b/src/agent/types.ts index 193aca9336..dd298aebcb 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -145,6 +145,11 @@ export interface AgentConfig { model?: ModelString; system: string | (() => string) | (() => Promise); tools?: true | Record; + /** + * Exact registered agent ids this agent may call through scoped + * `agent_` tools. Each delegate keeps its own model, skills, and tools. + */ + delegates?: string[]; /** * Optional sandbox selection for runtime-owned sandbox tools such as `bash`. * `id` attaches to an existing sandbox session and detaches on run cleanup. diff --git a/src/chat/stream-watchdog.test.ts b/src/chat/stream-watchdog.test.ts index f2e77b83e8..88de791186 100644 --- a/src/chat/stream-watchdog.test.ts +++ b/src/chat/stream-watchdog.test.ts @@ -12,6 +12,7 @@ const watchdogOptions = { idleTimeoutMs: 120, toolRunningTimeoutMs: 300, longRunningToolNames: ["invoke_agent"], + longRunningToolPrefixes: ["agent_"], }; describe("chat/stream-watchdog", () => { @@ -88,6 +89,28 @@ describe("chat/stream-watchdog", () => { ); }); + it("keeps scoped delegate tools alive by configured prefix", () => { + const running = getNextChatStreamWatchdogState( + { phase: "response_pending", timeoutMs: 120 }, + { + type: "tool-input-available", + toolCallId: "delegate-1", + toolName: "agent_extraction-agent", + input: {}, + }, + watchdogOptions, + ); + + assertEquals( + getNextChatStreamWatchdogState( + running, + { type: "message-metadata", messageMetadata: { modelId: "openai/gpt-5.4" } }, + watchdogOptions, + ), + running, + ); + }); + it("detects heartbeat-only metadata chunks", () => { assertEquals( isHeartbeatOnlyMetadataChunk({ type: "message-metadata", messageMetadata: {} }), diff --git a/src/chat/stream-watchdog.ts b/src/chat/stream-watchdog.ts index 1fb5806172..504a2de546 100644 --- a/src/chat/stream-watchdog.ts +++ b/src/chat/stream-watchdog.ts @@ -25,6 +25,7 @@ export type ChatStreamWatchdogOptions = { idleTimeoutMs?: number; toolRunningTimeoutMs?: number; longRunningToolNames?: Iterable; + longRunningToolPrefixes?: Iterable; setTimeoutFn?: typeof globalThis.setTimeout; clearTimeoutFn?: typeof globalThis.clearTimeout; }; @@ -69,11 +70,13 @@ export function createChatStreamWatchdogState( export function isLongRunningToolRunning( current: ChatStreamWatchdogState, longRunningToolNames: ReadonlySet, + longRunningToolPrefixes: readonly string[] = [], ): boolean { return ( current.phase === "tool_running" && typeof current.toolName === "string" && - longRunningToolNames.has(current.toolName) + (longRunningToolNames.has(current.toolName) || + longRunningToolPrefixes.some((prefix) => current.toolName?.startsWith(prefix))) ); } @@ -133,7 +136,11 @@ export function getNextChatStreamWatchdogState( ); case "message-metadata": - return isLongRunningToolRunning(currentState, resolvedOptions.longRunningToolNames) + return isLongRunningToolRunning( + currentState, + resolvedOptions.longRunningToolNames, + resolvedOptions.longRunningToolPrefixes, + ) ? currentState : createChatStreamWatchdogState("response_pending", undefined, resolvedOptions); @@ -141,7 +148,11 @@ export function getNextChatStreamWatchdogState( return createChatStreamWatchdogState("response_pending", undefined, resolvedOptions); default: - return isLongRunningToolRunning(currentState, resolvedOptions.longRunningToolNames) + return isLongRunningToolRunning( + currentState, + resolvedOptions.longRunningToolNames, + resolvedOptions.longRunningToolPrefixes, + ) ? currentState : createChatStreamWatchdogState("response_pending", undefined, resolvedOptions); } @@ -174,7 +185,13 @@ export function createChatStreamWatchdog(options?: ChatStreamWatchdogOptions) { clearTimer(); - if (isLongRunningToolRunning(state, resolvedOptions.longRunningToolNames)) { + if ( + isLongRunningToolRunning( + state, + resolvedOptions.longRunningToolNames, + resolvedOptions.longRunningToolPrefixes, + ) + ) { return; } @@ -195,7 +212,13 @@ export function createChatStreamWatchdog(options?: ChatStreamWatchdogOptions) { return lastTimeoutState; }, keepAlive() { - if (isLongRunningToolRunning(state, resolvedOptions.longRunningToolNames)) { + if ( + isLongRunningToolRunning( + state, + resolvedOptions.longRunningToolNames, + resolvedOptions.longRunningToolPrefixes, + ) + ) { return; } @@ -232,6 +255,7 @@ function resolveChatStreamWatchdogOptions(options?: ChatStreamWatchdogOptions) { // from the idle timeout. Embedding product-specific names here as a default // couples this shared utility to application concerns. longRunningToolNames: new Set(options?.longRunningToolNames ?? []), + longRunningToolPrefixes: [...(options?.longRunningToolPrefixes ?? [])], setTimeoutFn: options?.setTimeoutFn ?? defaultSetTimeout, clearTimeoutFn: options?.clearTimeoutFn ?? defaultClearTimeout, }; diff --git a/src/internal-agents/run-system-prompt.ts b/src/internal-agents/run-system-prompt.ts index cef6869af9..2196c048aa 100644 --- a/src/internal-agents/run-system-prompt.ts +++ b/src/internal-agents/run-system-prompt.ts @@ -139,6 +139,7 @@ export async function composeInternalAgentRunSystemPrompt( instructions: baseInstructions, }, runtimeBlocks, + availableToolNames: input.toolNames, ...(studioContext.environmentContext ? { environmentContext: studioContext.environmentContext } : {}), diff --git a/src/platform/cloud/resolver.test.ts b/src/platform/cloud/resolver.test.ts index fe4cd84743..c9792d3bb0 100644 --- a/src/platform/cloud/resolver.test.ts +++ b/src/platform/cloud/resolver.test.ts @@ -14,6 +14,7 @@ import { getDefaultVeryfrontCloudModel, getVeryfrontCloudAuthToken, getVeryfrontCloudBootstrap, + getVeryfrontCloudHostBootstrap, getVeryfrontCloudProjectSlug, isVeryfrontCloudEnabled, resolveVeryfrontApiBaseUrlFromHostEnv, @@ -126,6 +127,30 @@ describe("platform/cloud/resolver", () => { assertEquals(getVeryfrontCloudProjectSlug(), "env-project"); }); + it("keeps direct host bootstrap identity isolated from scoped request context", () => { + setEnv("VERYFRONT_API_URL", "https://api.veryfront.org"); + setEnv("VERYFRONT_API_TOKEN", "vf_host_token"); + setEnv("VERYFRONT_PROJECT_SLUG", "host-project"); + + runWithVeryfrontCloudContext( + { + apiBaseUrl: "https://untrusted.example.com", + apiToken: "vf_request_token", + projectSlug: "request-project", + }, + () => { + assertEquals(getVeryfrontCloudHostBootstrap(), { + apiBaseUrl: "https://api.veryfront.org", + apiToken: "vf_host_token", + projectSlug: "host-project", + serviceLayer: undefined, + hasRequestContext: false, + usesVeryfrontFs: false, + }); + }, + ); + }); + it("resolves the API base URL from host env without the config bridge", () => { const globals = globalThis as Record; const originalBridge = globals.__vfGetApiBaseUrlEnv; diff --git a/src/platform/cloud/resolver.ts b/src/platform/cloud/resolver.ts index f2b33a1747..23fde55911 100644 --- a/src/platform/cloud/resolver.ts +++ b/src/platform/cloud/resolver.ts @@ -127,6 +127,18 @@ export function getVeryfrontCloudBootstrap(): VeryfrontCloudBootstrap { }; } +/** Resolve the trusted host identity used by direct server-side platform clients. */ +export function getVeryfrontCloudHostBootstrap(): VeryfrontCloudBootstrap { + return { + apiBaseUrl: resolveVeryfrontApiBaseUrlFromHostEnv(), + apiToken: getHostEnv("VERYFRONT_API_TOKEN"), + projectSlug: getHostEnv("VERYFRONT_PROJECT_SLUG"), + serviceLayer: normalizeServiceLayer(getHostEnv("VERYFRONT_SERVICE_LAYER")), + hasRequestContext: false, + usesVeryfrontFs: false, + }; +} + export function isVeryfrontCloudEnabled(): boolean { const bootstrap = getVeryfrontCloudBootstrap(); diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index cdcfaf01a5..fc9ca4e388 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -1085,7 +1085,7 @@ describe("server/handlers/request/agent-stream.handler", () => { new Response( JSON.stringify({ jsonrpc: "2.0", - id: "veryfront-studio-mcp:tools:list", + id: "studio-mcp:tools:list", result: { tools: [ { @@ -1183,6 +1183,107 @@ describe("server/handlers/request/agent-stream.handler", () => { } }); + it("preserves an explicit Studio MCP opt-out", async () => { + let studioMcpFetchCalls = 0; + let capturedAllowedRemoteTools: string[] | undefined; + let capturedRemoteSourceCount = -1; + const originalFetch = globalThis.fetch; + const originalStudioMcpUrl = Deno.env.get("VERYFRONT_STUDIO_MCP_URL"); + + Deno.env.set("VERYFRONT_STUDIO_MCP_URL", "https://studio.veryfront.org/mcp"); + globalThis.fetch = ((url) => { + if (String(url) === "https://studio.veryfront.org/mcp") { + studioMcpFetchCalls += 1; + } + return Promise.resolve(new Response(null, { status: 503 })); + }) as typeof fetch; + + try { + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => {}, + getAgent: (id) => + id === "assistant-1" + ? createAgentWithConfig("assistant-1", { + tools: { studio_todo_write: true }, + mcpServers: [], + }) + : undefined, + getAllAgentIds: () => ["assistant-1"], + sessionManager: new AgentRunSessionManager(), + createRuntime: (runtimeAgent) => { + const runtimeConfig = runtimeAgent.config as + & typeof runtimeAgent.config + & RuntimeRemoteToolConfig; + capturedAllowedRemoteTools = runtimeConfig.__vfAllowedRemoteTools; + capturedRemoteSourceCount = runtimeConfig.__vfRemoteToolSources?.length ?? 0; + + 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" }, + forwardedProps: { + clientId: "veryfront-studio", + veryfront: { + client: { + id: "veryfront-studio", + type: "web", + platform: "browser", + }, + }, + runtimeOverrides: { + allowedTools: ["studio_todo_write"], + }, + }, + }); + 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(studioMcpFetchCalls, 0); + assertEquals(capturedAllowedRemoteTools, ["studio_todo_write"]); + assertEquals(capturedRemoteSourceCount, 0); + } finally { + globalThis.fetch = originalFetch; + if (originalStudioMcpUrl === undefined) Deno.env.delete("VERYFRONT_STUDIO_MCP_URL"); + else Deno.env.set("VERYFRONT_STUDIO_MCP_URL", originalStudioMcpUrl); + } + }); + it("fails closed for malformed runtime integration tool allowlists from forwarded props", async () => { let capturedAllowedTools: string[] | undefined; @@ -1315,9 +1416,117 @@ describe("server/handlers/request/agent-stream.handler", () => { } }); + it("fails closed when platform MCP is opted out or discovery fails", async () => { + const originalFetch = globalThis.fetch; + + try { + for ( + const testCase of [ + { + id: "explicit-opt-out", + name: "explicit opt-out", + mcpServers: [] as const, + expectedFetchCalls: 0, + }, + { + id: "failed-discovery", + name: "failed discovery", + mcpServers: undefined, + expectedFetchCalls: 1, + }, + ] + ) { + let mcpFetchCalls = 0; + let capturedAllowedRemoteTools: string[] | undefined; + let capturedRemoteSourceCount = -1; + globalThis.fetch = ((url) => { + if (String(url).endsWith("/mcp")) { + mcpFetchCalls += 1; + return Promise.reject(new Error(`${testCase.name} discovery unavailable`)); + } + return Promise.resolve(new Response(null, { status: 503 })); + }) as typeof fetch; + + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => {}, + getAgent: (id) => + id === "assistant-1" + ? createAgentWithConfig("assistant-1", { + tools: { get_file: true }, + ...(testCase.mcpServers === undefined + ? {} + : { mcpServers: [...testCase.mcpServers] }), + }) + : undefined, + getAllAgentIds: () => ["assistant-1"], + sessionManager: new AgentRunSessionManager(), + createRuntime: (runtimeAgent) => { + const runtimeConfig = runtimeAgent.config as + & typeof runtimeAgent.config + & RuntimeRemoteToolConfig; + capturedAllowedRemoteTools = runtimeConfig.__vfAllowedRemoteTools; + capturedRemoteSourceCount = runtimeConfig.__vfRemoteToolSources?.length ?? 0; + 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({ + runId: `run-${testCase.id}`, + credentials: { authToken: "request-scoped-user-token" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + audience: "support-agent-fork", + requestId: `run-${testCase.id}`, + }); + const result = await handler.handle( + new Request(`https://example.com/api/control-plane/runs/run-${testCase.id}/stream`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + { + ...createCtx(publicKeyPem), + proxyToken: "run-scoped-token", + projectSlug: "support-agent-fork", + }, + ); + + assertExists(result.response); + assertEquals(result.response.status, 200); + assertEquals(mcpFetchCalls, testCase.expectedFetchCalls); + assertEquals(capturedAllowedRemoteTools, undefined); + assertEquals(capturedRemoteSourceCount, 0); + } + } finally { + globalThis.fetch = originalFetch; + } + }); + it("exposes Veryfront API MCP tools requested through mcpServers policy", async () => { let capturedAllowedRemoteTools: string[] | undefined; let capturedRemoteToolNames: string[] = []; + let capturedToolArguments: Record | undefined; const originalFetch = globalThis.fetch; const originalApiUrl = Deno.env.get("VERYFRONT_API_URL"); const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); @@ -1330,17 +1539,38 @@ describe("server/handlers/request/agent-stream.handler", () => { new Headers(init?.headers).get("authorization"), "Bearer request-scoped-user-token", ); + const request = JSON.parse(String(init?.body)) as { + id: string; + method: string; + params?: { arguments?: Record }; + }; + if (request.method === "tools/call") { + capturedToolArguments = request.params?.arguments; + return Promise.resolve( + new Response( + JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { content: [] } }), + { headers: { "content-type": "application/json" } }, + ), + ); + } return Promise.resolve( new Response( JSON.stringify({ jsonrpc: "2.0", - id: "veryfront-platform-mcp:tools:list", + id: request.id, result: { tools: [ { name: "list_uploads", description: "List uploads", - inputSchema: { type: "object", properties: {} }, + inputSchema: { + type: "object", + properties: { + project_reference: { type: "string" }, + limit: { type: "number" }, + }, + required: ["project_reference"], + }, }, { name: "delete_upload", @@ -1378,9 +1608,15 @@ describe("server/handlers/request/agent-stream.handler", () => { & typeof runtimeAgent.config & RuntimeRemoteToolConfig; capturedAllowedRemoteTools = runtimeConfig.__vfAllowedRemoteTools; - capturedRemoteToolNames = (await runtimeConfig.__vfRemoteToolSources?.[0]?.listTools({ - projectId: "proj-1", + const platformSource = runtimeConfig.__vfRemoteToolSources?.[0]; + capturedRemoteToolNames = (await platformSource?.listTools({ + projectId: "untrusted-project", }))?.map((tool) => tool.name) ?? []; + await platformSource?.executeTool( + "list_uploads", + { project_reference: "untrusted-project", limit: 10 }, + { projectId: "untrusted-project" }, + ); callbacks?.onFinish?.({ text: "ok", messages: [], @@ -1430,6 +1666,10 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(result.response.status, 200); assertEquals(capturedAllowedRemoteTools, ["list_uploads"]); assertEquals(capturedRemoteToolNames, ["list_uploads", "delete_upload"]); + assertEquals(capturedToolArguments, { + project_reference: "proj-1", + limit: 10, + }); } finally { globalThis.fetch = originalFetch; if (originalApiUrl === undefined) Deno.env.delete("VERYFRONT_API_URL"); diff --git a/src/server/handlers/request/agent-stream.handler.ts b/src/server/handlers/request/agent-stream.handler.ts index f8f04fcd61..a2a0cdd7cc 100644 --- a/src/server/handlers/request/agent-stream.handler.ts +++ b/src/server/handlers/request/agent-stream.handler.ts @@ -3,7 +3,6 @@ import { createRemoteMCPToolSource, type RemoteToolSource, type ToolDefinition, - toolRegistry, } from "#veryfront/tool"; import { defaultChannelInvokeDeps } from "#veryfront/channels/invoke.ts"; import { type RuntimeAgentDiscoveryDeps } from "#veryfront/channels/control-plane.ts"; @@ -14,7 +13,13 @@ import { 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 { + bindRemoteToolSourceToProject, + getRequestedUnresolvedBooleanToolNames, + type RuntimeRemoteToolConfig, + VERYFRONT_API_MCP_SOURCE_ID, + VERYFRONT_STUDIO_MCP_SOURCE_ID, +} from "#veryfront/agent/runtime/mcp-server-tool-sources.ts"; import { buildStudioMcpHeaders } from "#veryfront/agent/project/live-studio-mcp-tools.ts"; import { clientAllowsStudioMcp, @@ -83,8 +88,6 @@ const defaultDeps: AgentStreamHandlerDeps = { }; const logger = serverLogger.component("agent-stream-handler"); const RUN_STREAM_PATH_REGEX = /^\/api\/control-plane\/runs\/([^/]+)\/stream$/; -const VERYFRONT_PLATFORM_REMOTE_TOOL_SOURCE_ID = "veryfront-platform-mcp"; -const VERYFRONT_STUDIO_REMOTE_TOOL_SOURCE_ID = "veryfront-studio-mcp"; const STUDIO_RUNTIME_REMOTE_TOOL_NAMES = new Set( [ "studio_suggestions", @@ -95,7 +98,6 @@ const STUDIO_RUNTIME_REMOTE_TOOL_NAMES = new Set( "studio_capture_screenshot", ] as const, ); -const LOCAL_RUNTIME_BOOLEAN_TOOL_NAMES = new Set(["bash"]); // Per-environment env var cache shared across all agent stream requests (60s TTL) const _agentEnvVarCache = new EnvironmentVariableCache( @@ -154,27 +156,6 @@ async function _resolveProductionEnvironmentId( } } -function getRequestedUnresolvedBooleanToolNames(input: { - agent: Agent; - availableToolNames?: string[]; -}): string[] { - const availableToolNames = new Set(input.availableToolNames ?? []); - const tools = input.agent.config.tools; - if (!tools || tools === true) { - return []; - } - - return Object.entries(tools) - .filter(([toolName, entry]) => - entry === true && - !toolRegistry.get(toolName) && - !availableToolNames.has(toolName) && - !LOCAL_RUNTIME_BOOLEAN_TOOL_NAMES.has(toolName) - ) - .map(([toolName]) => toolName) - .sort(); -} - function mergeAllowedRemoteTools( current: RuntimeRemoteToolConfig["__vfAllowedRemoteTools"], requestedToolNames: string[], @@ -349,14 +330,14 @@ function getVeryfrontApiMcpPolicy(agent: Agent): { function hasVeryfrontPlatformRemoteToolSource( remoteTools: RemoteToolSource[] | undefined, ): boolean { - return remoteTools?.some((source) => source.id === VERYFRONT_PLATFORM_REMOTE_TOOL_SOURCE_ID) ?? + return remoteTools?.some((source) => source.id === VERYFRONT_API_MCP_SOURCE_ID) ?? false; } function hasVeryfrontStudioRemoteToolSource( remoteTools: RemoteToolSource[] | undefined, ): boolean { - return remoteTools?.some((source) => source.id === VERYFRONT_STUDIO_REMOTE_TOOL_SOURCE_ID) ?? + return remoteTools?.some((source) => source.id === VERYFRONT_STUDIO_MCP_SOURCE_ID) ?? false; } @@ -392,17 +373,27 @@ async function withVeryfrontPlatformRemoteTools(input: { availableToolNames?: string[]; }): Promise { const veryfrontApiMcpPolicy = getVeryfrontApiMcpPolicy(input.agent); - const requestedToolNames = getRequestedUnresolvedBooleanToolNames({ - agent: input.agent, - availableToolNames: input.availableToolNames, - }).concat(veryfrontApiMcpPolicy.requestedToolNames); - if ((!veryfrontApiMcpPolicy.allowAll && requestedToolNames.length === 0) || !input.token) { + const implicitlyRequestedToolNames = input.agent.config.mcpServers === undefined + ? getRequestedUnresolvedBooleanToolNames({ + tools: input.agent.config.tools, + agentId: input.agent.id, + availableToolNames: input.availableToolNames, + }) + : []; + const requestedToolNames = implicitlyRequestedToolNames.concat( + veryfrontApiMcpPolicy.requestedToolNames, + ); + if ( + (!veryfrontApiMcpPolicy.allowAll && requestedToolNames.length === 0) || + !input.token || + !input.projectId + ) { return input.agent; } const apiUrl = resolveVeryfrontApiBaseUrlFromHostEnv(); const platformRemoteToolSource = createRemoteMCPToolSource({ - id: VERYFRONT_PLATFORM_REMOTE_TOOL_SOURCE_ID, + id: VERYFRONT_API_MCP_SOURCE_ID, endpoint: `${apiUrl}/mcp`, headers: { Authorization: `Bearer ${input.token}` }, }); @@ -418,14 +409,15 @@ async function withVeryfrontPlatformRemoteTools(input: { }); } - const platformToolNames = platformToolDefinitions - ? new Set(platformToolDefinitions.map((tool) => tool.name)) - : null; - const requestedPlatformToolNames = platformToolNames - ? (veryfrontApiMcpPolicy.allowAll ? [...platformToolNames] : requestedToolNames).filter(( + if (!platformToolDefinitions) { + return input.agent; + } + + const platformToolNames = new Set(platformToolDefinitions.map((tool) => tool.name)); + const requestedPlatformToolNames = + (veryfrontApiMcpPolicy.allowAll ? [...platformToolNames] : requestedToolNames).filter(( toolName, - ) => platformToolNames.has(toolName) && !veryfrontApiMcpPolicy.deniedToolNames.has(toolName)) - : requestedToolNames.filter((toolName) => !veryfrontApiMcpPolicy.deniedToolNames.has(toolName)); + ) => platformToolNames.has(toolName) && !veryfrontApiMcpPolicy.deniedToolNames.has(toolName)); if (requestedPlatformToolNames.length === 0) { return input.agent; } @@ -433,9 +425,10 @@ async function withVeryfrontPlatformRemoteTools(input: { const runtimeRemoteToolConfig = input.agent.config as Agent["config"] & RuntimeRemoteToolConfig; const remoteTools = runtimeRemoteToolConfig.__vfRemoteToolSources ?? []; const platformRemoteToolSources = hasVeryfrontPlatformRemoteToolSource(remoteTools) ? [] : [ - platformToolDefinitions - ? createStaticRemoteToolSource(platformRemoteToolSource, platformToolDefinitions) - : platformRemoteToolSource, + bindRemoteToolSourceToProject( + createStaticRemoteToolSource(platformRemoteToolSource, platformToolDefinitions), + input.projectId, + ), ]; const runtimeConfig: Agent["config"] & RuntimeRemoteToolConfig = { @@ -468,6 +461,7 @@ function withVeryfrontStudioRemoteTools(input: { availableToolNames: input.availableToolNames, }); if ( + input.agent.config.mcpServers !== undefined || !input.token || !studioMcpUrl || !clientAllowsStudioMcp(clientProfile) || @@ -480,7 +474,7 @@ function withVeryfrontStudioRemoteTools(input: { const remoteTools = runtimeRemoteToolConfig.__vfRemoteToolSources ?? []; const studioRemoteToolSources = hasVeryfrontStudioRemoteToolSource(remoteTools) ? [] : [ createRemoteMCPToolSource({ - id: VERYFRONT_STUDIO_REMOTE_TOOL_SOURCE_ID, + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, endpoint: studioMcpUrl, headers: () => buildStudioMcpHeaders( diff --git a/src/tool/project-scoped-remote-tools.test.ts b/src/tool/project-scoped-remote-tools.test.ts index 48ac0f7194..4902deb736 100644 --- a/src/tool/project-scoped-remote-tools.test.ts +++ b/src/tool/project-scoped-remote-tools.test.ts @@ -310,6 +310,24 @@ Deno.test("createProjectScopedRemoteToolCatalog rejects disallowed execution", a ); }); +Deno.test("createProjectScopedRemoteToolCatalog rejects tools absent from remote discovery", async () => { + const source: RemoteToolSource = { + id: "api", + async listTools() { + return [toolDefinition({ name: "list_files" })]; + }, + async executeTool() { + return { ok: true }; + }, + }; + const catalog = createProjectScopedRemoteToolCatalog({ source }); + + await assertRejectsWithMessage( + () => catalog.prepareExecution({ toolName: "delete_file", toolInput: {} }), + 'Tool "delete_file" is not advertised by remote source "api"', + ); +}); + Deno.test("createProjectScopedRemoteToolCatalog rejects missing required remote tool input", async () => { const source: RemoteToolSource = { id: "api", diff --git a/src/tool/project-scoped-remote-tools.ts b/src/tool/project-scoped-remote-tools.ts index 55fa87acf2..9fe009a4a2 100644 --- a/src/tool/project-scoped-remote-tools.ts +++ b/src/tool/project-scoped-remote-tools.ts @@ -42,7 +42,7 @@ export type ProjectScopedRemoteToolExecutionInput = { /** Public API contract for project scoped remote tool execution. */ export type ProjectScopedRemoteToolExecution = ProjectScopedRemoteToolDefinitions & { - toolDefinition: ToolDefinition | undefined; + toolDefinition: ToolDefinition; toolInput: Record; executeContext: ToolExecutionContext | undefined; }; @@ -312,6 +312,12 @@ export function createProjectScopedRemoteToolCatalog( const toolDefinition = toolDefinitions.find((definition) => definition.name === executionInput.toolName ); + if (!toolDefinition) { + throw PERMISSION_DENIED.create({ + detail: + `Tool "${executionInput.toolName}" is not advertised by remote source "${input.source.id}"`, + }); + } const toolInput = hydrateProjectScopedRemoteToolInput({ toolDefinition, activeProjectId, diff --git a/src/utils/version-constant.ts b/src/utils/version-constant.ts index 242962bde3..dfc3900b7a 100644 --- a/src/utils/version-constant.ts +++ b/src/utils/version-constant.ts @@ -1,4 +1,4 @@ // Keep in sync with deno.json version. // scripts/release.ts updates this constant during releases. /** Shared version value. */ -export const VERSION = "0.1.1105"; +export const VERSION = "0.1.1106";