Skip to content
Closed
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
5 changes: 5 additions & 0 deletions strands-py/src/strands/_middleware/stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..agent.agent import Agent
from ..experimental.bidi import BidiAgent
from ..interrupt import _InterruptState
from ..models.model import Model
from ..types._events import ModelStopReason, ToolResultEvent, TypedEvent
from ..types.content import Messages, SystemPrompt
from ..types.tools import AgentTool, ToolChoice, ToolSpec, ToolUse
Expand All @@ -25,6 +26,9 @@ class InvokeModelContext:
All collection fields (messages, system_prompt, tool_specs, tool_choice) are
defensive copies — middleware cannot accidentally mutate agent state.
invocation_state is shared by reference (hooks and tools write to it during streaming).

``model`` is the model this call invokes; it starts as ``agent.model`` and middleware
may replace it per call.
"""

agent: Agent
Expand All @@ -33,6 +37,7 @@ class InvokeModelContext:
tool_specs: list[ToolSpec]
tool_choice: ToolChoice | None
invocation_state: dict[str, Any]
model: Model

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue: The docstring here is updated nicely, but src/strands/_middleware/README.md — the reference doc for middleware authors — is now stale. Its "Defensive copies" section enumerates the context fields (messages, system_prompt, tool_specs, tool_choice) and states that model state "is excluded from the context entirely … The terminal reads it directly from the agent at invocation time." Neither the new model field nor its override capability is mentioned, so authors won't discover routing from the docs.

Suggestion: Add model to the README's field discussion and note that middleware may replace it via dataclasses.replace() to redirect a single call. (Important)

projected_input_tokens: int | None = None


Expand Down
8 changes: 4 additions & 4 deletions strands-py/src/strands/event_loop/event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ async def _handle_model_execution(
tool_specs=copy.deepcopy(tool_specs),
tool_choice=copy.deepcopy(structured_output_context.tool_choice),
invocation_state=invocation_state,
model=agent.model,
projected_input_tokens=projected_input_tokens,
)

Expand All @@ -559,8 +560,7 @@ async def _handle_model_execution(

if last_event is None:
raise RuntimeError(
"Middleware chain did not yield a result event. "
"Ensure middleware forwards events from next()."
"Middleware chain did not yield a result event. Ensure middleware forwards events from next()."
)

# Write the post-stream model state back to the agent. Skipped on error
Expand Down Expand Up @@ -663,7 +663,7 @@ def _make_invoke_model_terminal(
async def terminal(ctx: InvokeModelContext) -> AsyncGenerator[Any, None]:
system_prompt_str, system_prompt_content = split_system_prompt(ctx.system_prompt)

model_id = agent.model.config.get("model_id") if hasattr(agent.model, "config") else None
model_id = ctx.model.config.get("model_id") if hasattr(ctx.model, "config") else None
model_invoke_span = tracer.start_model_invoke_span(
messages=ctx.messages,
parent_span=cycle_span,
Expand All @@ -675,7 +675,7 @@ async def terminal(ctx: InvokeModelContext) -> AsyncGenerator[Any, None]:
with trace_api.use_span(model_invoke_span, end_on_exit=False):
try:
async for event in stream_messages(
agent.model,
ctx.model,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue / question (re-posting here since my earlier comment targeted an unchanged line): Now that the terminal streams ctx.model, note that just below this call it still streams against model_state — a snapshot of agent._model_state, which belongs to the original agent.model. When middleware overrides ctx.model (the whole point of this seam), that snapshot could be semantically wrong for the redirected model, and it's written back to agent._model_state after the chain.

This is inert today since nothing overrides the model yet, so it's not blocking this PR. But please capture it as a known gap for the ModelRouter follow-up so it isn't silently inherited: is the intent that routed models are stateless, or should model_state become per-model when routing lands? (Important — follow-up)

system_prompt_str,
ctx.messages,
ctx.tool_specs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def make_context(**overrides) -> InvokeModelContext:
tool_specs=[],
tool_choice=None,
invocation_state={},
model=Mock(),
)
defaults.update(overrides)
return InvokeModelContext(**defaults)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def invoke_ctx(messages: list[dict], agent: Any = None) -> InvokeModelContext:
tool_specs=[],
tool_choice=None,
invocation_state={},
model=MagicMock(),
)


Expand Down
1 change: 1 addition & 0 deletions strands-py/tests/strands/memory/test_memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,7 @@ def _invoke_ctx(messages: list[dict], agent: Any) -> Any:
tool_specs=[],
tool_choice=None,
invocation_state={},
model=getattr(agent, "model", None),
)


Expand Down
35 changes: 35 additions & 0 deletions strands-py/tests/strands/middleware/test_agent_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,3 +720,38 @@ async def retry_middleware(context, next_fn):
result = agent("test")
assert result.message["content"][0]["text"] == "Success!"
assert call_count == 3


# --- per-call model on context (routing plumbing) ---


def test_invoke_model_context_exposes_agent_model(agent):
"""InvokeModelContext.model defaults to agent.model."""
captured: list = []

async def capture(context, next_fn):
captured.append(context.model)
async for event in next_fn(context):
yield event

agent._middleware_registry.add_middleware(InvokeModelStage, capture)
agent("test")

assert captured == [agent.model]


def test_terminal_streams_context_model_override():
"""The terminal streams the model set on the context, not agent.model."""
model_a = MockedModelProvider([{"role": "assistant", "content": [{"text": "A"}]}])
model_b = MockedModelProvider([{"role": "assistant", "content": [{"text": "B"}]}])
agent = Agent(model=model_a, callback_handler=None)
model_a.stream = AsyncMock(wraps=model_a.stream)

def route_to_b(context):
return replace(context, model=model_b)

agent._middleware_registry.add_middleware(InvokeModelStage.Input, route_to_b)
result = agent("test")

assert result.message["content"][0]["text"] == "B"
model_a.stream.assert_not_called()
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def invoke_ctx(messages: list[dict], agent: Any) -> InvokeModelContext:
tool_specs=[],
tool_choice=None,
invocation_state={},
model=MagicMock(),
)


Expand Down
Loading