From bd7917d676b2aa526a9d004b417415b2e833f6f9 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:50:08 +0900 Subject: [PATCH 1/4] Read agents grouped below an engine's directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the engines named in gete.yaml, an agent refers to its engine by name, but the tree still does not show which agent is listed where: every agent sits directly below agents/, and the engine is one line inside each agent.yaml. With a dozen agents across several engines the natural layout is one directory per engine, and until now that layout was ignored without a word. load_project read agents/*/agent.yaml and nothing else, so validate passed, terraform wrote no module call for the agents below the group and dropped the ones it had written, and the plan destroyed their reasoning engines. A directory below agents/ that carries a named engine's name now holds the agents listed in that engine. They need no registration block; one that disagrees with the directory is reported, and the directory is what counts, since it is what the tree shows. The name still matches the agent's own directory, so the module address, the service account, and the archive are unchanged by the move: only the generated agent_directory changes, and nothing is recreated. Groups go one level deep. A directory below a group without agent.yaml is reported, and so is a directory below agents/ that is neither an agent nor a named engine, since stepping over it quietly is what made the layout dangerous: a misspelt engine would take every agent below it out of the project. Hidden directories are tooling and stay unreported, as they stay out of the archive. Two directories with one name, which a flat agents/ could not hold, are reported on both: the module, the service account, and the reasoning engine are named after it. 🤖 Generated with Claude Code --- src/gete/declaration.py | 116 +++++++++++++++++++++++++++++------- src/gete/validate.py | 26 +++++++- tests/conftest.py | 3 +- tests/test_archive.py | 17 ++++++ tests/test_graph.py | 17 ++++++ tests/test_register.py | 17 ++++++ tests/test_terraform.py | 22 +++++++ tests/test_validate.py | 129 +++++++++++++++++++++++++++++++++++++--- 8 files changed, 316 insertions(+), 31 deletions(-) diff --git a/src/gete/declaration.py b/src/gete/declaration.py index 2903292..d53063e 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,91 @@ 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) + 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 _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/validate.py b/src/gete/validate.py index 50080ea..bcaa40d 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,21 @@ 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: try: - project.engine_id(agent.engine) + project.engine_id(written) except DeclarationError as error: found.append(str(error)) + if agent.group 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. + 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_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..2215096 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,125 @@ 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] From c965011da6a3b0b7b0acd025bf4d3550ce7dc5c8 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:50:08 +0900 Subject: [PATCH 2/4] Let gete init scaffold an agent below an engine's directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gete init sales/mail-triage writes agents/sales/mail-triage/, where the agent is listed in sales by where it sits. The engine has to be named in gete.yaml first: validate refuses the directory otherwise, and a scaffold that fails its own validator teaches the wrong shape. One level only, like the loader. When init created the project and then refuses the group, the files it did write are still listed, since the gete.yaml among them is where the engine goes. 🤖 Generated with Claude Code --- src/gete/cli/__init__.py | 11 ++++++- src/gete/scaffold.py | 30 +++++++++++++++--- src/gete/templates/agent.yaml | 1 + tests/test_init.py | 58 +++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) 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/scaffold.py b/src/gete/scaffold.py index 863ddba..548d1bc 100644 --- a/src/gete/scaffold.py +++ b/src/gete/scaffold.py @@ -3,6 +3,7 @@ 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 @@ -26,17 +27,38 @@ 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 + directory = root / declared.get("agents_dir", DEFAULT_AGENTS_DIR) + if groups: + engines = (declared.get("gemini_enterprise") or {}).get("engines") or {} + 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] + 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/tests/test_init.py b/tests/test_init.py index a49ea8b..2a26929 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -80,3 +80,61 @@ 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 From fce0b3739cae7c387fe9d49a2fb5fa49811a0793 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:50:08 +0900 Subject: [PATCH 3/4] Describe engine groups in the README and the quickstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- README.md | 26 +++++++++++++++++++++++++- docs/quickstart.md | 5 +++-- 2 files changed, 28 insertions(+), 3 deletions(-) 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 From b64f6f5eb7dde2879942c2c8394344d56f705dd7 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:20:42 +0900 Subject: [PATCH 4/4] Keep an agent and an engine's group out of one directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the engines named, a top-level agent may carry an engine's name, and the loader reads a directory with agent.yaml as that agent before it looks for a group. An agent written below it, which gete init accepted, was then stepped over without a word and validate passed: the silent skip the grouped layout was meant to remove, back through a side door. The loader now reports an agent.yaml found below an agent named after an engine, and init refuses both shapes before writing: a group below a directory that is an agent, and an agent on top of a directory that already holds the engine's agents. A grouped agent whose registration block carries the engine's id was reported twice, once for not being a name and once for disagreeing with the directory. The directory is what counts there, so the block gets the one line that says so. 🤖 Generated with Claude Code --- src/gete/declaration.py | 28 +++++++++++++++++++++++++--- src/gete/scaffold.py | 29 ++++++++++++++++++++++++++--- src/gete/validate.py | 21 +++++++++++---------- tests/test_init.py | 30 ++++++++++++++++++++++++++++++ tests/test_validate.py | 27 +++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/gete/declaration.py b/src/gete/declaration.py index d53063e..44e3bd7 100644 --- a/src/gete/declaration.py +++ b/src/gete/declaration.py @@ -262,11 +262,13 @@ def load_project(path: Path) -> Project: agents: list[Agent] = [] problems: list[Problem] = [] engines = project.engines or {} - for directory in _visible_directories(project.agents_dir): + 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): + for member in visible_directories(directory): if (member / AGENT_FILE).is_file(): _load_agent(project, member, directory.name, agents, problems) else: @@ -282,7 +284,7 @@ def load_project(path: Path) -> Project: return Project(path=path, data=data, agents=tuple(agents), problems=tuple(problems)) -def _visible_directories(parent: Path) -> list[Path]: +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 @@ -297,6 +299,26 @@ def _visible_directories(parent: Path) -> list[Path]: ) +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, diff --git a/src/gete/scaffold.py b/src/gete/scaffold.py index 548d1bc..47b2843 100644 --- a/src/gete/scaffold.py +++ b/src/gete/scaffold.py @@ -6,7 +6,12 @@ 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 @@ -47,9 +52,13 @@ def init_agent(root: Path, name: str) -> list[Path]: project_file = root / PROJECT_FILE if project_file.is_file(): declared = read_yaml(project_file) or {} - directory = root / declared.get("agents_dir", DEFAULT_AGENTS_DIR) + 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: - engines = (declared.get("gemini_enterprise") or {}).get("engines") or {} if groups[0] not in engines: named = f" ({', '.join(engines)})" if engines else "" raise DeclarationError( @@ -58,6 +67,20 @@ def init_agent(root: Path, name: str) -> list[Path]: "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 diff --git a/src/gete/validate.py b/src/gete/validate.py index bcaa40d..67b6f3c 100644 --- a/src/gete/validate.py +++ b/src/gete/validate.py @@ -197,20 +197,21 @@ def _agent_problems( f"connections: {message}" for message in elimination_problems(known, registry) ) written = agent.registration_engine - if written is not None: + if written is not None and agent.group is None: try: project.engine_id(written) except DeclarationError as error: found.append(str(error)) - if agent.group 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. - 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" - ) + 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/test_init.py b/tests/test_init.py index 2a26929..92d8811 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -138,3 +138,33 @@ def test_cli_init_says_what_it_wrote_before_refusing_the_group( 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_validate.py b/tests/test_validate.py index 2215096..c31cbbc 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -1226,3 +1226,30 @@ def test_one_name_in_two_groups_is_reported_on_both(project: ProjectBuilder) -> 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]