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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.1105",
"version": "0.1.1106",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": {
Expand Down
22 changes: 22 additions & 0 deletions docs/guides/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -327,6 +348,7 @@ export default agent({
| `system` | `string \| () => string \| Promise<string>` | System prompt |
| `resolveRuntimeState` | `(request: RuntimeStateRequest) => ResolvedRuntimeState \| Promise<ResolvedRuntimeState \| undefined>` | Refresh system/context before later model steps in the same run |
| `tools` | `Record<string, boolean \| Tool>` | Tools the agent can use |
| `delegates` | `string[]` | Exact agent ids exposed as scoped `agent_<id>` 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 (`[]`) |
Expand Down
37 changes: 29 additions & 8 deletions docs/guides/multi-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions docs/guides/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 0 additions & 1 deletion scripts/lint/test-typecheck-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/agent/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion src/agent/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, string> = {
"Content-Type": "text/event-stream",
Expand Down Expand Up @@ -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),
};

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/agent/hosted/chat-execution-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -230,7 +231,10 @@ function createHostedChatExecutionCleanup(cleanup: () => Promise<void>): () => 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 {
Expand Down
5 changes: 3 additions & 2 deletions src/agent/hosted/chat-preparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/agent/hosted/chat-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export type HostedChatRuntimeInstructionsInput<TRuntimeAgentDefinition> = {
environmentContext?: string;
instructions: string;
skills: RuntimeSkillDefinition[];
availableToolNames?: readonly string[];
};

/** Input payload for hosted chat runtime creation preparation. */
Expand Down
37 changes: 37 additions & 0 deletions src/agent/hosted/chat-runtime-tool-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
21 changes: 21 additions & 0 deletions src/agent/hosted/child-fork-execution-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
},
Expand All @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
Loading