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
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
]

dependencies = [
"band-client-rest==0.0.41",
"band-client-rest==0.0.46",
"band-sdk-core==2.8.0",
"phoenix-channels-python-client>=0.2.5,<0.3",
"python-dotenv>=1.2.2",
Expand Down Expand Up @@ -100,7 +100,7 @@ parlant = [
"werkzeug>=3.1.6",
]
crewai = [
"crewai==1.15.22",
"crewai==1.15.27",
"openai>=2.0.0", # crewai 1.14.3 requires openai>=2.0.0
"nest-asyncio>=1.6.0", # Required for sync-to-async bridging in CrewAI tools
"pillow>=12.1.1",
Expand Down Expand Up @@ -285,7 +285,7 @@ dev-crewai = [
"band-testing-python==0.1.4",
"httpx>=0.24.0",
# CrewAI and its deps
"crewai==1.15.22",
"crewai==1.15.27",
"openai>=2.0.0",
"nest-asyncio>=1.6.0",
"pillow>=12.1.1",
Expand Down
65 changes: 61 additions & 4 deletions src/band/adapters/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
FunctionToolCallEvent,
FunctionToolResultEvent,
InstrumentationSettings,
ModelRequestNode,
RunContext,
UnexpectedModelBehavior,
capture_run_messages,
)
from pydantic_ai.capabilities import Hooks, ProcessHistory
from pydantic_ai.capabilities.abstract import AgentNode, NodeResult
from pydantic_ai.exceptions import SkipModelRequest
from pydantic_ai.messages import (
BinaryContent,
Expand All @@ -41,7 +43,16 @@
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models import Model, ModelRequestContext
from pydantic_ai.models import (
Model,
ModelRequestContext,
create_async_http_client,
infer_model,
)
from pydantic_ai.providers import Provider, infer_provider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.result import FinalResult
from pydantic_graph import End
from typing_extensions import Unpack

from band.converters.pydantic_ai import (
Expand Down Expand Up @@ -156,6 +167,27 @@ def _explicit_decline_is_terminal(request: ModelRequest) -> bool:
return declined


async def _finish_explicit_decline_after_tools(
ctx: RunContext[AgentToolsProtocol],
*,
node: AgentNode[Any],
result: NodeResult[Any],
) -> NodeResult[Any]:
"""End the run after a terminal decline without scheduling another model step.

pydantic-ai 2.55+ enforces ``request_limit`` before ``before_model_request`` hooks,
so a follow-up ``ModelRequestNode`` after tools can trip the limit even when the
hook would have skipped the call. Converting that node to ``End`` avoids the extra
step entirely.
"""
if isinstance(result, ModelRequestNode) and _explicit_decline_is_terminal(
result.request
):
ctx.messages.append(result.request)
return End(FinalResult(None))
return result


def _finish_explicit_decline(
ctx: RunContext[AgentToolsProtocol], request_context: ModelRequestContext
) -> ModelRequestContext:
Expand Down Expand Up @@ -297,6 +329,29 @@ def _takes_run_context(fn: Callable[..., Any]) -> bool:
return annotation is RunContext or get_origin(annotation) is RunContext


_OPENAI_LEGACY_HTTP_PROVIDERS = frozenset({"openai", "openai-chat"})


def _resolve_pydantic_ai_model(model: Model | str) -> Model | str:
"""Resolve a model string, patching OpenAI providers to use legacy ``httpx``.

pydantic-ai's OpenAI-compatible providers default to ``httpx2``, while the
``openai`` Python SDK still type-checks for ``httpx.AsyncClient``.
"""
if not isinstance(model, str):
return model
provider_name = model.partition(":")[0]
if provider_name not in _OPENAI_LEGACY_HTTP_PROVIDERS:
return model

def provider_factory(name: str) -> Provider[Any]:
if name in _OPENAI_LEGACY_HTTP_PROVIDERS:
return OpenAIProvider(http_client=create_async_http_client())
Comment thread
AlexanderZ-Band marked this conversation as resolved.
return infer_provider(name)

return infer_model(model, provider_factory)


class PydanticAIAdapterConfig(BaseAdapterConfig):
"""Settings for a Pydantic AI agent.

Expand Down Expand Up @@ -421,7 +476,7 @@ def _create_agent(self) -> Agent[AgentToolsProtocol, str | None]:
self._system_prompt = system

agent: Agent[AgentToolsProtocol, str | None] = Agent(
self._model,
_resolve_pydantic_ai_model(self._model),
# Pass the rendered prompt as `instructions`, not `system_prompt`.
# pydantic-ai materializes `system_prompt` as a single SystemPromptPart
# only on the first request, after which it ages into buried history;
Expand Down Expand Up @@ -455,8 +510,10 @@ def _create_agent(self) -> Agent[AgentToolsProtocol, str | None]:
# handled where the run is driven.
retries={"tools": 3, "output": 0},
capabilities=[
# Inspect native run metadata before replay history is processed.
Hooks(before_model_request=_finish_explicit_decline),
Hooks(
before_model_request=_finish_explicit_decline,
after_node_run=_finish_explicit_decline_after_tools,
),
# Strip content:null responses on every request, including mid-run
# ones the storage filter can't reach (see the function docstring).
ProcessHistory(_drop_non_replayable_messages),
Expand Down
43 changes: 39 additions & 4 deletions tests/adapters/test_pydantic_ai_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
FunctionToolCallEvent,
FunctionToolResultEvent,
InstrumentationSettings,
PydanticAIDeprecationWarning,
RunContext,
UnexpectedModelBehavior,
_tool_execution,
Expand All @@ -53,6 +54,7 @@
UserPromptPart,
)
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel
from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import UsageLimits

Expand All @@ -66,6 +68,7 @@
_is_replayable_history_message,
_log_native_progress,
_native_turn,
_resolve_pydantic_ai_model,
)
from band.core.exceptions import BandToolError
from band.core.protocols import (
Expand Down Expand Up @@ -346,6 +349,31 @@ async def test_agent_runs_the_live_llm(self):

assert adapter._agent.model is llm

def test_non_openai_models_skip_the_legacy_http_patch(self):
llm = TestModel()

assert _resolve_pydantic_ai_model(llm) is llm
assert _resolve_pydantic_ai_model("anthropic:claude") == "anthropic:claude"

@pytest.mark.asyncio
@pytest.mark.parametrize(
("model_name", "model_type"),
[
("openai:gpt-4o-mini", OpenAIResponsesModel),
("openai-chat:gpt-4o-mini", OpenAIChatModel),
],
)
async def test_openai_model_strings_use_the_legacy_http_client(
self, monkeypatch: pytest.MonkeyPatch, model_name: str, model_type: type[Any]
) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")

with pytest.warns(PydanticAIDeprecationWarning, match="httpx.AsyncClient"):
model = _resolve_pydantic_ai_model(model_name)

assert isinstance(model, model_type)
await model.provider.client.close()

@pytest.mark.parametrize(
("config", "llm"),
[
Expand All @@ -366,7 +394,7 @@ def test_create_agent_registers_content_null_history_processor(self):
an empty/thinking-only response within a single turn, and pydantic-ai would
otherwise replay it to the provider as assistant content:null.
"""
adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4"))
adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test"))
adapter.agent_name = "TestBot"

with patch("band.adapters.pydantic_ai.Agent") as MockAgent:
Expand Down Expand Up @@ -697,7 +725,7 @@ async def test_persists_rendered_system_prompt(self):
"""Should persist rendered prompt for capability-gating visibility."""
with patch("band.adapters.pydantic_ai.Agent"):
adapter = PydanticAIAdapter(
PydanticAIAdapterConfig(model="openai:gpt-5.4"),
PydanticAIAdapterConfig(model="test"),
capabilities=Capability.MEMORY,
)
await adapter.on_started(
Expand Down Expand Up @@ -734,6 +762,13 @@ async def test_agent_has_tools_registered(self, mock_pydantic_agent):
assert tool in tool_names, f"Tool {tool} not found"


def _wire_tool_call_args(args: Any) -> dict[str, Any] | str:
"""Shape tool args the way providers send them on the wire (JSON text or object)."""
if isinstance(args, dict | str):
return args
return json.dumps(args)


def _scripted_tool_calls(*calls: tuple[str, Any]) -> FunctionModel:
"""A model that makes ``calls``, one per turn, then answers in plain text.

Expand All @@ -746,7 +781,7 @@ def _scripted_tool_calls(*calls: tuple[str, Any]) -> FunctionModel:
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if pending:
name, args = pending.pop(0)
return ModelResponse(parts=[ToolCallPart(name, args)])
return ModelResponse(parts=[ToolCallPart(name, _wire_tool_call_args(args))])
return ModelResponse(parts=[TextPart("done")])

return FunctionModel(respond)
Expand Down Expand Up @@ -2969,7 +3004,7 @@ async def my_echo(ctx: RunContext[AgentToolsProtocol], message: str) -> str:
return f"Echo: {message}"

adapter = PydanticAIAdapter(
PydanticAIAdapterConfig(model="openai:gpt-5.4"), additional_tools=[my_echo]
PydanticAIAdapterConfig(model="test"), additional_tools=[my_echo]
)

# Mock the Agent class to track tool registrations
Expand Down
2 changes: 1 addition & 1 deletion tests/runtime/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ async def test_find_attachment_rejects_expired_naive_timestamp(
now()."""
expired = _attachment(
"file-1",
expires_at=datetime(2020, 1, 1), # noqa: DTZ001 -- naive on purpose, see docstring
expires_at=datetime(2020, 1, 1),
)
_mock_attachment_page(mock_rest_client, expired)
tools = AgentTools("room-123", mock_rest_client)
Expand Down
Loading
Loading