Skip to content

Commit 18fec56

Browse files
authored
feat: #679 Add runInParallel option to input guardrail initialization (#687)
1 parent 22865ae commit 18fec56

File tree

7 files changed

+235
-8
lines changed

7 files changed

+235
-8
lines changed

.changeset/bold-dancers-see.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openai/agents-core': patch
3+
---
4+
5+
feat: #679 Add runInParallel option to input guardrail initialization

packages/agents-core/src/agent.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,9 @@ export interface AgentConfiguration<
235235
mcpServers: MCPServer[];
236236

237237
/**
238-
* A list of checks that run in parallel to the agent's execution, before generating a response.
239-
* Runs only if the agent is the first agent in the chain.
238+
* A list of checks that run in parallel to the agent by default; set `runInParallel` to false to
239+
* block LLM/tool calls until the guardrail completes. Runs only if the agent is the first agent
240+
* in the chain.
240241
*/
241242
inputGuardrails: InputGuardrail[];
242243

packages/agents-core/src/guardrail.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export interface InputGuardrail {
6565
* The function that performs the guardrail check
6666
*/
6767
execute: InputGuardrailFunction;
68+
69+
/**
70+
* Whether the guardrail should execute alongside the agent (true, default) or block the
71+
* agent until it completes (false).
72+
*/
73+
runInParallel?: boolean;
6874
}
6975

7076
/**
@@ -105,6 +111,7 @@ export interface InputGuardrailMetadata {
105111
*/
106112
export interface InputGuardrailDefinition extends InputGuardrailMetadata {
107113
guardrailFunction: InputGuardrailFunction;
114+
runInParallel: boolean;
108115
run(args: InputGuardrailFunctionArgs): Promise<InputGuardrailResult>;
109116
}
110117

@@ -114,6 +121,7 @@ export interface InputGuardrailDefinition extends InputGuardrailMetadata {
114121
export interface DefineInputGuardrailArgs {
115122
name: string;
116123
execute: InputGuardrailFunction;
124+
runInParallel?: boolean;
117125
}
118126

119127
/**
@@ -122,10 +130,12 @@ export interface DefineInputGuardrailArgs {
122130
export function defineInputGuardrail({
123131
name,
124132
execute,
133+
runInParallel = true,
125134
}: DefineInputGuardrailArgs): InputGuardrailDefinition {
126135
return {
127136
type: 'input',
128137
name,
138+
runInParallel,
129139
guardrailFunction: execute,
130140
async run(args: InputGuardrailFunctionArgs): Promise<InputGuardrailResult> {
131141
return {

packages/agents-core/src/run.ts

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
defineOutputGuardrail,
55
InputGuardrail,
66
InputGuardrailDefinition,
7+
InputGuardrailResult,
78
OutputGuardrail,
89
OutputGuardrailDefinition,
910
OutputGuardrailFunctionArgs,
@@ -603,6 +604,34 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
603604
AgentOutputType<unknown>
604605
>[];
605606

607+
#getInputGuardrailDefinitions<
608+
TContext,
609+
TAgent extends Agent<TContext, AgentOutputType>,
610+
>(state: RunState<TContext, TAgent>): InputGuardrailDefinition[] {
611+
return this.inputGuardrailDefs.concat(
612+
state._currentAgent.inputGuardrails.map(defineInputGuardrail),
613+
);
614+
}
615+
616+
#splitInputGuardrails<
617+
TContext,
618+
TAgent extends Agent<TContext, AgentOutputType>,
619+
>(state: RunState<TContext, TAgent>) {
620+
const guardrails = this.#getInputGuardrailDefinitions(state);
621+
const blocking: InputGuardrailDefinition[] = [];
622+
const parallel: InputGuardrailDefinition[] = [];
623+
624+
for (const guardrail of guardrails) {
625+
if (guardrail.runInParallel === false) {
626+
blocking.push(guardrail);
627+
} else {
628+
parallel.push(guardrail);
629+
}
630+
}
631+
632+
return { blocking, parallel };
633+
}
634+
606635
/**
607636
* @internal
608637
* Resolves the effective model once so both run loops obey the same precedence rules.
@@ -738,8 +767,21 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
738767
`Running agent ${state._currentAgent.name} (turn ${state._currentTurn})`,
739768
);
740769

770+
let parallelGuardrailPromise:
771+
| Promise<InputGuardrailResult[]>
772+
| undefined;
741773
if (state._currentTurn === 1) {
742-
await this.#runInputGuardrails(state);
774+
const guardrails = this.#splitInputGuardrails(state);
775+
if (guardrails.blocking.length > 0) {
776+
await this.#runInputGuardrails(state, guardrails.blocking);
777+
}
778+
if (guardrails.parallel.length > 0) {
779+
parallelGuardrailPromise = this.#runInputGuardrails(
780+
state,
781+
guardrails.parallel,
782+
);
783+
parallelGuardrailPromise.catch(() => {});
784+
}
743785
}
744786

745787
const turnInput = serverConversationTracker
@@ -829,6 +871,10 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
829871
state._currentTurnPersistedItemCount = 0;
830872
}
831873
state._currentStep = turnResult.nextStep;
874+
875+
if (parallelGuardrailPromise) {
876+
await parallelGuardrailPromise;
877+
}
832878
}
833879

834880
if (
@@ -1007,8 +1053,25 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
10071053
`Running agent ${currentAgent.name} (turn ${result.state._currentTurn})`,
10081054
);
10091055

1056+
let guardrailError: unknown;
1057+
let parallelGuardrailPromise:
1058+
| Promise<InputGuardrailResult[]>
1059+
| undefined;
10101060
if (result.state._currentTurn === 1) {
1011-
await this.#runInputGuardrails(result.state);
1061+
const guardrails = this.#splitInputGuardrails(result.state);
1062+
if (guardrails.blocking.length > 0) {
1063+
await this.#runInputGuardrails(result.state, guardrails.blocking);
1064+
}
1065+
if (guardrails.parallel.length > 0) {
1066+
const promise = this.#runInputGuardrails(
1067+
result.state,
1068+
guardrails.parallel,
1069+
);
1070+
parallelGuardrailPromise = promise.catch((err) => {
1071+
guardrailError = err;
1072+
return [];
1073+
});
1074+
}
10121075
}
10131076

10141077
const turnInput = serverConversationTracker
@@ -1038,6 +1101,10 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
10381101
sessionInputUpdate,
10391102
);
10401103

1104+
if (guardrailError) {
1105+
throw guardrailError;
1106+
}
1107+
10411108
handedInputToModel = true;
10421109
await persistStreamInputIfNeeded();
10431110

@@ -1064,6 +1131,9 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
10641131
),
10651132
signal: options.signal,
10661133
})) {
1134+
if (guardrailError) {
1135+
throw guardrailError;
1136+
}
10671137
if (event.type === 'response_done') {
10681138
const parsed = StreamEventResponseCompleted.parse(event);
10691139
finalResponse = {
@@ -1080,6 +1150,13 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
10801150
result._addItem(new RunRawModelStreamEvent(event));
10811151
}
10821152

1153+
if (parallelGuardrailPromise) {
1154+
await parallelGuardrailPromise;
1155+
if (guardrailError) {
1156+
throw guardrailError;
1157+
}
1158+
}
1159+
10831160
result.state._noActiveAgentRun = false;
10841161

10851162
if (!finalResponse) {
@@ -1276,10 +1353,12 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
12761353
async #runInputGuardrails<
12771354
TContext,
12781355
TAgent extends Agent<TContext, AgentOutputType>,
1279-
>(state: RunState<TContext, TAgent>) {
1280-
const guardrails = this.inputGuardrailDefs.concat(
1281-
state._currentAgent.inputGuardrails.map(defineInputGuardrail),
1282-
);
1356+
>(
1357+
state: RunState<TContext, TAgent>,
1358+
guardrailsOverride?: InputGuardrailDefinition[],
1359+
): Promise<InputGuardrailResult[]> {
1360+
const guardrails =
1361+
guardrailsOverride ?? this.#getInputGuardrailDefinitions(state);
12831362
if (guardrails.length > 0) {
12841363
const guardrailArgs = {
12851364
agent: state._currentAgent,
@@ -1300,6 +1379,7 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
13001379
);
13011380
}),
13021381
);
1382+
state._inputGuardrailResults.push(...results);
13031383
for (const result of results) {
13041384
if (result.output.tripwireTriggered) {
13051385
if (state._currentAgentSpan) {
@@ -1315,6 +1395,7 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
13151395
);
13161396
}
13171397
}
1398+
return results;
13181399
} catch (e) {
13191400
if (e instanceof InputGuardrailTripwireTriggered) {
13201401
throw e;
@@ -1328,6 +1409,7 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
13281409
);
13291410
}
13301411
}
1412+
return [];
13311413
}
13321414

13331415
async #runOutputGuardrails<

packages/agents-core/test/guardrail.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,31 @@ describe('guardrail helpers', () => {
4444
expect(agent.outputGuardrails[0].name).toEqual('og');
4545
});
4646

47+
it('defaults input guardrails to run in parallel', () => {
48+
const guardrail = defineInputGuardrail({
49+
name: 'ig',
50+
execute: async (_args) => ({
51+
outputInfo: { ok: true },
52+
tripwireTriggered: false,
53+
}),
54+
});
55+
56+
expect(guardrail.runInParallel).toBe(true);
57+
});
58+
59+
it('uses configured runInParallel value for input guardrails', () => {
60+
const guardrail = defineInputGuardrail({
61+
name: 'blocking',
62+
execute: async (_args) => ({
63+
outputInfo: { ok: true },
64+
tripwireTriggered: false,
65+
}),
66+
runInParallel: false,
67+
});
68+
69+
expect(guardrail.runInParallel).toBe(false);
70+
});
71+
4772
it('executes input guardrail and returns expected result', async () => {
4873
const guardrailFn = vi.fn(async (_args: InputGuardrailFunctionArgs) => ({
4974
outputInfo: { ok: true },

packages/agents-core/test/run.stream.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,7 @@ describe('Runner.run (streaming)', () => {
944944

945945
const guardrail = {
946946
name: 'block',
947+
runInParallel: false,
947948
execute: vi.fn().mockResolvedValue({
948949
tripwireTriggered: true,
949950
outputInfo: { reason: 'blocked' },
@@ -973,6 +974,66 @@ describe('Runner.run (streaming)', () => {
973974

974975
expect(saveInputSpy).not.toHaveBeenCalled();
975976
});
977+
978+
it('runs blocking input guardrails before streaming starts', async () => {
979+
let guardrailFinished = false;
980+
981+
const guardrail = {
982+
name: 'blocking',
983+
runInParallel: false,
984+
execute: vi.fn(async () => {
985+
await Promise.resolve();
986+
guardrailFinished = true;
987+
return {
988+
tripwireTriggered: false,
989+
outputInfo: { ok: true },
990+
};
991+
}),
992+
};
993+
994+
class ExpectGuardrailBeforeStreamModel implements Model {
995+
getResponse(_request: ModelRequest): Promise<ModelResponse> {
996+
throw new Error('Unexpected call to getResponse');
997+
}
998+
999+
async *getStreamedResponse(
1000+
_request: ModelRequest,
1001+
): AsyncIterable<StreamEvent> {
1002+
expect(guardrailFinished).toBe(true);
1003+
yield {
1004+
type: 'response_done',
1005+
response: {
1006+
id: 'stream1',
1007+
usage: {
1008+
requests: 1,
1009+
inputTokens: 0,
1010+
outputTokens: 0,
1011+
totalTokens: 0,
1012+
},
1013+
output: [fakeModelMessage('ok')],
1014+
},
1015+
} satisfies StreamEvent;
1016+
}
1017+
}
1018+
1019+
const agent = new Agent({
1020+
name: 'BlockingStreamAgent',
1021+
model: new ExpectGuardrailBeforeStreamModel(),
1022+
inputGuardrails: [guardrail],
1023+
});
1024+
1025+
const runner = new Runner();
1026+
const result = await runner.run(agent, 'hi', { stream: true });
1027+
1028+
for await (const _ of result.toStream()) {
1029+
// consume
1030+
}
1031+
await result.completed;
1032+
1033+
expect(result.finalOutput).toBe('ok');
1034+
expect(result.inputGuardrailResults).toHaveLength(1);
1035+
expect(guardrail.execute).toHaveBeenCalledTimes(1);
1036+
});
9761037
});
9771038

9781039
class ImmediateStreamingModel implements Model {

packages/agents-core/test/run.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,49 @@ describe('Runner.run', () => {
355355
expect(guardrailFn).toHaveBeenCalledTimes(1);
356356
});
357357

358+
it('waits for blocking input guardrails before calling the model', async () => {
359+
let guardrailCompleted = false;
360+
const blockingGuardrail = {
361+
name: 'blocking-ig',
362+
runInParallel: false,
363+
execute: vi.fn(async () => {
364+
await Promise.resolve();
365+
guardrailCompleted = true;
366+
return { tripwireTriggered: false, outputInfo: {} };
367+
}),
368+
};
369+
370+
class ExpectGuardrailFirstModel implements Model {
371+
calls = 0;
372+
373+
async getResponse(_request: ModelRequest): Promise<ModelResponse> {
374+
this.calls++;
375+
expect(guardrailCompleted).toBe(true);
376+
return {
377+
output: [fakeModelMessage('done')],
378+
usage: new Usage(),
379+
};
380+
}
381+
382+
/* eslint-disable require-yield */
383+
async *getStreamedResponse(_request: ModelRequest) {
384+
throw new Error('not implemented');
385+
}
386+
/* eslint-enable require-yield */
387+
}
388+
389+
const agent = new Agent({
390+
name: 'BlockingGuard',
391+
model: new ExpectGuardrailFirstModel(),
392+
inputGuardrails: [blockingGuardrail],
393+
});
394+
395+
const result = await run(agent, 'hello');
396+
expect(result.finalOutput).toBe('done');
397+
expect(result.inputGuardrailResults).toHaveLength(1);
398+
expect(blockingGuardrail.execute).toHaveBeenCalledTimes(1);
399+
});
400+
358401
it('output guardrail success', async () => {
359402
const guardrailFn = vi.fn(async () => ({
360403
tripwireTriggered: false,

0 commit comments

Comments
 (0)