Skip to content

Commit 1c18bce

Browse files
authored
Update prompts.py
1 parent 1e178ec commit 1c18bce

1 file changed

Lines changed: 143 additions & 29 deletions

File tree

python_agent_harness/prompts.py

Lines changed: 143 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import os
1414
import re
15+
import subprocess
1516
from pathlib import Path
1617

1718
from . import config
@@ -74,6 +75,7 @@ def index_skills(skill_dir: Path | str | None) -> dict[str, tuple[str, str]]:
7475
return {}
7576
skills: dict[str, tuple[str, str]] = {}
7677
visited: set[str] = set()
78+
found: list[tuple[str, str, str]] = []
7779
for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
7880
real = os.path.realpath(dirpath)
7981
if real in visited:
@@ -89,7 +91,12 @@ def index_skills(skill_dir: Path | str | None) -> dict[str, tuple[str, str]]:
8991
parsed = _parse_skill_frontmatter(Path(path))
9092
if parsed:
9193
name, desc = parsed
92-
skills[name] = (path, desc)
94+
found.append((path, name, desc))
95+
# os.walk yields directories in arbitrary order, so resolve duplicate
96+
# names by path explicitly: sort first, then insert, and the last file
97+
# in sorted-path order wins (deterministic, like opencode's overwrite).
98+
for path, name, desc in sorted(found):
99+
skills[name] = (path, desc)
93100
return dict(sorted(skills.items()))
94101

95102

@@ -139,36 +146,137 @@ def load_agent_prompt(path: Path | str | None, skill_dir: Path | str | None = No
139146
return text or None
140147

141148

142-
def load_context_files(context_dir: Path | str | None) -> str | None:
143-
"""Read all files in *context_dir* and format them as context blocks.
149+
def _git_toplevel(directory: str) -> str:
150+
"""Return the git worktree root for *directory*.
151+
152+
Mirrors opencode's git.repo.discover: walk up for ``.git``, then
153+
run ``git rev-parse --show-toplevel``. Falls back to the nearest
154+
``.git`` parent when git itself is unusable, and to *directory*
155+
when no repository is found (so AGENTS.md lookup still works in
156+
non-git projects, bounded by the project directory).
157+
"""
158+
d = Path(directory).resolve()
159+
for parent in [d, *d.parents]:
160+
if (parent / ".git").exists():
161+
try:
162+
proc = subprocess.run(
163+
["git", "rev-parse", "--show-toplevel"],
164+
cwd=str(parent),
165+
capture_output=True,
166+
text=True,
167+
timeout=10,
168+
)
169+
if proc.returncode == 0 and proc.stdout.strip():
170+
return proc.stdout.strip()
171+
except (OSError, subprocess.SubprocessError):
172+
pass
173+
return str(parent)
174+
return str(d)
175+
176+
177+
def _find_up(filename: str, start: Path, stop: Path) -> list[str]:
178+
"""Every *filename* from *start* up to and including *stop*.
179+
180+
Mirrors opencode's ``FileSystem.findUp``: collect EVERY match along
181+
the way, not just the nearest one, and stop after *stop* (or at the
182+
filesystem root, whichever comes first).
183+
"""
184+
matches: list[str] = []
185+
current = start
186+
while True:
187+
candidate = current / filename
188+
if candidate.is_file():
189+
matches.append(str(candidate))
190+
if current == stop:
191+
break
192+
parent = current.parent
193+
if parent == current:
194+
break
195+
current = parent
196+
return matches
197+
198+
199+
def find_agents_md_files(project_dir: str) -> list[str]:
200+
"""Locate the project's ``AGENTS.md`` files, nearest first.
201+
202+
Collects EVERY ``AGENTS.md`` walking up from *project_dir* to the
203+
git worktree root (mirrors opencode's ``Instruction.systemPaths``),
204+
so running the agent in a subdirectory still picks up the repo-root
205+
instructions.
206+
207+
``AGENTS.md`` is the ONLY recognized file: there is no user-global
208+
instruction file and no ``CLAUDE.md``/``CONTEXT.md`` fallback, so a
209+
project without an ``AGENTS.md`` gets nothing injected. The walk
210+
only ever goes upward, bounded by the git worktree root (or
211+
*project_dir* outside a repo), so it neither escapes into unrelated
212+
parent directories nor discovers files in subdirectories.
213+
"""
214+
start = Path(project_dir).resolve()
215+
stop = Path(_git_toplevel(project_dir)).resolve()
216+
if not start.is_relative_to(stop):
217+
# git reported a worktree root that is not an ancestor of the
218+
# resolved project dir (differently-spelled paths — symlinked or
219+
# automounted checkouts). Without this guard _find_up would walk
220+
# to the filesystem root looking for `stop` and pick up AGENTS.md
221+
# files from unrelated ancestors.
222+
stop = start
223+
return _find_up("AGENTS.md", start, stop)
224+
225+
226+
def load_context_files(
227+
context_dir: Path | str | None,
228+
extra_files: list[str] | None = None,
229+
) -> str | None:
230+
"""Format *extra_files* plus every file in *context_dir* as context.
144231
145232
Returns a string like:
146233
Request context:
147234
148-
In file `~/.emacs.d/contexts/README.md`:
235+
In file `/path/to/project/AGENTS.md`:
236+
237+
<file contents>
238+
239+
In file `/path/to/project/contexts/README.md`:
149240
150-
```
151241
<file contents>
152-
```
153242
154-
Returns None if no context directory or no readable files.
243+
*extra_files* are individual files outside the context directory
244+
(the project's ``AGENTS.md`` files) and come first, in the order
245+
given; the context directory's own files follow, sorted by name.
246+
They are read and rendered identically: an ``In file `path`:``
247+
header is the only delimiter, and contents are NOT wrapped in a code
248+
fence (a fence would be closed early by any file containing one).
249+
250+
Unreadable and empty files are skipped, and a file reachable both
251+
ways (a *context_dir* that also holds a discovered ``AGENTS.md``) is
252+
rendered once. Returns None when there is nothing to inject.
155253
"""
156-
if not context_dir:
157-
return None
158-
d = Path(context_dir)
159-
if not d.is_dir():
160-
return None
254+
paths: list[Path] = []
255+
seen: set[Path] = set()
256+
257+
def _add(path: Path) -> None:
258+
resolved = path.resolve()
259+
if resolved in seen:
260+
return
261+
seen.add(resolved)
262+
paths.append(path)
263+
264+
for extra in extra_files or []:
265+
_add(Path(extra))
266+
d = Path(context_dir) if context_dir else None
267+
if d and d.is_dir():
268+
for child in sorted(d.iterdir()):
269+
if child.is_file():
270+
_add(child)
161271
blocks: list[str] = []
162-
for child in sorted(d.iterdir()):
163-
if not child.is_file():
164-
continue
272+
for path in paths:
165273
try:
166-
content = child.read_text(encoding="utf-8", errors="replace")
274+
content = path.read_text(encoding="utf-8", errors="replace")
167275
except OSError:
168276
continue
169277
if not content.strip():
170278
continue
171-
blocks.append(f"In file `{child}`:\n\n```\n{content.rstrip()}\n```")
279+
blocks.append(f"In file `{path}`:\n\n{content.rstrip()}")
172280
if not blocks:
173281
return None
174282
return "Request context:\n\n" + "\n\n".join(blocks)
@@ -200,25 +308,31 @@ def assemble_agent_prompt(
200308
) -> str | None:
201309
"""Assemble the effective system prompt for an agent run.
202310
203-
Order: [project context files] -> task-completion-rules.md ->
204-
the actual agent prompt. The completion rules are always the LAST
205-
context piece, immediately before the agent prompt, so they read as
206-
global ground rules rather than part of the task instructions.
207-
208-
``include_context=False`` drops the project context files (rules are
209-
still included). This function is NOT used for sub-agents: their
210-
system prompt is their own prompt file (subagent.md) only, with no
211-
context files and no task-completion rules (see
212-
``cli.make_session`` and ``subagent._subagent_system_prompt``).
213-
``context_path`` overrides the default context directory discovery.
311+
Order: [project context files, AGENTS.md first] ->
312+
task-completion-rules.md -> the actual agent prompt. The completion
313+
rules are always the LAST context piece, immediately before the
314+
agent prompt, so they read as global ground rules rather than part
315+
of the task instructions.
316+
317+
``include_context=False`` drops the context section (rules are still
318+
included). This function is NOT used for sub-agents: their system
319+
prompt is their own prompt file (subagent.md) only, with no context
320+
files and no task-completion rules (see ``cli.make_session`` and
321+
``subagent._subagent_system_prompt``). ``context_path`` overrides
322+
the default context directory discovery.
214323
Returns None if every part is empty/missing.
215324
"""
216325
parts: list[str] = []
217326
if include_context:
218327
# lazy import: harness imports this module at call time
219328
from .agent_session import find_context_dir
220329

221-
context_block = load_context_files(find_context_dir(project_dir, context_path))
330+
# the project's AGENTS.md files are just context files that live
331+
# outside the context directory — same block format, same section
332+
context_block = load_context_files(
333+
find_context_dir(project_dir, context_path),
334+
extra_files=find_agents_md_files(project_dir),
335+
)
222336
if context_block:
223337
parts.append(context_block)
224338
rules = load_task_completion_rules()

0 commit comments

Comments
 (0)