Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ Canonical product hierarchy (never reverse):
Mutiny (behavioral fuzz-testing engine)
→ Adapter Layer
→ OpenAI Agents SDK Adapter (Adapter #1)
→ Future adapters (LangGraph, PydanticAI, CrewAI, AutoGen, HTTP, …)
→ PydanticAI Adapter (source package; CLI-local)
→ Future adapters (LangGraph, CrewAI, AutoGen, HTTP, …)
→ Customer Project
```

Expand Down Expand Up @@ -231,10 +232,12 @@ remains the engine; optional Hosted sync uploads redacted local results.
These paths are roadmap placeholders, not shipped adapter locations. Adapter #1
is in `packages/mutiny_openai_agents`; the empty `integrations/` directory is
tracked separately in [issue #38](https://github.com/CodewithJha/mutiny/issues/38).
The additional [PydanticAI adapter](../packages/mutiny_pydantic_ai/README.md)
lives in `packages/mutiny_pydantic_ai` and is implemented but not yet published.

| Path | Role |
|---|---|
| LangGraph / CrewAI / PydanticAI / HTTP adapters | Roadmap (Beta/v1) — see [ROADMAP](./ROADMAP.md) |
| LangGraph / CrewAI / HTTP adapters | Roadmap (Beta/v1) — see [ROADMAP](./ROADMAP.md) |
| `integrations/mcp` | MCP tool wrappers around API/Core (roadmap) |
| `integrations/skills` | Instructional Skill markdown |

Expand Down Expand Up @@ -353,8 +356,8 @@ These exist to prevent demo failure and architecture drift. Override only via AD

| Constraint | Rule |
|---|---|
| Supported adapter | Adapter #1: OpenAI Agents SDK (Core supports more via same interface) |
| LangGraph, CrewAI, PydanticAI, AutoGen, MCP targets | Out of current scope — ROADMAP |
| Supported adapters | OpenAI Agents SDK; PydanticAI source package (unreleased). Both implement the same Core port and execute locally. |
| LangGraph, CrewAI, AutoGen, MCP targets | Out of current scope — ROADMAP |

---

Expand Down
7 changes: 5 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@

**Limitations / out of scope (explicit):**

- LangGraph, CrewAI, PydanticAI, AutoGen, HTTP adapters (Beta/v1 — same interface)
- LangGraph, CrewAI, AutoGen, HTTP adapters (Beta/v1 — same interface)
- PydanticAI adapter: implemented in source; see the Beta entry below (not yet published)
- MCP (unless stretch after green)
- Multi-tenant cloud
- Broad repo scanning
Expand Down Expand Up @@ -67,7 +68,9 @@ New adapters on the **same** `TargetAdapter` interface (Core unchanged):

- **LangGraph** adapter
- **CrewAI** adapter
- **PydanticAI** adapter
- **PydanticAI** adapter: [implemented in source](../packages/mutiny_pydantic_ai/README.md),
with offline function-tool observation and fail-closed unsupported-tool handling.
CLI-local; Hosted observe-only. PyPI release remains pending.
- Localhost OpenAI-compatible **HTTP endpoint adapter** with token gate
- Multi-campaign history UX
- Policy editor (still deterministic primitives)
Expand Down
82 changes: 82 additions & 0 deletions packages/mutiny_pydantic_ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Mutiny PydanticAI adapter (unreleased source package)

`PydanticAIAdapter` implements Mutiny's `TargetAdapter` for **local** PydanticAI
agents. Core is framework-independent. Hosted remains observe-only: customer
agents run on your machine; `mutiny run --hosted` uploads redacted lineage.

## Install from this checkout

```bash
uv sync --group dev
```

For an existing environment, install the local Core and adapter packages:

```bash
pip install -e packages/mutiny_core -e packages/mutiny_pydantic_ai
```

This package uses `pydantic-ai-slim>=2.61.0,<3` (tested with 2.61.0). Install your
chosen PydanticAI provider extra separately for live models. The offline example
below needs no provider, credentials, or network access. This package is not yet
advertised as available on PyPI.

## Wire a local project

Create `.mutiny/adapter.py` in the project containing your PydanticAI `Agent`:

```python
from agent import support_agent
from mutiny_pydantic_ai import PydanticAIAdapter

def create_adapter():
return PydanticAIAdapter(
support_agent,
context={"customer": {"email": "alice@example.com"}},
)
```

Run `mutiny init` if needed, then edit `policy.yaml` for the actual tools your
agent exposes. Keep the custom adapter factory when scaffolding. Run
`mutiny run`, minimize findings through the existing local campaign flow, and
replay saved findings with `mutiny test`. No CLI target enum or Hosted execution
path is added; the CLI already loads any local `TargetAdapter` factory.

## Offline tool observation

```python
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from mutiny_pydantic_ai import PydanticAIAdapter

agent = Agent(TestModel())

@agent.tool_plain
def issue_refund(amount: int) -> dict:
return {"refunded": amount} # Mock only: no real refund.

adapter = PydanticAIAdapter(agent)
turn = adapter.step("demo", "Please issue a refund")
assert turn.tool_calls[0].name == "issue_refund"
assert turn.tool_results[0]["call_id"] == turn.tool_calls[0].id
```

The adapter consumes PydanticAI's public function-tool events. Only validated
calls that execute are mapped to tool evidence; output-schema tools, argument
validation failures, and skipped calls do not become function-tool invocations.
No tool events on a text-only turn is valid. Missing observation surfaces,
unpaired executed calls, or provider-side built-in tool calls raise
`ToolsNotObservableError`. Tool evidence is never inferred from assistant text.

History is isolated per session. `reset(session_id)` clears that conversation;
use `on_reset` to reset mock tools or other external state. `deps` is passed to
the agent separately from the deterministic policy facts returned by `context`.
`context_provider` can supply fresh policy facts; returned facts are copied.
`request_limit` bounds model requests per turn (default 10). SDK failures
propagate to the existing candidate error handling. The SDK runs synchronously
inside the local runner; share neither an adapter nor stateful tools between
concurrent campaigns.

Public SDK references: [agent execution](https://pydantic.dev/docs/ai/api/pydantic-ai/agent/),
[tool events](https://pydantic.dev/docs/ai/api/pydantic-ai/messages/), and
[offline testing](https://pydantic.dev/docs/ai/guides/testing/).
21 changes: 21 additions & 0 deletions packages/mutiny_pydantic_ai/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[project]
name = "mutiny-pydantic-ai"
version = "0.2.0"
description = "Mutiny TargetAdapter for local PydanticAI agents"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = [
"mutiny-core>=0.2.0",
"pydantic-ai-slim>=2.61.0,<3",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/mutiny_pydantic_ai"]

[tool.hatch.build.targets.sdist]
include = ["/src", "/README.md", "/pyproject.toml"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Local PydanticAI adapter for the framework-independent Mutiny port."""

from mutiny_pydantic_ai.adapter import PydanticAIAdapter

__all__ = ["PydanticAIAdapter"]
149 changes: 149 additions & 0 deletions packages/mutiny_pydantic_ai/src/mutiny_pydantic_ai/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Observe local PydanticAI function-tool executions through SDK events."""

from __future__ import annotations

import copy
from collections.abc import Callable
from typing import Any

from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.messages import (
NativeToolCallPart,
FunctionToolCallEvent,
FunctionToolResultEvent,
ModelMessage,
OutputToolCallEvent,
ToolCallPart,
)
from pydantic_ai.usage import UsageLimits
from pydantic_core import to_jsonable_python

from mutiny_core.adapter import TargetAdapter, ToolsNotObservableError
from mutiny_core.trace import AdapterTurnResult, ToolCall


class PydanticAIAdapter(TargetAdapter):
"""Run an Agent locally, retaining separate message history per session.

Only validated function-tool calls are evidence. Output tools, rejected
arguments, and skipped calls are not executed function tools. Provider-side
built-in tools are unsupported and fail closed rather than hiding evidence.
"""

def __init__(
self,
agent: Agent[Any, Any],
*,
deps: Any = None,
context: dict[str, Any] | None = None,
context_provider: Callable[[], dict[str, Any]] | None = None,
on_reset: Callable[[], None] | None = None,
request_limit: int = 10,
) -> None:
if not isinstance(agent, Agent):
raise TypeError("PydanticAIAdapter requires a PydanticAI Agent")
if request_limit < 1:
raise ValueError("request_limit must be positive")
self._agent = agent
self._deps = deps
self._context = copy.deepcopy(context or {})
self._context_provider = context_provider
self._on_reset = on_reset
self._request_limit = request_limit
self._sessions: dict[str, list[ModelMessage]] = {}

def reset(self, session_id: str) -> None:
if self._on_reset is not None:
self._on_reset()
self._sessions[session_id] = []

def step(self, session_id: str, user_message: str) -> AdapterTurnResult:
if session_id not in self._sessions:
self.reset(session_id)
calls: list[ToolCall] = []
outputs: list[dict[str, Any]] = []
observed_call_ids: set[str] = set()
observed = False

async def observe(_context: Any, events: Any) -> None:
nonlocal observed
observed = True
async for event in events:
if isinstance(event, (FunctionToolCallEvent, OutputToolCallEvent)):
observed_call_ids.add(event.tool_call_id)
if (
isinstance(event, FunctionToolCallEvent)
and event.args_valid is True
):
part = event.part
if not part.tool_call_id or not part.tool_name:
raise ToolsNotObservableError(
"PydanticAI tool identity is missing"
)
calls.append(
ToolCall(
id=part.tool_call_id,
name=part.tool_name,
arguments=part.args_as_dict(),
)
)
elif isinstance(event, FunctionToolResultEvent):
# Skipped/invalid calls have results too; pair only actual calls.
if any(call.id == event.tool_call_id for call in calls):
outputs.append(
{
"call_id": event.tool_call_id,
"output": to_jsonable_python(event.part.content),
}
)

result = self._agent.run_sync(
user_message,
message_history=copy.deepcopy(self._sessions[session_id]),
deps=self._deps,
usage_limits=UsageLimits(request_limit=self._request_limit),
event_stream_handler=observe,
)
if not observed or not callable(getattr(result, "new_messages", None)):
raise ToolsNotObservableError(
"PydanticAI run did not expose tool observation"
)
messages = result.new_messages()
if isinstance(result.output, DeferredToolRequests):
raise ToolsNotObservableError(
"PydanticAI deferred tools require external execution"
)
if any(
isinstance(part, NativeToolCallPart)
for msg in messages
for part in msg.parts
):
raise ToolsNotObservableError(
"PydanticAI provider-side built-in tools are unsupported"
)
if any(
isinstance(part, ToolCallPart)
and part.tool_call_id not in observed_call_ids
for msg in messages
for part in msg.parts
):
raise ToolsNotObservableError(
"PydanticAI tool call is missing its observation event"
)
if any(not any(out["call_id"] == call.id for out in outputs) for call in calls):
raise ToolsNotObservableError(
"PydanticAI tool call has no observable result"
)
turn = AdapterTurnResult(
assistant_message=str(result.output) if result.output is not None else None,
tool_calls=calls,
tool_results=outputs,
raw={"adapter": "pydantic_ai", "session_id": session_id},
)
self._sessions[session_id].extend(copy.deepcopy(messages))
return turn

def context(self, session_id: str | None = None) -> dict[str, Any]:
_ = session_id
facts = self._context_provider() if self._context_provider else self._context
return copy.deepcopy(facts)
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
dev = [
"mutiny-core",
"mutiny-openai-agents",
"mutiny-pydantic-ai",
"mutiny-ai",
"demo-agent",
"mutiny-api",
Expand All @@ -17,6 +18,7 @@ llm = [
[tool.uv.sources]
mutiny-core = { workspace = true }
mutiny-openai-agents = { workspace = true }
mutiny-pydantic-ai = { workspace = true }
mutiny-ai = { workspace = true }
demo-agent = { workspace = true }
mutiny-api = { workspace = true }
Expand All @@ -25,6 +27,7 @@ mutiny-api = { workspace = true }
members = [
"packages/mutiny_core",
"packages/mutiny_openai_agents",
"packages/mutiny_pydantic_ai",
"packages/mutiny_cli",
"apps/demo_agent",
"apps/api",
Expand All @@ -43,6 +46,7 @@ testpaths = ["tests"]
pythonpath = [
"packages/mutiny_core/src",
"packages/mutiny_openai_agents/src",
"packages/mutiny_pydantic_ai/src",
"packages/mutiny_cli/src",
"apps/demo_agent/src",
"apps/api/src",
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_package_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PACKAGES = {
"mutiny-core": REPO / "packages" / "mutiny_core",
"mutiny-openai-agents": REPO / "packages" / "mutiny_openai_agents",
"mutiny-pydantic-ai": REPO / "packages" / "mutiny_pydantic_ai",
"mutiny-ai": REPO / "packages" / "mutiny_cli",
}
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.M)
Expand Down Expand Up @@ -71,6 +72,7 @@ def test_built_wheels_contain_current_surface(tmp_path: Path) -> None:
for name, pkg in (
("core", PACKAGES["mutiny-core"]),
("openai", PACKAGES["mutiny-openai-agents"]),
("pydantic-ai", PACKAGES["mutiny-pydantic-ai"]),
("cli", PACKAGES["mutiny-ai"]),
):
target = out / name
Expand All @@ -84,6 +86,16 @@ def test_built_wheels_contain_current_surface(tmp_path: Path) -> None:
core_whl = next((out / "core").glob("mutiny_core-*-py3-none-any.whl"))
openai_whl = next((out / "openai").glob("mutiny_openai_agents-*-py3-none-any.whl"))
cli_whl = next((out / "cli").glob("mutiny_ai-*-py3-none-any.whl"))
pydantic_whl = next((out / "pydantic-ai").glob("mutiny_pydantic_ai-*-py3-none-any.whl"))

with zipfile.ZipFile(pydantic_whl) as z:
names = set(z.namelist())
assert "mutiny_pydantic_ai/adapter.py" in names
metadata_path = next(n for n in names if n.endswith(".dist-info/METADATA"))
metadata = z.read(metadata_path).decode("utf-8")
assert "Requires-Dist: mutiny-core>=0.2.0" in metadata
assert "Requires-Dist: pydantic-ai-slim" in metadata
assert not any(n.startswith("mutiny_api/") for n in names)

with zipfile.ZipFile(core_whl) as z:
names = set(z.namelist())
Expand Down
Loading
Loading