Skip to content

Add BackgroundTools capability#222

Open
DouweM wants to merge 7 commits into
mainfrom
capability/background-tools
Open

Add BackgroundTools capability#222
DouweM wants to merge 7 commits into
mainfrom
capability/background-tools

Conversation

@DouweM
Copy link
Copy Markdown
Contributor

@DouweM DouweM commented Apr 25, 2026

Closes #221.

Summary

Adds the BackgroundTools capability that runs selected tools as fire-and-forget asyncio tasks. The agent receives an immediate acknowledgment string and keeps working; when the task completes, its result is delivered as a follow-up message via Pydantic AI's pending message queue (in-flight in pydantic/pydantic-ai#4980).

from pydantic_ai import Agent
from pydantic_ai_harness import BackgroundTools

agent = Agent('openai:gpt-5', capabilities=[BackgroundTools()])

@agent.tool_plain(metadata={'background': True})
async def slow_research(query: str) -> str:
    return await do_expensive_research(query)

Selector

Mirrors CodeMode's tools= API — accepts the standard ToolSelector:

  • dict (default {'background': True}) — by metadata
  • Sequence[str] — by name
  • Callable[(ctx, td), bool] — predicate
  • 'all' — every tool

Composes with SetToolMetadata and FunctionToolset.with_metadata(...) to mark a whole MCP server / toolset as background.

Implementation notes

  • Per-run state via for_run so concurrent runs don't share tasks
  • wrap_tool_execute spawns the task and returns the ack
  • after_node_run waits on at least one task before letting End propagate, so the core PendingMessageDrainCapability (which runs after us in reverse order via wrapped_by=[PendingMessageDrainCapability]) finds a follow-up to drain
  • wrap_run finally cancels remaining tasks; asyncio.CancelledError does not produce a spurious failure follow-up

Dependency note

[tool.uv.sources] points pydantic-ai-slim at the background-tools branch (where the pending message queue lives) until pydantic/pydantic-ai#4980 merges. We'll switch back to main once that lands.

Test plan

  • 6 tests covering: default metadata selector path (success / failure / unmarked / cancellation), name list selector, custom metadata key selector
  • 100% line + branch coverage on the new files
  • make lint && make typecheck && make test clean

Closes #221.

Runs selected tools as fire-and-forget asyncio tasks and delivers their
results via the pending message queue (pydantic-ai #4980): the model gets
an immediate ack and continues working; the real result is delivered as
a follow-up message when the task completes.

Default selector matches metadata['background']=True so users can opt
tools in piecemeal, mark a whole MCP server with SetToolMetadata, or
override entirely via the tools= parameter (matching CodeMode's pattern).

Points pydantic-ai-slim at the 'background-tools' branch where the
queue primitive lives until that PR merges to main.
@DouweM DouweM added auto-review Trigger automatic code review capability Standalone capability (AbstractCapability subclass) enhancement New feature or request labels Apr 25, 2026
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +157 to +158
handler: Any,
) -> Any:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 handler: Any in wrap_run violates "no Any types" rule

The AGENTS.md coding standard explicitly requires "pyright strict mode -- no Any types, full type annotations". In wrap_run, the handler parameter and return type both use Any (lines 157-158), whereas the analogous wrap_tool_execute method correctly imports and uses the specific WrapToolExecuteHandler type from pydantic_ai.capabilities.abstract (_capability.py:21). There should be a corresponding WrapRunHandler type (or equivalent) imported and used here instead of Any.

Prompt for agents
In pydantic_ai_harness/background_tools/_capability.py, the wrap_run method uses `handler: Any` and `-> Any` on lines 157-158. The AGENTS.md rule mandates no Any types. The wrap_tool_execute method already correctly imports WrapToolExecuteHandler from pydantic_ai.capabilities.abstract. Check pydantic_ai.capabilities.abstract for a WrapRunHandler type (or similar) and import it in the TYPE_CHECKING block at line 21 to replace the Any usages. The return type should also be specified concretely (likely the agent run result type from the abstract base class signature).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread tests/background_tools/test_background_tools.py
Comment thread pyproject.toml

[tool.uv.sources]
pydantic-ai-slim = { git = 'https://github.com/pydantic/pydantic-ai.git', branch = 'main', subdirectory = 'pydantic_ai_slim' }
pydantic-ai-slim = { git = 'https://github.com/pydantic/pydantic-ai.git', branch = 'background-tools', subdirectory = 'pydantic_ai_slim' }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 pydantic-ai-slim pinned to feature branch background-tools instead of main

The pyproject.toml source for pydantic-ai-slim was changed from branch = 'main' to branch = 'background-tools'. This is clearly intentional for development of this feature (the capability depends on APIs in that branch), but this PR should not be merged until the background-tools branch is merged into main in the pydantic-ai repo, otherwise the published package would depend on a feature branch that could be deleted or force-pushed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

It's been moved out of pydantic_ai.capabilities's public exports (matching
the existing ToolSearch pattern -- internal, auto-injected, not user-facing).
Bump the lock to pull the latest core branch with that change.
…'asap' default

pydantic-ai #4980 changed the enqueue API:
- `EnqueueContent` no longer accepts `SystemPromptPart` (Anthropic/Google hoist
  it to top-level system, breaking cache and losing positional intent — see
  pydantic-ai#5437). Background tool results are now passed as plain strings,
  which `enqueue` wraps in `UserPromptPart` — the right shape for "here's some
  context for the model" across all providers.
- Priorities renamed: `'steering'` → `'asap'`, `'follow_up'` → `'when_idle'`.
  Background tool results were previously `'follow_up'` (wait until end). They
  should be `'asap'` (default) — the new semantics deliver to the next model
  request if one's coming, or redirect from end-of-run if not. That matches the
  "deliver result whenever the agent next does anything" use case directly,
  including the case where a task completes during the model's final response.

`_follow_up_seen` test helper updated to look for `UserPromptPart` (was
`SystemPromptPart`). README updated to match.

Also bump the pydantic-ai-slim git pin to head of the `background-tools` branch
so the new `EnqueueContent` shape resolves.
@DouweM
Copy link
Copy Markdown
Contributor Author

DouweM commented May 22, 2026

@adtyavrdhn @dsfaccini Feel free to take over while I'm out!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Trigger automatic code review capability Standalone capability (AbstractCapability subclass) enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BackgroundTools capability (fire-and-forget tool execution)

1 participant