Skip to content

Commit 4860832

Browse files
authored
feat(core): expose request headers to dynamic agents (#1218)
1 parent fbbdc9e commit 4860832

14 files changed

Lines changed: 346 additions & 17 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@voltagent/core": patch
3+
"@voltagent/server-core": patch
4+
"@voltagent/server-hono": patch
5+
"@voltagent/server-elysia": patch
6+
"@voltagent/serverless-hono": patch
7+
---
8+
9+
feat(agent): expose request headers to dynamic agent configuration
10+
11+
Dynamic `instructions`, `model`, and `tools` functions now receive a `headers` map in
12+
`DynamicValueOptions` when an agent is called through the built-in HTTP endpoints. This makes it
13+
possible to configure tenant-aware models and request-scoped tools from headers such as
14+
`authorization`, `x-tenant-id`, or `x-user-id` without manually copying them into
15+
`options.context`.
16+
17+
Header names are normalized to lowercase:
18+
19+
```ts
20+
const agent = new Agent({
21+
name: "Tenant Agent",
22+
instructions: "You are a tenant-aware assistant.",
23+
model: ({ headers }) => {
24+
return headers?.["x-tenant-id"] === "enterprise" ? "openai/gpt-4o" : "openai/gpt-4o-mini";
25+
},
26+
tools: ({ headers }) => {
27+
return headers?.authorization ? [createTenantTool(headers.authorization)] : [];
28+
},
29+
});
30+
```
31+
32+
For direct in-process calls, pass `requestHeaders`:
33+
34+
```ts
35+
await agent.generateText("Hello", {
36+
requestHeaders: {
37+
authorization: "Bearer token",
38+
"x-tenant-id": "tenant-1",
39+
},
40+
});
41+
```
42+
43+
Fixes #1201

packages/core/src/agent/agent.spec-d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ describe("Agent Type System", () => {
227227
// Dynamic model function
228228
const dynamicModelFn: DynamicValue<AgentModelReference> = async (options) => {
229229
expectTypeOf(options.context).toMatchTypeOf<Map<string | symbol, unknown>>();
230+
expectTypeOf(options.headers).toMatchTypeOf<Record<string, string> | undefined>();
230231
return mockModel;
231232
};
232233

@@ -257,6 +258,7 @@ describe("Agent Type System", () => {
257258
// Dynamic value function
258259
const dynamicInstructions: InstructionsDynamicValue = async (options) => {
259260
expectTypeOf(options).toMatchTypeOf<{ context?: UserContext }>();
261+
expectTypeOf(options.headers).toMatchTypeOf<Record<string, string> | undefined>();
260262
return "Dynamic instructions";
261263
};
262264
expectTypeOf(dynamicInstructions).toMatchTypeOf<InstructionsDynamicValue>();
@@ -277,6 +279,7 @@ describe("Agent Type System", () => {
277279
// Dynamic model
278280
const dynamicModel: ModelDynamicValue<ModelRouterModelId> = async (options) => {
279281
expectTypeOf(options).toMatchTypeOf<{ context?: UserContext }>();
282+
expectTypeOf(options.headers).toMatchTypeOf<Record<string, string> | undefined>();
280283
return "openai/gpt-4o-mini";
281284
};
282285
expectTypeOf(dynamicModel).toMatchTypeOf<ModelDynamicValue<ModelRouterModelId>>();
@@ -311,6 +314,7 @@ describe("Agent Type System", () => {
311314
// Dynamic tools
312315
const dynamicTools: ToolsDynamicValue = async (options) => {
313316
expectTypeOf(options).toMatchTypeOf<{ context?: UserContext }>();
317+
expectTypeOf(options.headers).toMatchTypeOf<Record<string, string> | undefined>();
314318
return [tool];
315319
};
316320
expectTypeOf(dynamicTools).toMatchTypeOf<ToolsDynamicValue>();
@@ -430,6 +434,10 @@ describe("Agent Type System", () => {
430434
maxSteps: 5,
431435
signal: new AbortSignal(),
432436
context: new Map(),
437+
requestHeaders: {
438+
authorization: "Bearer token",
439+
"x-tenant-id": "tenant-1",
440+
},
433441
};
434442

435443
const legacyOptions: PublicGenerateOptions = {

packages/core/src/agent/agent.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4672,6 +4672,55 @@ Use pandas and summarize findings.`.split("\n"),
46724672
expect(systemMessage.content).toContain("Use markdown to format your answers");
46734673
});
46744674

4675+
it("should expose request headers to dynamic model, tools, and instructions", async () => {
4676+
const requestHeaders = {
4677+
authorization: "Bearer test-token",
4678+
"x-tenant-id": "tenant-1",
4679+
};
4680+
const dynamicInstructions = vi.fn().mockResolvedValue("Dynamic content");
4681+
const dynamicModel = vi.fn().mockResolvedValue(mockModel as any);
4682+
const dynamicTools = vi.fn().mockResolvedValue([]);
4683+
4684+
const agent = new Agent({
4685+
name: "TestAgent",
4686+
instructions: dynamicInstructions,
4687+
model: dynamicModel,
4688+
tools: dynamicTools,
4689+
});
4690+
4691+
vi.mocked(ai.generateText).mockResolvedValue({
4692+
text: "Response",
4693+
content: [{ type: "text", text: "Response" }],
4694+
reasoning: [],
4695+
files: [],
4696+
sources: [],
4697+
toolCalls: [],
4698+
toolResults: [],
4699+
finishReason: "stop",
4700+
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
4701+
warnings: [],
4702+
request: {},
4703+
response: {
4704+
id: "test",
4705+
modelId: "test-model",
4706+
timestamp: new Date(),
4707+
messages: [],
4708+
},
4709+
steps: [],
4710+
} as any);
4711+
4712+
await agent.generateText("Hello", {
4713+
requestHeaders,
4714+
});
4715+
4716+
expect(dynamicInstructions.mock.calls[0][0].headers).toEqual(requestHeaders);
4717+
expect(dynamicModel.mock.calls[0][0].headers).toEqual(requestHeaders);
4718+
expect(dynamicTools.mock.calls[0][0].headers).toEqual(requestHeaders);
4719+
4720+
const callArgs = vi.mocked(ai.generateText).mock.calls[0][0] as Record<string, unknown>;
4721+
expect(callArgs.requestHeaders).toBeUndefined();
4722+
});
4723+
46754724
it("should add retriever context correctly through enrichInstructions", async () => {
46764725
// Create mock retriever
46774726
const mockRetriever = {

packages/core/src/agent/agent.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,14 @@ export interface BaseGenerationOptions<TProviderOptions extends ProviderOptions
845845
*/
846846
conversationId?: string;
847847
context?: ContextInput;
848+
/**
849+
* HTTP request headers associated with this call.
850+
*
851+
* Server adapters populate this from the incoming request and expose it to
852+
* dynamic `model`, `instructions`, and `tools` callbacks as `headers`.
853+
* This is separate from AI SDK/provider `headers`.
854+
*/
855+
requestHeaders?: Record<string, string>;
848856
elicitation?: (request: unknown) => Promise<unknown>;
849857

850858
// Parent tracking
@@ -1259,6 +1267,7 @@ export class Agent {
12591267
conversationId,
12601268
memory: _memory,
12611269
context, // Explicitly exclude to prevent collision with AI SDK's future 'context' field
1270+
requestHeaders: _requestHeaders,
12621271
parentAgentId,
12631272
parentOperationContext,
12641273
hooks,
@@ -1877,6 +1886,7 @@ export class Agent {
18771886
conversationId,
18781887
memory: _memory,
18791888
context, // Explicitly exclude to prevent collision with AI SDK's future 'context' field
1889+
requestHeaders: _requestHeaders,
18801890
parentAgentId,
18811891
parentOperationContext,
18821892
hooks,
@@ -2780,6 +2790,7 @@ export class Agent {
27802790
conversationId,
27812791
memory: _memory,
27822792
context, // Explicitly exclude to prevent collision with AI SDK's future 'context' field
2793+
requestHeaders: _requestHeaders,
27832794
parentAgentId,
27842795
parentOperationContext,
27852796
hooks,
@@ -3153,6 +3164,7 @@ export class Agent {
31533164
conversationId,
31543165
memory: _memory,
31553166
context, // Explicitly exclude to prevent collision with AI SDK's future 'context' field
3167+
requestHeaders: _requestHeaders,
31563168
parentAgentId,
31573169
parentOperationContext,
31583170
hooks,
@@ -4058,6 +4070,7 @@ export class Agent {
40584070
return {
40594071
operationId,
40604072
context,
4073+
requestHeaders: options?.requestHeaders ?? options?.parentOperationContext?.requestHeaders,
40614074
systemContext,
40624075
isActive: true,
40634076
logger,
@@ -5080,6 +5093,7 @@ export class Agent {
50805093

50815094
const dynamicValueOptions: DynamicValueOptions = {
50825095
context: oc.context,
5096+
headers: oc.requestHeaders,
50835097
prompts: promptHelper,
50845098
};
50855099

@@ -5541,10 +5555,12 @@ export class Agent {
55415555
(this.prompts
55425556
? {
55435557
context: oc.context,
5558+
headers: oc.requestHeaders,
55445559
prompts: this.prompts,
55455560
}
55465561
: {
55475562
context: oc.context,
5563+
headers: oc.requestHeaders,
55485564
prompts: {
55495565
getPrompt: async () => ({ type: "text" as const, text: "" }),
55505566
},

packages/core/src/agent/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,9 @@ export interface CommonGenerateOptions {
10781078
// Optional user-defined context to be passed from a parent operation
10791079
context?: UserContext;
10801080

1081+
// HTTP request headers associated with this generation call, when available.
1082+
requestHeaders?: Record<string, string>;
1083+
10811084
// Optional hooks to be included only during the operation call and not persisted in the agent
10821085
hooks?: AgentHooks;
10831086
}
@@ -1306,6 +1309,9 @@ export type OperationContext = {
13061309
/** User-managed context map for this operation */
13071310
readonly context: Map<string | symbol, unknown>;
13081311

1312+
/** HTTP request headers associated with this operation, when available */
1313+
readonly requestHeaders?: Record<string, string>;
1314+
13091315
/** System-managed context map for internal operation tracking */
13101316
readonly systemContext: Map<string | symbol, unknown>;
13111317

packages/core/src/voltops/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export type PromptHelper = {
5656
export interface DynamicValueOptions {
5757
/** User context map */
5858
context: Map<string | symbol, unknown>;
59+
/** HTTP request headers for server-triggered agent calls, when available */
60+
headers?: Record<string, string>;
5961
/** Prompt helper (available when VoltOpsClient is configured) */
6062
prompts: PromptHelper;
6163
}

packages/server-core/src/handlers/agent.handlers.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ToolDeniedError } from "@voltagent/core";
22
import { describe, expect, it, vi } from "vitest";
3+
import { processAgentOptions } from "../utils/options";
34
import { handleChatStream, handleGenerateText } from "./agent.handlers";
45

56
describe("server-core: agent.handlers ClientHTTPError mapping", () => {
@@ -56,6 +57,71 @@ describe("server-core: agent.handlers ClientHTTPError mapping", () => {
5657
error: "Model timeout",
5758
});
5859
});
60+
61+
it("handleGenerateText should pass request headers into agent options", async () => {
62+
const logger = { error: vi.fn() } as any;
63+
64+
const mockAgent = {
65+
generateText: vi.fn(async () => ({
66+
text: "ok",
67+
usage: undefined,
68+
finishReason: "stop",
69+
toolCalls: [],
70+
toolResults: [],
71+
feedback: null,
72+
})),
73+
} as any;
74+
75+
const deps = {
76+
agentRegistry: {
77+
getAgent: vi.fn(() => mockAgent),
78+
},
79+
} as any;
80+
81+
const headers = new Headers({
82+
Authorization: "Bearer test-token",
83+
"X-Tenant-ID": "tenant-1",
84+
});
85+
86+
const res = await handleGenerateText(
87+
"agent-1",
88+
{ input: "hi" },
89+
deps,
90+
logger,
91+
undefined,
92+
headers,
93+
);
94+
95+
expect(res.success).toBe(true);
96+
expect(mockAgent.generateText).toHaveBeenCalledWith(
97+
"hi",
98+
expect.objectContaining({
99+
requestHeaders: {
100+
authorization: "Bearer test-token",
101+
"x-tenant-id": "tenant-1",
102+
},
103+
}),
104+
);
105+
});
106+
});
107+
108+
describe("server-core: processAgentOptions", () => {
109+
it("processAgentOptions should lowercase Headers entries", () => {
110+
const headers = new Headers();
111+
vi.spyOn(headers, "entries").mockReturnValue(
112+
[
113+
["Authorization", "Bearer test-token"],
114+
["X-Tenant-ID", "tenant-1"],
115+
][Symbol.iterator]() as HeadersIterator<[string, string]>,
116+
);
117+
118+
const options = processAgentOptions({ options: {} }, undefined, headers);
119+
120+
expect(options.requestHeaders).toEqual({
121+
authorization: "Bearer test-token",
122+
"x-tenant-id": "tenant-1",
123+
});
124+
});
59125
});
60126

61127
describe("server-core: agent.handlers resumable memory envelope", () => {

0 commit comments

Comments
 (0)