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