From dec001f1cee015841aabd48df81aba5ac62a26b8 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Mon, 1 Jun 2026 12:14:45 -0400 Subject: [PATCH 1/2] feat(strands-py-wasm): add DecoratedTool for host-side Python tools Introduce DecoratedTool and the @tool decorator as the way to register Python callbacks that the agent can invoke. Replaces the prior Tool / PydanticTool pair with a single user-facing type. On the guest side, fix mapContentBlock so the toJSON outer wrappers from SDK content blocks are stripped to match the WIT inner-record shape, and add mapToolResultBlock / mapToolResultContent helpers for tool result events. Pin pgrayy-wasmtime to the v46.0.7 release wheel. --- strands-py-wasm/pyproject.toml | 4 +- strands-py-wasm/src/strands/__init__.py | 81 +++++++++-------------- strands-py-wasm/tests/test_tools.py | 29 ++++++++ strands-wasm/entry.ts | 88 +++++++++++++++++++------ 4 files changed, 130 insertions(+), 72 deletions(-) create mode 100644 strands-py-wasm/tests/test_tools.py diff --git a/strands-py-wasm/pyproject.toml b/strands-py-wasm/pyproject.toml index cd4d14764..44fac34d3 100644 --- a/strands-py-wasm/pyproject.toml +++ b/strands-py-wasm/pyproject.toml @@ -28,8 +28,8 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - # Imports as `wasmtime`. Pinned to pgrayy/wasm-deps git URL until upstream PRs land. - "pgrayy-wasmtime @ git+https://github.com/pgrayy/wasm-deps.git@4b5dc41512109ebafe4c4f1edd592c739872c640#subdirectory=wasmtime-py", + # Imports as `wasmtime`. Pinned to a pgrayy/wasm-deps release wheel until upstream PRs land. + "pgrayy-wasmtime @ https://github.com/pgrayy/wasm-deps/releases/download/wasmtime-v46.0.7/pgrayy_wasmtime-46.0.7-py3-none-macosx_11_0_arm64.whl", "botocore>=1.29.0,<2.0.0", ] diff --git a/strands-py-wasm/src/strands/__init__.py b/strands-py-wasm/src/strands/__init__.py index 8ff68d9ee..fe0421761 100644 --- a/strands-py-wasm/src/strands/__init__.py +++ b/strands-py-wasm/src/strands/__init__.py @@ -266,40 +266,24 @@ def __init__(self, *, interrupt_id: str, response: Any) -> None: super().__init__(interrupt_id=interrupt_id, response=payload) -class PydanticTool: - """Tool whose input schema is derived from a pydantic ``BaseModel``.""" +class DecoratedTool: + """A Python function exposed to the agent as a tool. - def __init__( - self, - *, - name: str, - description: str, - input_model: type, - func: Callable[..., Any], - ) -> None: - if not hasattr(input_model, "model_json_schema") or not hasattr(input_model, "model_validate"): - raise TypeError(f"input_model must be a pydantic BaseModel subclass; got {input_model!r}") - self.name = name - self.description = description - self._input_model = input_model - self.input_schema = input_model.model_json_schema() - self.func = func + Build one with the :func:`tool` decorator, then pass it to + :class:`Agent` via ``tools=[...]``. The agent will call the function + when the model invokes the tool by name. - def to_spec(self) -> types.ToolSpec: - return types.ToolSpec( - name=self.name, - description=self.description, - input_schema=json.dumps(self.input_schema), - ) + Callbacks must be synchronous. Async functions are not yet supported. - def invoke(self, raw_input: str) -> list[Any]: - payload = json.loads(raw_input) if raw_input else {} - validated = self._input_model.model_validate(payload) - return _coerce_tool_result(self.func(validated)) + Example:: + @tool + def get_weather(city: str) -> str: + \"\"\"Return the current weather for a city.\"\"\" + return f"It is 72F and sunny in {city}." -class Tool: - """Registered tool: spec plus Python callable.""" + agent = Agent(model=BedrockModel(...), tools=[get_weather]) + """ def __init__( self, @@ -321,12 +305,13 @@ def to_spec(self) -> types.ToolSpec: input_schema=json.dumps(self.input_schema), ) - def invoke(self, raw_input: str) -> list[Any]: + def invoke(self, raw_input: str) -> list[types.ToolResultContent]: + """Run the tool with a JSON object of keyword arguments.""" kwargs = json.loads(raw_input) if raw_input else {} return _coerce_tool_result(self.func(**kwargs)) -def _coerce_tool_result(result: Any) -> list[Any]: +def _coerce_tool_result(result: Any) -> list[types.ToolResultContent]: if isinstance(result, str): return [types.ToolResultContent.Text(types.TextBlock(text=result))] if isinstance(result, types.TextBlock): @@ -348,9 +333,12 @@ def tool( name: str | None = None, description: str | None = None, ) -> Any: - """Decorator that turns a Python function into a :class:`Tool`.""" + """Decorator that turns a Python function into a :class:`DecoratedTool`. + + Only synchronous functions are supported at this time. + """ - def wrap(f: Callable[..., Any]) -> Tool: + def wrap(f: Callable[..., Any]) -> DecoratedTool: hints = get_type_hints(f) sig = inspect.signature(f) properties: dict[str, Any] = {} @@ -362,7 +350,7 @@ def wrap(f: Callable[..., Any]) -> Tool: schema: dict[str, Any] = {"type": "object", "properties": properties} if required: schema["required"] = required - return Tool( + return DecoratedTool( name=name or f.__name__, description=description or (f.__doc__ or "").strip() or f.__name__, input_schema=schema, @@ -372,19 +360,10 @@ def wrap(f: Callable[..., Any]) -> Tool: return wrap(func) if func is not None else wrap -_ToolInput = Tool | PydanticTool | Callable[..., Any] # String shorthand picks a tool by name; otherwise pass a tagged ToolChoice arm. _ToolChoiceInput = str | types.ToolChoice | None -def _coerce_tool(item: _ToolInput) -> Tool | PydanticTool: - if isinstance(item, (Tool, PydanticTool)): - return item - if callable(item): - return tool(item) - raise TypeError(f"unsupported tool: {type(item).__name__}") - - class Agent: """Strands agent. Construct once; call :meth:`invoke` or :meth:`stream_async`.""" @@ -394,7 +373,7 @@ def __init__( model: types.ModelInput | None = None, messages: list[types.Message] | None = None, system_prompt: PromptInput | None = None, - tools: list[_ToolInput] | None = None, + tools: list[DecoratedTool] | None = None, agent_tools: list[types.AgentAsToolConfig] | None = None, vended_tools: list[types.VendedToolInput] | None = None, vended_plugins: list[types.VendedPluginInput] | None = None, @@ -413,7 +392,7 @@ def __init__( app_state: dict[str, Any] | None = None, model_state: dict[str, Any] | None = None, ) -> None: - self._tools: list[Tool | PydanticTool] = [_coerce_tool(t) for t in (tools or [])] + self._tools: list[DecoratedTool] = list(tools or []) identity = None if name is not None or id is not None or description is not None: identity = types.AgentIdentity(name=name, id=id, description=description) @@ -467,7 +446,7 @@ async def _ensure_runtime_async(self) -> Any: await rt.async_init() return rt - def _lookup_tool(self, name: str) -> Tool | PydanticTool: + def _lookup_tool(self, name: str) -> DecoratedTool: for t in self._tools: if getattr(t, "name", None) == name: return t @@ -476,11 +455,11 @@ def _lookup_tool(self, name: str) -> Tool | PydanticTool: def _build_invoke_args( self, prompt: PromptInput, - tools: list[_ToolInput] | None, + tools: list[DecoratedTool] | None, tool_choice: _ToolChoiceInput, structured_output_schema: str | None, ) -> types.InvokeArgs: - extra_tools = [_coerce_tool(t).to_spec() for t in (tools or [])] or None + extra_tools = [t.to_spec() for t in (tools or [])] or None return types.InvokeArgs( input=_marshalling.coerce_prompt(prompt), tools=extra_tools, @@ -492,7 +471,7 @@ async def stream_async( self, prompt: PromptInput, *, - tools: list[_ToolInput] | None = None, + tools: list[DecoratedTool] | None = None, tool_choice: _ToolChoiceInput = None, structured_output_schema: str | None = None, ) -> AsyncIterator[types.StreamEvent]: @@ -507,7 +486,7 @@ async def invoke_async( self, prompt: PromptInput, *, - tools: list[_ToolInput] | None = None, + tools: list[DecoratedTool] | None = None, tool_choice: _ToolChoiceInput = None, structured_output_schema: str | None = None, ) -> AgentResult: @@ -526,7 +505,7 @@ def invoke( self, prompt: PromptInput, *, - tools: list[_ToolInput] | None = None, + tools: list[DecoratedTool] | None = None, tool_choice: _ToolChoiceInput = None, structured_output_schema: str | None = None, ) -> AgentResult: diff --git a/strands-py-wasm/tests/test_tools.py b/strands-py-wasm/tests/test_tools.py new file mode 100644 index 000000000..41cc5bfa1 --- /dev/null +++ b/strands-py-wasm/tests/test_tools.py @@ -0,0 +1,29 @@ +import pytest + +from strands import Agent, BedrockModel, tool + + +@pytest.fixture +def model(): + return BedrockModel("us.anthropic.claude-haiku-4-5-20251001-v1:0") + + +@pytest.fixture +def weather_tool(): + @tool + def get_weather(city: str) -> str: + """Return the current weather for a city.""" + return f"It is 72F and sunny in {city}." + + return get_weather + + +@pytest.fixture +def agent(model, weather_tool): + return Agent(model=model, tools=[weather_tool]) + + +@pytest.mark.asyncio +async def test_decorated_tool_invocation(agent): + result = await agent.invoke_async("What is the weather in Seattle?") + assert "72" in str(result) diff --git a/strands-wasm/entry.ts b/strands-wasm/entry.ts index 5e1f5b6ec..a0d44cf9d 100644 --- a/strands-wasm/entry.ts +++ b/strands-wasm/entry.ts @@ -18,7 +18,13 @@ /// import type { AgentConfig, InvokeArgs, RespondArgs, AgentError } from 'strands:agent/api@0.1.0' -import type { Message as WitMessage, PromptInput } from 'strands:agent/messages@0.1.0' +import type { + ContentBlock as WitContentBlock, + Message as WitMessage, + PromptInput, + ToolResultBlock as WitToolResultBlock, + ToolResultContent as WitToolResultContent, +} from 'strands:agent/messages@0.1.0' import type { StreamEvent as WitStreamEvent, StopEvent as WitStopEvent, @@ -52,6 +58,8 @@ import type { ToolChoice, ModelStreamEvent, ContentBlock, + ToolResultBlock, + ToolResultContent, SaveLatestStrategy, JSONValue, } from '@strands-agents/sdk' @@ -129,22 +137,30 @@ function mapMessage(message: Message): WitMessage { } as WitMessage } -/** Serialize a TS SDK ContentBlock to the WIT tagged-variant shape. */ -function mapContentBlock(block: ContentBlock): import('strands:agent/messages@0.1.0').ContentBlock { - type WitBlock = import('strands:agent/messages@0.1.0').ContentBlock - // block.type is the SDK class discriminator; toJSON drops class identity but keeps fields. +/** + * Serialize a TS SDK ContentBlock to the WIT `content-block` tagged variant. + * + * Most SDK blocks `toJSON()` to `{: }` (e.g. + * `{toolUse: {...}}`). The matching WIT record is the inner shape only; + * the discriminator already lives in `tag`. We strip that outer wrapper + * here so the WIT marshaler sees the right fields. `text` and `json` are + * the exception: their toJSON is already the inner shape. + */ +function mapContentBlock(block: ContentBlock): WitContentBlock { const payload = JSON.parse(JSON.stringify(block)) switch (block.type) { - case 'textBlock': return { tag: 'text', val: payload } as WitBlock - case 'toolUseBlock': return { tag: 'tool-use', val: payload } as WitBlock - case 'toolResultBlock': return { tag: 'tool-result', val: payload } as WitBlock - case 'reasoningBlock': return { tag: 'reasoning', val: payload } as WitBlock - case 'cachePointBlock': return { tag: 'cache-point', val: payload } as WitBlock - case 'imageBlock': return { tag: 'image', val: payload } as WitBlock - case 'videoBlock': return { tag: 'video', val: payload } as WitBlock - case 'documentBlock': return { tag: 'document', val: payload } as WitBlock - case 'citationsBlock': return { tag: 'citations', val: payload } as WitBlock - case 'guardContentBlock': return { tag: 'guard-content', val: payload } as WitBlock + case 'textBlock': return { tag: 'text', val: payload } as WitContentBlock + case 'toolUseBlock': + return { tag: 'tool-use', val: { ...payload.toolUse, input: JSON.stringify(payload.toolUse.input) } } as WitContentBlock + case 'toolResultBlock': + return { tag: 'tool-result', val: mapToolResultBlock(block) } as WitContentBlock + case 'reasoningBlock': return { tag: 'reasoning', val: payload.reasoning } as WitContentBlock + case 'cachePointBlock': return { tag: 'cache-point', val: payload.cachePoint } as WitContentBlock + case 'imageBlock': return { tag: 'image', val: payload.image } as WitContentBlock + case 'videoBlock': return { tag: 'video', val: payload.video } as WitContentBlock + case 'documentBlock': return { tag: 'document', val: payload.document } as WitContentBlock + case 'citationsBlock': return { tag: 'citations', val: payload.citations } as WitContentBlock + case 'guardContentBlock': return { tag: 'guard-content', val: payload.guardContent } as WitContentBlock default: { block satisfies never throw new Error(`unknown content block: ${(block as { type: string }).type}`) @@ -152,6 +168,31 @@ function mapContentBlock(block: ContentBlock): import('strands:agent/messages@0. } } +/** Serialize a TS SDK `ToolResultBlock` to the WIT `tool-result-block` record. */ +function mapToolResultBlock(block: ToolResultBlock): WitToolResultBlock { + return { + toolUseId: block.toolUseId, + status: block.status, + content: block.content.map(mapToolResultContent), + } +} + +/** Serialize a TS SDK `ToolResultContent` to the WIT `tool-result-content` tagged variant. */ +function mapToolResultContent(block: ToolResultContent): WitToolResultContent { + const payload = JSON.parse(JSON.stringify(block)) + switch (block.type) { + case 'textBlock': return { tag: 'text', val: payload } as WitToolResultContent + case 'jsonBlock': return { tag: 'json', val: payload } as WitToolResultContent + case 'imageBlock': return { tag: 'image', val: payload.image } as WitToolResultContent + case 'videoBlock': return { tag: 'video', val: payload.video } as WitToolResultContent + case 'documentBlock': return { tag: 'document', val: payload.document } as WitToolResultContent + default: { + block satisfies never + throw new Error(`unsupported tool-result-content type: ${(block as { type: string }).type}`) + } + } +} + // // --- stream event mapping ------------------------------------------------ // @@ -211,7 +252,7 @@ function mapEvent(event: AgentStreamEvent): WitStreamEvent | null { toolUseId: event.toolUse.toolUseId, input: JSON.stringify(event.toolUse.input ?? {}), }, - toolResult: mapContentBlock(event.result) as unknown as import('strands:agent/messages@0.1.0').ToolResultBlock, + toolResult: mapToolResultBlock(event.result), error: event.error ? { tag: 'execution-failed', val: event.error.message } : undefined, }, } @@ -225,7 +266,7 @@ function mapEvent(event: AgentStreamEvent): WitStreamEvent | null { case 'toolResultEvent': return { tag: 'tool-result-hook', - val: { toolResult: mapContentBlock(event.result) as unknown as import('strands:agent/messages@0.1.0').ToolResultBlock }, + val: { toolResult: mapToolResultBlock(event.result) }, } case 'toolStreamUpdateEvent': return { tag: 'tool-update', val: { data: JSON.stringify(event.event.data ?? null) } } @@ -365,8 +406,17 @@ function createTools(specs: ToolSpec[] | undefined): FunctionTool[] | undefined case 'data': // Streaming tool progress is not surfaced to the SDK caller today. continue - case 'complete': - return value.val as unknown as JSONValue + case 'complete': { + // The host pushes WIT `tool-result-content` variant arms; the + // TS FunctionTool expects the single-key data shape that + // `toolResultContentFromData` accepts. text/json arms already + // carry that shape inline; other arms need an explicit wrap. + const content = (value.val as Array<{ tag: string; val: unknown }>).map((c) => { + if (c.tag === 'text' || c.tag === 'json') return c.val + return { [c.tag]: c.val } + }) + return content as unknown as JSONValue + } case 'error': throw new Error(`tool ${spec.name} failed: ${value.val.tag}`) } From 6ea8797d715c4d5c9bfc87688109a48ae57e6117 Mon Sep 17 00:00:00 2001 From: Patrick Gray Date: Mon, 1 Jun 2026 13:20:41 -0400 Subject: [PATCH 2/2] chore(strands-py-wasm): address PR feedback on DecoratedTool Rename _coerce_tool_result to _normalize_tool_result and fix the docstring reference to render as @tool. --- strands-py-wasm/src/strands/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/strands-py-wasm/src/strands/__init__.py b/strands-py-wasm/src/strands/__init__.py index fe0421761..26ef459e7 100644 --- a/strands-py-wasm/src/strands/__init__.py +++ b/strands-py-wasm/src/strands/__init__.py @@ -269,7 +269,7 @@ def __init__(self, *, interrupt_id: str, response: Any) -> None: class DecoratedTool: """A Python function exposed to the agent as a tool. - Build one with the :func:`tool` decorator, then pass it to + Build one with the ``@tool`` decorator, then pass it to :class:`Agent` via ``tools=[...]``. The agent will call the function when the model invokes the tool by name. @@ -308,10 +308,10 @@ def to_spec(self) -> types.ToolSpec: def invoke(self, raw_input: str) -> list[types.ToolResultContent]: """Run the tool with a JSON object of keyword arguments.""" kwargs = json.loads(raw_input) if raw_input else {} - return _coerce_tool_result(self.func(**kwargs)) + return _normalize_tool_result(self.func(**kwargs)) -def _coerce_tool_result(result: Any) -> list[types.ToolResultContent]: +def _normalize_tool_result(result: Any) -> list[types.ToolResultContent]: if isinstance(result, str): return [types.ToolResultContent.Text(types.TextBlock(text=result))] if isinstance(result, types.TextBlock):