Skip to content

Commit 69df7c8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/gifted-hawking-03sdhe
# Conflicts: # aai_cli/AGENTS.md # aai_cli/agent_cascade/config.py # aai_cli/commands/agent_cascade/_exec.py
2 parents 3ed59d6 + fb4e97c commit 69df7c8

10 files changed

Lines changed: 296 additions & 13 deletions

File tree

REFERENCE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,14 @@ missing `npx`/`uvx`, an offline host) drops only its own tools, so a single brok
160160
tool never sinks the session. MCP tools are a live-run feature and are not
161161
reflected in `--show-code` output.
162162

163+
If the directory you launch from has an `AGENTS.md` or `CLAUDE.md`, `assembly live`
164+
reads it into the agent's context — the same convention coding agents follow — so
165+
spoken answers are grounded in the project at hand. `AGENTS.md` takes precedence
166+
(and identical content, e.g. a `CLAUDE.md` symlinked to it, is included once); an
167+
oversized file is truncated so it can't crowd out the conversation. This is
168+
independent of `--files` (it happens even under `--no-files`, when the agent can't
169+
touch the filesystem) and is not reflected in `--show-code` output.
170+
163171
The agent reads, writes, and runs code in the directory you launch it from (on by
164172
default; pass `--no-files` to disable). Reads run immediately; a write, edit, or command run pauses
165173
the turn for confirmation in the voice TUI — press `y`/`n` (`a` approves the rest of the

aai_cli/AGENTS.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

aai_cli/agent_cascade/brain.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,12 @@ def invoke(
5252
"""Run one step of the graph, returning the updated state (incl. messages)."""
5353

5454

55-
# Verbose (`-v`) flow logging for the agent's tool loop. `invoke` runs the whole loop
56-
# internally, so without this `-v` only shows the httpx request lines and never which
57-
# tools the agent reached for or what they returned — exactly what you need to see when
58-
# a spoken turn stalls mid-tool. Logged at INFO so plain `-v` surfaces it.
55+
# Verbose (`-v`) flow logging for the agent's tool loop: `invoke` runs the whole loop internally,
56+
# so without this `-v` never shows which tools the agent reached for when a spoken turn stalls.
5957
_FLOW_LOG = logging.getLogger("aai_cli.agent_cascade.brain")
6058

61-
# Tool outputs (a fetched page, a search payload) can be huge; cap what we log per result
62-
# so a single tool call doesn't bury the rest of the flow in stderr. The exact cap is an
63-
# arbitrary tuning knob — a +-1 shift is behaviorally equivalent, so no test can kill it.
59+
# Tool outputs (a fetched page, a search payload) can be huge; cap what we log per result so a
60+
# single tool call doesn't bury the flow. The exact cap is an arbitrary knob (no test can kill it).
6461
_RESULT_LOG_CAP = 500 # pragma: no mutate
6562

6663
# Human, speakable labels for the tool affordance the live UI shows while a tool runs (so a
@@ -90,8 +87,7 @@ def _tool_label(name: str) -> str:
9087
# Spoken filler the agent says aloud when it pauses for a tool, so a hands-free turn fills the
9188
# silent tool round-trip with *why* it paused instead of dead air (the audible counterpart to the
9289
# visual `_TOOL_LABELS` affordance). Each tool gets a few short, speakable variants the engine
93-
# rotates across turns; unknown/MCP tools fall back to `_GENERIC_FILLERS`. Spoken-style only — no
94-
# markdown, no trailing detail — since they're synthesized straight to TTS ahead of the answer.
90+
# rotates across turns; unknown/MCP tools fall back to `_GENERIC_FILLERS` (spoken-style, no markdown).
9591
_GENERIC_FILLERS: tuple[str, ...] = ("One sec.", "Let me check.")
9692

9793
_TOOL_FILLERS: dict[str, tuple[str, ...]] = {
@@ -278,7 +274,11 @@ def build_graph(
278274
model=model,
279275
tools=builtin + extra,
280276
system_prompt=build_system_prompt(
281-
config.system_prompt, tools=builtin, extra_tools=extra, files=config.files
277+
config.system_prompt,
278+
tools=builtin,
279+
extra_tools=extra,
280+
files=config.files,
281+
project_context=config.project_context,
282282
),
283283
middleware=_build_middleware(config),
284284
**_graph_kwargs(config),

aai_cli/agent_cascade/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,6 @@ class CascadeConfig:
7979
# paths are normalized virtual roots (always leading "/", no ".."), matching the virtual_mode
8080
# backend the model addresses. execute is never auto-approved — it can't be path-scoped.
8181
auto_write_paths: tuple[str, ...] = ()
82+
# The launch directory's AGENTS.md/CLAUDE.md, read into the system prompt so the agent
83+
# answers grounded in the project it's run from (None when no instruction file is present).
84+
project_context: str | None = None
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Read project-instruction files (``AGENTS.md``/``CLAUDE.md``) into the live agent's context.
2+
3+
`assembly live` runs in the user's working directory, so — like a coding agent — it reads the
4+
project's instruction files into its system prompt when present, giving spoken answers grounded
5+
in the project it's launched from. ``AGENTS.md`` is the cross-agent standard and ``CLAUDE.md`` is
6+
frequently a symlink to it, so identical content is included once, and the total is capped so an
7+
oversized instructions file can't crowd the conversation out of the model's window.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
# The instruction files an agentic CLI reads into context, highest precedence first.
15+
CONTEXT_FILENAMES = ("AGENTS.md", "CLAUDE.md")
16+
17+
# Cap the injected context: the spoken agent only needs the project's gist, and an unusually
18+
# large instructions file would otherwise crowd the live conversation out of the model's window.
19+
# A +-1 shift in the budget is behaviorally equivalent, so no test can kill a mutant on it.
20+
MAX_CONTEXT_CHARS = 16000 # pragma: no mutate
21+
22+
# Appended when the content is truncated, so the model knows it's seeing only the head of the file.
23+
_TRUNCATION_MARKER = "\n\n[project context truncated]"
24+
25+
26+
def _read_instructions(path: Path) -> str | None:
27+
"""The stripped contents of one instruction file, or ``None`` if absent/unreadable/empty."""
28+
try:
29+
text = path.read_text(encoding="utf-8").strip()
30+
except OSError:
31+
return None
32+
return text or None
33+
34+
35+
def _truncate(combined: str) -> str:
36+
"""Cap the combined context at :data:`MAX_CONTEXT_CHARS`, marking it when truncated.
37+
38+
The marker is counted against the budget (the slice leaves room for it), so the returned
39+
string never exceeds :data:`MAX_CONTEXT_CHARS` — the cap is a true upper bound, not a target
40+
the marker then overshoots.
41+
"""
42+
if len(combined) > MAX_CONTEXT_CHARS:
43+
return combined[: MAX_CONTEXT_CHARS - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
44+
return combined
45+
46+
47+
def load_project_context(directory: Path | None = None) -> str | None:
48+
"""Read the project-instruction files in *directory* into one de-duplicated string.
49+
50+
Looks for each name in :data:`CONTEXT_FILENAMES` under *directory* (the current working
51+
directory by default), returning their stripped contents joined under a per-file heading —
52+
or ``None`` when none are present, readable, or non-empty. Identical files (``CLAUDE.md`` is
53+
commonly a symlink to ``AGENTS.md``) are included once, and the combined text is truncated to
54+
:data:`MAX_CONTEXT_CHARS` so a huge file can't crowd out the live conversation.
55+
"""
56+
base = Path.cwd() if directory is None else directory
57+
sections: list[str] = []
58+
seen: set[str] = set()
59+
for name in CONTEXT_FILENAMES:
60+
text = _read_instructions(base / name)
61+
if text is None or text in seen:
62+
continue
63+
seen.add(text)
64+
sections.append(f"# {name}\n\n{text}")
65+
if not sections:
66+
return None
67+
return _truncate("\n\n".join(sections))

aai_cli/agent_cascade/prompt.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@
6969
"replacing the whole file unless asked."
7070
)
7171

72+
# Introduces the launch directory's AGENTS.md/CLAUDE.md when one is present, so the model treats
73+
# it as project background to ground its answers rather than as another instruction to recite.
74+
_PROJECT_CONTEXT_INTRO = (
75+
"The following is background on the project in your working directory, taken from its "
76+
"AGENTS.md/CLAUDE.md. Use it to ground your answers, but keep your reply short and spoken."
77+
)
78+
79+
80+
def _append_project_context(prompt: str, project_context: str | None) -> str:
81+
"""Append the launch directory's instruction files to the prompt as project background."""
82+
if not project_context:
83+
return prompt
84+
return f"{prompt}\n\n{_PROJECT_CONTEXT_INTRO}\n\n{project_context}"
85+
7286

7387
def _join_clause(parts: list[str]) -> str:
7488
"""Join capability phrases into a readable clause: ``a``, ``a and b``, ``a, b, and c``."""
@@ -121,6 +135,7 @@ def build_system_prompt(
121135
tools: Sequence[BaseTool],
122136
extra_tools: Sequence[BaseTool] = (),
123137
files: bool = False,
138+
project_context: str | None = None,
124139
) -> str:
125140
"""The live agent's system prompt: the user's persona plus tool guidance.
126141
@@ -134,6 +149,8 @@ def build_system_prompt(
134149
its own knowledge. Whenever tools are bound the guidance also tells the model to report
135150
tool outcomes honestly (never narrate a success the tool didn't return), and the
136151
``--files`` path adds a warning to confirm before irreversible writes or code execution.
152+
``project_context`` (the launch directory's AGENTS.md/CLAUDE.md) is appended as project
153+
background when present, so the agent's answers are grounded in the project it's run from.
137154
"""
138155
capabilities = _tool_capabilities(tools)
139156
extra = _extra_capability(extra_tools)
@@ -142,7 +159,9 @@ def build_system_prompt(
142159
if files:
143160
capabilities.append(_FILE_CAPABILITY)
144161
if not capabilities:
145-
return f"{persona}\n\n{_PERSONA_LATCH} {_NO_TOOLS_GUIDANCE}"
162+
return _append_project_context(
163+
f"{persona}\n\n{_PERSONA_LATCH} {_NO_TOOLS_GUIDANCE}", project_context
164+
)
146165
guidance = (
147166
f"You can use tools to help answer: {_join_clause(capabilities)}. Reach for a "
148167
"tool when a question needs fresh or external information; answer directly and "
@@ -151,4 +170,6 @@ def build_system_prompt(
151170
)
152171
if files:
153172
guidance = f"{guidance} {_FILE_SAFETY_GUIDANCE}"
154-
return f"{persona}\n\n{_PERSONA_LATCH} {guidance} {_SPOKEN_TAIL}"
173+
return _append_project_context(
174+
f"{persona}\n\n{_PERSONA_LATCH} {guidance} {_SPOKEN_TAIL}", project_context
175+
)

aai_cli/commands/agent_cascade/_exec.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from aai_cli.agent.render import AgentRenderer
2121
from aai_cli.agent_cascade import engine, firecrawl_search, mcp_tools, voices
2222
from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig
23+
from aai_cli.agent_cascade.project_context import load_project_context
2324
from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt
2425
from aai_cli.app.agent_shared import validate_voice
2526
from aai_cli.app.context import AppState
@@ -373,6 +374,9 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode:
373374
mcp_servers=mcp_servers,
374375
files=opts.files,
375376
auto_write_paths=auto_write_paths,
377+
# Read the launch directory's AGENTS.md/CLAUDE.md into context, so the agent answers
378+
# grounded in the project it's run from (like a coding agent).
379+
project_context=load_project_context(),
376380
)
377381

378382
if _should_use_tui(from_file=from_file, json_mode=json_mode, text_mode=text_mode):

tests/test_agent_cascade_brain.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,24 @@ def fake_create(*, model, tools, system_prompt, middleware):
318318
assert any(isinstance(mw, ToolCallLimitMiddleware) for mw in captured["middleware"])
319319

320320

321+
def test_build_graph_threads_project_context_into_system_prompt(monkeypatch):
322+
import deepagents
323+
324+
captured = {}
325+
326+
def fake_create(*, model, tools, system_prompt, middleware):
327+
del model, tools, middleware
328+
captured["system_prompt"] = system_prompt
329+
return "graph"
330+
331+
monkeypatch.setattr(deepagents, "create_deep_agent", fake_create)
332+
monkeypatch.setattr(model_mod, "build_model", lambda *a, **k: object())
333+
cfg = CascadeConfig(project_context="# AGENTS.md\n\nRun uv sync first.")
334+
brain.build_graph("k", cfg, tools=[], mcp_tools=[])
335+
# The launch directory's instruction file rides into the live agent's system prompt.
336+
assert "Run uv sync first." in captured["system_prompt"]
337+
338+
321339
def test_build_graph_loads_mcp_tools_from_config_when_not_injected(monkeypatch):
322340
import deepagents
323341

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Tests for the live agent's project-context loader (aai_cli.agent_cascade.project_context).
2+
3+
`assembly live` reads the launch directory's AGENTS.md/CLAUDE.md into its system prompt so a
4+
spoken answer is grounded in the project it's run from — the same convention coding agents follow.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import types
10+
11+
from aai_cli.agent_cascade import project_context
12+
from aai_cli.app.context import AppState
13+
from aai_cli.commands.agent_cascade import _exec
14+
from aai_cli.commands.agent_cascade._exec import run_agent_cascade
15+
from aai_cli.core import config
16+
from tests.test_agent_cascade_command import _opts
17+
18+
19+
def test_returns_none_when_no_instruction_files(tmp_path):
20+
# An empty directory has nothing to inject, so the prompt stays the plain persona.
21+
assert project_context.load_project_context(tmp_path) is None
22+
23+
24+
def test_reads_agents_md_under_a_heading(tmp_path):
25+
(tmp_path / "AGENTS.md").write_text("Use uv run for everything.", encoding="utf-8")
26+
loaded = project_context.load_project_context(tmp_path)
27+
# The content is included verbatim under a per-file heading naming its source.
28+
assert loaded == "# AGENTS.md\n\nUse uv run for everything."
29+
30+
31+
def test_reads_claude_md_when_agents_md_absent(tmp_path):
32+
(tmp_path / "CLAUDE.md").write_text("Project rules here.", encoding="utf-8")
33+
loaded = project_context.load_project_context(tmp_path)
34+
assert loaded == "# CLAUDE.md\n\nProject rules here."
35+
36+
37+
def test_includes_both_files_in_precedence_order_when_they_differ(tmp_path):
38+
(tmp_path / "AGENTS.md").write_text("Agents rules.", encoding="utf-8")
39+
(tmp_path / "CLAUDE.md").write_text("Claude rules.", encoding="utf-8")
40+
loaded = project_context.load_project_context(tmp_path)
41+
# Both distinct files are present, AGENTS.md first (its precedence), then CLAUDE.md.
42+
assert loaded == "# AGENTS.md\n\nAgents rules.\n\n# CLAUDE.md\n\nClaude rules."
43+
44+
45+
def test_identical_content_is_included_once(tmp_path):
46+
# CLAUDE.md is commonly a symlink to AGENTS.md (as in this repo); identical content must not
47+
# be duplicated into the prompt. We assert the dedup on content, so it covers the symlink case
48+
# without depending on symlink support being available on the test platform.
49+
(tmp_path / "AGENTS.md").write_text("Same guidance.", encoding="utf-8")
50+
(tmp_path / "CLAUDE.md").write_text("Same guidance.", encoding="utf-8")
51+
loaded = project_context.load_project_context(tmp_path)
52+
assert loaded == "# AGENTS.md\n\nSame guidance."
53+
assert loaded.count("Same guidance.") == 1
54+
55+
56+
def test_whitespace_only_file_is_skipped(tmp_path):
57+
# A blank instruction file carries no guidance, so it's treated as absent (None, not an
58+
# empty heading) — the stripped-empty branch.
59+
(tmp_path / "AGENTS.md").write_text(" \n\t\n", encoding="utf-8")
60+
assert project_context.load_project_context(tmp_path) is None
61+
62+
63+
def test_oversized_content_is_truncated_to_the_budget(tmp_path):
64+
body = "x" * (project_context.MAX_CONTEXT_CHARS + 5000)
65+
(tmp_path / "AGENTS.md").write_text(body, encoding="utf-8")
66+
loaded = project_context.load_project_context(tmp_path)
67+
assert loaded is not None
68+
# The marker is counted against the budget, so the total never exceeds the cap — it's a true
69+
# upper bound, not a target the marker overshoots.
70+
assert loaded.endswith("[project context truncated]")
71+
assert len(loaded) == project_context.MAX_CONTEXT_CHARS
72+
assert len(loaded) < len(body)
73+
74+
75+
def test_content_at_the_budget_is_left_whole(tmp_path):
76+
# A file exactly at the cap is included untruncated (the boundary is inclusive).
77+
# Account for the "# AGENTS.md\n\n" heading so the combined string lands exactly at the cap.
78+
heading = "# AGENTS.md\n\n"
79+
body = "y" * (project_context.MAX_CONTEXT_CHARS - len(heading))
80+
(tmp_path / "AGENTS.md").write_text(body, encoding="utf-8")
81+
loaded = project_context.load_project_context(tmp_path)
82+
assert loaded is not None
83+
assert "truncated" not in loaded
84+
assert len(loaded) == project_context.MAX_CONTEXT_CHARS
85+
86+
87+
def test_defaults_to_the_current_working_directory(tmp_path, monkeypatch):
88+
(tmp_path / "AGENTS.md").write_text("cwd guidance", encoding="utf-8")
89+
monkeypatch.chdir(tmp_path)
90+
# No directory argument -> reads cwd, so the live command picks up the project it's launched in.
91+
assert project_context.load_project_context() == "# AGENTS.md\n\ncwd guidance"
92+
93+
94+
def test_missing_directory_reads_as_no_context(tmp_path):
95+
# A nonexistent base directory raises OSError per candidate, which is swallowed -> None.
96+
assert project_context.load_project_context(tmp_path / "does-not-exist") is None
97+
98+
99+
def test_context_filenames_order():
100+
# AGENTS.md (the cross-agent standard) takes precedence over CLAUDE.md.
101+
assert project_context.CONTEXT_FILENAMES == ("AGENTS.md", "CLAUDE.md")
102+
103+
104+
# --- command wiring: run_agent_cascade reads the loader into the config ------
105+
106+
107+
def test_run_reads_project_context_into_config(monkeypatch):
108+
monkeypatch.setattr(_exec.tts_session, "require_available", lambda _c: None)
109+
monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k")
110+
monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000))
111+
monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav")
112+
# Stub the loader so the assertion doesn't depend on the repo's own (large) instruction file.
113+
monkeypatch.setattr(_exec, "load_project_context", lambda: "# AGENTS.md\n\nProject background.")
114+
captured = {}
115+
116+
def fake_real(api_key, config, *, audio, stt_params, approver=None):
117+
captured["config"] = config
118+
return "deps"
119+
120+
monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real)
121+
monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None)
122+
run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False)
123+
# The launch directory's AGENTS.md/CLAUDE.md rides into the cascade config.
124+
assert captured["config"].project_context == "# AGENTS.md\n\nProject background."

tests/test_agent_cascade_prompt.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,41 @@ def test_datetime_tool_advertised_in_system_prompt():
200200
"persona", tools=[_NamedTool(datetime_tool.DATETIME_TOOL_NAME)]
201201
)
202202
assert "current date and time" in text
203+
204+
205+
# --- project context (AGENTS.md/CLAUDE.md) -----------------------------------
206+
207+
208+
def test_system_prompt_appends_project_context_when_present():
209+
# The launch directory's instruction file is appended as project background, introduced so
210+
# the model treats it as grounding rather than another instruction to recite.
211+
text = prompt.build_system_prompt(
212+
"persona", tools=[], project_context="# AGENTS.md\n\nUse uv run."
213+
)
214+
assert "background on the project in your working directory" in text
215+
assert "# AGENTS.md\n\nUse uv run." in text
216+
# It lands after the persona/guidance, not before it.
217+
assert text.index("persona") < text.index("Use uv run.")
218+
219+
220+
def test_system_prompt_appends_project_context_on_the_tools_path():
221+
# The append happens whether or not tools are bound (the capabilities branch too).
222+
text = prompt.build_system_prompt(
223+
"persona",
224+
tools=[_NamedTool(prompt.WEB_SEARCH_TOOL_NAME)],
225+
project_context="# AGENTS.md\n\nProject facts.",
226+
)
227+
assert "search the web" in text
228+
assert "Project facts." in text
229+
230+
231+
def test_system_prompt_omits_project_context_section_when_absent():
232+
# With no instruction file the prompt is unchanged — no dangling background heading.
233+
text = prompt.build_system_prompt("persona", tools=[], project_context=None)
234+
assert "background on the project in your working directory" not in text
235+
236+
237+
def test_system_prompt_treats_empty_project_context_as_absent():
238+
# An empty string is falsy, so no background section is appended.
239+
text = prompt.build_system_prompt("persona", tools=[], project_context="")
240+
assert "background on the project in your working directory" not in text

0 commit comments

Comments
 (0)