diff --git a/code_puppy_core_plugins/puppy_kennel/tools.py b/code_puppy_core_plugins/puppy_kennel/tools.py index 50c6407..eda425f 100644 --- a/code_puppy_core_plugins/puppy_kennel/tools.py +++ b/code_puppy_core_plugins/puppy_kennel/tools.py @@ -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 @@ -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]]: diff --git a/tests/test_puppy_kennel_agent_wing.py b/tests/test_puppy_kennel_agent_wing.py new file mode 100644 index 0000000..186c5f1 --- /dev/null +++ b/tests/test_puppy_kennel_agent_wing.py @@ -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"