diff --git a/README.md b/README.md index d2ba2f2..3378f69 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ covers pointing it at a mirror. | Command | What it does | Touches GCP | |---|---|---| -| `gete init ` | Scaffold an agent (and the project if there is none) | no | +| `gete init [/]` | Scaffold an agent (and the project if there is none); below an engine's directory when one is given | no | | `gete validate [--check-secrets] [--import-check]` | Schemas and rules; optionally secrets' versions and a deployment-shaped import | `--check-secrets` reads | | `gete run ` | Local conversation; tokens from `GETE_TOKEN_` | calls the model | | `gete graph [name...]` | Mermaid diagram of agents, engines, tools, connections | no | @@ -168,6 +168,30 @@ since accepting both would let an id copied from the agent next door land on the wrong engine unnoticed. Each id is written once, so a rebuilt engine is one line, and `gete graph` and `gete register` say the name. +With the engines named, the tree itself can say where each agent is listed. +A directory below `agents/` that carries an engine's name holds the agents +listed in that engine, and they declare no `registration` block: + +``` +agents/ + sales/ + partner-review/agent.yaml listed in sales + mail-triage/agent.yaml listed in sales + support/ + ticket-digest/agent.yaml listed in support + local-only/agent.yaml listed nowhere, as before +``` + +`gete init sales/partner-review` starts an agent there. The name still +matches the agent's own directory, so the module address, service account, +and archive do not change when an agent moves into a group: the generated +module call points at the new path, and nothing is recreated. Groups go one +level deep. A directory below `agents/` with no `agent.yaml` that names no +engine is reported by `validate` rather than stepped over, since a misspelt +engine would otherwise drop every agent below it from the project, and the +next apply would destroy them. A `registration` block inside a group is +accepted while it agrees with the directory, and reported once it does not. + ### Tools from an OpenAPI description A service that publishes an OpenAPI description but runs no MCP server can be diff --git a/docs/quickstart.md b/docs/quickstart.md index 2710218..a3ab24f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -213,8 +213,9 @@ registration: ``` With several engines, name them once in `gete.yaml` under -`gemini_enterprise.engines` and write the name here instead; the README -shows the shape. Then: +`gemini_enterprise.engines` and write the name here instead, or move the +agent below `agents//` and leave the block out; the README +shows both. Then: ```sh gete register diff --git a/src/gete/cli/__init__.py b/src/gete/cli/__init__.py index 4250d50..ff86da1 100644 --- a/src/gete/cli/__init__.py +++ b/src/gete/cli/__init__.py @@ -182,7 +182,12 @@ def register(names: tuple[str, ...], notice: Path, reset: tuple[str, ...]) -> No @main.command() @click.argument("name") def init(name: str) -> None: - """Create agents/NAME from a template; create gete.yaml too if there is none.""" + """Create agents/NAME from a template; create gete.yaml too if there is none. + + NAME may be ENGINE/NAME, with ENGINE named in gete.yaml under + gemini_enterprise.engines: the agent is then listed in that engine by + where it sits, and declares no registration block. + """ from gete.scaffold import init_agent, init_project try: @@ -194,6 +199,10 @@ def init(name: str) -> None: try: written.extend(init_agent(root, name)) except GeteError as error: + # The project files are on disk by now; say so, since a refused + # group is fixed by editing the gete.yaml just written. + for path in written: + click.echo(str(path)) click.echo(str(error), err=True) sys.exit(1) for path in written: diff --git a/src/gete/declaration.py b/src/gete/declaration.py index 2903292..44e3bd7 100644 --- a/src/gete/declaration.py +++ b/src/gete/declaration.py @@ -58,6 +58,9 @@ class Agent: directory: Path data: Mapping[str, Any] + # The engine whose directory the agent sits in, agents//. + # None for an agent directly below agents_dir. + group: str | None = None @property def path(self) -> Path: @@ -129,8 +132,8 @@ def instruction_text(self) -> str: return path.read_text(encoding="utf-8") if path else self.instruction @property - def engine(self) -> str | None: - """What registration.gemini_enterprise.engine says, or None when unlisted. + def registration_engine(self) -> str | None: + """What registration.gemini_enterprise.engine says, or None when absent. A name from gemini_enterprise.engines when gete.yaml declares them, the engine's id otherwise; Project.engine_id tells which. @@ -139,6 +142,17 @@ def engine(self) -> str | None: engine = registration.get("gemini_enterprise", {}).get("engine") return str(engine) if engine else None + @property + def engine(self) -> str | None: + """The engine the agent is listed in, or None when unlisted. + + The group's engine for an agent below agents//, what the + registration block says otherwise. The directory is what the tree + shows, so it wins over a block that disagrees; validate reports the + disagreement. + """ + return self.group or self.registration_engine + @property def source(self) -> Path | None: value = self.data.get("source") @@ -162,8 +176,9 @@ class Project: path: Path data: Mapping[str, Any] agents: tuple[Agent, ...] - # Agents whose agent.yaml did not pass the schema. They are reported and - # left out of agents, so the rules never see an unexpected shape. + # Agents whose agent.yaml did not pass the schema, and directories that + # are neither an agent nor an engine's group. They are reported and left + # out of agents, so the rules never see an unexpected shape. problems: tuple[Problem, ...] = () @property @@ -230,32 +245,113 @@ def find_project_file(start: Path) -> Path: def load_project(path: Path) -> Project: - """Read gete.yaml and every agents/*/agent.yaml below it. - - gete.yaml must pass its schema; nothing can be checked without it. Agents - that fail theirs are collected as problems rather than raised, so one broken - agent does not hide the state of the others. + """Read gete.yaml and the agents below it. + + An agent is agents//agent.yaml, or agents///agent.yaml + once gete.yaml names the engines: the directory then says where the agent + is listed. gete.yaml must pass its schema; nothing can be checked without + it. Agents that fail theirs are collected as problems rather than raised, + so one broken agent does not hide the state of the others. A directory + that is neither is a problem too, not something to step over: skipped + quietly, a misspelt engine would take every agent below it out of the + project, and the next apply would destroy them. """ data = read_yaml(path) validate_document("gete", data, source=path) project = Project(path=path, data=data, agents=()) agents: list[Agent] = [] problems: list[Problem] = [] - if project.agents_dir.is_dir(): - for directory in sorted(project.agents_dir.iterdir()): - agent_file = directory / AGENT_FILE - if not agent_file.is_file(): - continue - agent_data = read_yaml(agent_file) - found = schema_problems("agent", agent_data) - if found: - source = project.display(agent_file) - problems.extend(Problem(source, message) for message in found) - continue - agents.append(Agent(directory=directory, data=agent_data)) + engines = project.engines or {} + for directory in visible_directories(project.agents_dir): + if (directory / AGENT_FILE).is_file(): + _load_agent(project, directory, None, agents, problems) + if directory.name in engines: + problems.extend(_agents_below_an_agent(project, directory)) + elif directory.name in engines: + for member in visible_directories(directory): + if (member / AGENT_FILE).is_file(): + _load_agent(project, member, directory.name, agents, problems) + else: + problems.append( + Problem( + project.display(member), + f"no {AGENT_FILE}; a directory below an engine's " + "group is an agent, and groups do not nest", + ) + ) + else: + problems.append(Problem(project.display(directory), _not_a_group(project))) return Project(path=path, data=data, agents=tuple(agents), problems=tuple(problems)) +def visible_directories(parent: Path) -> list[Path]: + """The directories below parent, in name order; hidden ones are nobody's. + + Hidden entries hold tooling (a venv, a VCS directory) and stay out of the + archive for the same reason, so they are not reported as strays either. + """ + if not parent.is_dir(): + return [] + return sorted( + path + for path in parent.iterdir() + if path.is_dir() and not path.name.startswith(".") + ) + + +def _agents_below_an_agent(project: Project, directory: Path) -> list[Problem]: + """Agents put below an agent that carries an engine's name. + + The directory holds its own agent.yaml, so it is that agent and not the + engine's group, and an agent placed below it would be stepped over + without a word: the one thing this loader must not do. + """ + return [ + Problem( + project.display(member), + f"below {project.display(directory)}, which holds its own " + f"{AGENT_FILE} and so is the agent {directory.name!r}, not the group " + f"of the engine {directory.name!r}; an agent and an engine's group " + "cannot share a directory", + ) + for member in visible_directories(directory) + if (member / AGENT_FILE).is_file() + ] + + +def _load_agent( + project: Project, + directory: Path, + group: str | None, + agents: list[Agent], + problems: list[Problem], +) -> None: + agent_file = directory / AGENT_FILE + agent_data = read_yaml(agent_file) + found = schema_problems("agent", agent_data) + if found: + source = project.display(agent_file) + problems.extend(Problem(source, message) for message in found) + return + agents.append(Agent(directory=directory, data=agent_data, group=group)) + + +def _not_a_group(project: Project) -> str: + """Why a directory without agent.yaml is not read as an engine's group.""" + if project.engines is None: + return ( + f"no {AGENT_FILE}, and {PROJECT_FILE} names no engines; a directory " + "here is an agent, or an engine's group of agents once " + "gemini_enterprise.engines names the engine" + ) + names = ", ".join(project.engines) + return ( + f"no {AGENT_FILE}, and no engine of that name under " + f"gemini_enterprise.engines in {PROJECT_FILE} ({names}); a directory " + "here is an agent, or an engine's group of agents" + ) + + def resolve(project: Project, agent: Agent) -> dict[str, Any]: """Fold gete.yaml into one agent's declaration. diff --git a/src/gete/scaffold.py b/src/gete/scaffold.py index 863ddba..47b2843 100644 --- a/src/gete/scaffold.py +++ b/src/gete/scaffold.py @@ -3,9 +3,15 @@ import re from pathlib import Path from string import Template +from typing import Any from gete._yaml import read_yaml -from gete.declaration import AGENT_FILE, DEFAULT_AGENTS_DIR, PROJECT_FILE +from gete.declaration import ( + AGENT_FILE, + DEFAULT_AGENTS_DIR, + PROJECT_FILE, + visible_directories, +) from gete.errors import DeclarationError from gete.templates import template_text @@ -26,17 +32,56 @@ def init_project(root: Path) -> list[Path]: def init_agent(root: Path, name: str) -> list[Path]: - """Write agents//agent.yaml and instruction.md unless they exist.""" + """Write agents//agent.yaml and instruction.md unless they exist. + + / puts the agent below the engine's directory, where it is + listed in that engine. The engine has to be named in gete.yaml already: + validate would refuse the directory otherwise, and a scaffold that fails + its own validator teaches the wrong shape. + """ + *groups, name = name.split("/") + if len(groups) > 1: + raise DeclarationError( + f"{'/'.join(groups)!r}: agents group one level deep, as /" + ) if len(name) > 63 or not _LABEL.match(name): raise DeclarationError( f"{name!r} is not a valid agent name: lowercase letters, digits, hyphens" ) - agents_dir = root / DEFAULT_AGENTS_DIR + declared: dict[str, Any] = {} project_file = root / PROJECT_FILE if project_file.is_file(): declared = read_yaml(project_file) or {} - agents_dir = root / declared.get("agents_dir", DEFAULT_AGENTS_DIR) - directory = agents_dir / name + agents_dir = Path(declared.get("agents_dir", DEFAULT_AGENTS_DIR)) + directory = root / agents_dir + engines = (declared.get("gemini_enterprise") or {}).get("engines") or {} + # An agent and an engine's group cannot share a directory: the loader + # reads a directory with agent.yaml as the agent and steps over nothing + # below it, so either shape written here would hide agents. + if groups: + if groups[0] not in engines: + named = f" ({', '.join(engines)})" if engines else "" + raise DeclarationError( + f"{groups[0]!r} is not an engine named under " + f"gemini_enterprise.engines in {PROJECT_FILE}{named}; name it " + "there first, and agents below that directory are listed in it" + ) + directory = directory / groups[0] + if (directory / AGENT_FILE).is_file(): + raise DeclarationError( + f"{groups[0]!r} is an agent, not the engine's group: " + f"{agents_dir / groups[0] / AGENT_FILE} exists, and an agent and " + "an engine's group cannot share a directory" + ) + elif name in engines and any( + (member / AGENT_FILE).is_file() + for member in visible_directories(directory / name) + ): + raise DeclarationError( + f"{name!r} is the engine's group and holds agents; an agent and an " + "engine's group cannot share a directory" + ) + directory = directory / name return [ path for path, text in ( diff --git a/src/gete/templates/agent.yaml b/src/gete/templates/agent.yaml index 2b3be8f..02788cb 100644 --- a/src/gete/templates/agent.yaml +++ b/src/gete/templates/agent.yaml @@ -17,6 +17,7 @@ instruction: ./instruction.md # Where the agent is listed: the id from the Gemini Enterprise console URL, # or its name once gete.yaml names the engines under gemini_enterprise.engines. +# An agent below agents// is listed there and needs no block. # registration: # gemini_enterprise: # engine: my-app_1234567890 diff --git a/src/gete/validate.py b/src/gete/validate.py index 50080ea..67b6f3c 100644 --- a/src/gete/validate.py +++ b/src/gete/validate.py @@ -111,6 +111,18 @@ def _agent_problems( f"name {agent.name!r} differs from directory {agent.directory.name!r}; " "the module, service account, and archive are named after the directory" ) + # Possible once agents sit in groups: two directories, one name, and both + # would claim the module, the service account, and the reasoning engine. + twins = [ + project.display(other.directory) + for other in project.agents + if other.name == agent.name and other.directory != agent.directory + ] + if twins: + found.append( + f"name {agent.name!r} is also {', '.join(twins)}; the module, " + "service account, and reasoning engine are named after it" + ) account = f"{agent.name}{SERVICE_ACCOUNT_SUFFIX}" if not ( MIN_SERVICE_ACCOUNT_ID_LENGTH <= len(account) <= MAX_SERVICE_ACCOUNT_ID_LENGTH @@ -184,11 +196,22 @@ def _agent_problems( found.extend( f"connections: {message}" for message in elimination_problems(known, registry) ) - if agent.engine is not None: + written = agent.registration_engine + if written is not None and agent.group is None: try: - project.engine_id(agent.engine) + project.engine_id(written) except DeclarationError as error: found.append(str(error)) + elif written is not None and written != agent.group: + # The block an agent moved into a group brought along, and then + # edited: the directory is what the tree shows, so it is the one + # that counts, and the block has nothing left to say. Whether what + # it says is a name or an id makes no difference, so one line. + found.append( + f"registration.gemini_enterprise.engine: {written!r} differs from " + f"the directory, which lists the agent in {agent.group!r}; an " + "agent below an engine's group needs no registration block" + ) declared_shared: Mapping[str, Any] = project.data.get("shared_credentials", {}) for name in agent.shared_credentials: credential = SHARED_CREDENTIALS.get(name) diff --git a/tests/conftest.py b/tests/conftest.py index 24300ab..7d2aa71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,8 @@ def write_agent( ) -> Path: agent_dir = self.agents_dir / directory agent_dir.mkdir(parents=True, exist_ok=True) - body = {**MINIMAL_AGENT, "name": directory, **(document or {})} + # The directory may be /; the name is the last part. + body = {**MINIMAL_AGENT, "name": Path(directory).name, **(document or {})} (agent_dir / "agent.yaml").write_text(yaml.safe_dump(body, sort_keys=False)) if instruction is not None: (agent_dir / "instruction.md").write_text(instruction) diff --git a/tests/test_archive.py b/tests/test_archive.py index c828915..9f31096 100644 --- a/tests/test_archive.py +++ b/tests/test_archive.py @@ -562,3 +562,20 @@ def test_an_archive_refuses_a_declaration_pruning_cannot_keep( (directory / "specs" / "service.yaml").write_text(POINTED_OPENAPI_SPEC) with pytest.raises(DeclarationError, match="pruned"): build_archive(directory) + + +def test_an_agent_below_an_engines_directory_is_packed_from_there( + project: ProjectBuilder, +) -> None: + project.write_project( + { + "version": 1, + "project": "example-project", + "location": "us-central1", + "gemini_enterprise": {"engines": {"sales": "my-sales-app_1234567890"}}, + } + ) + directory = project.write_agent("sales/mail-triage") + result = build_archive(directory) + resolved = yaml.safe_load(members(result)[RESOLVED_FILE]) + assert resolved["name"] == "mail-triage" diff --git a/tests/test_graph.py b/tests/test_graph.py index d6393cb..a948310 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -221,3 +221,20 @@ def test_the_engine_is_drawn_by_its_name_when_gete_yaml_names_it( assert 'GE_sales["Gemini Enterprise
sales"]' in text assert "GE_sales --> finance" in text assert "my-sales-app_1234567890" not in text + + +def test_an_agent_below_an_engines_directory_hangs_from_that_engine( + project: ProjectBuilder, +) -> None: + project.write_project( + { + "version": 1, + "project": "example-project", + "location": "us-central1", + "gemini_enterprise": {"engines": {"sales": "my-sales-app_1234567890"}}, + } + ) + project.write_agent("sales/finance") + text = graph(project) + assert 'GE_sales["Gemini Enterprise
sales"]' in text + assert "GE_sales --> finance" in text diff --git a/tests/test_init.py b/tests/test_init.py index a49ea8b..92d8811 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -80,3 +80,91 @@ def test_cli_init_adds_to_an_existing_project_from_anywhere_below_it( result = CliRunner().invoke(main, ["init", "new-agent"]) assert result.exit_code == 0, result.output assert (project.root / "agents" / "new-agent" / "agent.yaml").is_file() + + +NAMED_ENGINES = ( + "version: 1\n" + "project: example-project\n" + "location: us-central1\n" + "gemini_enterprise:\n" + " engines:\n" + " sales: my-sales-app_1234567890\n" +) + + +def test_init_agent_below_an_engines_directory(tmp_path: Path) -> None: + """gete init sales/mail-triage: the agent is listed in sales by where it is.""" + init_project(tmp_path) + (tmp_path / "gete.yaml").write_text(NAMED_ENGINES) + written = init_agent(tmp_path, "sales/mail-triage") + assert {p.name for p in written} == {"agent.yaml", "instruction.md"} + assert (tmp_path / "agents" / "sales" / "mail-triage" / "agent.yaml").is_file() + project = load_project(tmp_path / "gete.yaml") + assert [str(p) for p in validate_project(project)] == [] + assert project.agents[0].name == "mail-triage" + assert project.agents[0].engine == "sales" + + +def test_init_agent_refuses_a_group_that_is_no_named_engine(tmp_path: Path) -> None: + """validate would refuse the directory; better to refuse before writing it.""" + from gete.errors import DeclarationError + + init_project(tmp_path) + with pytest.raises(DeclarationError, match="'sales'.*gemini_enterprise.engines"): + init_agent(tmp_path, "sales/mail-triage") + (tmp_path / "gete.yaml").write_text(NAMED_ENGINES) + with pytest.raises(DeclarationError, match="'support'.*sales"): + init_agent(tmp_path, "support/mail-triage") + assert not (tmp_path / "agents").exists() + + +def test_init_agent_refuses_a_second_directory_level(tmp_path: Path) -> None: + from gete.errors import DeclarationError + + init_project(tmp_path) + (tmp_path / "gete.yaml").write_text(NAMED_ENGINES) + with pytest.raises(DeclarationError, match="one level"): + init_agent(tmp_path, "sales/eu/mail-triage") + assert not (tmp_path / "agents").exists() + + +def test_cli_init_says_what_it_wrote_before_refusing_the_group( + outside_any_project: Path, +) -> None: + """A fresh gete.yaml names no engines, so the group is refused; the file + it did create is still reported, since that is where the engine goes.""" + result = CliRunner().invoke(main, ["init", "sales/mail-triage"]) + assert result.exit_code == 1, result.output + assert (outside_any_project / "gete.yaml").is_file() + assert "gete.yaml" in result.output + assert "'sales'" in result.output + + +def test_init_agent_refuses_a_group_whose_directory_is_an_agent( + tmp_path: Path, +) -> None: + """agents/sales/agent.yaml makes sales an agent; an agent below it would + be stepped over by the loader, so init refuses to write one.""" + from gete.errors import DeclarationError + + init_project(tmp_path) + (tmp_path / "gete.yaml").write_text(NAMED_ENGINES) + init_agent(tmp_path, "sales") + with pytest.raises(DeclarationError, match="'sales'.*agent"): + init_agent(tmp_path, "sales/mail-triage") + assert not (tmp_path / "agents" / "sales" / "mail-triage").exists() + + +def test_init_agent_refuses_an_agent_on_top_of_an_engines_group( + tmp_path: Path, +) -> None: + """The reverse: agents/sales/ already holds agents, so gete init sales + would turn the group into an agent and hide them.""" + from gete.errors import DeclarationError + + init_project(tmp_path) + (tmp_path / "gete.yaml").write_text(NAMED_ENGINES) + init_agent(tmp_path, "sales/mail-triage") + with pytest.raises(DeclarationError, match="'sales'.*group"): + init_agent(tmp_path, "sales") + assert not (tmp_path / "agents" / "sales" / "agent.yaml").exists() diff --git a/tests/test_register.py b/tests/test_register.py index 7dd5638..0e107ec 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -1248,3 +1248,20 @@ def test_a_name_no_engine_carries_fails_that_agent_before_anything_is_read( assert any("'app_1'" in line and "sales" in line for line in summary.messages) assert gcp.calls == [] assert not (tmp_path / "n.md").exists() + + +def test_an_agent_below_an_engines_directory_registers_with_that_engine( + project: ProjectBuilder, gcp: FakeGcp, tmp_path: Path +) -> None: + """The directory names the engine; the agent carries no registration block.""" + project.write_project(NAMED_ENGINES) + project.write_agent( + "sales/finance", {k: v for k, v in FINANCE.items() if k != "registration"} + ) + summary = register_project( + load_project(project.root / "gete.yaml"), gcp, tmp_path / "n.md" + ) + assert summary.failed == [] + assert summary.skipped == [] + assert "finance: Gemini Enterprise engine sales (app_1)" in summary.messages + assert any(url == AGENTS_URL for _, url, _, _ in gcp.calls) diff --git a/tests/test_terraform.py b/tests/test_terraform.py index 2fffd45..3b8a0ba 100644 --- a/tests/test_terraform.py +++ b/tests/test_terraform.py @@ -248,3 +248,25 @@ def test_an_agent_without_the_app_connection_gets_no_key( project.write_project(APP_PROJECT) project.write_agent("triage") assert "GETE_APP_KEY" not in files(project)["triage.tf"] + + +def test_an_agent_below_an_engines_directory_keeps_its_module_and_moves_its_path( + project: ProjectBuilder, +) -> None: + """Moving an agent into a group changes agent_directory and nothing else: + the module address and the service account stay, so nothing is recreated.""" + project.write_project( + { + "version": 1, + "project": "example-project", + "location": "us-central1", + "gemini_enterprise": {"engines": {"sales": "my-sales-app_1234567890"}}, + } + ) + project.write_agent("sales/mail-triage") + generated = files(project) + assert set(generated) == {"mail_triage.tf"} + text = generated["mail_triage.tf"] + assert 'module "mail_triage" {' in text + assert 'name = "mail-triage"' in text + assert 'agent_directory = "${path.module}/../agents/sales/mail-triage"' in text diff --git a/tests/test_validate.py b/tests/test_validate.py index 09ebeb6..c31cbbc 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -520,13 +520,6 @@ def test_agent_schema_errors_are_reported_with_their_file( assert any("agent.yaml" in p and "modle" in p for p in problems(project)) -def test_agents_without_declaration_are_skipped(project: ProjectBuilder) -> None: - """A stray directory under agents/ is not an agent.""" - (project.agents_dir / "notes").mkdir() - project.write_agent("mail-triage") - assert problems(project) == [] - - def test_duplicate_policy_names_are_reported(project: ProjectBuilder) -> None: project.write_policies("a", [{"name": "finance", "when": "always"}]) project.write_policies("b", [{"name": "finance", "when": "always"}]) @@ -1111,3 +1104,152 @@ def test_two_names_for_one_engine_are_reported(project: ProjectBuilder) -> None: assert len(found) == 1, found assert found[0].startswith("gete.yaml: gemini_enterprise.engines") assert "sales" in found[0] and "support" in found[0] and "app_1" in found[0] + + +# --- agents grouped by engine: agents// + + +def test_an_agent_below_an_engines_directory_is_listed_in_that_engine( + project: ProjectBuilder, +) -> None: + """The directory says where the agent is listed; no registration block.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage") + loaded = load_project(project.root / "gete.yaml") + assert [agent.name for agent in loaded.agents] == ["mail-triage"] + assert loaded.agents[0].directory == project.agents_dir / "sales" / "mail-triage" + assert loaded.agents[0].engine == "sales" + assert problems(project) == [] + + +def test_top_level_agents_and_groups_coexist_in_path_order( + project: ProjectBuilder, +) -> None: + """An agent listed nowhere stays directly below agents/, as before.""" + project.write_project(NAMED_ENGINES) + project.write_agent("support/a-agent") + project.write_agent("local-only") + project.write_agent("sales/z-agent") + project.write_agent("sales/b-agent") + loaded = load_project(project.root / "gete.yaml") + assert [(agent.name, agent.engine) for agent in loaded.agents] == [ + ("local-only", None), + ("b-agent", "sales"), + ("z-agent", "sales"), + ("a-agent", "support"), + ] + assert problems(project) == [] + + +def test_a_directory_that_is_neither_an_agent_nor_an_engine_is_reported( + project: ProjectBuilder, +) -> None: + """Stepped over quietly, a misspelt engine would drop every agent below + it, and the next apply would destroy them.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sale/mail-triage") + found = problems(project) + assert len(found) == 1, found + assert found[0].startswith("agents/sale: no agent.yaml") + assert "sales, support" in found[0] + + +def test_without_named_engines_a_directory_without_agent_yaml_is_reported( + project: ProjectBuilder, +) -> None: + (project.agents_dir / "notes").mkdir() + project.write_agent("mail-triage") + found = problems(project) + assert len(found) == 1, found + assert found[0].startswith("agents/notes: no agent.yaml") + assert "gemini_enterprise.engines" in found[0] + + +def test_groups_do_not_nest(project: ProjectBuilder) -> None: + """One level is enough; deeper, a typo in a path would open a new level.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage") + (project.agents_dir / "sales" / "docs").mkdir() + found = problems(project) + assert len(found) == 1, found + assert found[0].startswith("agents/sales/docs: no agent.yaml") + + +def test_hidden_directories_are_not_strays(project: ProjectBuilder) -> None: + """A venv or a VCS directory below agents/ is tooling, not a declaration.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage") + (project.agents_dir / ".venv").mkdir() + (project.agents_dir / "sales" / ".cache").mkdir() + assert problems(project) == [] + + +def test_a_grouped_agent_that_fails_the_schema_is_reported_with_its_file( + project: ProjectBuilder, +) -> None: + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage", {"modle": "typo"}) + found = problems(project) + assert any(p.startswith("agents/sales/mail-triage/agent.yaml") for p in found) + assert any("modle" in p for p in found) + + +def test_a_registration_that_disagrees_with_the_group_is_reported( + project: ProjectBuilder, +) -> None: + """The block is the stale copy an agent moved into a group leaves behind.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage", registered_with("support")) + found = problems(project) + assert len(found) == 1, found + assert found[0].startswith("agents/sales/mail-triage/agent.yaml: registration") + assert "'support'" in found[0] and "sales" in found[0] + + +def test_a_registration_that_agrees_with_the_group_is_accepted( + project: ProjectBuilder, +) -> None: + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage", registered_with("sales")) + assert problems(project) == [] + + +def test_one_name_in_two_groups_is_reported_on_both(project: ProjectBuilder) -> None: + """The module, service account, and reasoning engine are named after it; + two directories would fight over one of each.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage") + project.write_agent("support/mail-triage") + found = problems(project) + assert len(found) == 2, found + assert found[0].startswith("agents/sales/mail-triage/agent.yaml: name") + assert "agents/support/mail-triage" in found[0] + assert found[1].startswith("agents/support/mail-triage/agent.yaml: name") + assert "agents/sales/mail-triage" in found[1] + + +def test_an_agent_named_after_an_engine_is_not_that_engines_group( + project: ProjectBuilder, +) -> None: + """The directory holds its own agent.yaml, so it is an agent; an agent put + below it would otherwise be stepped over without a word.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales") + project.write_agent("sales/mail-triage") + loaded = load_project(project.root / "gete.yaml") + assert [agent.name for agent in loaded.agents] == ["sales"] + found = problems(project) + assert len(found) == 1, found + assert found[0].startswith("agents/sales/mail-triage: ") + assert "agents/sales" in found[0] and "'sales'" in found[0] + + +def test_a_grouped_agent_that_writes_the_engines_id_is_reported_once( + project: ProjectBuilder, +) -> None: + """One mistake, one line: the block is stale, and that is the whole story.""" + project.write_project(NAMED_ENGINES) + project.write_agent("sales/mail-triage", registered_with("my-sales-app_1234567890")) + found = problems(project) + assert len(found) == 1, found + assert "differs from the directory" in found[0]