Skip to content

feat: promote native tool delivery to a NativeTools capability - #836

Open
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/native-tools-capability
Open

feat: promote native tool delivery to a NativeTools capability#836
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/native-tools-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What

Ninth in the capability series (#828#835). Promotes native tool delivery — Code Puppy's own tool suite (file ops, shell, sub-agent invocation, browser, plugin extras, UC wrappers) — from post-construction @agent.tool registration onto the constructed pydantic_ai.Agent, to a first-class NativeTools capability on the get_toolset() seam.

New module: code_puppy/agents/_native_tools.py

  • build_native_toolset(tool_names, model_name, agent_name) — points the existing registry at a FunctionToolset. Zero changes to register_tools_for_agent: every register_* function only ever uses the .tool decorator, and FunctionToolset.tool is argument-compatible with Agent.tool (default spellings differ; resolved model-visible tool definitions are equivalent — pinned by a wire-parity test). All registry semantics (plugin tool merging, edit_file expansion, kill-switches, per-model filtering) live where they always did.
  • @dataclass NativeTools(AbstractCapability[Any]) — delivers the toolset via get_toolset(); returns None when empty (tool-less agents contribute nothing, same chain as before); get_serialization_name() -> None (built from the live registry — not spec-constructible, series precedent).

Both construction sites converted: _builder.build_pydantic_agent (+ the stripped tool probe) and tools/subagent_invocation.py.

Feature-parity notes

  • Retry budgets unchanged: both registration paths leave per-tool max_retries=None, which pydantic-ai resolves to the agent-level retries=3 at get_tools time. Pinned by test.
  • Two-pass probe build deleted — and it was hiding a bug. Pass 1 existed solely to introspect probe_agent._tools for MCP collision filtering; pydantic-ai v2 removed that attribute, so existing_tool_names was always and filter_conflicting_mcp_tools had silently become a no-op (a real collision would have raised a toolset-conflict error at run time). The native toolset knows its names up front, so filtering now works as documented again. Pinned by an end-to-end test (MCP shadow of read_file hidden; unique MCP tool survives).
  • One observable divergence: the built agent's internal _function_toolset is now empty — tools ride the capability toolset (CombinedToolset/CapabilityOwnedToolset chain on the public toolsets property, the exact traversal feat: deliver MCP toolsets via a pydantic-ai capability (McpToolsets) #834 verified DBOS-transparent). The only readers were the context-overhead estimators via _extract_pydantic_agent_tools, which now walks the public toolsets property (covers both shapes) before falling back to the legacy reads. No core-plugin reads the old location (audited).
  • Sub-agent site keeps its exact old call shape (agent_name not passed → plugin per-agent extras unchanged).

Tests

14 contract tests in tests/agents/test_native_tools_capability.py: registry semantics ride-through (expansion, unknown-tool skip), capability contract (populated/empty/serialization), tool-metadata parity between Agent.tool and FunctionToolset.tool, real dispatch through Agent.run(), builder + sub-agent integration (divergence pins), repaired collision filter end-to-end, probe countability, extractor on both shapes.

Full suite: 7592 passed; the single red (test_render_version_check_current) is pre-existing on main — verified failing with these changes stashed. ruff check + ruff format clean.

Not merged on purpose

Per series protocol — awaiting human review. Note this PR grazes the same two capabilities=[...] blocks as #828#835; whoever lands last inherits the (trivial) rebases.


Review round 1 (code-puppy clone): APPROVE with 2 non-blocking findings + 1 nit — all addressed in 13e4d24b (claim precision + wire-parity test; collision-set scope + extractor breadth documented).

Code Puppy's own tool suite (file ops, shell, sub-agents, browser,
plugin extras) was bolted onto the constructed pydantic-ai Agent after
the fact via @agent.tool registration. pydantic-ai has a dedicated
capability seam for exactly this contribution -- get_toolset() -- so the
tools now arrive as a NativeTools capability wrapping a FunctionToolset.

Registration mechanics are unchanged: every register_* function only
uses the .tool decorator, and FunctionToolset.tool is signature-identical
to Agent.tool, so build_native_toolset simply points the existing
registry (plugin merging, edit_file expansion, kill-switches, per-model
filtering, UC wrappers) at a toolset. Per-tool retry budgets are
unchanged too: both paths leave max_retries=None, which resolves to the
agent-level retries at get_tools time.

Because the toolset knows its tool names up front, the builder's
two-pass probe build is gone -- and with it a latent bug: the probe
introspected probe_agent._tools, an attribute pydantic-ai v2 removed,
so MCP collision filtering had silently become a no-op. Names now come
from the native toolset, restoring the documented filter behaviour.

_extract_pydantic_agent_tools (context-overhead estimators) now walks
the agent's public toolsets property -- covering both the capability
shape (CombinedToolset/CapabilityOwnedToolset chains) and legacy
@agent.tool registration -- before falling back to the old direct reads.

One observable divergence, pinned by test: the built agent's internal
_function_toolset stays empty; tools live in the capability toolset.
No code_puppy or core-plugin reader depends on the old location.

14 contract tests in tests/agents/test_native_tools_capability.py.
Full suite: 7592 passed; the single red (test_render_version_check_current)
is pre-existing on main (verified with changes stashed).
- Soften the 'signature-identical' claim: FunctionToolset.tool is
  argument-compatible with Agent.tool (default spellings differ, resolved
  tool definitions are equivalent) -- docstring reworded.
- New wire-parity contract test comparing the model-visible
  ToolDefinition across both registration paths (provenance bookkeeping
  toolset_id/capability_id normalized out; nothing in code_puppy reads
  either field).
- Document the collision-set scope in _builder.py: native tool names
  only; tools contributed by other capabilities (ToolOutputLimits'
  read_tool_result) are intentionally not in the set.
- Document the extractor's deliberate breadth (theoretical double-count
  with the separate mcp_servers estimator input is acceptable slack for
  a best-effort estimate).

15 contract tests now. Full suite re-run: 7593 passed, same single
pre-existing red.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant