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.
This commit is contained in:
Alessandro 2026-07-08 14:29:15 +02:00
parent f8b06e0b12
commit bcf2634000
8 changed files with 203 additions and 10 deletions

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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}}

View file

@ -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

View file

@ -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