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
71 changes: 61 additions & 10 deletions src/gaia/daemon/sidecars/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,62 @@ def _matches_dev_src_dir_shape(path: Path, agent_id: str) -> bool:
return tuple(p.lower() for p in tail) == tuple(t.lower() for t in expected_tail)


def _dev_src_dir_agent_id(path: Path) -> Optional[str]:
"""The agent id *path* is the dev-src dir of, or ``None`` if it isn't one.

Matches ``hub/agents/<any-id>/python`` with the agent slot wildcarded, so a
caller who pointed at ANOTHER agent's source tree can be told that, rather
than being told they passed a checkout root.
"""
parts = path.parts
if len(parts) < 4:
return None
head, agents, _, tail = (p.lower() for p in parts[-4:])
if (head, agents, tail) != ("hub", "agents", "python"):
return None
return parts[-2]


def _dev_src_dir_shape_error(resolved: Path, agent_id: str) -> str:
"""Explain why *resolved* isn't ``agent_id``'s dev-src dir (issue #3852).

A concrete "pass this instead" path is only named when it exists on disk —
blindly joining the expected tail onto whatever was typed turns a typo into
a longer typo and sends the caller to a path that was never there.
"""
expected = "/".join(_dev_src_dir_tail(agent_id))
other_id = _dev_src_dir_agent_id(resolved)
if other_id is not None:
sibling = resolved.parent.parent / agent_id / "python"
instead = (
f"pass '{sibling}' instead"
if sibling.is_dir()
else f"pass that checkout's {expected} directory instead"
)
return (
f"--dev-src-dir points at the '{other_id}' agent's source "
f"directory, but the agent requested is '{agent_id}'. Got "
f"'{resolved}'; {instead}, or start the other agent with "
f"`gaia daemon start-agent {other_id}`."
)

corrected = agent_dev_src_dir(resolved, agent_id)
if corrected.is_dir():
return (
f"--dev-src-dir must point at the {expected} directory inside a "
f"checkout, not the checkout root. Got '{resolved}'; pass "
f"'{corrected}' instead."
)

return (
f"--dev-src-dir must be an absolute path ending in {expected} — the "
f"agent's source directory inside a checkout. Got '{resolved}', which "
f"is neither that shape nor a checkout containing it. Check it for a "
f"typo, or pass the {expected} directory of the checkout you want to "
f"run from."
)


def repo_root_from_agent_dev_src_dir(dev_src_dir: Path, agent_id: str) -> Path:
"""Invert :func:`agent_dev_src_dir`: recover the repo root a per-agent
dev-mode source dir was joined from.
Expand Down Expand Up @@ -302,10 +358,10 @@ def resolve_caller_dev_src_dir(

*explicit* is validated client-side against the ``hub/agents/<agent_id>/
python`` shape (issue #2742) before it is ever sent to the daemon — a repo
root passed by mistake fails here, naming the exact corrected path,
instead of reaching the daemon and failing with
:func:`repo_root_from_agent_dev_src_dir`'s internal "restart remedy"
wording.
root passed by mistake fails here, naming the corrected path when that
path exists (see :func:`_dev_src_dir_shape_error`), instead of reaching
the daemon and failing with :func:`repo_root_from_agent_dev_src_dir`'s
internal "restart remedy" wording.

Raises:
DevSrcDirResolutionError: *explicit* is not an absolute path (a
Expand All @@ -326,12 +382,7 @@ def resolve_caller_dev_src_dir(
)
resolved = candidate.expanduser().resolve()
if not _matches_dev_src_dir_shape(resolved, agent_id):
corrected = agent_dev_src_dir(resolved, agent_id)
raise DevSrcDirResolutionError(
f"--dev-src-dir must point at the hub/agents/{agent_id}/python "
f"directory inside a checkout, not the checkout root. Got "
f"'{resolved}'; pass '{corrected}' instead."
)
raise DevSrcDirResolutionError(_dev_src_dir_shape_error(resolved, agent_id))
return resolved

resolved_cwd = cwd or Path.cwd()
Expand Down
52 changes: 48 additions & 4 deletions tests/unit/test_daemon_dev_anchor_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,19 +246,21 @@ def test_resolve_caller_dev_src_dir_rejects_repo_root_naming_corrected_path(tmp_
from gaia.daemon.sidecars.spec import resolve_caller_dev_src_dir

repo_root = tmp_path / "gaia"
repo_root.mkdir()
expected_corrected = repo_root / "hub" / "agents" / "email" / "python"
expected_corrected.mkdir(parents=True)

with pytest.raises(DevSrcDirResolutionError) as excinfo:
resolve_caller_dev_src_dir("email", explicit=str(repo_root), cwd=tmp_path)

message = str(excinfo.value)
assert "restart remedy" not in message
expected_corrected = repo_root.resolve() / "hub" / "agents" / "email" / "python"
assert str(expected_corrected) in message
assert str(expected_corrected.resolve()) in message
assert str(repo_root.resolve()) in message


def test_resolve_caller_dev_src_dir_rejects_unrelated_shape(tmp_path):
"""issue #3852: a path that isn't a checkout root must not be "corrected"
into a joined path that doesn't exist -- state the expected shape instead."""
from gaia.daemon.sidecars.errors import DevSrcDirResolutionError
from gaia.daemon.sidecars.spec import resolve_caller_dev_src_dir

Expand All @@ -268,7 +270,49 @@ def test_resolve_caller_dev_src_dir_rejects_unrelated_shape(tmp_path):
with pytest.raises(DevSrcDirResolutionError) as excinfo:
resolve_caller_dev_src_dir("email", explicit=str(unrelated), cwd=tmp_path)

assert "restart remedy" not in str(excinfo.value)
message = str(excinfo.value)
assert "restart remedy" not in message
fabricated = unrelated.resolve() / "hub" / "agents" / "email" / "python"
assert str(fabricated) not in message
assert "hub/agents/email/python" in message
assert "typo" in message
assert str(unrelated.resolve()) in message


def test_resolve_caller_dev_src_dir_names_other_agent_source_dir(tmp_path):
"""issue #3852: pointing at ANOTHER agent's dev-src dir is a wrong-agent
error, not a checkout-root one, and must never suggest nesting it in itself."""
from gaia.daemon.sidecars.errors import DevSrcDirResolutionError
from gaia.daemon.sidecars.spec import resolve_caller_dev_src_dir

other = tmp_path / "gaia" / "hub" / "agents" / "chat" / "python"
other.mkdir(parents=True)

with pytest.raises(DevSrcDirResolutionError) as excinfo:
resolve_caller_dev_src_dir("email", explicit=str(other), cwd=tmp_path)

message = str(excinfo.value)
assert "'chat' agent's source directory" in message
assert "gaia daemon start-agent chat" in message
nested = other.resolve() / "hub" / "agents" / "email" / "python"
assert str(nested) not in message


def test_resolve_caller_dev_src_dir_other_agent_names_existing_sibling(tmp_path):
"""The requested agent's sibling dir is named only when it exists."""
from gaia.daemon.sidecars.errors import DevSrcDirResolutionError
from gaia.daemon.sidecars.spec import resolve_caller_dev_src_dir

agents = tmp_path / "gaia" / "hub" / "agents"
other = agents / "chat" / "python"
other.mkdir(parents=True)
sibling = agents / "email" / "python"
sibling.mkdir(parents=True)

with pytest.raises(DevSrcDirResolutionError) as excinfo:
resolve_caller_dev_src_dir("email", explicit=str(other), cwd=tmp_path)

assert str(sibling.resolve()) in str(excinfo.value)


def test_resolve_caller_dev_src_dir_git_toplevel_joins_agent_dev_src_dir(
Expand Down
Loading