Skip to content

Commit 3f75409

Browse files
authored
fix(toolsets): send a return description the way pydantic-ai sends one (#110)
`render` appended a prose `Returns: …` paragraph. pydantic-ai does something else with a docstring's `Returns:` section — it wraps the prose in `<summary>` and the return description in `<returns>`: ``` <summary>Draw a chart of numbers you already have, so the user can see them. Use this whenever the user asks to plot, chart, graph or compare figures …</summary> <returns> <description>The chart specification, already on its way to the user.</description> </returns> ``` So a host that registers these tools beside tools of its own ends up with two conventions in one tool list: `create_chart`, built from a docstring, arriving as XML, and `ls` arriving as prose describing the same kind of thing. That shape was invented here, and inventing it was the mistake — the framework already had one. `render` emits the framework's shape now. The test pins it against a tool pydantic-ai renders itself rather than against a string literal: ```python @native.tool_plain def demo() -> str: """Do a thing. A second paragraph of usage. Returns: One line per entry. """ assert ToolText(summary=..., usage=..., returns=...).render() == native.tools["demo"].tool_def.description ``` If pydantic-ai changes how it renders that section, this fails here rather than leaving these tools quietly speaking the old dialect. A `ToolText` with no `returns` is still plain prose, which is also what the framework does with a docstring that has no `Returns:` section. The `*_DESCRIPTION` constants carry the new shape with them. A catalogue that wanted the prose reads `ToolText.summary` — the sentence it wanted in the first place, and what agenticos already shows beside its approval checkboxes. Verified: 1716 tests pass, coverage 100%, pyright and mypy strict clean.
1 parent 8a6d31e commit 3f75409

6 files changed

Lines changed: 104 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.29] - 2026-08-22
11+
12+
### Fixed
13+
14+
- **A tool's return description now reaches the model the way pydantic-ai sends
15+
every other one.** `render` appended a prose `Returns: …` paragraph, where the
16+
framework wraps a docstring's `Returns:` section in `<summary>` and `<returns>`
17+
tags — so a host registering these beside tools of its own put two conventions
18+
in one tool list, `create_chart` arriving as XML and `ls` as prose describing
19+
the same kind of thing. `render` emits the framework's shape now, and
20+
`tests/test_tool_text.py` pins it against a tool pydantic-ai renders itself, so
21+
a change on that side fails here rather than leaving these tools speaking the
22+
old dialect. The `*_DESCRIPTION` constants carry the new shape with them; a
23+
catalogue wanting prose reads `ToolText.summary`, which is what it wanted.
24+
1025
## [0.2.28] - 2026-08-22
1126

1227
### Changed

docs/.DS_Store

8 KB
Binary file not shown.

docs/concepts/console-toolset.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ TOOL_TEXT["grep"].args # keyed by parameter name
9191
TOOL_TEXT["grep"].render() # the description the model is given
9292
```
9393

94+
`render` produces what pydantic-ai produces for a docstring with a `Returns:`
95+
section — the prose inside `<summary>`, the return description inside
96+
`<returns>`:
97+
98+
```
99+
<summary>Search the contents of files for a regular expression.
100+
101+
Use this rather than a shell `grep` …</summary>
102+
<returns>
103+
<description>`files_with_matches` lists paths, `content` lists `path:line: text` …</description>
104+
</returns>
105+
```
106+
107+
That is deliberate rather than decorative: a host registers these beside tools of
108+
its own that were built from docstrings, and two conventions in one tool list is
109+
one more thing for the model to reconcile. `tests/test_tool_text.py` pins the
110+
shape against a tool the framework renders itself.
111+
94112
### Profiles
95113

96114
Two kinds of agent read these tools and they need different amounts of text. A

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pydantic-ai-backend"
3-
version = "0.2.28"
3+
version = "0.2.29"
44
description = "File storage and sandbox backends for AI agents"
55
readme = "README.md"
66
license = "MIT"

src/pydantic_ai_backends/toolsets/descriptions.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
`grep` answers three different shapes, that a listing may be a truncated slice,
99
and that a failed `execute` still returns its output.
1010
11+
What reaches the model is shaped the way pydantic-ai shapes a docstring with a
12+
`Returns:` section - `<summary>` around the prose, `<returns>` around the return
13+
description - so these tools read the same as every tool built from a docstring
14+
rather than introducing a second convention into one tool list.
15+
1116
Two audiences read this text and they need different amounts of it. A coding
1217
agent wants the git and dependency guidance; an agent that keeps a scratch
1318
workspace for a report never sees a repository and pays for those sentences on
@@ -69,6 +74,16 @@ class ToolText:
6974
def render(self, profile: Profile = DEFAULT_PROFILE) -> str:
7075
"""The description handed to the model.
7176
77+
Shaped the way pydantic-ai shapes a docstring that has a `Returns:`
78+
section - the prose inside `<summary>`, the return description inside
79+
`<returns>` - because that is what every tool built from a docstring
80+
already sends, and a host registering these beside its own would
81+
otherwise put two conventions in one tool list. A prose `Returns:`
82+
paragraph was the first attempt and is what that inconsistency looked
83+
like. `tests/test_tool_text.py` pins the shape against a tool the
84+
framework renders itself, so a change there fails here rather than
85+
drifting quietly.
86+
7287
Args:
7388
profile: Which audience to write for.
7489
"""
@@ -77,9 +92,13 @@ def render(self, profile: Profile = DEFAULT_PROFILE) -> str:
7792
parts.append(self.usage)
7893
if self.coding and profile == "coding":
7994
parts.append(self.coding)
80-
if self.returns:
81-
parts.append(f"Returns: {self.returns}")
82-
return "\n\n".join(parts)
95+
body = "\n\n".join(parts)
96+
if not self.returns:
97+
return body
98+
return (
99+
f"<summary>{body}</summary>\n"
100+
f"<returns>\n<description>{self.returns}</description>\n</returns>"
101+
)
83102

84103
def docstring(self) -> str:
85104
"""A Google-style docstring carrying the argument text.

tests/test_tool_text.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66
from pydantic_ai.exceptions import UserError
7+
from pydantic_ai.toolsets import FunctionToolset
78

89
from pydantic_ai_backends import create_console_toolset
910
from pydantic_ai_backends.toolsets import descriptions as text_module
@@ -70,6 +71,53 @@ def test_the_exported_constants_render_the_same_objects(self) -> None:
7071
) == text_module.HASHLINE_READ_FILE_DESCRIPTION
7172

7273

74+
class TestTheShapeIsTheFrameworksOwn:
75+
"""One convention per tool list, and it is not ours to invent."""
76+
77+
def test_it_matches_a_tool_pydantic_ai_renders_itself(self) -> None:
78+
"""A tool built from a docstring is what a host registers these beside.
79+
80+
Pinned against the framework rather than against a string literal: if
81+
pydantic-ai changes how it renders a `Returns:` section, this fails here
82+
rather than leaving these tools quietly speaking the old dialect.
83+
"""
84+
native: FunctionToolset[None] = FunctionToolset()
85+
86+
@native.tool_plain
87+
def demo() -> str:
88+
"""Do a thing.
89+
90+
A second paragraph of usage.
91+
92+
Returns:
93+
One line per entry.
94+
"""
95+
return "" # pragma: no cover - never called, only described
96+
97+
rendered = ToolText(
98+
summary="Do a thing.",
99+
usage="A second paragraph of usage.",
100+
returns="One line per entry.",
101+
).render()
102+
103+
assert rendered == native.tools["demo"].tool_def.description
104+
105+
def test_a_tool_with_nothing_to_report_is_left_as_prose(self) -> None:
106+
"""No `Returns:` section, no tags - which is what the framework does."""
107+
assert ToolText(summary="Do a thing.", usage="Carefully.").render() == (
108+
"Do a thing.\n\nCarefully."
109+
)
110+
111+
def test_the_registered_description_carries_the_return_shape(self) -> None:
112+
toolset = create_console_toolset()
113+
114+
described = toolset.tools["grep"].tool_def.description
115+
116+
assert described is not None
117+
assert described.startswith("<summary>")
118+
assert "<description>`files_with_matches` lists paths" in described
119+
120+
73121
class TestProfiles:
74122
def test_the_agent_profile_drops_the_repository_guidance(self) -> None:
75123
"""An agent with a scratch workspace pays for none of it."""

0 commit comments

Comments
 (0)