From 7922bdb71b169e19772ba34921b1b52b065bea0e Mon Sep 17 00:00:00 2001 From: breedx Date: Thu, 3 Sep 2026 12:10:49 +0000 Subject: [PATCH] fix(kennel): read the agent name from RunContext.agent, not four dead probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_agent_name_from_context` tried `agent_name` and `name` on the RunContext, then the same two on `deps`, then returned "unknown". None of those fields exist on `RunContext` — pydantic-ai carries the agent on `.agent` and the name on `Agent.name`: RunContext.__dataclass_fields__ has "agent_name" -> False RunContext.__dataclass_fields__ has "name" -> False RunContext.__dataclass_fields__ has "agent" -> True So no probe could match and the fallback was not a fallback: it was the only reachable branch, on every call. `agent_wing()` faithfully turned that into `agent:unknown`, which means the wing documented as one agent's own cross-project reflections was really a single shared bucket for every agent in the process. Observed in a deployment: `kennel_list_wings` returned only `agent:unknown` and `repo:`; no per-agent wing had ever existed. Repo-wing bylines were unaffected, which is what made it hard to spot — they come from the `agent_run_end` callback parameter, not from this probe. Now reads `ctx.agent.name` and nothing else. `RunContext.agent` is typed `Agent | None` and an unnamed `Agent` is legal, so "no name" is a real answer. It returns `""` rather than a stand-in, and `_resolve_wing` raises `KennelScopeError` at the one point something asks to be scoped by the agent. Refusing at the lookup instead would break callers that never needed the name — `kennel_remember` defaults to the repo wing — so the refusal sits where the mis-scoped write would actually happen. Tests use `SimpleNamespace`, not `Mock`: a Mock answers to every attribute, so it satisfies the old probe and hides which field is read. Three of them fail against the previous implementation, including "two agents do not share one wing". 2122 passed, 2 skipped. --- code_puppy_core_plugins/puppy_kennel/tools.py | 44 +++++++---- tests/test_puppy_kennel_agent_wing.py | 76 +++++++++++++++++++ 2 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 tests/test_puppy_kennel_agent_wing.py 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"