Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ repl_state
.kiro
uv.lock
.audio_cache
CLAUDE.md
4 changes: 3 additions & 1 deletion src/strands/experimental/steering/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
LedgerBeforeToolCall,
LedgerProvider,
)
from .core.action import Guide, Interrupt, Proceed, SteeringAction
from .core.action import Guide, Interrupt, ModelSteeringAction, Proceed, SteeringAction, ToolSteeringAction
from .core.context import SteeringContextCallback, SteeringContextProvider
from .core.handler import SteeringHandler

Expand All @@ -32,6 +32,8 @@

__all__ = [
"SteeringAction",
"ToolSteeringAction",
"ModelSteeringAction",
"Proceed",
"Guide",
"Interrupt",
Expand Down
54 changes: 37 additions & 17 deletions src/strands/experimental/steering/core/action.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
"""SteeringAction types for steering evaluation results.

Defines structured outcomes from steering handlers that determine how tool calls
Defines structured outcomes from steering handlers that determine how agent actions
should be handled. SteeringActions enable modular prompting by providing just-in-time
feedback rather than front-loading all instructions in monolithic prompts.

Flow:
SteeringHandler.steer() → SteeringAction → BeforeToolCallEvent handling
↓ ↓
Evaluate context Action type Tool execution modified
SteeringHandler.steer_*() → SteeringAction → Event handling
↓ ↓ ↓
Evaluate context Action type Execution modified

SteeringAction types:
Proceed: Tool executes immediately (no intervention needed)
Guide: Tool cancelled, agent receives contextual feedback to explore alternatives
Interrupt: Tool execution paused for human input via interrupt system
Proceed: Allow execution to continue without intervention
Guide: Provide contextual guidance to redirect the agent
Interrupt: Pause execution for human input

Extensibility:
New action types can be added to the union. Always handle the default
Expand All @@ -25,9 +25,9 @@


class Proceed(BaseModel):
"""Allow tool to execute immediately without intervention.
"""Allow execution to continue without intervention.

The tool call proceeds as planned. The reason provides context
The action proceeds as planned. The reason provides context
for logging and debugging purposes.
"""

Expand All @@ -36,30 +36,50 @@ class Proceed(BaseModel):


class Guide(BaseModel):
"""Cancel tool and provide contextual feedback for agent to explore alternatives.
"""Provide contextual guidance to redirect the agent.

The tool call is cancelled and the agent receives the reason as contextual
feedback to help them consider alternative approaches while maintaining
adaptive reasoning capabilities.
The agent receives the reason as contextual feedback to help guide
its behavior. The specific handling depends on the steering context
(e.g., tool call vs. model response).
"""

type: Literal["guide"] = "guide"
reason: str


class Interrupt(BaseModel):
"""Pause tool execution for human input via interrupt system.
"""Pause execution for human input via interrupt system.

The tool call is paused and human input is requested through Strands'
Execution is paused and human input is requested through Strands'
interrupt system. The human can approve or deny the operation, and their
decision determines whether the tool executes or is cancelled.
decision determines whether execution continues or is cancelled.
"""

type: Literal["interrupt"] = "interrupt"
reason: str


# SteeringAction union - extensible for future action types
# Context-specific steering action types
ToolSteeringAction = Annotated[Proceed | Guide | Interrupt, Field(discriminator="type")]
"""Steering actions valid for tool steering (steer_before_tool).

- Proceed: Allow tool execution to continue
- Guide: Cancel tool and provide feedback for alternative approaches
- Interrupt: Pause for human input before tool execution
"""

ModelSteeringAction = Annotated[Proceed | Guide, Field(discriminator="type")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is interrupt excluded because we cannot? Is that something that you'll want next?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If so, I wonder if we should just expose Interrupt now, throw in the case for now, and unlock the ability to do so later

Copy link
Member Author

@dbschmigelski dbschmigelski Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No Interrupt until #1165 since we can only interrupt on tool steering. I chose to break symmetry in favor of "compile" time checks rather than a runtime exception or no op.

I think there is uncertainty here. Proceed will always be present since its the no-op case. What I am not confident about is whether or not model and tool steering will diverge.

"""Steering actions valid for model steering (steer_after_model).

- Proceed: Accept model response without modification
- Guide: Discard model response and retry with guidance
"""

# Generic SteeringAction union for backward compatibility
# IMPORTANT: Always handle the default case when pattern matching
# to maintain backward compatibility as new action types are added
SteeringAction = Annotated[Proceed | Guide | Interrupt, Field(discriminator="type")]
"""Generic steering action type for backward compatibility.

Use ToolSteeringAction or ModelSteeringAction for type-safe context-specific steering.
"""
139 changes: 111 additions & 28 deletions src/strands/experimental/steering/core/handler.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
"""Steering handler base class for providing contextual guidance to agents.

Provides modular prompting through contextual guidance that appears when relevant,
rather than front-loading all instructions. Handlers integrate with the Strands hook
system to intercept tool calls and provide just-in-time feedback based on local context.
system to intercept actions and provide just-in-time feedback based on local context.

Architecture:
BeforeToolCallEvent → Context Callbacks → Update steering_context → steer() → SteeringAction
Hook triggered Populate context Handler evaluates Handler decides Action taken
Hook Event → Context Callbacks → Update steering_context → steer_*() → SteeringAction
Hook triggered Populate context Handler evaluates Handler decides Action taken

Lifecycle:
1. Context callbacks update handler's steering_context on hook events
2. BeforeToolCallEvent triggers steering evaluation via steer() method
3. Handler accesses self.steering_context for guidance decisions
4. SteeringAction determines tool execution: Proceed/Guide/Interrupt
2. BeforeToolCallEvent triggers steer_before_tool() for tool steering
3. AfterModelCallEvent triggers steer_after_model() for model steering
4. Handler accesses self.steering_context for guidance decisions
5. SteeringAction determines execution flow

Implementation:
Subclass SteeringHandler and implement steer() method.
Pass context_callbacks in constructor to register context update functions.
Subclass SteeringHandler and override steer_before_tool() and/or steer_after_model().
Both methods have default implementations that return Proceed, so you only need to
override the methods you want to customize.
Pass context_providers in constructor to register context update functions.
Each handler maintains isolated steering_context that persists across calls.

SteeringAction handling:
SteeringAction handling for steer_before_tool:
Proceed: Tool executes immediately
Guide: Tool cancelled, agent receives contextual feedback to explore alternatives
Interrupt: Tool execution paused for human input via interrupt system

SteeringAction handling for steer_after_model:
Proceed: Model response accepted without modification
Guide: Discard model response and retry (message is dropped, model is called again)
Interrupt: Model response handling paused for human input via interrupt system
"""

import logging
from abc import ABC, abstractmethod
from abc import ABC
from typing import TYPE_CHECKING, Any

from ....hooks.events import BeforeToolCallEvent
from ....hooks.events import AfterModelCallEvent, BeforeToolCallEvent
from ....hooks.registry import HookProvider, HookRegistry
from ....types.content import Message
from ....types.streaming import StopReason
from ....types.tools import ToolUse
from .action import Guide, Interrupt, Proceed, SteeringAction
from .action import Guide, Interrupt, ModelSteeringAction, Proceed, SteeringAction, ToolSteeringAction
from .context import SteeringContext, SteeringContextProvider

if TYPE_CHECKING:
Expand Down Expand Up @@ -73,24 +83,27 @@
callback.event_type, lambda event, callback=callback: callback(event, self.steering_context)
)

# Register steering guidance
registry.add_callback(BeforeToolCallEvent, self._provide_steering_guidance)
# Register tool steering guidance
registry.add_callback(BeforeToolCallEvent, self._provide_tool_steering_guidance)

# Register model steering guidance
registry.add_callback(AfterModelCallEvent, self._provide_model_steering_guidance)

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

try:
action = await self.steer(event.agent, event.tool_use)
action = await self.steer_before_tool(agent=event.agent, tool_use=event.tool_use)
except Exception as e:
logger.debug("tool_name=<%s>, error=<%s> | steering handler guidance failed", tool_name, e)
logger.debug("tool_name=<%s>, error=<%s> | tool steering handler guidance failed", tool_name, e)
return

self._handle_steering_action(action, event, tool_name)
self._handle_tool_steering_action(action, event, tool_name)

def _handle_steering_action(self, action: SteeringAction, event: BeforeToolCallEvent, tool_name: str) -> None:
"""Handle the steering action by modifying tool execution flow.
def _handle_tool_steering_action(self, action: SteeringAction, event: BeforeToolCallEvent, tool_name: str) -> None:
"""Handle the steering action for tool calls by modifying tool execution flow.

Proceed: Tool executes normally
Guide: Tool cancelled with contextual feedback for agent to consider alternatives
Expand All @@ -114,21 +127,91 @@
else:
logger.debug("tool_name=<%s> | tool call approved manually", tool_name)
else:
raise ValueError(f"Unknown steering action type: {action}")
raise ValueError(f"Unknown steering action type for tool call: {action}")

async def _provide_model_steering_guidance(self, event: AfterModelCallEvent) -> None:
"""Provide steering guidance for model response."""
logger.debug("providing model steering guidance")

# Only steer on successful model responses
if event.stop_response is None:
logger.debug("no stop response available | skipping model steering")
return

try:
action = await self.steer_after_model(
agent=event.agent, message=event.stop_response.message, stop_reason=event.stop_response.stop_reason
)
except Exception as e:
logger.debug("error=<%s> | model steering handler guidance failed", e)
return

await self._handle_model_steering_action(action, event)

async def _handle_model_steering_action(self, action: ModelSteeringAction, event: AfterModelCallEvent) -> None:
"""Handle the steering action for model responses by modifying response handling flow.

@abstractmethod
async def steer(self, agent: "Agent", tool_use: ToolUse, **kwargs: Any) -> SteeringAction:
"""Provide contextual guidance to help agent navigate complex workflows.
Proceed: Model response accepted without modification
Guide: Discard model response and retry with guidance message added to conversation
"""
if isinstance(action, Proceed):
logger.debug("model response proceeding")
elif isinstance(action, Guide):
logger.debug("model response guided (retrying): %s", action.reason)
# Set retry flag to discard current response
event.retry = True
# Add guidance message to agent's conversation so model sees it on retry
await event.agent._append_messages({"role": "user", "content": [{"text": action.reason}]})
logger.debug("added guidance message to conversation for model retry")
else:
raise ValueError(f"Unknown steering action type for model response: {action}")

async def steer_before_tool(self, *, agent: "Agent", tool_use: ToolUse, **kwargs: Any) -> ToolSteeringAction:
"""Provide contextual guidance before tool execution.

This method is called before a tool is executed, allowing the handler to:
- Proceed: Allow tool execution to continue
- Guide: Cancel tool and provide feedback for alternative approaches
- Interrupt: Pause for human input before tool execution

Args:
agent: The agent instance
tool_use: The tool use object with name and arguments
**kwargs: Additional keyword arguments for guidance evaluation

Returns:
SteeringAction indicating how to guide the agent's next action
ToolSteeringAction indicating how to guide the tool execution

Note:
Access steering context via self.steering_context
Default implementation returns Proceed (allow tool execution)
Override this method to implement custom tool steering logic
"""
return Proceed(reason="Default implementation: allowing tool execution")

async def steer_after_model(
self, *, agent: "Agent", message: Message, stop_reason: StopReason, **kwargs: Any
) -> ModelSteeringAction:
"""Provide contextual guidance after model response.

This method is called after the model generates a response, allowing the handler to:
- Proceed: Accept the model response without modification
- Guide: Discard the response and retry (message is dropped, model is called again)

Note: Interrupt is not supported for model steering as the model has already responded.

Args:
agent: The agent instance
message: The model's generated message
stop_reason: The reason the model stopped generating
**kwargs: Additional keyword arguments for guidance evaluation

Returns:
ModelSteeringAction indicating how to handle the model response

Note:
Access steering context via self.steering_context
Default implementation returns Proceed (accept response as-is)
Override this method to implement custom model steering logic
"""
...
return Proceed(reason="Default implementation: accepting model response")
8 changes: 4 additions & 4 deletions src/strands/experimental/steering/handlers/llm/llm_handler.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""LLM-based steering handler that uses an LLM to provide contextual guidance."""

from __future__ import annotations
Expand All @@ -10,7 +10,7 @@
from .....models import Model
from .....types.tools import ToolUse
from ...context_providers.ledger_provider import LedgerProvider
from ...core.action import Guide, Interrupt, Proceed, SteeringAction
from ...core.action import Guide, Interrupt, Proceed, ToolSteeringAction
from ...core.context import SteeringContextProvider
from ...core.handler import SteeringHandler
from .mappers import DefaultPromptMapper, LLMPromptMapper
Expand Down Expand Up @@ -58,7 +58,7 @@
self.prompt_mapper = prompt_mapper or DefaultPromptMapper()
self.model = model

async def steer(self, agent: "Agent", tool_use: ToolUse, **kwargs: Any) -> SteeringAction:
async def steer_before_tool(self, *, agent: "Agent", tool_use: ToolUse, **kwargs: Any) -> ToolSteeringAction:
"""Provide contextual guidance for tool usage.

Args:
Expand All @@ -67,7 +67,7 @@
**kwargs: Additional keyword arguments for steering evaluation

Returns:
SteeringAction indicating how to guide the agent's next action
SteeringAction indicating how to guide the tool execution
"""
# Generate steering prompt
prompt = self.prompt_mapper.create_steering_prompt(self.steering_context, tool_use=tool_use)
Expand All @@ -91,5 +91,5 @@
case "interrupt":
return Interrupt(reason=llm_result.reason)
case _:
logger.warning("decision=<%s> | uŹknown llm decision, defaulting to proceed", llm_result.decision) # type: ignore[unreachable]
logger.warning("decision=<%s> | unknown llm decision, defaulting to proceed", llm_result.decision) # type: ignore[unreachable]
return Proceed(reason="Unknown LLM decision, defaulting to proceed")
Loading
Loading