diff --git a/package-lock.json b/package-lock.json
index 4291edab61..ee5d28fd6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4542,9 +4542,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.25",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
- "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
+ "version": "4.12.32",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz",
+ "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==",
"license": "MIT",
"peer": true,
"engines": {
diff --git a/site/src/content/changelog/harness/python-v1.50.0.md b/site/src/content/changelog/harness/python-v1.50.0.md
new file mode 100644
index 0000000000..a800342a5a
--- /dev/null
+++ b/site/src/content/changelog/harness/python-v1.50.0.md
@@ -0,0 +1,32 @@
+---
+sdk: harness
+language: python
+version: "1.50.0"
+tag: python/v1.50.0
+date: 2026-07-24
+releaseUrl: https://github.com/strands-agents/harness-sdk/releases/tag/python/v1.50.0
+packageUrl: https://pypi.org/project/strands-agents/1.50.0/
+entries:
+ - { type: chore, breaking: false, scope: null, areas: [mcp, community], title: "merge strands-agents/mcp-server into monorepo", pr: 3300, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3300", commit: "7acaca8", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/7acaca8", author: zastrowm }
+ - { type: fix, breaking: false, scope: ci, areas: [community], title: "make the release pip-audit step actually run", pr: 3335, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3335", commit: "54124de", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/54124de", author: pgrayy }
+ - { type: docs, breaking: false, scope: site, areas: [], title: "replace duplicated examples guide with reference pointer", pr: 3288, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3288", commit: "41f9f59", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/41f9f59", author: yonib05 }
+ - { type: other, breaking: false, scope: typescript, areas: [community], title: "bump brace-expansion from 5.0.6 to 5.0.7", pr: 3370, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3370", commit: "1b5fddb", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/1b5fddb", author: "dependabot[bot]" }
+ - { type: refactor, breaking: false, scope: memory, areas: [], title: "refactor TestMemoryStore to use the unified storage interface", pr: 3260, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3260", commit: "41ccf5e", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/41ccf5e", author: opieter-aws }
+ - { type: feat, breaking: false, scope: middleware, areas: [hooks, tool], title: "add ExecuteToolStage with middleware-initiated interrupts", pr: 3233, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3233", commit: "50d76c3", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/50d76c3", author: zastrowm }
+ - { type: other, breaking: false, scope: typescript, areas: [community], title: "bump body-parser from 2.2.2 to 2.3.0", pr: 3387, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3387", commit: "8685e1a", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/8685e1a", author: "dependabot[bot]" }
+ - { type: fix, breaking: false, scope: openai, areas: [devx, model], title: "replay assistant text history as valid string-content input in Responses adapters", pr: 3399, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3399", commit: "6d1eda9", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/6d1eda9", author: agent-of-mkmeral }
+ - { type: other, breaking: false, scope: null, areas: [community], title: "bump actions/setup-python from 6 to 7", pr: 3352, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3352", commit: "c6e8b96", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/c6e8b96", author: "dependabot[bot]" }
+ - { type: feat, breaking: false, scope: py, areas: [devx, agent], title: "configurable retry exceptions (#1597)", pr: 3340, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3340", commit: "50d388e", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/50d388e", author: crazybolillo }
+ - { type: fix, breaking: false, scope: graph, areas: [multiagent], title: "keep fan-in node out of resume while a parallel sibling is in-flight", pr: 3390, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3390", commit: "b0ff5ef", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/b0ff5ef", author: opieter-aws }
+ - { type: other, breaking: false, scope: null, areas: [community], title: "bump astral-sh/setup-uv from 8.3.0 to 9.0.0", pr: 3407, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3407", commit: "ba297cf", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/ba297cf", author: "dependabot[bot]" }
+ - { type: feat, breaking: false, scope: bidi, areas: [bidirectional-streaming], title: "propose bidi webrtc design", pr: 3386, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3386", commit: "b7529d2", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/b7529d2", author: mehtarac }
+ - { type: other, breaking: false, scope: null, areas: [community], title: "bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1", pr: 3406, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3406", commit: "7b0717c", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/7b0717c", author: "dependabot[bot]" }
+ - { type: fix, breaking: false, scope: null, areas: [persistence], title: "reject keys for s3 storage if not configured", pr: 3411, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3411", commit: "33f5f74", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/33f5f74", author: opieter-aws }
+ - { type: fix, breaking: false, scope: null, areas: [community], title: "verify aws region", pr: 3412, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3412", commit: "81bccd8", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/81bccd8", author: opieter-aws }
+ - { type: feat, breaking: false, scope: vended-tools, areas: [tool], title: "add http_request to strands-py", pr: 3395, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3395", commit: "18484f2", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/18484f2", author: chaynabors }
+ - { type: fix, breaking: false, scope: models/llamacpp, areas: [model], title: "send llama.cpp sampler params at the top level, not under extra_body", pr: 3423, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3423", commit: "9c077c4", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/9c077c4", author: ebarkhordar }
+ - { type: feat, breaking: false, scope: vended-tools, areas: [tool, agent], title: "add stop tool", pr: 3397, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3397", commit: "767b801", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/767b801", author: chaynabors }
+ - { type: fix, breaking: false, scope: multiagent, areas: [context, multiagent], title: "preserve shared context and cumulative accounting across serialize/deserialize", pr: 3396, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3396", commit: "ba37a27", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/ba37a27", author: opieter-aws }
+ - { type: feat, breaking: false, scope: vended-tools, areas: [tool], title: "add sleep tool", pr: 3393, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3393", commit: "d9f9314", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/d9f9314", author: chaynabors }
+ - { type: fix, breaking: false, scope: python, areas: [model], title: "surface Responses stream failures", pr: 3427, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3427", commit: "e03f864", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/e03f864", author: strandly-the-agent }
+---
diff --git a/site/src/content/changelog/harness/typescript-v1.11.0.md b/site/src/content/changelog/harness/typescript-v1.11.0.md
new file mode 100644
index 0000000000..f63cda3e51
--- /dev/null
+++ b/site/src/content/changelog/harness/typescript-v1.11.0.md
@@ -0,0 +1,29 @@
+---
+sdk: harness
+language: typescript
+version: "1.11.0"
+tag: typescript/v1.11.0
+date: 2026-07-24
+releaseUrl: https://github.com/strands-agents/harness-sdk/releases/tag/typescript/v1.11.0
+packageUrl: https://www.npmjs.com/package/@strands-agents/sdk/v/1.11.0
+entries:
+ - { type: chore, breaking: false, scope: null, areas: [mcp, community], title: "merge strands-agents/mcp-server into monorepo", pr: 3300, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3300", commit: "7acaca8", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/7acaca8", author: zastrowm }
+ - { type: fix, breaking: false, scope: ci, areas: [community], title: "make the release pip-audit step actually run", pr: 3335, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3335", commit: "54124de", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/54124de", author: pgrayy }
+ - { type: refactor, breaking: false, scope: ts, areas: [devx], title: "extract shared registerNodeDefaults() to prevent src/test drift", pr: 3303, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3303", commit: "961b1b6", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/961b1b6", author: gautamsirdeshmukh }
+ - { type: fix, breaking: false, scope: vercel, areas: [model], title: "detect tool use from streamed content when finish_reason is non-tool", pr: 3206, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3206", commit: "d2fc508", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/d2fc508", author: maxmilian }
+ - { type: docs, breaking: false, scope: site, areas: [], title: "replace duplicated examples guide with reference pointer", pr: 3288, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3288", commit: "41f9f59", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/41f9f59", author: yonib05 }
+ - { type: fix, breaking: false, scope: openai, areas: [model], title: "surface Responses stream failures", pr: 3290, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3290", commit: "3c234b2", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/3c234b2", author: didhd }
+ - { type: other, breaking: false, scope: typescript, areas: [community], title: "bump brace-expansion from 5.0.6 to 5.0.7", pr: 3370, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3370", commit: "1b5fddb", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/1b5fddb", author: "dependabot[bot]" }
+ - { type: refactor, breaking: false, scope: memory, areas: [], title: "refactor TestMemoryStore to use the unified storage interface", pr: 3260, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3260", commit: "41ccf5e", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/41ccf5e", author: opieter-aws }
+ - { type: feat, breaking: false, scope: ts, areas: [language, tool], title: "add ToolExecutor class hierarchy", pr: 3268, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3268", commit: "a3f54c8", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/a3f54c8", author: gautamsirdeshmukh }
+ - { type: other, breaking: false, scope: typescript, areas: [community], title: "bump body-parser from 2.2.2 to 2.3.0", pr: 3387, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3387", commit: "8685e1a", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/8685e1a", author: "dependabot[bot]" }
+ - { type: fix, breaking: false, scope: openai, areas: [devx, model], title: "replay assistant text history as valid string-content input in Responses adapters", pr: 3399, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3399", commit: "6d1eda9", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/6d1eda9", author: agent-of-mkmeral }
+ - { type: other, breaking: false, scope: null, areas: [community], title: "bump actions/setup-python from 6 to 7", pr: 3352, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3352", commit: "c6e8b96", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/c6e8b96", author: "dependabot[bot]" }
+ - { type: other, breaking: false, scope: null, areas: [community], title: "bump astral-sh/setup-uv from 8.3.0 to 9.0.0", pr: 3407, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3407", commit: "ba297cf", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/ba297cf", author: "dependabot[bot]" }
+ - { type: feat, breaking: false, scope: bidi, areas: [bidirectional-streaming], title: "propose bidi webrtc design", pr: 3386, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3386", commit: "b7529d2", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/b7529d2", author: mehtarac }
+ - { type: other, breaking: false, scope: null, areas: [community], title: "bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1", pr: 3406, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3406", commit: "7b0717c", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/7b0717c", author: "dependabot[bot]" }
+ - { type: fix, breaking: false, scope: null, areas: [persistence], title: "reject keys for s3 storage if not configured", pr: 3411, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3411", commit: "33f5f74", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/33f5f74", author: opieter-aws }
+ - { type: fix, breaking: false, scope: null, areas: [community], title: "verify aws region", pr: 3412, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3412", commit: "81bccd8", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/81bccd8", author: opieter-aws }
+ - { type: feat, breaking: false, scope: vended-tools, areas: [tool, agent], title: "add stop tool", pr: 3397, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3397", commit: "767b801", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/767b801", author: chaynabors }
+ - { type: feat, breaking: false, scope: vended-tools, areas: [tool], title: "add sleep tool", pr: 3393, prUrl: "https://github.com/strands-agents/harness-sdk/pull/3393", commit: "d9f9314", commitUrl: "https://github.com/strands-agents/harness-sdk/commit/d9f9314", author: chaynabors }
+---
diff --git a/site/src/content/docs/user-guide/concepts/tools/vended-tools-imports.ts b/site/src/content/docs/user-guide/concepts/tools/vended-tools-imports.ts
index 4e4ed97d5e..9123a74c55 100644
--- a/site/src/content/docs/user-guide/concepts/tools/vended-tools-imports.ts
+++ b/site/src/content/docs/user-guide/concepts/tools/vended-tools-imports.ts
@@ -48,5 +48,5 @@ import { makeSleep } from '@strands-agents/sdk/vended-tools/sleep'
// --8<-- [start:stop_import]
import { Agent } from '@strands-agents/sdk'
-import { stop } from '@strands-agents/sdk/vended-tools/stop'
+import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop'
// --8<-- [end:stop_import]
diff --git a/site/src/content/docs/user-guide/concepts/tools/vended-tools.mdx b/site/src/content/docs/user-guide/concepts/tools/vended-tools.mdx
index ff775c5913..aa47bd76e5 100644
--- a/site/src/content/docs/user-guide/concepts/tools/vended-tools.mdx
+++ b/site/src/content/docs/user-guide/concepts/tools/vended-tools.mdx
@@ -11,10 +11,10 @@ sourceLinks:
- path: strands-ts/src/vended-tools/http-request/http-request.ts
- path: strands-ts/src/vended-tools/notebook/notebook.ts
- path: strands-ts/src/vended-tools/sleep/sleep.ts
- - path: strands-ts/src/vended-tools/stop/stop.ts
+ - path: strands-ts/src/experimental/vended-tools/stop/stop.ts
- path: strands-py/src/strands/vended_tools/http_request/http_request.py
- path: strands-py/src/strands/vended_tools/sleep/sleep.py
- - path: strands-py/src/strands/vended_tools/stop/stop.py
+ - path: strands-py/src/strands/experimental/tools/stop/stop.py
---
Vended tools are pre-built tools included directly in the Strands SDK for common agent tasks like file operations, shell commands, HTTP requests, and persistent notes.
@@ -40,7 +40,7 @@ Each tool is imported from its own subpath under `@strands-agents/sdk/vended-too
| [Notebook](#notebook) | Manage persistent text notebooks | TypeScript (Node.js, browsers) |
| [Bash](#bash) | Execute shell commands with persistent sessions | Python, TypeScript (Node.js, Unix/Linux/macOS) |
| [Sleep](#sleep) | Pause execution for a bounded, cancellable duration | Python, TypeScript (Node.js, browsers) |
-| [Stop](#stop) | Gracefully end the agent loop when the task is complete | Python, TypeScript (Node.js, browsers) |
+| [Stop](#stop-experimental) | Gracefully end the agent loop when the task is complete | Python, TypeScript (Node.js, browsers) |
### File Editor
@@ -269,7 +269,10 @@ agent = Agent(tools=[short_sleep])
---
-### Stop
+### Stop (Experimental)
+
+> This tool is experimental and subject to change in future
+> revisions without notice.
Lets the model gracefully end the agent loop with an optional final message. The default loop already terminates when the model returns without any tool call; the stop tool is useful when you want an explicit "I am done" affordance, when a workflow enforces that termination is a deliberate model decision, or when a sub-agent needs to signal completion back to a coordinator via the loop's last assistant message.
@@ -295,7 +298,7 @@ The two SDKs shim onto different loop-termination primitives, which produces a s
```python
from strands import Agent
-from strands.vended_tools import stop
+from strands.experimental.tools import stop
agent = Agent(
tools=[stop],
@@ -307,7 +310,7 @@ result = agent("Summarize the changes in ./CHANGELOG.md")
-📖 [Full API Reference](https://github.com/strands-agents/harness-sdk/blob/main/strands-ts/src/vended-tools/stop/README.md)
+📖 [Full API Reference](https://github.com/strands-agents/harness-sdk/blob/main/strands-ts/src/experimental/vended-tools/stop/README.md)
---
diff --git a/site/src/content/docs/user-guide/concepts/tools/vended-tools.ts b/site/src/content/docs/user-guide/concepts/tools/vended-tools.ts
index 2ea13d5e2c..48890ce531 100644
--- a/site/src/content/docs/user-guide/concepts/tools/vended-tools.ts
+++ b/site/src/content/docs/user-guide/concepts/tools/vended-tools.ts
@@ -7,7 +7,7 @@ import { notebook } from '@strands-agents/sdk/vended-tools/notebook'
// --8<-- [end:basic_import]
import { SessionManager, FileStorage } from '@strands-agents/sdk'
import { sleep, makeSleep } from '@strands-agents/sdk/vended-tools/sleep'
-import { stop } from '@strands-agents/sdk/vended-tools/stop'
+import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop'
// Agent with vended tools example
async function agentWithVendedToolsExample() {
diff --git a/strands-py/src/strands/_context_manager/modes/agentic/agentic_context.py b/strands-py/src/strands/_context_manager/modes/agentic/agentic_context.py
index d2d3e34f76..248ae32320 100644
--- a/strands-py/src/strands/_context_manager/modes/agentic/agentic_context.py
+++ b/strands-py/src/strands/_context_manager/modes/agentic/agentic_context.py
@@ -8,7 +8,7 @@
import logging
from dataclasses import replace
-from typing import TYPE_CHECKING, Literal
+from typing import Literal
from ...._middleware.stages import InvokeModelContext
from ...._middleware.types import MiddlewareInputHandler
@@ -26,9 +26,6 @@
from ....types.exceptions import ContextWindowOverflowException
from ....types.tools import ToolContext
-if TYPE_CHECKING:
- from ....models.model import Model
-
logger = logging.getLogger(__name__)
# Default number of recent messages to preserve verbatim during summarization or truncation.
@@ -286,15 +283,13 @@ def _matches_pin_filter(message: Message, filter: Literal["user", "assistant", "
return True # type: ignore[unreachable]
-def create_token_usage_middleware(model: "Model") -> MiddlewareInputHandler:
+def create_token_usage_middleware() -> MiddlewareInputHandler:
"""Create middleware that appends a ```` block to the last message.
- The block reports projected input-token usage against the model's context window limit so the
- model can decide when to compress. The original messages are not mutated; the last message is
- copied and the status text appended to the copy.
-
- Args:
- model: The model whose context window limit is reported.
+ The block reports projected input-token usage against the context window limit of the
+ model that will actually handle the call (``context.model``), so guidance stays correct
+ even when middleware has redirected the call to a different model. The original messages
+ are not mutated; the last message is copied and the status text appended to the copy.
Returns:
An async ``MiddlewareInputHandler`` for the ``InvokeModelStage.Input`` phase.
@@ -305,7 +300,7 @@ async def middleware(context: InvokeModelContext) -> InvokeModelContext:
if projected_input_tokens is None:
return context
- context_window_limit = model.context_window_limit or DEFAULT_CONTEXT_WINDOW_LIMIT
+ context_window_limit = context.model.context_window_limit or DEFAULT_CONTEXT_WINDOW_LIMIT
remaining = max(0, context_window_limit - projected_input_tokens)
percent_used = (projected_input_tokens / context_window_limit) * 100
diff --git a/strands-py/src/strands/_middleware/README.md b/strands-py/src/strands/_middleware/README.md
index c6c0b5e498..6f3382487c 100644
--- a/strands-py/src/strands/_middleware/README.md
+++ b/strands-py/src/strands/_middleware/README.md
@@ -170,6 +170,13 @@ short-circuiting before the chain. `ExecuteToolContext.tool` is therefore `Agent
Context fields (`messages`, `system_prompt`, `tool_specs`, `tool_choice`) are deep-copied when building the middleware context. `invocation_state` is shared by reference. `model_state` is excluded from the context entirely — middleware cannot access or modify it. The terminal reads it directly from the agent at invocation time.
+## Per-call model
+
+`InvokeModelContext.model` is the model the terminal invokes, initialized from `agent.model`. Middleware can point a single call at a different model via `replace()`, without mutating agent state; the terminal streams `context.model`, so the replacement also drives the trace span's `model_id`:
+```python
+modified = replace(context, model=other_model)
+```
+
## Context transformation
Middleware creates modified contexts via `dataclasses.replace()`:
diff --git a/strands-py/src/strands/_middleware/stages.py b/strands-py/src/strands/_middleware/stages.py
index ad8c65838c..6feeea8350 100644
--- a/strands-py/src/strands/_middleware/stages.py
+++ b/strands-py/src/strands/_middleware/stages.py
@@ -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
@@ -22,9 +23,11 @@
class InvokeModelContext:
"""Context passed to InvokeModelStage middleware.
- 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).
+ The collection fields (messages, system_prompt, tool_specs, tool_choice) are defensive
+ copies, so middleware cannot accidentally mutate agent state. ``invocation_state`` and
+ ``model`` are instead shared by reference: ``invocation_state`` is the live dict hooks and
+ tools write to during streaming, and ``model`` is the model this call invokes (it starts
+ as ``agent.model``; middleware may replace it per call).
"""
agent: Agent
@@ -33,6 +36,7 @@ class InvokeModelContext:
tool_specs: list[ToolSpec]
tool_choice: ToolChoice | None
invocation_state: dict[str, Any]
+ model: Model
projected_input_tokens: int | None = None
diff --git a/strands-py/src/strands/agent/agent.py b/strands-py/src/strands/agent/agent.py
index 61ef385605..38b6f86106 100644
--- a/strands-py/src/strands/agent/agent.py
+++ b/strands-py/src/strands/agent/agent.py
@@ -62,6 +62,7 @@
from ..memory import MemoryManager, MemoryManagerConfig
from ..models.bedrock import BedrockModel
from ..models.model import Model, _ModelPlugin
+from ..models.routing import ModelRouter
from ..plugins import Plugin
from ..plugins.registry import _PluginRegistry
from ..sandbox import Sandbox
@@ -160,7 +161,7 @@ class Agent(AgentBase):
def __init__(
self,
- model: Model | str | None = None,
+ model: Model | str | ModelRouter | None = None,
messages: Messages | None = None,
tools: list[Union[str, dict[str, str], "ToolProvider", Any]] | None = None,
system_prompt: str | list[SystemContentBlock] | None = None,
@@ -291,7 +292,17 @@ def __init__(
Raises:
ValueError: If agent id contains path separators.
"""
- self.model = BedrockModel() if not model else BedrockModel(model_id=model) if isinstance(model, str) else model
+ # A ModelRouter installs per-call routing (registered as a plugin below); agent.model
+ # exposes its default concrete model so readers (tracing, count_tokens, etc.) still work.
+ self._model_router: ModelRouter | None = model if isinstance(model, ModelRouter) else None
+ if isinstance(model, ModelRouter):
+ self.model = model.default_model
+ elif not model:
+ self.model = BedrockModel()
+ elif isinstance(model, str):
+ self.model = BedrockModel(model_id=model)
+ else:
+ self.model = model
self.messages = messages if messages is not None else []
if sandbox is not None and not isinstance(sandbox, Sandbox):
raise TypeError(f"sandbox must be a Sandbox instance or None, got {type(sandbox).__name__}")
@@ -411,7 +422,7 @@ def __init__(
from .._context_manager.modes.agentic.agentic_context import create_token_usage_middleware
from .._middleware.stages import InvokeModelStage
- self._middleware_registry.add_middleware(InvokeModelStage.Input, create_token_usage_middleware(self.model))
+ self._middleware_registry.add_middleware(InvokeModelStage.Input, create_token_usage_middleware())
self._plugin_registry = _PluginRegistry(self)
@@ -478,6 +489,10 @@ def __init__(
# Register built-in plugins
self._plugin_registry.add_and_init(_ModelPlugin())
+ # A ModelRouter passed via model= is a plugin; register it so it can install routing.
+ if self._model_router is not None:
+ self._plugin_registry.add_and_init(self._model_router)
+
plugins_to_register = resolved_plugins if resolved_plugins is not None else plugins
if plugins_to_register:
for plugin in plugins_to_register:
diff --git a/strands-py/src/strands/event_loop/event_loop.py b/strands-py/src/strands/event_loop/event_loop.py
index aa9536c9c3..93ac76a76b 100644
--- a/strands-py/src/strands/event_loop/event_loop.py
+++ b/strands-py/src/strands/event_loop/event_loop.py
@@ -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,
)
@@ -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
@@ -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,
@@ -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,
system_prompt_str,
ctx.messages,
ctx.tool_specs,
diff --git a/strands-py/src/strands/experimental/tools/__init__.py b/strands-py/src/strands/experimental/tools/__init__.py
index a23b7a10c6..1258ee8015 100644
--- a/strands-py/src/strands/experimental/tools/__init__.py
+++ b/strands-py/src/strands/experimental/tools/__init__.py
@@ -3,6 +3,8 @@
import warnings
from typing import Any
+from .stop import make_stop, stop
+
_DEPRECATED_NAMES = {"ToolProvider"}
@@ -19,4 +21,7 @@ def __getattr__(name: str) -> Any:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
-__all__: list[str] = []
+__all__ = [
+ "make_stop",
+ "stop",
+]
diff --git a/strands-py/src/strands/vended_tools/stop/__init__.py b/strands-py/src/strands/experimental/tools/stop/__init__.py
similarity index 63%
rename from strands-py/src/strands/vended_tools/stop/__init__.py
rename to strands-py/src/strands/experimental/tools/stop/__init__.py
index 1ba3d2204a..c330c313f5 100644
--- a/strands-py/src/strands/vended_tools/stop/__init__.py
+++ b/strands-py/src/strands/experimental/tools/stop/__init__.py
@@ -1,9 +1,11 @@
"""Tool for gracefully ending the agent loop.
+This tool is experimental and subject to change in future revisions without notice.
+
Example Usage:
```python
from strands import Agent
- from strands.vended_tools import stop
+ from strands.experimental.tools import stop
agent = Agent(tools=[stop])
```
diff --git a/strands-py/src/strands/vended_tools/stop/stop.py b/strands-py/src/strands/experimental/tools/stop/stop.py
similarity index 95%
rename from strands-py/src/strands/vended_tools/stop/stop.py
rename to strands-py/src/strands/experimental/tools/stop/stop.py
index 793eab6341..1b03f69bfc 100644
--- a/strands-py/src/strands/vended_tools/stop/stop.py
+++ b/strands-py/src/strands/experimental/tools/stop/stop.py
@@ -1,5 +1,7 @@
"""Tool for gracefully ending the agent loop.
+This tool is experimental and subject to change in future revisions without notice.
+
Provides :func:`make_stop` (a factory for customized stop tools) and :data:`stop`
(the default instance). The tool shims onto the SDK's existing loop-termination
primitive: it sets ``invocation_state["request_state"]["stop_event_loop"] = True``,
@@ -24,11 +26,11 @@
from typing import TYPE_CHECKING
-from ...tools.decorator import tool
-from ...types.tools import ToolContext
+from ....tools.decorator import tool
+from ....types.tools import ToolContext
if TYPE_CHECKING:
- from ...tools.decorator import DecoratedFunctionTool
+ from ....tools.decorator import DecoratedFunctionTool
_DEFAULT_MESSAGE = "Agent loop stopped."
DEFAULT_MAX_MESSAGE_LENGTH = 4096
diff --git a/strands-py/src/strands/models/routing/__init__.py b/strands-py/src/strands/models/routing/__init__.py
new file mode 100644
index 0000000000..f3b17187ab
--- /dev/null
+++ b/strands-py/src/strands/models/routing/__init__.py
@@ -0,0 +1,15 @@
+"""Model routing: choose which model handles a call at runtime.
+
+This package is internal while the API is stabilized; public exports are added later.
+"""
+
+from .router import CandidateInput, ModelRouter, RoutingCandidate
+from .strategy import RoutingContext, RoutingStrategy
+
+__all__ = [
+ "CandidateInput",
+ "ModelRouter",
+ "RoutingCandidate",
+ "RoutingContext",
+ "RoutingStrategy",
+]
diff --git a/strands-py/src/strands/models/routing/router.py b/strands-py/src/strands/models/routing/router.py
new file mode 100644
index 0000000000..972be9ab0f
--- /dev/null
+++ b/strands-py/src/strands/models/routing/router.py
@@ -0,0 +1,147 @@
+"""ModelRouter: a reusable, immutable routing profile over candidate models.
+
+A router holds an ordered set of candidates and a strategy. It is a ``Plugin`` so an agent
+can accept it through ``model=`` and later phases can install selection middleware. In this
+phase the router normalizes candidates, exposes the first candidate resolved to a concrete
+model for ``agent.model``, and rejects stateful candidates; it does not yet select per call.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Union
+
+from ...plugins.plugin import Plugin
+from ..bedrock import BedrockModel
+from ..model import Model
+from .strategy import RoutingStrategy
+
+
+@dataclass(frozen=True)
+class RoutingCandidate:
+ """A routing candidate: a model plus optional selection metadata.
+
+ ``name`` and ``description`` are optional. Order- and metric-based strategies ignore
+ them; a semantic strategy (e.g. a judge model) requires unique names and descriptions
+ to form a classification contract.
+ """
+
+ model: Model | str | ModelRouter
+ name: str | None = None
+ description: str | None = None
+
+
+CandidateInput = Union[Model, str, "ModelRouter", RoutingCandidate]
+
+_ROUTER_PLUGIN_NAME = "strands:model-router"
+
+
+class ModelRouter(Plugin):
+ """A reusable routing profile: ordered candidate models plus a selection strategy."""
+
+ def __init__(self, models: Sequence[CandidateInput], strategy: RoutingStrategy) -> None:
+ """Initialize the router.
+
+ Args:
+ models: Candidates as a sequence. Each is a ``Model``, a model-id string
+ (resolved to a ``BedrockModel``), a nested ``ModelRouter``, or a
+ ``RoutingCandidate`` carrying an optional name/description. The first
+ candidate is the router's default.
+ strategy: The strategy that chooses a candidate per call.
+
+ Raises:
+ TypeError: If ``models`` is not a sequence of candidates.
+ ValueError: If ``models`` is empty, candidate names collide, or any candidate is
+ a stateful model.
+ """
+ super().__init__()
+
+ candidates = self._normalize(models)
+ if not candidates:
+ raise ValueError("ModelRouter requires at least one candidate model")
+ self._reject_stateful(candidates)
+ self._reject_duplicate_names(candidates)
+
+ self._candidates = candidates
+ self._strategy = strategy
+
+ @property
+ def name(self) -> str:
+ """Stable plugin identifier."""
+ return _ROUTER_PLUGIN_NAME
+
+ @property
+ def strategy(self) -> RoutingStrategy:
+ """The configured selection strategy."""
+ return self._strategy
+
+ @property
+ def candidates(self) -> tuple[RoutingCandidate, ...]:
+ """The router's normalized candidates, in declaration order."""
+ return self._candidates
+
+ @property
+ def default_model(self) -> Model:
+ """The first candidate resolved to a concrete model (recursing nested routers)."""
+ return self._resolve(self._candidates[0].model)
+
+ @classmethod
+ def _resolve(cls, model: Model | str | ModelRouter) -> Model:
+ if isinstance(model, ModelRouter):
+ return model.default_model
+ if isinstance(model, str): # normalization resolves strings; defensive fallback
+ return BedrockModel(model_id=model)
+ return model
+
+ @classmethod
+ def _normalize(cls, models: object) -> tuple[RoutingCandidate, ...]:
+ # A mapping is a Sequence-like but name-keyed shape we intentionally do not accept;
+ # names live on RoutingCandidate. str/bytes are Sequences too but not candidate lists.
+ if isinstance(models, (str, bytes, Mapping)) or not isinstance(models, Sequence):
+ raise TypeError("models must be a sequence of candidates")
+ return tuple(cls._as_candidate(item) for item in models)
+
+ @classmethod
+ def _as_candidate(cls, item: CandidateInput) -> RoutingCandidate:
+ if isinstance(item, RoutingCandidate):
+ return RoutingCandidate(
+ model=cls._resolve_shorthand(item.model), name=item.name, description=item.description
+ )
+ return RoutingCandidate(model=cls._resolve_shorthand(item))
+
+ @staticmethod
+ def _resolve_shorthand(model: Model | str | ModelRouter) -> Model | ModelRouter:
+ if isinstance(model, str):
+ return BedrockModel(model_id=model)
+ return model
+
+ @classmethod
+ def _reject_stateful(cls, candidates: tuple[RoutingCandidate, ...]) -> None:
+ # Nested routers already validated their own candidates at construction.
+ for candidate in candidates:
+ if isinstance(candidate.model, Model) and candidate.model.stateful:
+ label = candidate.name or cls._model_id(candidate.model) or "candidate"
+ raise ValueError(f"candidate=<{label}> is stateful; routing among stateful models is not supported")
+
+ @staticmethod
+ def _reject_duplicate_names(candidates: tuple[RoutingCandidate, ...]) -> None:
+ seen: set[str] = set()
+ for candidate in candidates:
+ if candidate.name is None:
+ continue
+ if candidate.name in seen:
+ raise ValueError(f"duplicate candidate name=<{candidate.name}>")
+ seen.add(candidate.name)
+
+ @staticmethod
+ def _model_id(model: Model) -> str | None:
+ config = getattr(model, "config", None)
+ if config is None:
+ try:
+ config = model.get_config()
+ except Exception:
+ config = None
+ if isinstance(config, dict):
+ return config.get("model_id")
+ return getattr(config, "model_id", None)
diff --git a/strands-py/src/strands/models/routing/strategy.py b/strands-py/src/strands/models/routing/strategy.py
new file mode 100644
index 0000000000..784c0c8bf5
--- /dev/null
+++ b/strands-py/src/strands/models/routing/strategy.py
@@ -0,0 +1,42 @@
+"""Routing strategy protocol and the context strategies see when selecting a model."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
+
+if TYPE_CHECKING:
+ from ...types.content import Messages, SystemPrompt
+ from ...types.tools import ToolSpec
+ from .router import RoutingCandidate
+
+
+@dataclass(frozen=True)
+class RoutingContext:
+ """Immutable request data a routing strategy sees when choosing a candidate.
+
+ ``candidates`` are the router's normalized candidates; a strategy inspects them (e.g.
+ each concrete model's ``context_window_limit``) and returns one of them.
+ """
+
+ messages: Messages
+ system_prompt: SystemPrompt
+ tool_specs: tuple[ToolSpec, ...]
+ candidates: tuple[RoutingCandidate, ...]
+ invocation_state: Mapping[str, Any]
+
+
+@runtime_checkable
+class RoutingStrategy(Protocol):
+ """How a router chooses a candidate for a call.
+
+ ``select`` runs before the call and returns one of ``context.candidates``. The router
+ validates the returned candidate and raises for any other value.
+ """
+
+ name: str
+
+ async def select(self, context: RoutingContext) -> RoutingCandidate:
+ """Return the candidate to use for this call (one of ``context.candidates``)."""
+ ...
diff --git a/strands-py/src/strands/vended_tools/__init__.py b/strands-py/src/strands/vended_tools/__init__.py
index 1abb7d2338..9e134ea67d 100644
--- a/strands-py/src/strands/vended_tools/__init__.py
+++ b/strands-py/src/strands/vended_tools/__init__.py
@@ -1,4 +1,4 @@
-"""Built-in tools for commands, files, HTTP, pausing, and agent-loop control.
+"""Built-in tools for commands, files, HTTP, and pausing.
The :data:`bash` tool runs a
persistent shell on the host; the :func:`make_bash` and :func:`make_file_editor`
@@ -6,8 +6,6 @@
:class:`~strands.sandbox.base.Sandbox` at creation (as the built-in Docker/SSH
sandboxes do when vending tools) or read the sandbox from the agent at call time.
The :data:`sleep` tool pauses execution for a bounded, cancellable duration.
-The :data:`stop` tool signals the event loop to end the current invocation
-gracefully.
The :data:`http_request` tool makes raw HTTP calls with a strict default
security posture (private-network denial, redirect and body-size caps,
@@ -17,9 +15,9 @@
Example Usage:
```python
from strands import Agent
- from strands.vended_tools import bash, file_editor, http_request, sleep, stop
+ from strands.vended_tools import bash, file_editor, http_request, sleep
- agent = Agent(tools=[bash, file_editor, http_request, sleep, stop])
+ agent = Agent(tools=[bash, file_editor, http_request, sleep])
```
"""
@@ -27,7 +25,6 @@
from .file_editor import file_editor, make_file_editor
from .http_request import http_request, make_http_request
from .sleep import make_sleep, sleep
-from .stop import make_stop, stop
__all__ = [
"bash",
@@ -37,7 +34,5 @@
"make_file_editor",
"make_http_request",
"make_sleep",
- "make_stop",
"sleep",
- "stop",
]
diff --git a/strands-py/tests/strands/agent/conversation_manager/test_token_usage_middleware.py b/strands-py/tests/strands/agent/conversation_manager/test_token_usage_middleware.py
index 1c55a3c2ad..cf5cf330fb 100644
--- a/strands-py/tests/strands/agent/conversation_manager/test_token_usage_middleware.py
+++ b/strands-py/tests/strands/agent/conversation_manager/test_token_usage_middleware.py
@@ -25,6 +25,7 @@ def make_context(**overrides) -> InvokeModelContext:
tool_specs=[],
tool_choice=None,
invocation_state={},
+ model=mock_model(200_000),
)
defaults.update(overrides)
return InvokeModelContext(**defaults)
@@ -33,7 +34,7 @@ def make_context(**overrides) -> InvokeModelContext:
@pytest.mark.asyncio
class TestCreateTokenUsageMiddleware:
async def test_returns_context_unchanged_when_projected_tokens_not_set(self):
- middleware = create_token_usage_middleware(mock_model(200_000))
+ middleware = create_token_usage_middleware()
context = make_context()
result = await middleware(context)
@@ -41,7 +42,7 @@ async def test_returns_context_unchanged_when_projected_tokens_not_set(self):
assert result is context
async def test_returns_context_unchanged_when_messages_are_empty(self):
- middleware = create_token_usage_middleware(mock_model(200_000))
+ middleware = create_token_usage_middleware()
context = make_context(messages=[], projected_input_tokens=50_000)
result = await middleware(context)
@@ -49,8 +50,8 @@ async def test_returns_context_unchanged_when_messages_are_empty(self):
assert result is context
async def test_appends_context_status_to_last_message_content(self):
- middleware = create_token_usage_middleware(mock_model(200_000))
- context = make_context(projected_input_tokens=50_000)
+ middleware = create_token_usage_middleware()
+ context = make_context(model=mock_model(200_000), projected_input_tokens=50_000)
result = await middleware(context)
@@ -63,8 +64,19 @@ async def test_appends_context_status_to_last_message_content(self):
assert "" in status_text
assert "" in status_text
+ async def test_uses_the_effective_context_model_limit_not_a_captured_default(self):
+ # guards against reporting the agent's default-model window for a redirected call
+ middleware = create_token_usage_middleware()
+ context = make_context(model=mock_model(10_000), projected_input_tokens=8_000)
+
+ result = await middleware(context)
+
+ status_text = result.messages[0]["content"][1]["text"]
+ assert "8,000 / 10,000 tokens (80.0%)" in status_text
+ assert "~2,000 tokens" in status_text
+
async def test_does_not_mutate_the_original_messages(self):
- middleware = create_token_usage_middleware(mock_model(200_000))
+ middleware = create_token_usage_middleware()
original_message: Message = {"role": "user", "content": [{"text": "hello"}]}
context = make_context(messages=[original_message], projected_input_tokens=50_000)
@@ -74,8 +86,8 @@ async def test_does_not_mutate_the_original_messages(self):
assert len(original_message["content"]) == 1
async def test_uses_default_context_window_limit_when_model_has_no_limit(self):
- middleware = create_token_usage_middleware(mock_model(None))
- context = make_context(projected_input_tokens=100_000)
+ middleware = create_token_usage_middleware()
+ context = make_context(model=mock_model(None), projected_input_tokens=100_000)
result = await middleware(context)
@@ -85,7 +97,7 @@ async def test_uses_default_context_window_limit_when_model_has_no_limit(self):
assert "200,000" in status_text
async def test_preserves_message_metadata(self):
- middleware = create_token_usage_middleware(mock_model(200_000))
+ middleware = create_token_usage_middleware()
metadata = {"usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15}}
context = make_context(
messages=[{"role": "user", "content": [{"text": "hello"}], "metadata": metadata}],
@@ -97,8 +109,8 @@ async def test_preserves_message_metadata(self):
assert result.messages[0]["metadata"] == metadata
async def test_reports_correct_remaining_tokens(self):
- middleware = create_token_usage_middleware(mock_model(100_000))
- context = make_context(projected_input_tokens=80_000)
+ middleware = create_token_usage_middleware()
+ context = make_context(model=mock_model(100_000), projected_input_tokens=80_000)
result = await middleware(context)
diff --git a/strands-py/tests/strands/vended_tools/test_stop.py b/strands-py/tests/strands/experimental/tools/test_stop.py
similarity index 98%
rename from strands-py/tests/strands/vended_tools/test_stop.py
rename to strands-py/tests/strands/experimental/tools/test_stop.py
index a388e0dc74..f14fa1b2a4 100644
--- a/strands-py/tests/strands/vended_tools/test_stop.py
+++ b/strands-py/tests/strands/experimental/tools/test_stop.py
@@ -12,8 +12,8 @@
import pytest
+from strands.experimental.tools.stop import make_stop, stop
from strands.types.tools import ToolContext
-from strands.vended_tools.stop import make_stop, stop
def _tool_context(invocation_state: dict | None = None) -> ToolContext:
diff --git a/strands-py/tests/strands/injection/test_message_injection.py b/strands-py/tests/strands/injection/test_message_injection.py
index 4a13144749..bf3386e27f 100644
--- a/strands-py/tests/strands/injection/test_message_injection.py
+++ b/strands-py/tests/strands/injection/test_message_injection.py
@@ -51,6 +51,7 @@ def invoke_ctx(messages: list[dict], agent: Any = None) -> InvokeModelContext:
tool_specs=[],
tool_choice=None,
invocation_state={},
+ model=MagicMock(),
)
diff --git a/strands-py/tests/strands/memory/test_memory_manager.py b/strands-py/tests/strands/memory/test_memory_manager.py
index 94ab713e00..591e961bc9 100644
--- a/strands-py/tests/strands/memory/test_memory_manager.py
+++ b/strands-py/tests/strands/memory/test_memory_manager.py
@@ -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),
)
diff --git a/strands-py/tests/strands/middleware/test_agent_middleware.py b/strands-py/tests/strands/middleware/test_agent_middleware.py
index 9bd2a7ce3b..260575ce20 100644
--- a/strands-py/tests/strands/middleware/test_agent_middleware.py
+++ b/strands-py/tests/strands/middleware/test_agent_middleware.py
@@ -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()
diff --git a/strands-py/tests/strands/models/routing/__init__.py b/strands-py/tests/strands/models/routing/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/strands-py/tests/strands/models/routing/test_router.py b/strands-py/tests/strands/models/routing/test_router.py
new file mode 100644
index 0000000000..45ff1bbf87
--- /dev/null
+++ b/strands-py/tests/strands/models/routing/test_router.py
@@ -0,0 +1,181 @@
+"""Tests for ModelRouter core: candidate normalization, default resolution, guards."""
+
+import dataclasses
+
+import pytest
+
+from strands import Agent, Plugin
+from strands.models import BedrockModel
+from strands.models.routing import ModelRouter, RoutingCandidate
+from strands.models.routing.strategy import RoutingStrategy
+from tests.fixtures.mocked_model_provider import MockedModelProvider
+
+
+class _StubStrategy:
+ """Minimal strategy; routing selection is exercised in later phases."""
+
+ name = "stub"
+
+ async def select(self, context):
+ return context.candidates[0]
+
+
+class StatefulModel(MockedModelProvider):
+ @property
+ def stateful(self):
+ return True
+
+
+def _model(text="hi"):
+ return MockedModelProvider([{"role": "assistant", "content": [{"text": text}]}])
+
+
+# --- RoutingCandidate ---
+
+
+def test_routing_candidate_is_frozen_with_optional_metadata():
+ bare = RoutingCandidate(model=_model())
+ assert (bare.name, bare.description) == (None, None)
+
+ named = RoutingCandidate(model=_model(), name="a", description="desc")
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ named.name = "b"
+
+
+# --- plugin identity ---
+
+
+def test_router_is_a_plugin_with_stable_name():
+ router = ModelRouter(models=[_model()], strategy=_StubStrategy())
+
+ assert isinstance(router, Plugin)
+ assert router.name == "strands:model-router"
+
+
+def test_stub_strategy_satisfies_protocol():
+ assert isinstance(_StubStrategy(), RoutingStrategy)
+
+
+# --- default resolution (first candidate) ---
+
+
+def test_default_model_is_first_candidate():
+ m0, m1 = _model("0"), _model("1")
+ router = ModelRouter(models=[m0, m1], strategy=_StubStrategy())
+
+ assert router.default_model is m0
+
+
+def test_default_model_uses_first_even_with_names():
+ haiku = BedrockModel(model_id="haiku")
+ opus = BedrockModel(model_id="opus")
+ router = ModelRouter(
+ models=[RoutingCandidate(model=haiku, name="cheap"), RoutingCandidate(model=opus, name="strong")],
+ strategy=_StubStrategy(),
+ )
+
+ assert router.default_model is haiku
+
+
+# --- candidates + naming ---
+
+
+def test_bare_models_become_unnamed_candidates():
+ m0, m1 = _model(), _model()
+ router = ModelRouter(models=[m0, m1], strategy=_StubStrategy())
+
+ assert [(c.model, c.name) for c in router.candidates] == [(m0, None), (m1, None)]
+
+
+def test_routing_candidate_metadata_is_preserved():
+ m = _model()
+ router = ModelRouter(
+ models=[RoutingCandidate(model=m, name="routine", description="simple tasks")],
+ strategy=_StubStrategy(),
+ )
+
+ candidate = router.candidates[0]
+ assert (candidate.model, candidate.name, candidate.description) == (m, "routine", "simple tasks")
+
+
+def test_duplicate_candidate_names_raise():
+ with pytest.raises(ValueError, match="duplicate"):
+ ModelRouter(
+ models=[RoutingCandidate(_model(), name="a"), RoutingCandidate(_model(), name="a")],
+ strategy=_StubStrategy(),
+ )
+
+
+def test_repeated_unnamed_candidates_are_allowed():
+ router = ModelRouter(models=[_model(), _model()], strategy=_StubStrategy())
+ assert len(router.candidates) == 2
+
+
+# --- shorthand + nesting resolution ---
+
+
+def test_string_candidate_resolves_to_bedrock_model():
+ router = ModelRouter(models=["my-model-id"], strategy=_StubStrategy())
+ default = router.default_model
+
+ assert isinstance(default, BedrockModel)
+ assert default.config.get("model_id") == "my-model-id"
+
+
+def test_nested_router_default_resolves_recursively():
+ inner_model = _model()
+ inner = ModelRouter(models=[inner_model], strategy=_StubStrategy())
+ outer = ModelRouter(models=[inner, _model("x")], strategy=_StubStrategy())
+
+ assert outer.default_model is inner_model
+
+
+# --- guards ---
+
+
+def test_empty_models_raises():
+ with pytest.raises(ValueError, match="at least one"):
+ ModelRouter(models=[], strategy=_StubStrategy())
+
+
+def test_stateful_candidate_raises():
+ with pytest.raises(ValueError, match="stateful"):
+ ModelRouter(models=[StatefulModel([])], strategy=_StubStrategy())
+
+
+def test_mapping_models_raises():
+ with pytest.raises(TypeError, match="sequence of candidates"):
+ ModelRouter(models={"cheap": _model()}, strategy=_StubStrategy())
+
+
+def test_bare_string_models_raises():
+ with pytest.raises(TypeError, match="sequence of candidates"):
+ ModelRouter(models="my-model-id", strategy=_StubStrategy())
+
+
+# --- agent integration ---
+
+
+def test_agent_accepts_model_router_and_exposes_default():
+ m = _model("routed")
+ router = ModelRouter(models=[m], strategy=_StubStrategy())
+ agent = Agent(model=router, callback_handler=None)
+
+ assert agent.model is m
+ assert agent._model_router is router
+
+
+def test_agent_registers_router_as_plugin():
+ router = ModelRouter(models=[_model()], strategy=_StubStrategy())
+ agent = Agent(model=router, callback_handler=None)
+
+ assert router.name in agent._plugin_registry._plugins
+
+
+def test_agent_runs_with_router_using_first_candidate():
+ router = ModelRouter(models=[_model("routed")], strategy=_StubStrategy())
+ agent = Agent(model=router, callback_handler=None)
+
+ result = agent("hello")
+
+ assert result.message["content"][0]["text"] == "routed"
diff --git a/strands-py/tests/strands/vended_plugins/context_injector/test_plugin.py b/strands-py/tests/strands/vended_plugins/context_injector/test_plugin.py
index 372e62cdbf..6d9ce3b575 100644
--- a/strands-py/tests/strands/vended_plugins/context_injector/test_plugin.py
+++ b/strands-py/tests/strands/vended_plugins/context_injector/test_plugin.py
@@ -40,6 +40,7 @@ def invoke_ctx(messages: list[dict], agent: Any) -> InvokeModelContext:
tool_specs=[],
tool_choice=None,
invocation_state={},
+ model=MagicMock(),
)
diff --git a/strands-py/tests_integ/models/test_model_routing.py b/strands-py/tests_integ/models/test_model_routing.py
new file mode 100644
index 0000000000..e69d00a206
--- /dev/null
+++ b/strands-py/tests_integ/models/test_model_routing.py
@@ -0,0 +1,73 @@
+"""End-to-end integration tests for model routing.
+
+Validates that an ``Agent`` accepts a ``ModelRouter`` over real Bedrock models, exposes the
+first candidate as ``agent.model``, and completes a real invocation. Per-call selection
+lands in a later phase; here the router resolves to its first (default) candidate, so these
+tests exercise the Agent -> router -> InvokeModelStage -> concrete model wiring end to end.
+"""
+
+import pytest
+
+from strands import Agent
+from strands.models import BedrockModel
+from strands.models.routing import ModelRouter, RoutingCandidate
+
+_HAIKU_MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
+
+
+class _FirstCandidateStrategy:
+ """Selects the first candidate. Stand-in until built-in strategies land."""
+
+ name = "first-candidate"
+
+ async def select(self, context):
+ return context.candidates[0]
+
+
+@pytest.fixture
+def haiku():
+ return BedrockModel(model_id=_HAIKU_MODEL_ID)
+
+
+@pytest.fixture
+def default_model():
+ return BedrockModel()
+
+
+def test_router_agent_completes_invocation_using_first_candidate(haiku, default_model):
+ router = ModelRouter(models=[haiku, default_model], strategy=_FirstCandidateStrategy())
+ agent = Agent(model=router, load_tools_from_directory=False)
+
+ # agent.model exposes the router's first candidate as the concrete default.
+ assert agent.model is haiku
+
+ result = agent("What is 2 + 2? Reply with just the number.")
+
+ assert "4" in result.message["content"][0]["text"]
+
+
+def test_router_agent_runs_with_named_candidates(haiku, default_model):
+ router = ModelRouter(
+ models=[
+ RoutingCandidate(model=haiku, name="routine", description="Simple, direct questions."),
+ RoutingCandidate(model=default_model, name="complex", description="Harder reasoning."),
+ ],
+ strategy=_FirstCandidateStrategy(),
+ )
+ agent = Agent(model=router, load_tools_from_directory=False)
+
+ result = agent("Name the capital of France in one word.")
+
+ assert "paris" in result.message["content"][0]["text"].lower()
+
+
+def test_router_agent_resolves_nested_router_end_to_end(haiku, default_model):
+ inner = ModelRouter(models=[haiku], strategy=_FirstCandidateStrategy())
+ outer = ModelRouter(models=[inner, default_model], strategy=_FirstCandidateStrategy())
+ agent = Agent(model=outer, load_tools_from_directory=False)
+
+ assert agent.model is haiku
+
+ result = agent("Reply with the word: ok")
+
+ assert "ok" in result.message["content"][0]["text"].lower()
diff --git a/strands-ts/package.json b/strands-ts/package.json
index ac03984726..827a0dc282 100644
--- a/strands-ts/package.json
+++ b/strands-ts/package.json
@@ -72,9 +72,9 @@
"types": "./dist/src/vended-tools/sleep/index.d.ts",
"default": "./dist/src/vended-tools/sleep/index.js"
},
- "./vended-tools/stop": {
- "types": "./dist/src/vended-tools/stop/index.d.ts",
- "default": "./dist/src/vended-tools/stop/index.js"
+ "./experimental/vended-tools/stop": {
+ "types": "./dist/src/experimental/vended-tools/stop/index.d.ts",
+ "default": "./dist/src/experimental/vended-tools/stop/index.js"
},
"./a2a": {
"types": "./dist/src/a2a/index.d.ts",
diff --git a/strands-ts/src/vended-tools/stop/README.md b/strands-ts/src/experimental/vended-tools/stop/README.md
similarity index 95%
rename from strands-ts/src/vended-tools/stop/README.md
rename to strands-ts/src/experimental/vended-tools/stop/README.md
index a9dc873db6..5bce96970a 100644
--- a/strands-ts/src/vended-tools/stop/README.md
+++ b/strands-ts/src/experimental/vended-tools/stop/README.md
@@ -1,4 +1,6 @@
-# Stop Tool
+# Stop Tool (Experimental)
+
+> **This tool is experimental and subject to change in future revisions without notice.**
Lets the model gracefully end the agent loop when it decides its work is complete.
@@ -29,7 +31,7 @@ If none of the above applies, you probably don't need to install this tool.
```typescript
import { Agent } from '@strands-agents/sdk'
-import { stop } from '@strands-agents/sdk/vended-tools/stop'
+import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop'
const agent = new Agent({
model,
diff --git a/strands-ts/src/vended-tools/stop/__tests__/stop.test.ts b/strands-ts/src/experimental/vended-tools/stop/__tests__/stop.test.ts
similarity index 97%
rename from strands-ts/src/vended-tools/stop/__tests__/stop.test.ts
rename to strands-ts/src/experimental/vended-tools/stop/__tests__/stop.test.ts
index 657b04fa75..f4b3c9b25c 100644
--- a/strands-ts/src/vended-tools/stop/__tests__/stop.test.ts
+++ b/strands-ts/src/experimental/vended-tools/stop/__tests__/stop.test.ts
@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest'
import { stop, makeStop } from '../index.js'
import { DEFAULT_STOP_MESSAGE, DEFAULT_MAX_STOP_MESSAGE_LENGTH, STOP_INVOCATION_STATE_KEY } from '../types.js'
-import type { ToolContext } from '../../../index.js'
-import { AfterToolsEvent } from '../../../hooks/events.js'
-import { Message } from '../../../types/messages.js'
-import { createMockAgent, invokeTrackedHook, type MockAgent } from '../../../__fixtures__/agent-helpers.js'
+import type { ToolContext } from '../../../../index.js'
+import { AfterToolsEvent } from '../../../../hooks/events.js'
+import { Message } from '../../../../types/messages.js'
+import { createMockAgent, invokeTrackedHook, type MockAgent } from '../../../../__fixtures__/agent-helpers.js'
const createFreshContext = (
invocationState: Record = {}
diff --git a/strands-ts/src/vended-tools/stop/index.ts b/strands-ts/src/experimental/vended-tools/stop/index.ts
similarity index 100%
rename from strands-ts/src/vended-tools/stop/index.ts
rename to strands-ts/src/experimental/vended-tools/stop/index.ts
diff --git a/strands-ts/src/vended-tools/stop/stop.ts b/strands-ts/src/experimental/vended-tools/stop/stop.ts
similarity index 91%
rename from strands-ts/src/vended-tools/stop/stop.ts
rename to strands-ts/src/experimental/vended-tools/stop/stop.ts
index b41916b08f..3bdb694b28 100644
--- a/strands-ts/src/vended-tools/stop/stop.ts
+++ b/strands-ts/src/experimental/vended-tools/stop/stop.ts
@@ -1,6 +1,6 @@
-import { tool } from '../../tools/tool-factory.js'
-import { AfterToolsEvent } from '../../hooks/events.js'
-import type { LocalAgent } from '../../types/agent.js'
+import { tool } from '../../../tools/tool-factory.js'
+import { AfterToolsEvent } from '../../../hooks/events.js'
+import type { LocalAgent } from '../../../types/agent.js'
import {
DEFAULT_MAX_STOP_MESSAGE_LENGTH,
DEFAULT_STOP_DESCRIPTION,
@@ -75,6 +75,8 @@ function ensureHookInstalled(agent: LocalAgent): void {
/**
* Create a stop tool that gracefully ends the agent loop.
*
+ * **Experimental** — this tool is subject to change in future revisions without notice.
+ *
* Shims onto the SDK's existing `AfterToolsEvent.endTurn` primitive: the tool
* records the model's optional final message on `invocationState`, then a
* lazily-installed hook on the agent reads that marker on the terminating
@@ -84,7 +86,7 @@ function ensureHookInstalled(agent: LocalAgent): void {
* @example
* ```typescript
* import { Agent } from '@strands-agents/sdk'
- * import { stop } from '@strands-agents/sdk/vended-tools/stop'
+ * import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop'
*
* const agent = new Agent({ model, tools: [stop] })
* ```
@@ -121,6 +123,8 @@ export function makeStop(options?: MakeStopOptions): ReturnType {
/**
* Default stop tool.
*
+ * **Experimental** — this tool is subject to change in future revisions without notice.
+ *
* Ends the agent loop cooperatively when called by the model. Any tools the
* model requested alongside `stop` in the same turn still run to completion —
* the loop halts after the batch, without calling the model again.
diff --git a/strands-ts/src/vended-tools/stop/types.ts b/strands-ts/src/experimental/vended-tools/stop/types.ts
similarity index 100%
rename from strands-ts/src/vended-tools/stop/types.ts
rename to strands-ts/src/experimental/vended-tools/stop/types.ts
diff --git a/strands-ts/src/vended-tools/index.ts b/strands-ts/src/vended-tools/index.ts
index faa2a46840..fa036b422d 100644
--- a/strands-ts/src/vended-tools/index.ts
+++ b/strands-ts/src/vended-tools/index.ts
@@ -3,7 +3,7 @@
*
* Provides a single import path for consumers who want all built-in tools:
* ```typescript
- * import { bash, fileEditor, httpRequest, notebook, sleep, stop } from '@strands-agents/sdk/vended-tools'
+ * import { bash, fileEditor, httpRequest, notebook, sleep } from '@strands-agents/sdk/vended-tools'
* ```
*
* Note: This module requires a Node.js environment because the `bash` tool
@@ -16,4 +16,3 @@ export * from './file-editor/index.js'
export * from './http-request/index.js'
export * from './notebook/index.js'
export * from './sleep/index.js'
-export * from './stop/index.js'