diff --git a/.changeset/execution-validators.md b/.changeset/execution-validators.md new file mode 100644 index 000000000..06fd0c879 --- /dev/null +++ b/.changeset/execution-validators.md @@ -0,0 +1,42 @@ +--- +"@voltagent/core": minor +"@voltagent/server-core": patch +--- + +feat(core): add pre-execution validators for tools and workflows + +Agents and workflows can now define `executionValidators` that run before tool execution or +workflow step execution. Validators can return `false` or `{ pass: false }` to block the run with +an `ExecutionValidationError`, including a custom message, code, HTTP status, and metadata. They +can run synchronously or asynchronously. + +```ts +const agent = new Agent({ + name: "Policy Controlled Assistant", + instructions: "You enforce tenant policy.", + model: "openai/gpt-4o", + tools: [updateRecordTool], + executionValidators: { + tools: [ + async ({ toolName, operationContext }) => { + const tenant = operationContext?.requestHeaders?.["x-tenant-id"]; + const allowed = await checkTenantToolAccess({ tenant, toolName }); + + if (toolName === "update_record" && !allowed) { + return { + pass: false, + message: "This tenant cannot update records.", + code: "TOOL_TENANT_DENIED", + httpStatus: 403, + }; + } + }, + ], + }, +}); +``` + +Server-core direct tool and workflow handlers now preserve `ClientHTTPError` details so blocked +executions can return the validator's status and code instead of a generic 500. + +Fixes #1213 diff --git a/packages/core/src/agent/agent.spec.ts b/packages/core/src/agent/agent.spec.ts index 4108f566c..7ffe41eff 100644 --- a/packages/core/src/agent/agent.spec.ts +++ b/packages/core/src/agent/agent.spec.ts @@ -1714,6 +1714,67 @@ Use pandas and summarize findings.`.split("\n"), operationContext.traceContext.end("completed"); }); + + it("runs execution validators before tool start hooks and execution", async () => { + const validator = vi.fn(() => ({ + pass: false as const, + message: "Destination is outside the allowed tenant", + code: "TENANT_POLICY_BLOCKED", + httpStatus: 412 as const, + })); + const onToolStart = vi.fn(); + const executeTool = vi.fn().mockResolvedValue("should-not-run"); + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + hooks: createHooks({ onToolStart }), + executionValidators: { + tools: [validator], + }, + }); + + const tool = new Tool({ + name: "send-message", + description: "Send a message", + parameters: z.object({ destination: z.string() }), + execute: executeTool, + }); + + const operationContext = (agent as any).createOperationContext("input"); + const executeFactory = (agent as any).createToolExecutionFactory( + operationContext, + agent.hooks, + ); + + const execute = executeFactory(tool); + const result = await execute({ destination: "external" }); + + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + type: "tool", + agent, + tool, + toolName: "send-message", + args: { destination: "external" }, + operationContext, + timestamp: expect.any(Date), + }), + ); + expect(onToolStart).not.toHaveBeenCalled(); + expect(executeTool).not.toHaveBeenCalled(); + expect(operationContext.abortController.signal.aborted).toBe(true); + expect(result).toMatchObject({ + error: true, + name: "ExecutionValidationError", + message: "Destination is outside the allowed tenant", + code: "TENANT_POLICY_BLOCKED", + httpStatus: 412, + toolName: "send-message", + }); + + operationContext.traceContext.end("completed"); + }); }); describe("Agent as Tool (toTool)", () => { diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index b99883d97..c3fcdb42f 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -48,6 +48,11 @@ import { validateUIMessages, } from "ai"; import { z } from "zod"; +import { + type AgentExecutionValidators, + type ToolExecutionValidationContext, + runExecutionValidators, +} from "../execution-validation"; import { LogEvents, LoggerProxy } from "../logger"; import { ActionType, buildAgentLogMessage } from "../logger/message-builder"; import { Memory } from "../memory"; @@ -97,6 +102,7 @@ import { createVoltAgentError, isBailError, isClientHTTPError, + isExecutionValidationError, isMiddlewareAbortError, isToolDeniedError, isVoltAgentError, @@ -279,6 +285,20 @@ const firstDefined = (...values: Array): T | undefined return undefined; }; +const normalizeAgentExecutionValidators = ( + validators?: AgentExecutionValidators, +): AgentExecutionValidators | undefined => { + const tools = validators?.tools?.filter((validator) => typeof validator === "function") ?? []; + return tools.length > 0 ? { tools: [...tools] } : undefined; +}; + +const mergeAgentExecutionValidators = ( + ...configs: Array +): AgentExecutionValidators | undefined => { + const tools = configs.flatMap((config) => config?.tools ?? []); + return tools.length > 0 ? { tools } : undefined; +}; + type OpenRouterUsageCost = { cost?: number; isByok?: boolean; @@ -902,6 +922,9 @@ export interface BaseGenerationOptions[]; @@ -1004,6 +1027,7 @@ export class Agent { private readonly prompts?: PromptHelper; private readonly evalConfig?: AgentEvalConfig; private readonly feedbackOptions?: AgentFeedbackOptions | boolean; + private readonly executionValidators?: AgentExecutionValidators; private readonly inputGuardrails: NormalizedInputGuardrail[]; private readonly outputGuardrails: NormalizedOutputGuardrail[]; private readonly inputMiddlewares: NormalizedInputMiddleware[]; @@ -1048,6 +1072,7 @@ export class Agent { this.voltOpsClient = options.voltOpsClient; this.evalConfig = options.eval; this.feedbackOptions = options.feedback; + this.executionValidators = normalizeAgentExecutionValidators(options.executionValidators); this.inputGuardrails = normalizeInputGuardrailList(options.inputGuardrails || []); this.outputGuardrails = normalizeOutputGuardrailList(options.outputGuardrails || []); this.inputMiddlewares = normalizeInputMiddlewareList(options.inputMiddlewares || []); @@ -1140,6 +1165,53 @@ export class Agent { // Public API Methods // ============================================================================ + async validateToolExecution({ + tool, + args, + options, + toolCallId, + messages, + operationContext, + }: { + tool: Tool | ProviderTool; + args: unknown; + options?: ToolExecuteOptions; + toolCallId?: string; + messages?: unknown[]; + operationContext?: OperationContext; + }): Promise { + const validators = options?.executionValidators?.tools ?? this.executionValidators?.tools; + if (!validators || validators.length === 0) { + return; + } + + const resolvedToolCallId = toolCallId ?? options?.toolContext?.callId ?? randomUUID(); + const resolvedOperationContext = + operationContext ?? + (options?.operationId && options.context && options.systemContext + ? (options as OperationContext) + : undefined); + const context: ToolExecutionValidationContext = { + type: "tool", + agent: this, + tool, + toolName: tool.name, + args, + options, + operationContext: resolvedOperationContext, + toolCallId: resolvedToolCallId, + messages: messages ?? options?.toolContext?.messages ?? [], + timestamp: new Date(), + }; + + await runExecutionValidators( + validators, + context, + `Tool ${tool.name} execution blocked by validation.`, + "TOOL_VALIDATION_FAILED", + ); + } + /** * Generate text response */ @@ -4071,6 +4143,11 @@ export class Agent { operationId, context, requestHeaders: options?.requestHeaders ?? options?.parentOperationContext?.requestHeaders, + executionValidators: mergeAgentExecutionValidators( + options?.parentOperationContext?.executionValidators, + this.executionValidators, + options?.executionValidators, + ), systemContext, isActive: true, logger, @@ -6351,7 +6428,7 @@ export class Agent { options: executionOptions, }); - if (isToolDeniedError(errorValue)) { + if (isToolDeniedError(errorValue) || isExecutionValidationError(errorValue)) { oc.abortController.abort(errorValue); } @@ -6367,6 +6444,14 @@ export class Agent { return async function* (this: Agent): AsyncGenerator { try { await oc.traceContext.withSpan(toolSpan, async () => { + await this.validateToolExecution({ + tool: tool as Tool, + args, + options: executionOptions, + toolCallId, + messages, + operationContext: oc, + }); await runToolStartHooks(); }); @@ -6426,6 +6511,14 @@ export class Agent { return oc.traceContext.withSpan(toolSpan, async () => { try { + await this.validateToolExecution({ + tool: tool as Tool, + args, + options: executionOptions, + toolCallId, + messages, + operationContext: oc, + }); // Call tool start hook - can throw ToolDeniedError await runToolStartHooks(); @@ -6946,6 +7039,15 @@ export class Agent { executionOptions.toolContext?.callId ?? randomUUID(), ); + await this.validateToolExecution({ + tool, + args, + options: executionOptions, + toolCallId: executionOptions.toolContext?.callId, + messages: executionOptions.toolContext?.messages ?? [], + operationContext: oc, + }); + const tools: Record = { [tool.name]: tool, }; diff --git a/packages/core/src/agent/errors/client-http-errors.ts b/packages/core/src/agent/errors/client-http-errors.ts index fd6e3d161..195e7491d 100644 --- a/packages/core/src/agent/errors/client-http-errors.ts +++ b/packages/core/src/agent/errors/client-http-errors.ts @@ -46,6 +46,12 @@ export type ToolDeniedErrorCode = | "TOOL_PLAN_REQUIRED" | "TOOL_QUOTA_EXCEEDED"; +export type ExecutionValidationErrorCode = + | "EXECUTION_VALIDATION_FAILED" + | "TOOL_VALIDATION_FAILED" + | "WORKFLOW_VALIDATION_FAILED" + | string; + /** * Error thrown when a tool execution is denied by a controller or policy layer */ @@ -72,3 +78,31 @@ export function isClientHTTPError(error: unknown): error is ClientHTTPError { export function isToolDeniedError(error: unknown): error is ToolDeniedError { return error instanceof ToolDeniedError; } + +/** + * Error thrown when an execution validator denies a tool or workflow execution. + */ +export class ExecutionValidationError extends ClientHTTPError { + readonly metadata?: Record; + + constructor({ + targetName = "ExecutionValidationError", + message, + code = "EXECUTION_VALIDATION_FAILED", + httpStatus = 403, + metadata, + }: { + targetName?: string; + message: string; + code?: ExecutionValidationErrorCode; + httpStatus?: ClientHttpErrorCode; + metadata?: Record; + }) { + super(targetName, httpStatus, code, message); + this.metadata = metadata; + } +} + +export function isExecutionValidationError(error: unknown): error is ExecutionValidationError { + return error instanceof ExecutionValidationError; +} diff --git a/packages/core/src/agent/errors/index.ts b/packages/core/src/agent/errors/index.ts index 9b5f1e9e8..fed95c255 100644 --- a/packages/core/src/agent/errors/index.ts +++ b/packages/core/src/agent/errors/index.ts @@ -5,10 +5,17 @@ export type { VoltAgentError } from "./voltagent-error"; export type { AbortError } from "./abort-error"; export type { BailError } from "./bail-error"; export type { MiddlewareAbortError, MiddlewareAbortOptions } from "./middleware-abort-error"; +export type { + ClientHttpErrorCode, + ExecutionValidationErrorCode, + ToolDeniedErrorCode, +} from "./client-http-errors"; export { ToolDeniedError, ClientHTTPError, + ExecutionValidationError, isClientHTTPError, + isExecutionValidationError, isToolDeniedError, } from "./client-http-errors"; export { createAbortError, isAbortError } from "./abort-error"; diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 55a01958f..4f8a55e99 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -32,6 +32,7 @@ import type { VoltAgentTextStreamPart } from "./subagent/types"; import type { Logger } from "@voltagent/internal"; import type { LocalScorerDefinition, SamplingPolicy } from "../eval/runtime"; +import type { AgentExecutionValidators } from "../execution-validation"; import type { MemoryOptions, MemoryStorageMetadata, WorkingMemorySummary } from "../memory/types"; import type { VoltAgentObservability } from "../observability"; import type { ModelRouterModelId } from "../registries/model-provider-types.generated"; @@ -695,6 +696,9 @@ export type AgentOptions = { // Hooks hooks?: AgentHooks; + // Execution validators + executionValidators?: AgentExecutionValidators; + // Guardrails inputGuardrails?: InputGuardrail[]; outputGuardrails?: OutputGuardrail[]; @@ -1083,6 +1087,9 @@ export interface CommonGenerateOptions { // Optional hooks to be included only during the operation call and not persisted in the agent hooks?: AgentHooks; + + // Optional execution validators to include only during this operation + executionValidators?: AgentExecutionValidators; } /** @@ -1312,6 +1319,9 @@ export type OperationContext = { /** HTTP request headers associated with this operation, when available */ readonly requestHeaders?: Record; + /** Execution validators active for this operation */ + readonly executionValidators?: AgentExecutionValidators; + /** System-managed context map for internal operation tracking */ readonly systemContext: Map; diff --git a/packages/core/src/execution-validation.ts b/packages/core/src/execution-validation.ts new file mode 100644 index 000000000..7d6fd6308 --- /dev/null +++ b/packages/core/src/execution-validation.ts @@ -0,0 +1,103 @@ +import type { Logger } from "@voltagent/internal"; +import type { Agent } from "./agent/agent"; +import { + type ClientHttpErrorCode, + ExecutionValidationError, +} from "./agent/errors/client-http-errors"; +import type { ToolExecuteOptions } from "./agent/providers/base/types"; +import type { OperationContext } from "./agent/types"; +import type { ProviderTool, Tool } from "./tool"; +import type { WorkflowRunOptions, WorkflowStateStore } from "./workflow/types"; + +export type ExecutionValidationFailure = { + pass: false; + message?: string; + code?: string; + httpStatus?: ClientHttpErrorCode; + metadata?: Record; +}; + +export type ExecutionValidationPass = { + pass: true; +}; + +export type ExecutionValidationResult = + | undefined + | boolean + | ExecutionValidationPass + | ExecutionValidationFailure; + +export type ExecutionValidator = ( + context: TContext, +) => ExecutionValidationResult | Promise; + +export interface ToolExecutionValidationContext { + type: "tool"; + agent?: Agent; + tool: Tool | ProviderTool; + toolName: string; + args: unknown; + options?: ToolExecuteOptions; + operationContext?: OperationContext; + toolCallId: string; + messages: unknown[]; + timestamp: Date; +} + +export type ToolExecutionValidator = ExecutionValidator; + +export interface AgentExecutionValidators { + tools?: ToolExecutionValidator[]; +} + +export interface WorkflowExecutionValidationContext { + type: "workflow"; + workflowId: string; + workflowName?: string; + input: unknown; + options?: WorkflowRunOptions; + executionId: string; + context: Map; + workflowState: WorkflowStateStore; + timestamp: Date; + logger?: Logger; +} + +export type WorkflowExecutionValidator = ExecutionValidator; + +const isValidationFailure = (value: unknown): value is ExecutionValidationFailure => + typeof value === "object" && + value !== null && + "pass" in value && + (value as { pass?: unknown }).pass === false; + +export async function runExecutionValidators( + validators: readonly ExecutionValidator[] | undefined, + context: TContext, + defaultFailureMessage: string, + defaultCode: string, +): Promise { + if (!validators || validators.length === 0) { + return; + } + + for (const validator of validators) { + const result = await validator(context); + + if (result === false) { + throw new ExecutionValidationError({ + message: defaultFailureMessage, + code: defaultCode, + }); + } + + if (isValidationFailure(result)) { + throw new ExecutionValidationError({ + message: result.message ?? defaultFailureMessage, + code: result.code ?? defaultCode, + httpStatus: result.httpStatus, + metadata: result.metadata, + }); + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42c239ef2..770ceaad7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -244,12 +244,31 @@ export type { export type { VoltAgentError, AbortError, + ClientHttpErrorCode, + ExecutionValidationErrorCode, MiddlewareAbortError, MiddlewareAbortOptions, + ToolDeniedErrorCode, +} from "./agent/errors"; +export { ToolDeniedError, ClientHTTPError, ExecutionValidationError } from "./agent/errors"; +export { + isAbortError, + isExecutionValidationError, + isMiddlewareAbortError, + isVoltAgentError, } from "./agent/errors"; -export { ToolDeniedError, ClientHTTPError } from "./agent/errors"; -export { isAbortError, isMiddlewareAbortError, isVoltAgentError } from "./agent/errors"; export type { AgentHooks } from "./agent/hooks"; +export type { + AgentExecutionValidators, + ExecutionValidationFailure, + ExecutionValidationPass, + ExecutionValidationResult, + ExecutionValidator, + ToolExecutionValidationContext, + ToolExecutionValidator, + WorkflowExecutionValidationContext, + WorkflowExecutionValidator, +} from "./execution-validation"; export * from "./types"; export * from "./utils"; export { zodSchemaToJsonUI } from "./utils/toolParser"; diff --git a/packages/core/src/workflow/core.spec.ts b/packages/core/src/workflow/core.spec.ts index 7cc7f5e81..662bec3bd 100644 --- a/packages/core/src/workflow/core.spec.ts +++ b/packages/core/src/workflow/core.spec.ts @@ -1,6 +1,7 @@ import { Output, type UIMessageChunk } from "ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { ExecutionValidationError } from "../agent/errors"; import { createTestAgent } from "../agent/test-utils"; import { Memory } from "../memory"; import { InMemoryStorageAdapter } from "../memory/adapters/storage/in-memory"; @@ -80,6 +81,58 @@ describe.sequential("workflow.run", () => { }); }); + it("runs execution validators before executing workflow steps", async () => { + const memory = new Memory({ storage: new InMemoryStorageAdapter() }); + const validator = vi.fn(() => ({ + pass: false as const, + message: "Workflow payload expired", + code: "PAYLOAD_EXPIRED", + httpStatus: 412 as const, + })); + const stepExecute = vi.fn(async ({ data }) => data); + + const workflow = createWorkflow( + { + id: "validated-workflow", + name: "Validated Workflow", + input: z.object({ value: z.string() }), + result: z.object({ value: z.string() }), + memory, + executionValidators: [validator], + }, + andThen({ + id: "step-1", + execute: stepExecute, + }), + ); + + let caughtError: unknown; + try { + await workflow.run({ value: "test" }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(ExecutionValidationError); + expect(caughtError).toMatchObject({ + message: "Workflow payload expired", + code: "PAYLOAD_EXPIRED", + httpStatus: 412, + }); + + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + type: "workflow", + workflowId: "validated-workflow", + workflowName: "Validated Workflow", + input: { value: "test" }, + executionId: expect.any(String), + timestamp: expect.any(Date), + }), + ); + expect(stepExecute).not.toHaveBeenCalled(); + }); + it("should persist workflowState across steps", async () => { const memory = new Memory({ storage: new InMemoryStorageAdapter() }); diff --git a/packages/core/src/workflow/core.ts b/packages/core/src/workflow/core.ts index 55c0199f6..aa6dc0398 100644 --- a/packages/core/src/workflow/core.ts +++ b/packages/core/src/workflow/core.ts @@ -2,6 +2,7 @@ import { type Logger, safeStringify } from "@voltagent/internal"; import type { DangerouslyAllowAny } from "@voltagent/internal/types"; import { z } from "zod"; import type { UsageInfo } from "../agent/providers"; +import { runExecutionValidators } from "../execution-validation"; import { LoggerProxy } from "../logger"; import { Memory as MemoryV2 } from "../memory"; import { InMemoryStorageAdapter } from "../memory/adapters/storage/in-memory"; @@ -922,6 +923,7 @@ export function createWorkflow< resumeSchema, inputGuardrails: workflowInputGuardrails, outputGuardrails: workflowOutputGuardrails, + executionValidators: workflowExecutionValidators, guardrailAgent: workflowGuardrailAgent, memory: workflowMemory, observability: workflowObservability, @@ -1217,6 +1219,28 @@ export function createWorkflow< spanId: rootSpan.spanContext().spanId, }); + const executionValidators = [ + ...(workflowExecutionValidators ?? []), + ...(options?.executionValidators ?? []), + ]; + await runExecutionValidators( + executionValidators, + { + type: "workflow", + workflowId: id, + workflowName: name, + input, + options, + executionId, + context: contextMap, + workflowState: workflowStateStore, + timestamp: new Date(), + logger: runLogger, + }, + `Workflow ${id} execution blocked by validation.`, + "WORKFLOW_VALIDATION_FAILED", + ); + // Check if resuming an existing execution if (options?.resumeFrom?.executionId && !options?.replayFrom) { runLogger.debug(`Resuming execution ${executionId} for workflow ${id}`); diff --git a/packages/core/src/workflow/types.ts b/packages/core/src/workflow/types.ts index bf61c8c73..78ada0d09 100644 --- a/packages/core/src/workflow/types.ts +++ b/packages/core/src/workflow/types.ts @@ -7,6 +7,7 @@ import type { Agent } from "../agent/agent"; import type { BaseMessage } from "../agent/providers"; import type { UsageInfo } from "../agent/providers"; import type { InputGuardrail, OutputGuardrail, UserContext } from "../agent/types"; +import type { WorkflowExecutionValidator } from "../execution-validation"; import type { Memory } from "../memory"; import type { VoltAgentObservability } from "../observability"; import type { WorkflowExecutionContext } from "./context"; @@ -374,6 +375,10 @@ export interface WorkflowRunOptions { * Output guardrails to run after workflow execution */ outputGuardrails?: OutputGuardrail[]; + /** + * Deterministic validators to run immediately before workflow execution starts + */ + executionValidators?: WorkflowExecutionValidator[]; /** * Optional agent instance to supply to workflow guardrails */ @@ -661,6 +666,10 @@ export type WorkflowConfig< * Output guardrails to run after workflow execution */ outputGuardrails?: OutputGuardrail[]; + /** + * Deterministic validators to run immediately before workflow execution starts + */ + executionValidators?: WorkflowExecutionValidator[]; /** * Optional agent instance to supply to workflow guardrails */ @@ -739,6 +748,10 @@ export type Workflow< * Output guardrails configured for this workflow */ outputGuardrails?: OutputGuardrail[]; + /** + * Execution validators configured for this workflow + */ + executionValidators?: WorkflowExecutionValidator[]; /** * Optional agent instance supplied to workflow guardrails */ diff --git a/packages/server-core/src/handlers/tool.handlers.spec.ts b/packages/server-core/src/handlers/tool.handlers.spec.ts new file mode 100644 index 000000000..eccdeb557 --- /dev/null +++ b/packages/server-core/src/handlers/tool.handlers.spec.ts @@ -0,0 +1,63 @@ +import { ClientHTTPError, Tool } from "@voltagent/core"; +import { describe, expect, it, vi } from "vitest"; +import { handleExecuteTool } from "./tool.handlers"; + +class TestExecutionValidationError extends ClientHTTPError { + constructor(message: string) { + super("ExecutionValidationError", 412, "DESTINATION_BLOCKED", message); + } +} + +describe("handleExecuteTool", () => { + const logger = { + error: vi.fn(), + } as any; + + it("runs agent execution validators before direct tool execution", async () => { + const execute = vi.fn().mockResolvedValue("sent"); + const validateToolExecution = vi + .fn() + .mockRejectedValue(new TestExecutionValidationError("Tool destination blocked")); + const tool = new Tool({ + name: "send-message", + description: "Send a message", + parameters: {} as any, + execute, + }); + const agent = { + id: "agent-1", + name: "Agent 1", + getTools: vi.fn(() => [tool]), + validateToolExecution, + }; + const deps = { + agentRegistry: { + getAllAgents: vi.fn(() => [agent]), + }, + } as any; + + const response = await handleExecuteTool( + "send-message", + { input: { destination: "external" } }, + deps, + logger, + ); + + expect(validateToolExecution).toHaveBeenCalledWith( + expect.objectContaining({ + tool, + args: { destination: "external" }, + toolCallId: expect.any(String), + messages: [], + }), + ); + expect(execute).not.toHaveBeenCalled(); + expect(response).toMatchObject({ + success: false, + error: "Tool destination blocked", + code: "DESTINATION_BLOCKED", + name: "ExecutionValidationError", + httpStatus: 412, + }); + }); +}); diff --git a/packages/server-core/src/handlers/tool.handlers.ts b/packages/server-core/src/handlers/tool.handlers.ts index 6d11407fe..57b554f10 100644 --- a/packages/server-core/src/handlers/tool.handlers.ts +++ b/packages/server-core/src/handlers/tool.handlers.ts @@ -1,4 +1,4 @@ -import { type Tool, zodSchemaToJsonUI } from "@voltagent/core"; +import { ClientHTTPError, type Tool, zodSchemaToJsonUI } from "@voltagent/core"; import type { ServerProviderDeps } from "@voltagent/core"; import { type Logger, safeStringify } from "@voltagent/internal"; import type { ApiResponse } from "../types"; @@ -20,6 +20,13 @@ type AgentWithTools = { id: string; name?: string; getTools: () => Tool[]; + validateToolExecution?: (params: { + tool: Tool; + args: unknown; + options?: Record; + toolCallId?: string; + messages?: unknown[]; + }) => Promise; }; function findTool( @@ -188,6 +195,7 @@ export async function handleExecuteTool( const executionStart = Date.now(); const abortController = new AbortController(); + const toolCallId = generateId(); try { const userId = @@ -197,7 +205,7 @@ export async function handleExecuteTool( body?.conversationId; // Build a minimal execution context for tools - const result = await tool.execute(parsedInput, { + const executionOptions = { userId, conversationId, context: contextMap, @@ -205,13 +213,23 @@ export async function handleExecuteTool( abortController, toolContext: { name: tool.name, - callId: generateId(), + callId: toolCallId, messages: [], abortSignal: abortController.signal, }, logger, + }; + + await agent.validateToolExecution?.({ + tool, + args: parsedInput, + options: executionOptions, + toolCallId, + messages: [], }); + const result = await tool.execute(parsedInput, executionOptions); + const executionTime = Date.now() - executionStart; return { @@ -230,6 +248,16 @@ export async function handleExecuteTool( error: error instanceof Error ? error.message : safeStringify(error), }); + if (error instanceof ClientHTTPError) { + return { + success: false, + error: error.message, + code: error.code, + name: error.name, + httpStatus: error.httpStatus, + }; + } + return { success: false, error: error instanceof Error ? error.message : "Unknown error", diff --git a/packages/server-core/src/handlers/workflow.handlers.spec.ts b/packages/server-core/src/handlers/workflow.handlers.spec.ts index 3c07b39b9..0486cec3c 100644 --- a/packages/server-core/src/handlers/workflow.handlers.spec.ts +++ b/packages/server-core/src/handlers/workflow.handlers.spec.ts @@ -1,6 +1,12 @@ -import type { ServerProviderDeps, WorkflowStateEntry } from "@voltagent/core"; +import { ClientHTTPError, type ServerProviderDeps, type WorkflowStateEntry } from "@voltagent/core"; import { describe, expect, it, vi } from "vitest"; -import { handleListWorkflowRuns } from "./workflow.handlers"; +import { handleExecuteWorkflow, handleListWorkflowRuns } from "./workflow.handlers"; + +class TestExecutionValidationError extends ClientHTTPError { + constructor(message: string) { + super("ExecutionValidationError", 412, "PAYLOAD_EXPIRED", message); + } +} function createWorkflowState( id: string, @@ -190,3 +196,59 @@ describe("handleListWorkflowRuns", () => { ); }); }); + +describe("handleExecuteWorkflow", () => { + const logger = { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + trace: vi.fn(), + child: vi.fn(() => logger), + } as any; + + it("maps execution validation failures from workflow execution", async () => { + const run = vi + .fn() + .mockRejectedValue(new TestExecutionValidationError("Workflow payload expired")); + const suspendController = { + signal: new AbortController().signal, + suspend: vi.fn(), + cancel: vi.fn(), + isSuspended: vi.fn(() => false), + isCancelled: vi.fn(() => false), + getReason: vi.fn(), + getCancelReason: vi.fn(), + }; + const deps = { + agentRegistry: {} as any, + workflowRegistry: { + getWorkflow: vi.fn(() => ({ + workflow: { + createSuspendController: vi.fn(() => suspendController), + run, + }, + })), + on: vi.fn(), + off: vi.fn(), + activeExecutions: new Map(), + }, + } as any; + + const response = await handleExecuteWorkflow( + "wf-1", + { input: { value: "test" } }, + deps, + logger, + ); + + expect(run).toHaveBeenCalledTimes(1); + expect(response).toMatchObject({ + success: false, + error: "Workflow payload expired", + code: "PAYLOAD_EXPIRED", + name: "ExecutionValidationError", + httpStatus: 412, + }); + }); +}); diff --git a/packages/server-core/src/handlers/workflow.handlers.ts b/packages/server-core/src/handlers/workflow.handlers.ts index b1076e968..53bf3534e 100644 --- a/packages/server-core/src/handlers/workflow.handlers.ts +++ b/packages/server-core/src/handlers/workflow.handlers.ts @@ -1,10 +1,11 @@ -import type { - ServerProviderDeps, - Workflow, - WorkflowRunQuery, - WorkflowStateEntry, +import { + ClientHTTPError, + type ServerProviderDeps, + type Workflow, + type WorkflowRunQuery, + type WorkflowStateEntry, + zodSchemaToJsonUI, } from "@voltagent/core"; -import { zodSchemaToJsonUI } from "@voltagent/core"; import type { Logger } from "@voltagent/internal"; import type { z } from "zod"; import type { WorkflowReplayRequestSchema } from "../schemas/agent.schemas"; @@ -14,6 +15,14 @@ import { formatSSE } from "../utils/sse"; const MAX_STREAM_REPLAY_HISTORY = 500; +const mapClientHTTPError = (error: ClientHTTPError): ErrorResponse => ({ + success: false, + error: error.message, + code: error.code, + name: error.name, + httpStatus: error.httpStatus, +}); + type StreamQueryValue = string | number | null | undefined; type SSEEncoder = { @@ -483,6 +492,9 @@ export async function handleExecuteWorkflow( } } catch (error) { logger.error("Failed to execute workflow", { error }); + if (error instanceof ClientHTTPError) { + return mapClientHTTPError(error); + } return { success: false, error: error instanceof Error ? error.message : "Failed to execute workflow", @@ -549,6 +561,9 @@ export async function handleStreamWorkflow( return createWorkflowSessionStream(session); } catch (error) { logger.error("Failed to initiate workflow stream", { error }); + if (error instanceof ClientHTTPError) { + return mapClientHTTPError(error); + } return { success: false, error: error instanceof Error ? error.message : "Failed to initiate workflow stream", diff --git a/website/docs/agents/tools.md b/website/docs/agents/tools.md index 17a628102..04b412f82 100644 --- a/website/docs/agents/tools.md +++ b/website/docs/agents/tools.md @@ -865,6 +865,72 @@ Allowed `code` values: - `TOOL_QUOTA_EXCEEDED` - Custom codes (e.g., `"TOOL_REGION_BLOCKED"`) +### Pre-execution Tool Validators + +Use `executionValidators.tools` when a policy must run before any tool hook or tool execution. +Validators are useful for deterministic checks such as tenant policy, region restrictions, quotas, +or request-scoped allow lists. Validators can be sync or async. Return `false` or +`{ pass: false }` to block the tool call. + +```ts +import { Agent } from "@voltagent/core"; + +const agent = new Agent({ + name: "Policy Controlled Assistant", + instructions: "You are a controlled assistant.", + model: "openai/gpt-4o", + tools: [queryTool, updateTool], + executionValidators: { + tools: [ + async ({ toolName, operationContext }) => { + const tenant = operationContext?.requestHeaders?.["x-tenant-id"]; + const allowed = await checkTenantToolAccess({ tenant, toolName }); + + if (!allowed) { + return { + pass: false, + message: "This tenant cannot update records.", + code: "TOOL_TENANT_DENIED", + httpStatus: 403, + }; + } + }, + ], + }, +}); +``` + +You can also add request-scoped validators: + +```ts +await agent.generateText("Update this record", { + executionValidators: { + tools: [ + async ({ tool, operationContext }) => { + const userId = operationContext?.userId; + const canRunDestructiveTool = await checkUserToolPermission({ + userId, + toolName: tool.name, + }); + + if (tool.tags?.includes("destructive") && !canRunDestructiveTool) { + return { + pass: false, + code: "TOOL_REQUIRES_APPROVAL", + httpStatus: 409, + }; + } + + return true; + }, + ], + }, +}); +``` + +When a validator blocks execution, VoltAgent throws an `ExecutionValidationError`. Server handlers +that surface this error preserve the validator's `message`, `code`, and `httpStatus`. + ### Multi-modal Tool Results Tools can return images and media content to the LLM using the `toModelOutput` function. This enables visual workflows where tools can provide screenshots, generated images, or other media for the LLM to analyze. diff --git a/website/docs/workflows/overview.md b/website/docs/workflows/overview.md index c7c242bda..511744e39 100644 --- a/website/docs/workflows/overview.md +++ b/website/docs/workflows/overview.md @@ -560,6 +560,55 @@ const workflow = createWorkflowChain({ Input guardrails only accept string or message inputs. For structured data, use output guardrails. If your guardrails rely on agent APIs or metadata, pass `guardrailAgent` in the workflow config or run options. +### Pre-execution Workflow Validators + +Use `executionValidators` for deterministic checks that must run before any workflow step starts. +Validators can be sync or async. Return `false` or `{ pass: false }` to block the run with an +`ExecutionValidationError`. + +```typescript +const workflow = createWorkflowChain({ + id: "tenant-report", + input: z.object({ tenantId: z.string() }), + result: z.object({ ok: z.boolean() }), + executionValidators: [ + async ({ input, context }) => { + const allowedTenants = context.get("allowedTenants"); + + if ( + Array.isArray(allowedTenants) && + typeof input === "object" && + input !== null && + "tenantId" in input && + !(await checkTenantWorkflowAccess({ + tenantId: input.tenantId, + allowedTenants, + })) + ) { + return { + pass: false, + message: "Tenant is not allowed for this workflow.", + code: "WORKFLOW_TENANT_DENIED", + httpStatus: 403, + }; + } + }, + ], +}).andThen({ + id: "finish", + execute: async () => ({ ok: true }), +}); + +await workflow.run( + { tenantId: "tenant-a" }, + { + context: new Map([["allowedTenants", ["tenant-b"]]]), + } +); +``` + +You can also pass validators per run with `workflow.run(input, { executionValidators: [...] })`. + ### Workflow History & Observability ![VoltOps Workflow Observability](https://cdn.voltagent.dev/docs/workflow-observability-demo.gif)