Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit c004f66

Browse files
authored
feat: add interventions primitive (#861)
1 parent 6cc46e7 commit c004f66

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

site/src/config/navigation.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ sidebar:
8686
- docs/user-guide/concepts/plugins/skills
8787
- docs/user-guide/concepts/plugins/steering
8888
- docs/user-guide/concepts/plugins/context-offloader
89+
- docs/user-guide/concepts/agents/interventions
8990
- label: Streaming
9091
items:
9192
- docs/user-guide/concepts/streaming/async-iterators
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
title: Interventions
3+
description: "A composable control layer for authorization, guardrails, and steering with typed actions, ordered evaluation, and short-circuiting."
4+
tags: [hooks, event-loop, tool-execution]
5+
languages: [typescript]
6+
---
7+
8+
Interventions are a composable control layer for agents. They provide a typed action model for common control concerns — authorization, guardrails, steering, and content transformation — with ordered evaluation and short-circuiting. Unlike raw [hooks](./hooks.mdx) and [plugins](../plugins/index.mdx) which mutate event objects directly, intervention handlers return typed decisions (`proceed`, `deny`, `guide`, `confirm`, `transform`) that the framework applies with well-defined semantics — enabling automatic short-circuiting, feedback accumulation, and conflict resolution.
9+
10+
## Basic Usage
11+
12+
Create an intervention handler by extending `InterventionHandler` and overriding the lifecycle methods you need. Register handlers via the `interventions` option in agent configuration:
13+
14+
```typescript
15+
--8<-- "user-guide/concepts/agents/interventions.ts:basic_usage"
16+
```
17+
18+
Handlers only need to override the lifecycle methods relevant to their concern — all methods default to `proceed()`.
19+
20+
## Action Types
21+
22+
Each lifecycle method returns one of five typed actions:
23+
24+
| Action | Factory | Description |
25+
|--------|---------|-------------|
26+
| Proceed | `InterventionActions.proceed()` | Allow the operation to continue unchanged |
27+
| Deny | `InterventionActions.deny(reason)` | Block the operation. Short-circuits remaining handlers |
28+
| Guide | `InterventionActions.guide(feedback)` | Cancel and provide feedback for the model to retry with |
29+
| Confirm | `InterventionActions.confirm(prompt)` | Pause for human approval |
30+
| Transform | `InterventionActions.transform(apply)` | Modify event content in-place before execution continues |
31+
32+
The following examples show each action type in a realistic handler:
33+
34+
```typescript
35+
--8<-- "user-guide/concepts/agents/interventions.ts:action_types"
36+
```
37+
38+
## Lifecycle Methods
39+
40+
Intervention handlers can override five lifecycle methods. Each method supports a specific subset of actions:
41+
42+
| Method | Valid Actions | When it Runs |
43+
|--------|-------------|--------------|
44+
| `beforeInvocation` | Proceed, Deny, Guide, Transform | Before the agent loop starts |
45+
| `beforeToolCall` | Proceed, Deny, Guide, Confirm, Transform | Before each tool execution |
46+
| `afterToolCall` | Proceed, Transform | After each tool execution |
47+
| `beforeModelCall` | Proceed, Deny, Guide, Transform | Before each model API call |
48+
| `afterModelCall` | Proceed, Guide, Transform | After each model response |
49+
50+
How actions behave depends on the lifecycle method:
51+
52+
| Action | Before events | After events |
53+
|--------|--------------|--------------|
54+
| **Deny** | Sets `event.cancel`, short-circuits remaining handlers | No effect (warns at runtime) |
55+
| **Guide** | On `beforeToolCall`/`beforeInvocation`: cancels with accumulated feedback. On `beforeModelCall`: injects feedback as user message | Injects feedback and retries |
56+
| **Confirm** | Pauses agent via interrupt/resume for human approval; denied responses set `event.cancel` | Not supported |
57+
| **Transform** | Calls `action.apply(event)` — later handlers see modified content | Calls `action.apply(event)` |
58+
59+
## Evaluation Order and Short-Circuiting
60+
61+
Handlers evaluate in **registration order**. If any handler returns `deny()`, remaining handlers are skipped — the operation is blocked immediately. This enables efficient pipelines where fast checks (like authorization) run first and prevent expensive evaluations (like LLM-based steering) from running unnecessarily.
62+
63+
```typescript
64+
--8<-- "user-guide/concepts/agents/interventions.ts:short_circuiting"
65+
```
66+
67+
For `guide()` actions, all handlers continue to run and their feedback is accumulated — the model receives combined guidance from all guiding handlers.
68+
69+
## Error Handling
70+
71+
The `onError` property controls what happens when a handler throws an exception:
72+
73+
| Value | Behavior |
74+
|-------|----------|
75+
| `'throw'` | Rethrow the error (default). The invocation fails. |
76+
| `'proceed'` | Log the error and continue as if `proceed()` was returned. |
77+
| `'deny'` | Log the error and treat it as a `deny()` (fail-closed). |
78+
79+
```typescript
80+
--8<-- "user-guide/concepts/agents/interventions.ts:error_handling"
81+
```
82+
83+
Use `'deny'` for security-critical handlers where a failure should block execution. Use `'proceed'` for non-critical handlers like logging where availability is more important than enforcement.
84+
85+
## Relationship to Hooks and Plugins
86+
87+
Interventions are built on top of the [hooks](./hooks.mdx) system — under the hood, each lifecycle method registers a hook callback. The difference is in how they communicate with the framework.
88+
89+
[Hooks](./hooks.mdx) and [plugins](../plugins/index.mdx) mutate event properties directly (e.g., setting `event.cancel = "reason"`). The framework doesn't know _why_ something was cancelled — was it a hard authorization denial or soft guidance to retry differently? Multiple plugins modifying the same event can conflict silently with last-write-wins semantics.
90+
91+
Interventions return typed actions that the framework interprets. This enables:
92+
93+
- **Short-circuiting** — a `deny()` from an authorization handler skips all remaining handlers automatically. With hooks, each plugin must independently check `event.cancel` before doing work.
94+
- **Feedback accumulation** — multiple handlers can return `guide()` and their feedback is combined into a single message to the model, rather than overwriting each other.
95+
- **Human-in-the-loop**`confirm()` integrates with the SDK's interrupt/resume system to pause for approval without the handler needing to manage interrupt lifecycle.
96+
- **Ordered evaluation** — handlers always run in registration order with well-defined precedence (deny > confirm > guide > transform > proceed).
97+
- **Error policies** — each handler declares its own failure mode via `onError`. A logging handler can use `'proceed'` (skip on failure), while an auth handler can use `'deny'` (fail closed). Hooks have no equivalent — a thrown error always propagates.
98+
99+
## Related topics
100+
101+
- [Hooks](./hooks.mdx) — Low-level event callbacks for observing and modifying agent behavior
102+
- [Plugins](../plugins/index.mdx) — Bundle related hooks and tools into reusable modules
103+
- [Interrupts](../interrupts.mdx) — The interrupt/resume system that `confirm()` builds on
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { Agent, FunctionTool, InterventionHandler, InterventionActions } from '@strands-agents/sdk'
2+
import type { OnError } from '@strands-agents/sdk'
3+
import {
4+
BeforeInvocationEvent,
5+
BeforeToolCallEvent,
6+
AfterToolCallEvent,
7+
BeforeModelCallEvent,
8+
AfterModelCallEvent,
9+
} from '@strands-agents/sdk'
10+
11+
// Mock tools for examples
12+
const searchTool = new FunctionTool({
13+
name: 'search',
14+
description: 'Search for information',
15+
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
16+
callback: async (input: unknown) => 'search results',
17+
})
18+
19+
const sendEmailTool = new FunctionTool({
20+
name: 'send_email',
21+
description: 'Send an email',
22+
inputSchema: {
23+
type: 'object',
24+
properties: {
25+
to: { type: 'string' },
26+
body: { type: 'string' },
27+
},
28+
},
29+
callback: async (input: unknown) => 'email sent',
30+
})
31+
32+
const deleteTool = new FunctionTool({
33+
name: 'delete_file',
34+
description: 'Delete a file',
35+
inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
36+
callback: async (input: unknown) => 'deleted',
37+
})
38+
39+
// =====================
40+
// Basic Usage
41+
// =====================
42+
43+
async function basicUsageExample() {
44+
// --8<-- [start:basic_usage]
45+
class ToolGuard extends InterventionHandler {
46+
readonly name = 'tool-guard'
47+
private blockedTools: string[]
48+
49+
constructor(blockedTools: string[]) {
50+
super()
51+
this.blockedTools = blockedTools
52+
}
53+
54+
override beforeToolCall(event: BeforeToolCallEvent) {
55+
if (this.blockedTools.includes(event.toolUse.name)) {
56+
return InterventionActions.deny(
57+
`Tool '${event.toolUse.name}' is not allowed in this environment`
58+
)
59+
}
60+
return InterventionActions.proceed()
61+
}
62+
}
63+
64+
const agent = new Agent({
65+
tools: [searchTool, deleteTool],
66+
interventions: [new ToolGuard(['delete_file'])],
67+
})
68+
69+
// The agent can search freely, but any attempt to call delete_file
70+
// is blocked before execution — the model sees the denial reason
71+
// and adjusts its approach
72+
await agent.invoke('Clean up the temp directory')
73+
// --8<-- [end:basic_usage]
74+
}
75+
76+
// =====================
77+
// Action Types
78+
// =====================
79+
80+
async function actionTypesExample() {
81+
// --8<-- [start:action_types]
82+
// deny — block tool calls that access production resources
83+
class EnvironmentGuard extends InterventionHandler {
84+
readonly name = 'environment-guard'
85+
86+
override beforeToolCall(event: BeforeToolCallEvent) {
87+
const input = event.toolUse.input as Record<string, string>
88+
if (input.database?.includes('prod')) {
89+
return InterventionActions.deny('Production database access is not allowed')
90+
}
91+
return InterventionActions.proceed()
92+
}
93+
}
94+
95+
// guide — steer the model when it tries to send emails without a subject
96+
class EmailValidator extends InterventionHandler {
97+
readonly name = 'email-validator'
98+
99+
override beforeToolCall(event: BeforeToolCallEvent) {
100+
if (event.toolUse.name === 'send_email') {
101+
const input = event.toolUse.input as Record<string, string>
102+
if (!input.subject) {
103+
return InterventionActions.guide('All emails must include a subject line.')
104+
}
105+
}
106+
return InterventionActions.proceed()
107+
}
108+
}
109+
110+
// confirm — require human approval before deleting files
111+
class DeleteApproval extends InterventionHandler {
112+
readonly name = 'delete-approval'
113+
114+
override beforeToolCall(event: BeforeToolCallEvent) {
115+
if (event.toolUse.name === 'delete_file') {
116+
const input = event.toolUse.input as Record<string, string>
117+
return InterventionActions.confirm(
118+
`Approve deleting "${input.path}"?`
119+
)
120+
}
121+
return InterventionActions.proceed()
122+
}
123+
}
124+
125+
// transform — redact PII from outgoing email bodies
126+
class PiiRedactor extends InterventionHandler {
127+
readonly name = 'pii-redactor'
128+
129+
override beforeToolCall(event: BeforeToolCallEvent) {
130+
if (event.toolUse.name === 'send_email') {
131+
return InterventionActions.transform((e) => {
132+
const toolEvent = e as BeforeToolCallEvent
133+
const input = toolEvent.toolUse.input as Record<string, string>
134+
input.body = input.body.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED]')
135+
})
136+
}
137+
return InterventionActions.proceed()
138+
}
139+
}
140+
// --8<-- [end:action_types]
141+
}
142+
143+
// =====================
144+
// Short-Circuiting
145+
// =====================
146+
147+
async function shortCircuitingExample() {
148+
// --8<-- [start:short_circuiting]
149+
class RateLimiter extends InterventionHandler {
150+
readonly name = 'rate-limiter'
151+
private callCount = 0
152+
153+
override beforeToolCall(event: BeforeToolCallEvent) {
154+
this.callCount++
155+
if (this.callCount > 10) {
156+
// deny() short-circuits: handlers registered after this one are skipped
157+
return InterventionActions.deny('Rate limit exceeded')
158+
}
159+
return InterventionActions.proceed()
160+
}
161+
}
162+
163+
class ToneSteeringHandler extends InterventionHandler {
164+
readonly name = 'tone-steering'
165+
166+
override afterModelCall(event: AfterModelCallEvent) {
167+
// This handler never runs for denied tool calls
168+
return InterventionActions.guide('Use a more professional tone.')
169+
}
170+
}
171+
172+
// Handlers evaluate in registration order
173+
const agent = new Agent({
174+
tools: [searchTool],
175+
interventions: [
176+
new RateLimiter(), // Evaluates first
177+
new ToneSteeringHandler(), // Skipped if RateLimiter denies
178+
],
179+
})
180+
// --8<-- [end:short_circuiting]
181+
}
182+
183+
// =====================
184+
// Error Handling
185+
// =====================
186+
187+
async function errorHandlingExample() {
188+
// --8<-- [start:error_handling]
189+
// 'proceed' — if this handler throws, continue as if proceed() was returned
190+
class BestEffortLogger extends InterventionHandler {
191+
readonly name = 'best-effort-logger'
192+
readonly onError: OnError = 'proceed'
193+
194+
override beforeToolCall(event: BeforeToolCallEvent) {
195+
// If the logging service is unreachable, the agent continues normally
196+
console.log(`Tool called: ${event.toolUse.name}`)
197+
return InterventionActions.proceed()
198+
}
199+
}
200+
201+
// 'deny' — if this handler throws, treat it as a deny (fail-closed)
202+
class StrictAuth extends InterventionHandler {
203+
readonly name = 'strict-auth'
204+
readonly onError: OnError = 'deny'
205+
206+
override beforeToolCall(event: BeforeToolCallEvent) {
207+
// If the auth service is down (throws), the operation is denied
208+
if (!this.checkPermission(event.toolUse.name)) {
209+
return InterventionActions.deny('Unauthorized')
210+
}
211+
return InterventionActions.proceed()
212+
}
213+
214+
private checkPermission(toolName: string): boolean {
215+
// ... call external auth service
216+
return true
217+
}
218+
}
219+
220+
// 'throw' (default) — errors propagate and fail the invocation
221+
class CriticalValidator extends InterventionHandler {
222+
readonly name = 'critical-validator'
223+
// onError defaults to 'throw'
224+
225+
override beforeToolCall(event: BeforeToolCallEvent) {
226+
// If this throws, the entire invocation fails
227+
return InterventionActions.proceed()
228+
}
229+
}
230+
// --8<-- [end:error_handling]
231+
}
232+
233+
// Suppress unused function warnings
234+
void basicUsageExample
235+
void actionTypesExample
236+
void shortCircuitingExample
237+
void errorHandlingExample

0 commit comments

Comments
 (0)