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.

Alessandro committed Jul 8, 2026 at 14:29 UTC bcf2634000dbe3655afae15686e1d0831036a213
8 files changed +203 -10
extensions/python/system_prompt/AGENTS.md
+1 -1
@@ -7,7 +7,7 @@
7 ## Ownership
8
9 - Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
10 -- Active project instruction bodies are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
10 +- 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.
11
12 ## Local Contracts
13
extensions/python/system_prompt/_14_project_prompt.py
+7
@@ -25,9 +25,16 @@ async def build_prompt(agent: Agent, loop_data: LoopData | None = None) -> str:
25 result = agent.read_prompt("agent.system.projects.main.md")
26 project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
27 if loop_data:
28 + loop_data.protocol_persistent.pop("agents_md_instructions", None)
29 loop_data.protocol_persistent.pop("project_instructions", None)
30 if project_name:
31 project_vars = projects.build_system_prompt_vars(project_name)
32 + if loop_data and project_vars.get("include_agents_md", True):
33 + agents_md_protocol = projects.build_agents_md_protocol(project_name)
34 + if agents_md_protocol:
35 + loop_data.protocol_persistent["agents_md_instructions"] = (
36 + agents_md_protocol
37 + )
38 if loop_data and project_vars.get("project_instructions"):
39 loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
40 "agent.protocol.projects.instructions.md",
helpers/AGENTS.md
+1 -1
@@ -15,7 +15,7 @@
15 - Preserve public helper APIs used by core code and plugins unless all callers, docs, and tests are updated.
16 - Use structured parsers and serializers for YAML, JSON, paths, and URLs instead of ad hoc string handling.
17 - Keep path handling constrained to intended roots for user files, uploads, downloads, projects, and workdirs.
18 -- 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.
18 +- 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.
19 - Do not hardcode secrets, provider keys, local absolute paths, or environment-specific values.
20 - Use `RepairableException` for errors an agent may be able to fix.
21 - 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.
helpers/projects.py
+76 -8
@@ -1,5 +1,5 @@
1 import os
2 -from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast
2 +from typing import NotRequired, TypedDict, TYPE_CHECKING, cast
3
4 from helpers import files, dirty_json, persist_chat, file_tree, extension
5 from helpers.print_style import PrintStyle
@@ -15,7 +15,13 @@ PROJECT_KNOWLEDGE_DIR = "knowledge"
15 PROJECT_SKILLS_DIR = "skills"
16 PROJECT_HEADER_FILE = "project.json"
17 PROJECT_MCP_SERVERS_FILE = "mcp_servers.json"
18 -PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md")
18 +PROJECT_AGENTS_MD_FILES = (
19 + "AGENTS.override.md",
20 + "AGENTS.Override.md",
21 + "AGENTS.md",
22 + "Agents.md",
23 + "agents.md",
24 +)
25 DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
26
27 CONTEXT_DATA_KEY_PROJECT = "project"
@@ -466,9 +472,10 @@ def deactivate_project_in_chats(name: str):
472 def build_system_prompt_vars(name: str):
473 project_data = load_basic_project_data(name)
474 main_instructions = project_data.get("instructions", "") or ""
475 + include_agents_md = project_data.get("include_agents_md", True)
476 instruction_files = get_project_instruction_files(
477 name,
471 - include_agents_md=project_data.get("include_agents_md", True),
478 + include_agents_md=include_agents_md,
479 )
480 instruction_parts = [
481 main_instructions,
@@ -481,11 +488,75 @@ def build_system_prompt_vars(name: str):
488 "project_name": project_data.get("title", ""),
489 "project_description": project_data.get("description", ""),
490 "project_instructions": complete_instructions or "",
491 + "include_agents_md": include_agents_md,
492 "project_path": files.normalize_a0_path(get_project_folder(name)),
493 "project_git_url": project_data.get("git_url", ""),
494 }
495
496
497 +def get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]:
498 + root_real = os.path.realpath(files.fix_dev_path(root))
499 + target_real = os.path.realpath(files.fix_dev_path(target))
500 + if os.path.isfile(target_real):
501 + target_real = os.path.dirname(target_real)
502 +
503 + if files.is_in_dir(target_real, root_real):
504 + dirs = []
505 + cursor = target_real
506 + while True:
507 + dirs.append(cursor)
508 + if cursor == root_real:
509 + break
510 + parent = os.path.dirname(cursor)
511 + if parent == cursor:
512 + break
513 + cursor = parent
514 + dirs.reverse()
515 + else:
516 + dirs = [root_real]
517 +
518 + chain = []
519 + for dir_path in dirs:
520 + for filename in PROJECT_AGENTS_MD_FILES:
521 + matches = files.read_text_files_in_dir(dir_path, pattern=filename)
522 + if filename not in matches:
523 + continue
524 + chain.append((files.get_abs_path(dir_path, filename), matches[filename]))
525 + break
526 + return chain
527 +
528 +
529 +def build_agents_md_protocol(name: str, target: str | None = None) -> str:
530 + project_folder = get_project_folder(name)
531 + project_agents_md = get_project_agents_md_instruction_file(name)
532 + project_agents_md_path = (
533 + os.path.realpath(files.fix_dev_path(project_agents_md[0]))
534 + if project_agents_md
535 + else ""
536 + )
537 + entries = [
538 + (path, content)
539 + for path, content in get_agents_md_chain(
540 + files.get_abs_path(""),
541 + target or project_folder,
542 + )
543 + if os.path.realpath(path) != project_agents_md_path
544 + ]
545 + if not entries:
546 + return ""
547 +
548 + instructions = []
549 + for path, content in entries:
550 + instructions.append(
551 + f"### path: {files.normalize_a0_path(path)}\n\n{content.strip()}"
552 + )
553 + return files.read_prompt_file(
554 + "agent.protocol.projects.agents_md.md",
555 + _directories=["prompts"],
556 + agents_md_instructions="\n\n".join(instructions),
557 + ).strip()
558 +
559 +
560 def get_additional_instructions_files(name: str):
561 instructions_folder = files.get_abs_path(
562 get_project_folder(name), PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR
@@ -522,11 +593,8 @@ def get_project_instruction_files(
593
594 def get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None:
595 project_folder = get_project_folder(name)
525 - for filename in PROJECT_AGENTS_MD_FILES:
526 - matches = files.read_text_files_in_dir(project_folder, pattern=filename)
527 - if filename in matches:
528 - path = files.get_abs_path(project_folder, filename)
529 - return (files.normalize_a0_path(path), matches[filename])
596 + for path, content in get_agents_md_chain(project_folder, project_folder):
597 + return (files.normalize_a0_path(path), content)
598 return None
599
600
helpers/projects.py.dox.md
+4
@@ -47,6 +47,8 @@
47 - `reactivate_project_in_chats(name: str)`
48 - `deactivate_project_in_chats(name: str)`
49 - `build_system_prompt_vars(name: str)`
50 +- `get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]`
51 +- `build_agents_md_protocol(name: str, target: str | None=...) -> str`
52 - `get_additional_instructions_files(name: str)`
53 - `get_project_instruction_files(name: str, include_agents_md: bool=...) -> list[tuple[str, str]]`
54 - `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None`
@@ -63,6 +65,8 @@
65 - Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`.
66 - 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.
67 - Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently.
68 +- AGENTS.md discovery is a linear root-to-target chain walk with `AGENTS.override.md` precedence; sibling directories are not scanned.
69 +- 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`.
70 - Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
71 - Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
72 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
prompts/agent.protocol.projects.agents_md.md new
+6
@@ -0,0 +1,6 @@
1 +# AGENTS.md instructions
2 +- these path-owned instructions are binding for the current project/workdir chain
3 +- active project root AGENTS.md is loaded separately in project instructions
4 +- before editing a deeper target, read AGENTS.md files on the direct path to that target; ignore siblings
5 +
6 +{{agents_md_instructions}}
tests/test_projects.py
+62
@@ -107,6 +107,20 @@ def test_project_system_prompt_includes_root_agents_md_with_path(monkeypatch, tm
107 assert "Folder instruction rule." in instructions
108
109
110 +def test_project_system_prompt_prefers_agents_override_md(monkeypatch, tmp_path):
111 + _prepare_project_tree(monkeypatch, tmp_path)
112 + projects.create_project("demo", {"title": "Demo"})
113 + project_root = tmp_path / "usr" / "projects" / "demo"
114 + (project_root / "AGENTS.md").write_text("Standard rule.", encoding="utf-8")
115 + (project_root / "AGENTS.override.md").write_text("Override rule.", encoding="utf-8")
116 +
117 + instructions = projects.build_system_prompt_vars("demo")["project_instructions"]
118 +
119 + assert "### path: /a0/usr/projects/demo/AGENTS.override.md" in instructions
120 + assert "Override rule." in instructions
121 + assert "Standard rule." not in instructions
122 +
123 +
124 def test_project_system_prompt_respects_disabled_agents_md(monkeypatch, tmp_path):
125 _prepare_project_tree(monkeypatch, tmp_path)
126 projects.create_project(
@@ -123,3 +137,51 @@ def test_project_system_prompt_respects_disabled_agents_md(monkeypatch, tmp_path
137
138 assert "Root AGENTS rule." not in prompt_vars["project_instructions"]
139 assert "AGENTS.md" not in prompt_vars["project_instructions"]
140 +
141 +
142 +def test_agents_md_chain_walks_direct_path_only(monkeypatch, tmp_path):
143 + _prepare_project_tree(monkeypatch, tmp_path)
144 + root = tmp_path
145 + (root / "AGENTS.md").write_text("root doc", encoding="utf-8")
146 + target = root / "services" / "payments"
147 + sibling = root / "services" / "auth"
148 + target.mkdir(parents=True)
149 + sibling.mkdir(parents=True)
150 + (root / "services" / "AGENTS.md").write_text("services doc", encoding="utf-8")
151 + (target / "AGENTS.md").write_text("payments doc", encoding="utf-8")
152 + (sibling / "AGENTS.md").write_text("auth doc", encoding="utf-8")
153 +
154 + chain = projects.get_agents_md_chain(str(root), str(target / "handler.py"))
155 + contents = [content for _, content in chain]
156 +
157 + assert contents == ["root doc", "services doc", "payments doc"]
158 +
159 +
160 +def test_agents_md_protocol_excludes_project_root_and_keeps_subdir(
161 + monkeypatch, tmp_path
162 +):
163 + _prepare_project_tree(monkeypatch, tmp_path)
164 + prompt_name = "agent.protocol.projects.agents_md.md"
165 + prompt_source = Path(__file__).resolve().parents[1] / "prompts" / prompt_name
166 + prompt_dir = tmp_path / "prompts"
167 + prompt_dir.mkdir()
168 + (prompt_dir / prompt_name).write_text(
169 + prompt_source.read_text(encoding="utf-8"),
170 + encoding="utf-8",
171 + )
172 + projects.create_project("demo", {"title": "Demo"})
173 + (tmp_path / "AGENTS.md").write_text("framework doc", encoding="utf-8")
174 + project_root = tmp_path / "usr" / "projects" / "demo"
175 + (project_root / "AGENTS.md").write_text("project root doc", encoding="utf-8")
176 + api_dir = project_root / "api"
177 + api_dir.mkdir()
178 + (api_dir / "AGENTS.md").write_text("api doc", encoding="utf-8")
179 +
180 + protocol = projects.build_agents_md_protocol(
181 + "demo",
182 + target=str(api_dir / "handler.py"),
183 + )
184 +
185 + assert "framework doc" in protocol
186 + assert "api doc" in protocol
187 + assert "project root doc" not in protocol
tests/test_prompt_protocol.py
+46
@@ -94,6 +94,7 @@ async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch
94 "project_name": "Demo",
95 "project_description": "",
96 "project_instructions": "Project rule.",
97 + "include_agents_md": True,
98 "project_path": "/a0/usr/projects/demo",
99 "project_git_url": "",
100 }
@@ -121,6 +122,11 @@ async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch
122 "build_system_prompt_vars",
123 lambda _name: project_vars,
124 )
125 + monkeypatch.setattr(
126 + project_prompt.projects,
127 + "build_agents_md_protocol",
128 + lambda _name: "AGENTS path rule.",
129 + )
130
131 prompt = await project_prompt.build_prompt.__wrapped__( # type: ignore[attr-defined]
132 FakeAgent(),
@@ -128,4 +134,44 @@ async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch
134 )
135
136 assert "Project rule." not in prompt
137 + assert "AGENTS path rule." not in prompt
138 + assert list(loop_data.protocol_persistent) == [
139 + "agents_md_instructions",
140 + "project_instructions",
141 + ]
142 + assert "AGENTS path rule." in loop_data.protocol_persistent["agents_md_instructions"]
143 assert "Project rule." in loop_data.protocol_persistent["project_instructions"]
144 +
145 +
146 +@pytest.mark.asyncio
147 +async def test_project_prompt_does_not_load_agents_md_without_project(monkeypatch):
148 + loop_data = LoopData()
149 +
150 + class FakeContext:
151 + def get_data(self, key):
152 + assert key == project_prompt.projects.CONTEXT_DATA_KEY_PROJECT
153 + return None
154 +
155 + class FakeAgent:
156 + context = FakeContext()
157 +
158 + def read_prompt(self, prompt_file: str, **kwargs) -> str:
159 + if prompt_file == "agent.system.projects.main.md":
160 + return "project context may be active"
161 + if prompt_file == "agent.system.projects.inactive.md":
162 + return "no active project"
163 + raise AssertionError(f"Unexpected prompt file: {prompt_file}")
164 +
165 + monkeypatch.setattr(
166 + project_prompt.projects,
167 + "build_agents_md_protocol",
168 + lambda _name: (_ for _ in ()).throw(AssertionError("unexpected AGENTS load")),
169 + )
170 +
171 + await project_prompt.build_prompt.__wrapped__( # type: ignore[attr-defined]
172 + FakeAgent(),
173 + loop_data=loop_data,
174 + )
175 +
176 + assert "agents_md_instructions" not in loop_data.protocol_persistent
177 + assert "project_instructions" not in loop_data.protocol_persistent