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'