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
13 changes: 13 additions & 0 deletions .changeset/parallel-input-guardrails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@voltagent/core": minor
---

Add parallel input guardrails for `streamText` so async input checks can run while the model starts, buffer streamed output until they pass, and replace blocked streams without persisting generated assistant output.

UI streams produced with `toUIMessageStreamResponse()` or consumed by AI SDK `useChat` receive a `data-input-guardrail-blocked` event before the replacement assistant text, so UIs can translate the block state without string-matching the fallback message:

```tsx
const blocked =
message.role === "assistant" &&
message.parts?.some((part) => part.type === "data-input-guardrail-blocked");
```
82 changes: 82 additions & 0 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ModelProviderRegistry } from "../registries/model-provider-registry";
import { Tool } from "../tool";
import { Workspace } from "../workspace";
import { Agent, renameProviderOptions } from "./agent";
import { SPECULATIVE_INPUT_GUARDRAIL_CONTEXT_KEY } from "./context-keys";
import { ConversationBuffer } from "./conversation-buffer";
import { ToolDeniedError } from "./errors";
import { createHooks } from "./hooks";
Expand Down Expand Up @@ -1660,6 +1661,87 @@ Use pandas and summarize findings.`.split("\n"),
operationContext.traceContext.end("completed");
});

it("waits for parallel input guardrails before executing server-side tools", async () => {
let releaseGuardrail!: () => void;
const guardrailWait = new Promise<{ status: "passed" }>((resolve) => {
releaseGuardrail = () => resolve({ status: "passed" });
});
const toolExecute = vi.fn(async () => "tool output");
const agent = new Agent({
name: "TestAgent",
instructions: "Test",
model: mockModel as any,
});
const tool = new Tool({
name: "guarded-tool",
description: "Must wait for input guardrail.",
parameters: z.object({}),
execute: toolExecute,
});

const operationContext = (agent as any).createOperationContext("input");
operationContext.systemContext.set(SPECULATIVE_INPUT_GUARDRAIL_CONTEXT_KEY, {
wait: vi.fn(() => guardrailWait),
hasPassed: vi.fn(() => false),
});
const executeFactory = (agent as any).createToolExecutionFactory(
operationContext,
agent.hooks,
);

const resultPromise = executeFactory(tool)({});
await Promise.resolve();

expect(toolExecute).not.toHaveBeenCalled();
releaseGuardrail();

await expect(resultPromise).resolves.toBe("tool output");
expect(toolExecute).toHaveBeenCalledTimes(1);

operationContext.traceContext.end("completed");
});

it("does not execute server-side tools when a parallel input guardrail blocks", async () => {
const toolExecute = vi.fn(async () => "tool output");
const agent = new Agent({
name: "TestAgent",
instructions: "Test",
model: mockModel as any,
});
const tool = new Tool({
name: "blocked-tool",
description: "Must not run after input block.",
parameters: z.object({}),
execute: toolExecute,
});
const guardrailError = new Error("Input blocked by policy.");

const operationContext = (agent as any).createOperationContext("input");
operationContext.systemContext.set(SPECULATIVE_INPUT_GUARDRAIL_CONTEXT_KEY, {
wait: vi.fn(async () => ({
status: "blocked",
error: guardrailError,
message: guardrailError.message,
})),
hasPassed: vi.fn(() => false),
});
const executeFactory = (agent as any).createToolExecutionFactory(
operationContext,
agent.hooks,
);

const result = await executeFactory(tool)({});

expect(toolExecute).not.toHaveBeenCalled();
expect(result).toMatchObject({
error: true,
message: "Input blocked by policy.",
toolName: "blocked-tool",
});

operationContext.traceContext.end("completed");
});

it("supports tool-level hooks for start and end", async () => {
const toolOnStart = vi.fn();
const toolOnEnd = vi.fn().mockResolvedValue({ output: "tool-hook" });
Expand Down
Loading
Loading