Skip to content

Commit fb4e97c

Browse files
alexkromanclaude
andauthored
Ground live agent answers in project context (AGENTS.md/CLAUDE.md) (#263)
The live agent now reads the launch directory's `AGENTS.md` or `CLAUDE.md` into its system prompt, grounding spoken answers in the project it's run from — following the same convention coding agents use. ## Changes - **New module `aai_cli/agent_cascade/project_context.py`**: Loads project instruction files with deduplication (identical content included once) and truncation (capped at 16KB so oversized files don't crowd the conversation). Reads `AGENTS.md` first (higher precedence), then `CLAUDE.md`, defaulting to the current working directory. - **Updated `aai_cli/agent_cascade/prompt.py`**: `build_system_prompt()` now accepts an optional `project_context` parameter and appends it as project background when present, introduced with a heading so the model treats it as grounding rather than another instruction to recite. - **Updated `aai_cli/agent_cascade/brain.py`**: Threads `project_context` from the cascade config into the system prompt when building the agent graph. - **Updated `aai_cli/agent_cascade/config.py`**: Added `project_context` field to `CascadeConfig` to carry the loaded instruction file through the cascade pipeline. - **Updated `aai_cli/commands/agent_cascade/_exec.py`**: `run_agent_cascade()` calls `load_project_context()` and passes it to the config before building the cascade engine. - **Updated `REFERENCE.md`**: Documented the new behavior, noting that it's independent of `--files` and not reflected in `--show-code` output. - **Comprehensive test coverage**: - `tests/test_agent_cascade_project_context.py` (new): Tests the loader's file reading, deduplication, truncation, and edge cases. - `tests/test_agent_cascade_prompt.py`: Tests system prompt integration with and without project context. - `tests/test_agent_cascade_brain.py`: Tests that project context flows into the built graph's system prompt. ## Implementation details - Identical file content (e.g., `CLAUDE.md` symlinked to `AGENTS.md`) is deduplicated on content, not filename, so it works across platforms without depending on symlink support. - Whitespace-only files are treated as absent (return `None`, not an empty heading). - The truncation marker `[project context truncated]` signals to the model when it's seeing only the head of an oversized file. - The feature is always active when instruction files are present; it's not gated by `--files` or any other flag. https://claude.ai/code/session_01QegBRMt1QU2LRvbEMyCdiT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 371055d commit fb4e97c

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
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ heavily-reworked commands with long bodies; small commands keep the inline
153153
- **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback.
154154
- **`core/sync_stt.py`** + **`core/signals.py`** + `commands/dictate/``assembly dictate`: headless dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead.
155155
- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`).
156-
- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): **context-window management is the brain's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`, and binds one gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool; spec in `agent_cascade/subagents.py`, omitting `model`/`tools` so it inherits both) for delegating a focused subtask. The subagent's own `interrupt_on` mirrors `_WRITE_TOOLS`, and a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated.
156+
- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): **context-window management is the brain's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). **Project grounding (independent of `--files`):** `_exec.run_agent_cascade` reads the launch directory's `AGENTS.md`/`CLAUDE.md` via `agent_cascade/project_context.load_project_context()` into `CascadeConfig.project_context`, which `brain.build_graph` threads into `prompt.build_system_prompt(..., project_context=…)` (appended as project background after the persona/tool guidance). `AGENTS.md` wins precedence, identical content (a symlinked `CLAUDE.md`) is de-duplicated, and the total is capped at `project_context.MAX_CONTEXT_CHARS`. It's read at the command boundary (not in `build_graph`) so the brain stays hermetic, and the `--show-code` path builds its own config without it. Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`, and binds one gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool; spec in `agent_cascade/subagents.py`, omitting `model`/`tools` so it inherits both) for delegating a focused subtask. The subagent's own `interrupt_on` mirrors `_WRITE_TOOLS`, and a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated.
157157
- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless.
158158
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
159159
- **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.

aai_cli/agent_cascade/brain.py

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

5353

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

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

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

9692
_TOOL_FILLERS: dict[str, tuple[str, ...]] = {
@@ -281,7 +277,11 @@ def build_graph(
281277
model=model,
282278
tools=builtin + extra,
283279
system_prompt=build_system_prompt(
284-
config.system_prompt, tools=builtin, extra_tools=extra, files=config.files
280+
config.system_prompt,
281+
tools=builtin,
282+
extra_tools=extra,
283+
files=config.files,
284+
project_context=config.project_context,
285285
),
286286
middleware=_build_middleware(config),
287287
**_graph_kwargs(config),

aai_cli/agent_cascade/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,6 @@ class CascadeConfig:
7373
# behavior unchanged (the default in-memory backend, no gating, nothing advertised); on
7474
# swaps to a real-cwd FilesystemBackend and gates writes behind human approval.
7575
files: bool = False
76+
# The launch directory's AGENTS.md/CLAUDE.md, read into the system prompt so the agent
77+
# answers grounded in the project it's run from (None when no instruction file is present).
78+
project_context: str | None = None

0 commit comments

Comments
 (0)