Skip to content

Commit 65799d4

Browse files
YujohnNattrassYJMastra Code (anthropic/claude-opus-4-8)
authored
Wire request context through dataset experiments (#17597)
Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) Co-authored-by: YJ <yj@example.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
1 parent dd499f6 commit 65799d4

23 files changed

Lines changed: 579 additions & 15 deletions

.changeset/heavy-flowers-count.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed per-item request context being dropped for inline experiment data. When running an experiment with inline `data`, each item's `requestContext` is now passed to the agent or workflow and merged over the global request context (per-item values win on key collisions), matching the behavior of storage-backed datasets.
6+
7+
```ts
8+
await dataset.startExperiment({
9+
data: [{ input: { prompt: 'Hello' }, requestContext: { clinicId: 'clinic-1' } }],
10+
targetType: 'agent',
11+
targetId: 'support-agent',
12+
});
13+
// Tools can now read clinicId from requestContext during inline experiments
14+
```

.changeset/silver-trees-attend.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@internal/playground': patch
3+
---
4+
5+
Wire request context into dataset experiments in Studio.
6+
7+
You can now define a dataset's `requestContextSchema` when creating or editing a dataset. You can set per-item `requestContext` values on dataset items. You can also provide run-level request context when triggering an experiment.
8+
9+
The run dialog renders a schema-driven form when the dataset declares a `requestContextSchema`, and falls back to a raw JSON editor otherwise. This lets values like `clinicId` flow from Studio through to agent/workflow experiment runs.
10+
11+
```ts
12+
// 1. Dataset declares the request context it expects
13+
const dataset = await client.createDataset({
14+
name: 'patients',
15+
requestContextSchema: { type: 'object', properties: { clinicId: { type: 'string' } } },
16+
});
17+
18+
// 2. A dataset item provides per-item request context
19+
await client.addDatasetItem({
20+
datasetId: dataset.id,
21+
input: { patientId: 'p-123' },
22+
requestContext: { clinicId: 'clinic-a' },
23+
});
24+
25+
// 3. Triggering an experiment can supply run-level request context
26+
await client.triggerDatasetExperiment({
27+
datasetId: dataset.id,
28+
targetType: 'agent',
29+
targetId: 'clinicDirectAgent',
30+
requestContext: { clinicId: 'clinic-a' },
31+
});
32+
```

examples/agent/request-context-presets.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,15 @@
3939
"advancedTools": false,
4040
"analytics": false
4141
}
42+
},
43+
"clinic-a": {
44+
"clinicId": "clinic-a",
45+
"environment": "production",
46+
"userId": "clinician-001"
47+
},
48+
"clinic-b": {
49+
"clinicId": "clinic-b",
50+
"environment": "production",
51+
"userId": "clinician-002"
4252
}
4353
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Agent } from '@mastra/core/agent';
2+
import { clinicLookupTool } from '../tools/clinic-tools';
3+
4+
/**
5+
* Clinic Context Agents
6+
*
7+
* Test agents for verifying requestContext propagation through dataset
8+
* experiments in multi-tenant clinic scenarios.
9+
*
10+
* - clinicDirectAgent: calls clinicLookupTool directly
11+
* - clinicSupervisorAgent: delegates to clinicSpecialistAgent, which calls clinicLookupTool
12+
*
13+
* The tool throws if clinicId is missing from requestContext, so experiment
14+
* items fail visibly instead of hanging silently.
15+
*/
16+
17+
export const clinicDirectAgent = new Agent({
18+
id: 'clinic-direct-agent',
19+
name: 'Clinic Direct Agent',
20+
description: 'Looks up patient records directly. Requires clinicId in requestContext.',
21+
instructions: `You are a clinic assistant that looks up patient records.
22+
Always use the clinic-lookup tool to retrieve patient information.
23+
Include the clinicId and patientId in your response so the caller can verify tenant isolation.`,
24+
model: 'openai/gpt-5.4-mini',
25+
tools: {
26+
clinicLookupTool,
27+
},
28+
});
29+
30+
export const clinicSpecialistAgent = new Agent({
31+
id: 'clinic-specialist-agent',
32+
name: 'Clinic Specialist Agent',
33+
description: 'Specialist sub-agent that looks up patient records. Requires clinicId in requestContext.',
34+
instructions: `You are a clinical specialist. When asked to look up patient data, use the clinic-lookup tool.
35+
Always include the clinicId and patientId in your response.`,
36+
model: 'openai/gpt-5.4-mini',
37+
tools: {
38+
clinicLookupTool,
39+
},
40+
});
41+
42+
export const clinicSupervisorAgent = new Agent({
43+
id: 'clinic-supervisor-agent',
44+
name: 'Clinic Supervisor Agent',
45+
description: 'Supervisor agent that delegates patient lookups to the specialist sub-agent. Tests requestContext propagation through agent delegation.',
46+
instructions: `You are a clinic supervisor. When a user asks you to look up patient data, delegate the task to the clinic-specialist-agent.
47+
Do not look up records yourself — always hand off to the specialist.
48+
Report back the specialist's response, including the clinicId they used.`,
49+
model: 'openai/gpt-5.4-mini',
50+
agents: {
51+
clinicSpecialistAgent,
52+
},
53+
});

examples/agent/src/mastra/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
} from './processors/index';
8989
import { gatewayAgent } from './agents/gateway';
9090
import { codeModeAgent } from './agents/code-mode-agent';
91+
import { clinicDirectAgent, clinicSpecialistAgent, clinicSupervisorAgent } from './agents/clinic-context-agents';
9192

9293
const libsqlStore = new LibSQLStore({
9394
id: 'mastra-storage',
@@ -132,6 +133,9 @@ export const mastra = new Mastra({
132133
cryptoResearchAgent,
133134
slackDemoAgent,
134135
codeModeAgent,
136+
clinicDirectAgent,
137+
clinicSpecialistAgent,
138+
clinicSupervisorAgent,
135139
},
136140
processors: {
137141
moderationProcessor,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createTool } from '@mastra/core/tools';
2+
import { z } from 'zod';
3+
4+
/**
5+
* Clinic-aware patient lookup tool.
6+
*
7+
* Requires `clinicId` in requestContext — the tool throws if the value is
8+
* absent so that missing-tenant failures surface immediately instead of
9+
* silently hanging during dataset experiment runs.
10+
*/
11+
export const clinicLookupTool = createTool({
12+
id: 'clinic-lookup',
13+
description: 'Looks up patient records for the current clinic tenant. Requires a clinicId in requestContext.',
14+
inputSchema: z.object({
15+
patientId: z.string().describe('The patient ID to look up'),
16+
}),
17+
requestContextSchema: z.object({
18+
clinicId: z.string(),
19+
}),
20+
execute: async ({ patientId }, { requestContext }) => {
21+
const clinicId = requestContext?.get('clinicId') as string | undefined;
22+
23+
if (!clinicId) {
24+
throw new Error(
25+
'clinicId is missing from requestContext. The tool cannot execute without tenant isolation.',
26+
);
27+
}
28+
29+
return {
30+
clinicId,
31+
patientId,
32+
tenant: clinicId,
33+
status: 'success',
34+
record: {
35+
patientId,
36+
clinicId,
37+
lastVisit: '2026-06-01',
38+
diagnosis: 'routine checkup',
39+
},
40+
};
41+
},
42+
});

packages/core/src/datasets/experiment/__tests__/runExperiment.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,32 @@ describe('runExperiment', () => {
553553
expect(result.results[0].input).toEqual({ prompt: 'from-factory' });
554554
});
555555

556+
// Test 2b — Per-item requestContext on inline data reaches agent.generate
557+
it('forwards per-item requestContext from inline data, merged over the global context', async () => {
558+
const mockAgent = createMockAgent('Response');
559+
const localMastra = {
560+
...mastra,
561+
getAgent: vi.fn().mockReturnValue(mockAgent),
562+
getAgentById: vi.fn().mockReturnValue(mockAgent),
563+
} as unknown as Mastra;
564+
565+
await runExperiment(localMastra, {
566+
datasetId,
567+
data: [{ input: { prompt: 'Hello' }, requestContext: { clinicId: 'clinic-1' } }],
568+
targetType: 'agent',
569+
targetId: 'test-agent',
570+
// Global context — per-item value should win on key collision
571+
requestContext: { clinicId: 'global-clinic', environment: 'development' },
572+
});
573+
574+
const callOptions = (mockAgent.generate as ReturnType<typeof vi.fn>).mock.calls[0][1];
575+
expect(callOptions.requestContext).toBeInstanceOf(RequestContext);
576+
expect(callOptions.requestContext.all).toEqual({
577+
clinicId: 'clinic-1',
578+
environment: 'development',
579+
});
580+
});
581+
556582
// Test 3 — Inline task function
557583
it('runs experiment with inline task function', async () => {
558584
const result = await runExperiment(mastra, {

packages/core/src/datasets/experiment/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export async function runExperiment(mastra: Mastra, config: ExperimentConfig): P
122122
datasetVersion: null,
123123
input: dataItem.input,
124124
groundTruth: dataItem.groundTruth,
125+
requestContext: dataItem.requestContext,
125126
metadata: dataItem.metadata,
126127
resumeSteps: dataItem.resumeSteps,
127128
resumeData: dataItem.resumeData,

packages/core/src/datasets/experiment/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface DataItem<I = unknown, E = unknown> {
1717
groundTruth?: E;
1818
/** Additional metadata */
1919
metadata?: Record<string, unknown>;
20+
/** Per-item request context merged over the global request context (item takes precedence) */
21+
requestContext?: Record<string, unknown>;
2022
/**
2123
* Resume data for suspended workflow steps, keyed by step ID.
2224
* When a workflow suspends during experiment execution, the executor

packages/playground/src/domains/datasets/components/add-item-dialog.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function AddItemDialog({ datasetId, open, onOpenChange, onSuccess }: AddI
6969
const [input, setInput] = useState('{}');
7070
const [groundTruth, setGroundTruth] = useState('');
7171
const [expectedTrajectory, setExpectedTrajectory] = useState('');
72+
const [requestContext, setRequestContext] = useState('');
7273
const [validationErrors, setValidationErrors] = useState<SchemaValidationError | null>(null);
7374
const { addItem } = useDatasetMutations();
7475

@@ -105,12 +106,24 @@ export function AddItemDialog({ datasetId, open, onOpenChange, onSuccess }: AddI
105106
}
106107
}
107108

109+
// Parse requestContext if provided
110+
let parsedRequestContext: Record<string, unknown> | undefined;
111+
if (requestContext.trim()) {
112+
try {
113+
parsedRequestContext = JSON.parse(requestContext);
114+
} catch {
115+
toast.error('Request Context must be valid JSON');
116+
return;
117+
}
118+
}
119+
108120
try {
109121
await addItem.mutateAsync({
110122
datasetId,
111123
input: parsedInput,
112124
groundTruth: parsedGroundTruth,
113125
expectedTrajectory: parsedTrajectory,
126+
requestContext: parsedRequestContext,
114127
});
115128

116129
toast.success('Item added successfully');
@@ -120,6 +133,7 @@ export function AddItemDialog({ datasetId, open, onOpenChange, onSuccess }: AddI
120133
setInput('{}');
121134
setGroundTruth('');
122135
setExpectedTrajectory('');
136+
setRequestContext('');
123137
onOpenChange(false);
124138

125139
onSuccess?.();
@@ -154,6 +168,7 @@ export function AddItemDialog({ datasetId, open, onOpenChange, onSuccess }: AddI
154168
setInput('{}');
155169
setGroundTruth('');
156170
setExpectedTrajectory('');
171+
setRequestContext('');
157172
setValidationErrors(null);
158173
onOpenChange(false);
159174
};
@@ -197,6 +212,16 @@ export function AddItemDialog({ datasetId, open, onOpenChange, onSuccess }: AddI
197212
/>
198213
</div>
199214

215+
<div className="space-y-2">
216+
<Label htmlFor="item-request-context">Request Context (JSON, optional)</Label>
217+
<CodeEditor
218+
value={requestContext}
219+
onChange={setRequestContext}
220+
showCopyButton={false}
221+
className="min-h-[80px]"
222+
/>
223+
</div>
224+
200225
<div className="flex justify-end gap-2 pt-4">
201226
<Button type="button" onClick={handleCancel}>
202227
Cancel

0 commit comments

Comments
 (0)