Skip to content
Open
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
44 changes: 29 additions & 15 deletions code_puppy_core_plugins/puppy_kennel/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ def _resolve_wing(value: str, agent_name: str, cwd: Any) -> str:
if v == "" or v == "repo":
return repo_wing(cwd)
if v == "agent":
if not agent_name:
raise KennelScopeError(
"cannot resolve the 'agent' wing: the run context carries no "
"agent name. Refusing to fall back to a shared wing."
)
return agent_wing(agent_name)
if v == "user":
return USER_WING
Expand Down Expand Up @@ -419,23 +424,32 @@ async def kennel_stats(context: RunContext) -> KennelStatsOutput:
)


def _agent_name_from_context(context: RunContext) -> str:
"""Best-effort extraction of the calling agent's name from the run context.
class KennelScopeError(RuntimeError):
"""The kennel could not tell which agent is writing, so it refused to."""

Falls back to ``"unknown"`` if the framework doesn't expose it on this
version of pydantic_ai.

def _agent_name_from_context(context: RunContext) -> str:
"""The calling agent's name, or ``""`` when there is no agent run.

pydantic-ai carries the running agent on ``RunContext.agent`` and the name
on ``Agent.name``.

This previously probed ``agent_name`` and ``name`` on the context and
again on ``deps``, then defaulted to ``"unknown"``. None of those fields
exist on ``RunContext`` (checked against 2.35), so no probe could match
and the default was the only reachable branch -- every call, not an edge
case. ``agent_wing()`` turned that into ``agent:unknown``, making the
per-agent wing a single shared bucket for every agent in the process.

``RunContext.agent`` is ``Agent | None`` and an unnamed ``Agent`` is
legal, so "no name" is a real answer and is returned as ``""``. Most
calls never need it -- writes default to the repo wing -- so refusing
happens in ``_resolve_wing``, at the one point something asks to be
scoped by the agent.
"""
for attr in ("agent_name", "name"):
val = getattr(context, attr, None)
if val:
return str(val)
deps = getattr(context, "deps", None)
if deps is not None:
for attr in ("agent_name", "name"):
val = getattr(deps, attr, None)
if val:
return str(val)
return "unknown"
agent = getattr(context, "agent", None)
name = getattr(agent, "name", None) if agent is not None else None
return str(name) if name else ""


def register_tools_callback() -> list[dict[str, Any]]:
Expand Down
76 changes: 76 additions & 0 deletions tests/test_puppy_kennel_agent_wing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Which wing a kennel write lands in.

The agent wing resolved to `agent:unknown` for every agent on every call,
because `_agent_name_from_context` probed four attributes that do not exist
on `RunContext`. A default that is always taken looks exactly like a working
feature, so nothing noticed.
"""

from __future__ import annotations

from types import SimpleNamespace

import pytest

from code_puppy_core_plugins.puppy_kennel.tools import (
KennelScopeError,
_agent_name_from_context,
_resolve_wing,
)
from code_puppy_core_plugins.puppy_kennel.wings import agent_wing


def ctx(agent):
"""Shaped like the real thing: `.agent`, and nothing else.

Deliberately not a Mock -- a Mock answers to every attribute, so it would
satisfy the old probe and hide which field is actually read.
"""
return SimpleNamespace(agent=agent, deps=None)


def test_the_name_comes_from_the_field_that_has_it():
assert (
_agent_name_from_context(ctx(SimpleNamespace(name="code-puppy")))
== "code-puppy"
)


def test_the_wing_is_the_agent_not_a_constant():
name = _agent_name_from_context(ctx(SimpleNamespace(name="code-puppy")))
assert agent_wing(name) == "agent:code-puppy"


def test_two_agents_do_not_share_one_wing():
first = agent_wing(_agent_name_from_context(ctx(SimpleNamespace(name="alpha"))))
second = agent_wing(_agent_name_from_context(ctx(SimpleNamespace(name="beta"))))
assert first != second


def test_no_name_is_reported_as_absent_not_invented():
assert _agent_name_from_context(ctx(None)) == ""
assert _agent_name_from_context(ctx(SimpleNamespace(name=None))) == ""


def test_asking_for_the_agent_wing_without_a_name_refuses():
with pytest.raises(KennelScopeError):
_resolve_wing("agent", "", cwd=None)


def test_a_nameless_run_can_still_use_the_repo_wing():
# The common path must not be collateral damage: writes default to `repo`
# and never consult the agent name.
assert _resolve_wing("", "", cwd=None).startswith("repo:")
assert _resolve_wing("repo", "", cwd=None).startswith("repo:")


def test_a_misleading_context_attribute_does_not_win():
# The old probe read `context.name` / `context.agent_name`. If either is
# present alongside a real agent, the real agent must still win.
misleading = SimpleNamespace(
agent=SimpleNamespace(name="code-puppy"),
name="not-the-agent",
agent_name="also-not-the-agent",
deps=SimpleNamespace(name="definitely-not"),
)
assert _agent_name_from_context(misleading) == "code-puppy"