Skip to content

Commit 8b7f6cc

Browse files
feat(steering): allow steering on AfterModelCallEvents (strands-agents#1429)
1 parent 51cbe7b commit 8b7f6cc

11 files changed

Lines changed: 597 additions & 90 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ repl_state
1414
.kiro
1515
uv.lock
1616
.audio_cache
17+
CLAUDE.md

src/strands/experimental/steering/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- SteeringHandler: Base class for guidance logic with local context
1010
- SteeringContextCallback: Protocol for context update functions
1111
- SteeringContextProvider: Protocol for multi-event context providers
12-
- SteeringAction: Proceed/Guide/Interrupt decisions
12+
- ToolSteeringAction/ModelSteeringAction: Proceed/Guide/Interrupt decisions
1313
1414
Usage:
1515
handler = LLMSteeringHandler(system_prompt="...")
@@ -23,15 +23,16 @@
2323
LedgerBeforeToolCall,
2424
LedgerProvider,
2525
)
26-
from .core.action import Guide, Interrupt, Proceed, SteeringAction
26+
from .core.action import Guide, Interrupt, ModelSteeringAction, Proceed, ToolSteeringAction
2727
from .core.context import SteeringContextCallback, SteeringContextProvider
2828
from .core.handler import SteeringHandler
2929

3030
# Handler implementations
3131
from .handlers.llm import LLMPromptMapper, LLMSteeringHandler
3232

3333
__all__ = [
34-
"SteeringAction",
34+
"ToolSteeringAction",
35+
"ModelSteeringAction",
3536
"Proceed",
3637
"Guide",
3738
"Interrupt",
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Core steering system interfaces and base classes."""
22

3-
from .action import Guide, Interrupt, Proceed, SteeringAction
3+
from .action import Guide, Interrupt, ModelSteeringAction, Proceed, ToolSteeringAction
44
from .handler import SteeringHandler
55

6-
__all__ = ["SteeringAction", "Proceed", "Guide", "Interrupt", "SteeringHandler"]
6+
__all__ = ["ToolSteeringAction", "ModelSteeringAction", "Proceed", "Guide", "Interrupt", "SteeringHandler"]
Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
"""SteeringAction types for steering evaluation results.
22
3-
Defines structured outcomes from steering handlers that determine how tool calls
3+
Defines structured outcomes from steering handlers that determine how agent actions
44
should be handled. SteeringActions enable modular prompting by providing just-in-time
55
feedback rather than front-loading all instructions in monolithic prompts.
66
77
Flow:
8-
SteeringHandler.steer() → SteeringAction → BeforeToolCallEvent handling
9-
↓ ↓
10-
Evaluate context Action type Tool execution modified
8+
SteeringHandler.steer_*() → SteeringAction → Event handling
9+
↓ ↓ ↓
10+
Evaluate context Action type Execution modified
1111
1212
SteeringAction types:
13-
Proceed: Tool executes immediately (no intervention needed)
14-
Guide: Tool cancelled, agent receives contextual feedback to explore alternatives
15-
Interrupt: Tool execution paused for human input via interrupt system
13+
Proceed: Allow execution to continue without intervention
14+
Guide: Provide contextual guidance to redirect the agent
15+
Interrupt: Pause execution for human input
1616
1717
Extensibility:
1818
New action types can be added to the union. Always handle the default
@@ -25,9 +25,9 @@
2525

2626

2727
class Proceed(BaseModel):
28-
"""Allow tool to execute immediately without intervention.
28+
"""Allow execution to continue without intervention.
2929
30-
The tool call proceeds as planned. The reason provides context
30+
The action proceeds as planned. The reason provides context
3131
for logging and debugging purposes.
3232
"""
3333

@@ -36,30 +36,41 @@ class Proceed(BaseModel):
3636

3737

3838
class Guide(BaseModel):
39-
"""Cancel tool and provide contextual feedback for agent to explore alternatives.
39+
"""Provide contextual guidance to redirect the agent.
4040
41-
The tool call is cancelled and the agent receives the reason as contextual
42-
feedback to help them consider alternative approaches while maintaining
43-
adaptive reasoning capabilities.
41+
The agent receives the reason as contextual feedback to help guide
42+
its behavior. The specific handling depends on the steering context
43+
(e.g., tool call vs. model response).
4444
"""
4545

4646
type: Literal["guide"] = "guide"
4747
reason: str
4848

4949

5050
class Interrupt(BaseModel):
51-
"""Pause tool execution for human input via interrupt system.
51+
"""Pause execution for human input via interrupt system.
5252
53-
The tool call is paused and human input is requested through Strands'
53+
Execution is paused and human input is requested through Strands'
5454
interrupt system. The human can approve or deny the operation, and their
55-
decision determines whether the tool executes or is cancelled.
55+
decision determines whether execution continues or is cancelled.
5656
"""
5757

5858
type: Literal["interrupt"] = "interrupt"
5959
reason: str
6060

6161

62-
# SteeringAction union - extensible for future action types
63-
# IMPORTANT: Always handle the default case when pattern matching
64-
# to maintain backward compatibility as new action types are added
65-
SteeringAction = Annotated[Proceed | Guide | Interrupt, Field(discriminator="type")]
62+
# Context-specific steering action types
63+
ToolSteeringAction = Annotated[Proceed | Guide | Interrupt, Field(discriminator="type")]
64+
"""Steering actions valid for tool steering (steer_before_tool).
65+
66+
- Proceed: Allow tool execution to continue
67+
- Guide: Cancel tool and provide feedback for alternative approaches
68+
- Interrupt: Pause for human input before tool execution
69+
"""
70+
71+
ModelSteeringAction = Annotated[Proceed | Guide, Field(discriminator="type")]
72+
"""Steering actions valid for model steering (steer_after_model).
73+
74+
- Proceed: Accept model response without modification
75+
- Guide: Discard model response and retry with guidance
76+
"""

src/strands/experimental/steering/core/handler.py

Lines changed: 113 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,48 @@
22
33
Provides modular prompting through contextual guidance that appears when relevant,
44
rather than front-loading all instructions. Handlers integrate with the Strands hook
5-
system to intercept tool calls and provide just-in-time feedback based on local context.
5+
system to intercept actions and provide just-in-time feedback based on local context.
66
77
Architecture:
8-
BeforeToolCallEvent → Context Callbacks → Update steering_context → steer() → SteeringAction
9-
10-
Hook triggered Populate context Handler evaluates Handler decides Action taken
8+
Hook Event → Context Callbacks → Update steering_context → steer_*() → SteeringAction
9+
10+
Hook triggered Populate context Handler evaluates Handler decides Action taken
1111
1212
Lifecycle:
1313
1. Context callbacks update handler's steering_context on hook events
14-
2. BeforeToolCallEvent triggers steering evaluation via steer() method
15-
3. Handler accesses self.steering_context for guidance decisions
16-
4. SteeringAction determines tool execution: Proceed/Guide/Interrupt
14+
2. BeforeToolCallEvent triggers steer_before_tool() for tool steering
15+
3. AfterModelCallEvent triggers steer_after_model() for model steering
16+
4. Handler accesses self.steering_context for guidance decisions
17+
5. SteeringAction determines execution flow
1718
1819
Implementation:
19-
Subclass SteeringHandler and implement steer() method.
20-
Pass context_callbacks in constructor to register context update functions.
20+
Subclass SteeringHandler and override steer_before_tool() and/or steer_after_model().
21+
Both methods have default implementations that return Proceed, so you only need to
22+
override the methods you want to customize.
23+
Pass context_providers in constructor to register context update functions.
2124
Each handler maintains isolated steering_context that persists across calls.
2225
23-
SteeringAction handling:
26+
SteeringAction handling for steer_before_tool:
2427
Proceed: Tool executes immediately
2528
Guide: Tool cancelled, agent receives contextual feedback to explore alternatives
2629
Interrupt: Tool execution paused for human input via interrupt system
30+
31+
SteeringAction handling for steer_after_model:
32+
Proceed: Model response accepted without modification
33+
Guide: Discard model response and retry (message is dropped, model is called again)
34+
Interrupt: Model response handling paused for human input via interrupt system
2735
"""
2836

2937
import logging
30-
from abc import ABC, abstractmethod
38+
from abc import ABC
3139
from typing import TYPE_CHECKING, Any
3240

33-
from ....hooks.events import BeforeToolCallEvent
41+
from ....hooks.events import AfterModelCallEvent, BeforeToolCallEvent
3442
from ....hooks.registry import HookProvider, HookRegistry
43+
from ....types.content import Message
44+
from ....types.streaming import StopReason
3545
from ....types.tools import ToolUse
36-
from .action import Guide, Interrupt, Proceed, SteeringAction
46+
from .action import Guide, Interrupt, ModelSteeringAction, Proceed, ToolSteeringAction
3747
from .context import SteeringContext, SteeringContextProvider
3848

3949
if TYPE_CHECKING:
@@ -73,24 +83,29 @@ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
7383
callback.event_type, lambda event, callback=callback: callback(event, self.steering_context)
7484
)
7585

76-
# Register steering guidance
77-
registry.add_callback(BeforeToolCallEvent, self._provide_steering_guidance)
86+
# Register tool steering guidance
87+
registry.add_callback(BeforeToolCallEvent, self._provide_tool_steering_guidance)
88+
89+
# Register model steering guidance
90+
registry.add_callback(AfterModelCallEvent, self._provide_model_steering_guidance)
7891

79-
async def _provide_steering_guidance(self, event: BeforeToolCallEvent) -> None:
92+
async def _provide_tool_steering_guidance(self, event: BeforeToolCallEvent) -> None:
8093
"""Provide steering guidance for tool call."""
8194
tool_name = event.tool_use["name"]
82-
logger.debug("tool_name=<%s> | providing steering guidance", tool_name)
95+
logger.debug("tool_name=<%s> | providing tool steering guidance", tool_name)
8396

8497
try:
85-
action = await self.steer(event.agent, event.tool_use)
98+
action = await self.steer_before_tool(agent=event.agent, tool_use=event.tool_use)
8699
except Exception as e:
87-
logger.debug("tool_name=<%s>, error=<%s> | steering handler guidance failed", tool_name, e)
100+
logger.debug("tool_name=<%s>, error=<%s> | tool steering handler guidance failed", tool_name, e)
88101
return
89102

90-
self._handle_steering_action(action, event, tool_name)
103+
self._handle_tool_steering_action(action, event, tool_name)
91104

92-
def _handle_steering_action(self, action: SteeringAction, event: BeforeToolCallEvent, tool_name: str) -> None:
93-
"""Handle the steering action by modifying tool execution flow.
105+
def _handle_tool_steering_action(
106+
self, action: ToolSteeringAction, event: BeforeToolCallEvent, tool_name: str
107+
) -> None:
108+
"""Handle the steering action for tool calls by modifying tool execution flow.
94109
95110
Proceed: Tool executes normally
96111
Guide: Tool cancelled with contextual feedback for agent to consider alternatives
@@ -114,21 +129,91 @@ def _handle_steering_action(self, action: SteeringAction, event: BeforeToolCallE
114129
else:
115130
logger.debug("tool_name=<%s> | tool call approved manually", tool_name)
116131
else:
117-
raise ValueError(f"Unknown steering action type: {action}")
132+
raise ValueError(f"Unknown steering action type for tool call: {action}")
133+
134+
async def _provide_model_steering_guidance(self, event: AfterModelCallEvent) -> None:
135+
"""Provide steering guidance for model response."""
136+
logger.debug("providing model steering guidance")
137+
138+
# Only steer on successful model responses
139+
if event.stop_response is None:
140+
logger.debug("no stop response available | skipping model steering")
141+
return
142+
143+
try:
144+
action = await self.steer_after_model(
145+
agent=event.agent, message=event.stop_response.message, stop_reason=event.stop_response.stop_reason
146+
)
147+
except Exception as e:
148+
logger.debug("error=<%s> | model steering handler guidance failed", e)
149+
return
150+
151+
await self._handle_model_steering_action(action, event)
152+
153+
async def _handle_model_steering_action(self, action: ModelSteeringAction, event: AfterModelCallEvent) -> None:
154+
"""Handle the steering action for model responses by modifying response handling flow.
118155
119-
@abstractmethod
120-
async def steer(self, agent: "Agent", tool_use: ToolUse, **kwargs: Any) -> SteeringAction:
121-
"""Provide contextual guidance to help agent navigate complex workflows.
156+
Proceed: Model response accepted without modification
157+
Guide: Discard model response and retry with guidance message added to conversation
158+
"""
159+
if isinstance(action, Proceed):
160+
logger.debug("model response proceeding")
161+
elif isinstance(action, Guide):
162+
logger.debug("model response guided (retrying): %s", action.reason)
163+
# Set retry flag to discard current response
164+
event.retry = True
165+
# Add guidance message to agent's conversation so model sees it on retry
166+
await event.agent._append_messages({"role": "user", "content": [{"text": action.reason}]})
167+
logger.debug("added guidance message to conversation for model retry")
168+
else:
169+
raise ValueError(f"Unknown steering action type for model response: {action}")
170+
171+
async def steer_before_tool(self, *, agent: "Agent", tool_use: ToolUse, **kwargs: Any) -> ToolSteeringAction:
172+
"""Provide contextual guidance before tool execution.
173+
174+
This method is called before a tool is executed, allowing the handler to:
175+
- Proceed: Allow tool execution to continue
176+
- Guide: Cancel tool and provide feedback for alternative approaches
177+
- Interrupt: Pause for human input before tool execution
122178
123179
Args:
124180
agent: The agent instance
125181
tool_use: The tool use object with name and arguments
126182
**kwargs: Additional keyword arguments for guidance evaluation
127183
128184
Returns:
129-
SteeringAction indicating how to guide the agent's next action
185+
ToolSteeringAction indicating how to guide the tool execution
186+
187+
Note:
188+
Access steering context via self.steering_context
189+
Default implementation returns Proceed (allow tool execution)
190+
Override this method to implement custom tool steering logic
191+
"""
192+
return Proceed(reason="Default implementation: allowing tool execution")
193+
194+
async def steer_after_model(
195+
self, *, agent: "Agent", message: Message, stop_reason: StopReason, **kwargs: Any
196+
) -> ModelSteeringAction:
197+
"""Provide contextual guidance after model response.
198+
199+
This method is called after the model generates a response, allowing the handler to:
200+
- Proceed: Accept the model response without modification
201+
- Guide: Discard the response and retry (message is dropped, model is called again)
202+
203+
Note: Interrupt is not supported for model steering as the model has already responded.
204+
205+
Args:
206+
agent: The agent instance
207+
message: The model's generated message
208+
stop_reason: The reason the model stopped generating
209+
**kwargs: Additional keyword arguments for guidance evaluation
210+
211+
Returns:
212+
ModelSteeringAction indicating how to handle the model response
130213
131214
Note:
132215
Access steering context via self.steering_context
216+
Default implementation returns Proceed (accept response as-is)
217+
Override this method to implement custom model steering logic
133218
"""
134-
...
219+
return Proceed(reason="Default implementation: accepting model response")

src/strands/experimental/steering/handlers/llm/llm_handler.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .....models import Model
1111
from .....types.tools import ToolUse
1212
from ...context_providers.ledger_provider import LedgerProvider
13-
from ...core.action import Guide, Interrupt, Proceed, SteeringAction
13+
from ...core.action import Guide, Interrupt, Proceed, ToolSteeringAction
1414
from ...core.context import SteeringContextProvider
1515
from ...core.handler import SteeringHandler
1616
from .mappers import DefaultPromptMapper, LLMPromptMapper
@@ -58,7 +58,7 @@ def __init__(
5858
self.prompt_mapper = prompt_mapper or DefaultPromptMapper()
5959
self.model = model
6060

61-
async def steer(self, agent: Agent, tool_use: ToolUse, **kwargs: Any) -> SteeringAction:
61+
async def steer_before_tool(self, *, agent: Agent, tool_use: ToolUse, **kwargs: Any) -> ToolSteeringAction:
6262
"""Provide contextual guidance for tool usage.
6363
6464
Args:
@@ -67,7 +67,7 @@ async def steer(self, agent: Agent, tool_use: ToolUse, **kwargs: Any) -> Steerin
6767
**kwargs: Additional keyword arguments for steering evaluation
6868
6969
Returns:
70-
SteeringAction indicating how to guide the agent's next action
70+
SteeringAction indicating how to guide the tool execution
7171
"""
7272
# Generate steering prompt
7373
prompt = self.prompt_mapper.create_steering_prompt(self.steering_context, tool_use=tool_use)
@@ -91,5 +91,5 @@ async def steer(self, agent: Agent, tool_use: ToolUse, **kwargs: Any) -> Steerin
9191
case "interrupt":
9292
return Interrupt(reason=llm_result.reason)
9393
case _:
94-
logger.warning("decision=<%s> | uŹknown llm decision, defaulting to proceed", llm_result.decision) # type: ignore[unreachable]
94+
logger.warning("decision=<%s> | unknown llm decision, defaulting to proceed", llm_result.decision) # type: ignore[unreachable]
9595
return Proceed(reason="Unknown LLM decision, defaulting to proceed")

0 commit comments

Comments
 (0)