From bcf2634000dbe3655afae15686e1d0831036a213 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:29:15 +0200 Subject: [PATCH] Add AGENTS.md protocol guidance Load active-project AGENTS.md path-chain guidance into the protocol area without duplicating the project root instructions. Move the AGENTS.md protocol wording into a prompt template, reuse existing file/path helpers, and cover direct-path discovery plus prompt assembly with focused tests. --- extensions/python/system_prompt/AGENTS.md | 2 +- .../system_prompt/_14_project_prompt.py | 7 ++ helpers/AGENTS.md | 2 +- helpers/projects.py | 84 +++++++++++++++++-- helpers/projects.py.dox.md | 4 + prompts/agent.protocol.projects.agents_md.md | 6 ++ tests/test_projects.py | 62 ++++++++++++++ tests/test_prompt_protocol.py | 46 ++++++++++ 8 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 prompts/agent.protocol.projects.agents_md.md diff --git a/extensions/python/system_prompt/AGENTS.md b/extensions/python/system_prompt/AGENTS.md index 34b90f78c..c4aa1bfd8 100644 --- a/extensions/python/system_prompt/AGENTS.md +++ b/extensions/python/system_prompt/AGENTS.md @@ -7,7 +7,7 @@ ## Ownership - Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections. -- Active project instruction bodies are moved into prompt protocol; the system prompt keeps project metadata and stable project rules. +- Active project instruction bodies and active-project AGENTS.md path-chain guidance are moved into prompt protocol; the system prompt keeps project metadata and stable project rules. ## Local Contracts diff --git a/extensions/python/system_prompt/_14_project_prompt.py b/extensions/python/system_prompt/_14_project_prompt.py index cd0363b4c..4afa620cd 100644 --- a/extensions/python/system_prompt/_14_project_prompt.py +++ b/extensions/python/system_prompt/_14_project_prompt.py @@ -25,9 +25,16 @@ async def build_prompt(agent: Agent, loop_data: LoopData | None = None) -> str: result = agent.read_prompt("agent.system.projects.main.md") project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT) if loop_data: + loop_data.protocol_persistent.pop("agents_md_instructions", None) loop_data.protocol_persistent.pop("project_instructions", None) if project_name: project_vars = projects.build_system_prompt_vars(project_name) + if loop_data and project_vars.get("include_agents_md", True): + agents_md_protocol = projects.build_agents_md_protocol(project_name) + if agents_md_protocol: + loop_data.protocol_persistent["agents_md_instructions"] = ( + agents_md_protocol + ) if loop_data and project_vars.get("project_instructions"): loop_data.protocol_persistent["project_instructions"] = agent.read_prompt( "agent.protocol.projects.instructions.md", diff --git a/helpers/AGENTS.md b/helpers/AGENTS.md index dc7584d32..39d24d71e 100644 --- a/helpers/AGENTS.md +++ b/helpers/AGENTS.md @@ -15,7 +15,7 @@ - Preserve public helper APIs used by core code and plugins unless all callers, docs, and tests are updated. - Use structured parsers and serializers for YAML, JSON, paths, and URLs instead of ad hoc string handling. - Keep path handling constrained to intended roots for user files, uploads, downloads, projects, and workdirs. -- Project metadata defaults must remain backwards-compatible; missing `include_agents_md` is treated as enabled and project instruction file content is injected with an explicit source path. +- Project metadata defaults must remain backwards-compatible; missing `include_agents_md` is treated as enabled, project instruction file content is injected with an explicit source path, and active-project AGENTS.md path-chain guidance is assembled into prompt protocol without duplicating the project root AGENTS.md. - Do not hardcode secrets, provider keys, local absolute paths, or environment-specific values. - Use `RepairableException` for errors an agent may be able to fix. - This directory is a file-documented DOX profile: every direct `*.py` helper module must have a same-directory `*.py.dox.md` file named by appending `.dox.md` to the full Python filename. diff --git a/helpers/projects.py b/helpers/projects.py index f146d02a6..41bbf9d44 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -1,5 +1,5 @@ import os -from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast +from typing import NotRequired, TypedDict, TYPE_CHECKING, cast from helpers import files, dirty_json, persist_chat, file_tree, extension from helpers.print_style import PrintStyle @@ -15,7 +15,13 @@ PROJECT_KNOWLEDGE_DIR = "knowledge" PROJECT_SKILLS_DIR = "skills" PROJECT_HEADER_FILE = "project.json" PROJECT_MCP_SERVERS_FILE = "mcp_servers.json" -PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md") +PROJECT_AGENTS_MD_FILES = ( + "AGENTS.override.md", + "AGENTS.Override.md", + "AGENTS.md", + "Agents.md", + "agents.md", +) DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}' CONTEXT_DATA_KEY_PROJECT = "project" @@ -466,9 +472,10 @@ def deactivate_project_in_chats(name: str): def build_system_prompt_vars(name: str): project_data = load_basic_project_data(name) main_instructions = project_data.get("instructions", "") or "" + include_agents_md = project_data.get("include_agents_md", True) instruction_files = get_project_instruction_files( name, - include_agents_md=project_data.get("include_agents_md", True), + include_agents_md=include_agents_md, ) instruction_parts = [ main_instructions, @@ -481,11 +488,75 @@ def build_system_prompt_vars(name: str): "project_name": project_data.get("title", ""), "project_description": project_data.get("description", ""), "project_instructions": complete_instructions or "", + "include_agents_md": include_agents_md, "project_path": files.normalize_a0_path(get_project_folder(name)), "project_git_url": project_data.get("git_url", ""), } +def get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]: + root_real = os.path.realpath(files.fix_dev_path(root)) + target_real = os.path.realpath(files.fix_dev_path(target)) + if os.path.isfile(target_real): + target_real = os.path.dirname(target_real) + + if files.is_in_dir(target_real, root_real): + dirs = [] + cursor = target_real + while True: + dirs.append(cursor) + if cursor == root_real: + break + parent = os.path.dirname(cursor) + if parent == cursor: + break + cursor = parent + dirs.reverse() + else: + dirs = [root_real] + + chain = [] + for dir_path in dirs: + for filename in PROJECT_AGENTS_MD_FILES: + matches = files.read_text_files_in_dir(dir_path, pattern=filename) + if filename not in matches: + continue + chain.append((files.get_abs_path(dir_path, filename), matches[filename])) + break + return chain + + +def build_agents_md_protocol(name: str, target: str | None = None) -> str: + project_folder = get_project_folder(name) + project_agents_md = get_project_agents_md_instruction_file(name) + project_agents_md_path = ( + os.path.realpath(files.fix_dev_path(project_agents_md[0])) + if project_agents_md + else "" + ) + entries = [ + (path, content) + for path, content in get_agents_md_chain( + files.get_abs_path(""), + target or project_folder, + ) + if os.path.realpath(path) != project_agents_md_path + ] + if not entries: + return "" + + instructions = [] + for path, content in entries: + instructions.append( + f"### path: {files.normalize_a0_path(path)}\n\n{content.strip()}" + ) + return files.read_prompt_file( + "agent.protocol.projects.agents_md.md", + _directories=["prompts"], + agents_md_instructions="\n\n".join(instructions), + ).strip() + + def get_additional_instructions_files(name: str): instructions_folder = files.get_abs_path( get_project_folder(name), PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR @@ -522,11 +593,8 @@ def get_project_instruction_files( def get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None: project_folder = get_project_folder(name) - for filename in PROJECT_AGENTS_MD_FILES: - matches = files.read_text_files_in_dir(project_folder, pattern=filename) - if filename in matches: - path = files.get_abs_path(project_folder, filename) - return (files.normalize_a0_path(path), matches[filename]) + for path, content in get_agents_md_chain(project_folder, project_folder): + return (files.normalize_a0_path(path), content) return None diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index aae7514d0..e1e1b592e 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -47,6 +47,8 @@ - `reactivate_project_in_chats(name: str)` - `deactivate_project_in_chats(name: str)` - `build_system_prompt_vars(name: str)` +- `get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]` +- `build_agents_md_protocol(name: str, target: str | None=...) -> str` - `get_additional_instructions_files(name: str)` - `get_project_instruction_files(name: str, include_agents_md: bool=...) -> list[tuple[str, str]]` - `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None` @@ -63,6 +65,8 @@ - Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`. - Project extension save payloads exclude core project fields and transient inputs such as `git_token`; plugins needing core metadata should load it by project name. - Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently. +- AGENTS.md discovery is a linear root-to-target chain walk with `AGENTS.override.md` precedence; sibling directories are not scanned. +- Active-project AGENTS.md protocol guidance excludes the exact project root AGENTS.md because `build_system_prompt_vars(...)` already loads it into project instructions; prose for that protocol block lives in `prompts/agent.protocol.projects.agents_md.md`. - Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`. - Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling. diff --git a/prompts/agent.protocol.projects.agents_md.md b/prompts/agent.protocol.projects.agents_md.md new file mode 100644 index 000000000..916f3c122 --- /dev/null +++ b/prompts/agent.protocol.projects.agents_md.md @@ -0,0 +1,6 @@ +# AGENTS.md instructions +- these path-owned instructions are binding for the current project/workdir chain +- active project root AGENTS.md is loaded separately in project instructions +- before editing a deeper target, read AGENTS.md files on the direct path to that target; ignore siblings + +{{agents_md_instructions}} diff --git a/tests/test_projects.py b/tests/test_projects.py index 8dac0d2c1..0a2192ebb 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -107,6 +107,20 @@ def test_project_system_prompt_includes_root_agents_md_with_path(monkeypatch, tm assert "Folder instruction rule." in instructions +def test_project_system_prompt_prefers_agents_override_md(monkeypatch, tmp_path): + _prepare_project_tree(monkeypatch, tmp_path) + projects.create_project("demo", {"title": "Demo"}) + project_root = tmp_path / "usr" / "projects" / "demo" + (project_root / "AGENTS.md").write_text("Standard rule.", encoding="utf-8") + (project_root / "AGENTS.override.md").write_text("Override rule.", encoding="utf-8") + + instructions = projects.build_system_prompt_vars("demo")["project_instructions"] + + assert "### path: /a0/usr/projects/demo/AGENTS.override.md" in instructions + assert "Override rule." in instructions + assert "Standard rule." not in instructions + + def test_project_system_prompt_respects_disabled_agents_md(monkeypatch, tmp_path): _prepare_project_tree(monkeypatch, tmp_path) projects.create_project( @@ -123,3 +137,51 @@ def test_project_system_prompt_respects_disabled_agents_md(monkeypatch, tmp_path assert "Root AGENTS rule." not in prompt_vars["project_instructions"] assert "AGENTS.md" not in prompt_vars["project_instructions"] + + +def test_agents_md_chain_walks_direct_path_only(monkeypatch, tmp_path): + _prepare_project_tree(monkeypatch, tmp_path) + root = tmp_path + (root / "AGENTS.md").write_text("root doc", encoding="utf-8") + target = root / "services" / "payments" + sibling = root / "services" / "auth" + target.mkdir(parents=True) + sibling.mkdir(parents=True) + (root / "services" / "AGENTS.md").write_text("services doc", encoding="utf-8") + (target / "AGENTS.md").write_text("payments doc", encoding="utf-8") + (sibling / "AGENTS.md").write_text("auth doc", encoding="utf-8") + + chain = projects.get_agents_md_chain(str(root), str(target / "handler.py")) + contents = [content for _, content in chain] + + assert contents == ["root doc", "services doc", "payments doc"] + + +def test_agents_md_protocol_excludes_project_root_and_keeps_subdir( + monkeypatch, tmp_path +): + _prepare_project_tree(monkeypatch, tmp_path) + prompt_name = "agent.protocol.projects.agents_md.md" + prompt_source = Path(__file__).resolve().parents[1] / "prompts" / prompt_name + prompt_dir = tmp_path / "prompts" + prompt_dir.mkdir() + (prompt_dir / prompt_name).write_text( + prompt_source.read_text(encoding="utf-8"), + encoding="utf-8", + ) + projects.create_project("demo", {"title": "Demo"}) + (tmp_path / "AGENTS.md").write_text("framework doc", encoding="utf-8") + project_root = tmp_path / "usr" / "projects" / "demo" + (project_root / "AGENTS.md").write_text("project root doc", encoding="utf-8") + api_dir = project_root / "api" + api_dir.mkdir() + (api_dir / "AGENTS.md").write_text("api doc", encoding="utf-8") + + protocol = projects.build_agents_md_protocol( + "demo", + target=str(api_dir / "handler.py"), + ) + + assert "framework doc" in protocol + assert "api doc" in protocol + assert "project root doc" not in protocol diff --git a/tests/test_prompt_protocol.py b/tests/test_prompt_protocol.py index c4d29bfdc..fcafcb7b5 100644 --- a/tests/test_prompt_protocol.py +++ b/tests/test_prompt_protocol.py @@ -94,6 +94,7 @@ async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch "project_name": "Demo", "project_description": "", "project_instructions": "Project rule.", + "include_agents_md": True, "project_path": "/a0/usr/projects/demo", "project_git_url": "", } @@ -121,6 +122,11 @@ async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch "build_system_prompt_vars", lambda _name: project_vars, ) + monkeypatch.setattr( + project_prompt.projects, + "build_agents_md_protocol", + lambda _name: "AGENTS path rule.", + ) prompt = await project_prompt.build_prompt.__wrapped__( # type: ignore[attr-defined] FakeAgent(), @@ -128,4 +134,44 @@ async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch ) assert "Project rule." not in prompt + assert "AGENTS path rule." not in prompt + assert list(loop_data.protocol_persistent) == [ + "agents_md_instructions", + "project_instructions", + ] + assert "AGENTS path rule." in loop_data.protocol_persistent["agents_md_instructions"] assert "Project rule." in loop_data.protocol_persistent["project_instructions"] + + +@pytest.mark.asyncio +async def test_project_prompt_does_not_load_agents_md_without_project(monkeypatch): + loop_data = LoopData() + + class FakeContext: + def get_data(self, key): + assert key == project_prompt.projects.CONTEXT_DATA_KEY_PROJECT + return None + + class FakeAgent: + context = FakeContext() + + def read_prompt(self, prompt_file: str, **kwargs) -> str: + if prompt_file == "agent.system.projects.main.md": + return "project context may be active" + if prompt_file == "agent.system.projects.inactive.md": + return "no active project" + raise AssertionError(f"Unexpected prompt file: {prompt_file}") + + monkeypatch.setattr( + project_prompt.projects, + "build_agents_md_protocol", + lambda _name: (_ for _ in ()).throw(AssertionError("unexpected AGENTS load")), + ) + + await project_prompt.build_prompt.__wrapped__( # type: ignore[attr-defined] + FakeAgent(), + loop_data=loop_data, + ) + + assert "agents_md_instructions" not in loop_data.protocol_persistent + assert "project_instructions" not in loop_data.protocol_persistent