Skip to content

Commit d80640e

Browse files
authored
Merge branch 'main' into fix-gateway-streaming-tool-call-id
2 parents 6385996 + 5f42144 commit d80640e

16 files changed

Lines changed: 975 additions & 111 deletions

REFERENCE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,27 @@ writes:
139139
derives that title from the transcript via the LLM Gateway once the stream ends,
140140
renaming the files to match (the timestamp stem is kept if the title is empty).
141141
The two are mutually exclusive.
142+
143+
## Live agent tools (MCP)
144+
145+
`assembly live` answers each spoken turn with a tool-using agent, so it can reach
146+
external tools mid-conversation. Out of the box it loads its built-in URL fetch,
147+
the AssemblyAI docs, and a curated, no-auth MCP toolset: `time` and `fetch`
148+
(`uvx`), `memory` and `filesystem` (`npx`, the latter rooted at the working
149+
directory), and an NWS-backed `weather` server.
150+
151+
Firecrawl web search also loads when a `FIRECRAWL_API_KEY` is set; without it the
152+
session prints a one-line notice and runs without web search (every other default
153+
tool needs no key).
154+
155+
`--mcp-config FILE` adds your own servers on top of the defaults, from a standard
156+
`mcpServers` JSON file — the same
157+
`{"mcpServers": {"name": {"command": "…", "args": […]}}}` shape Claude Desktop and
158+
Claude Code use. Repeat the flag to merge several files; a later file (or a config
159+
entry sharing a default's name) wins on a clash. Remote servers use `{"url": "…"}`
160+
instead of `command`/`args`.
161+
162+
Each server is launched independently and best-effort: one that won't start (a
163+
missing `npx`/`uvx`, an offline host) drops only its own tools, so a single broken
164+
tool never sinks the session. MCP tools are a live-run feature and are not
165+
reflected in `--show-code` output.

aai_cli/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ heavily-reworked commands with long bodies; small commands keep the inline
151151
- **`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.
152152
- **`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.
153153
- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`).
154-
- **`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, per-sentence TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker.
154+
- **`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, per-sentence TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`); under `-v` (`debuglog.active()`) `brain._run_graph` *streams* that graph instead of `invoke`-ing it and logs each tool call/result/interim line as it lands (reusing `code_agent.events.message_events`), so a spoken turn that stalls mid-tool is debuggable — plain `invoke` runs the whole loop internally and `-v` would otherwise show only the httpx lines.
155155
- **`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.
156156
- **`code_agent/`** + `commands/code/` — `assembly code`: a terminal coding agent (a bespoke port of langchain-ai/deepagents' `code` agent) that talks **only** to the LLM Gateway. `model.py` pins the model to `ChatOpenAI` against `llm_gateway_base`; `agent.py` builds the deepagents graph over a cwd-scoped `LocalShellBackend` (filesystem + shell tools), plus extra tools: the custom `assembly` CLI tool (`cli_tool.py`, runs `python -m aai_cli` with the key via child env, never argv), a URL `fetch_url` tool (`fetch_tool.py`), Tavily web search when `TAVILY_API_KEY` is set (`web_search.py`), an `ask_user` tool routed through an `AskBridge` to the front-end (`ask_tool.py`), and best-effort docs MCP tools (`docs_mcp.py`). Middleware adds installed skills (`skills.py`) and long-term memory (`memory.py`), each over its own dedicated backend. Sessions persist via a SQLite checkpointer (`store.py`) keyed by `--session`, so conversations resume. Approval gates the mutating tools (write/edit/execute/`assembly`/`fetch_url`); the general-purpose `task` subagent comes from deepagents by default. `session.py` drives the graph turn-by-turn (interrupt/resume = human approval), emitting framework-agnostic `events.py` to either the Textual TUI (`tui.py`, modeled on deepagents-code: transcript + input + approval/ask modals + clipboard copy) or the Rich fallback (`render.py`). The whole orchestration is tested by driving the **real** graph with a fake `BaseChatModel` (`tests/test_code_agent.py`), so no network/TTY is needed. **Voice is the default front-end in an interactive TTY** (`voice.py` + `_exec._run_voice`): `VoiceSession.listen` captures one spoken turn over Streaming STT (gating the mic shut the instant a turn finalizes) and `VoiceSession.speak` reads each assistant reply back over streaming TTS. It runs the **Rich REPL** loop (not the keyboard TUI) with a voice `read_line` + a reply-speaking sink. Readback needs streaming TTS, so it's **sandbox-only** (`tts.session.is_available`); in production the mic input still works and replies stay on screen. A mic-less box degrades to typed input on the first `AUDIO_ERROR_TYPES` `CLIError`; `--no-voice` selects the TUI, and a non-TTY (pipe/CI) the headless loop. Both legs (STT/TTS) are injected like the cascade's, so `tests/test_code_voice.py` drives it with fakes — no mic/speaker/socket.
157157
- **`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`).

aai_cli/agent_cascade/brain.py

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,31 @@
1616

1717
from __future__ import annotations
1818

19+
import logging
1920
from collections.abc import Callable, Sequence
2021
from typing import TYPE_CHECKING
2122

2223
from aai_cli.agent_cascade.config import CascadeConfig
2324
from aai_cli.code_agent.agent import CompiledAgent
2425
from aai_cli.code_agent.fetch_tool import FETCH_TOOL_NAME
25-
from aai_cli.code_agent.web_search import WEB_SEARCH_TOOL_NAME
26+
from aai_cli.code_agent.firecrawl_search import WEB_SEARCH_TOOL_NAME
27+
from aai_cli.core import debuglog
2628

2729
if TYPE_CHECKING:
2830
from langchain_core.tools import BaseTool
2931
from openai.types.chat import ChatCompletionMessageParam
3032

33+
# Verbose (`-v`) flow logging for the agent's tool loop. `invoke` runs the whole loop
34+
# internally, so without this `-v` only shows the httpx request lines and never which
35+
# tools the agent reached for or what they returned — exactly what you need to see when
36+
# a spoken turn stalls mid-tool. Logged at INFO so plain `-v` surfaces it.
37+
_FLOW_LOG = logging.getLogger("aai_cli.agent_cascade.brain")
38+
39+
# Tool outputs (a fetched page, a search payload) can be huge; cap what we log per result
40+
# so a single tool call doesn't bury the rest of the flow in stderr. The exact cap is an
41+
# arbitrary tuning knob — a +-1 shift is behaviorally equivalent, so no test can kill it.
42+
_RESULT_LOG_CAP = 500 # pragma: no mutate
43+
3144
# Closes every guidance variant: the reply is spoken, so it must stay short and plain.
3245
_SPOKEN_TAIL = (
3346
"Your reply is read aloud, so keep it short and spoken — no markdown, lists, code, or raw URLs."
@@ -60,8 +73,8 @@ def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]:
6073
"""The spoken-capability phrases backed by an actually-present tool.
6174
6275
Derived from the resolved tool names so the prompt never advertises a capability the
63-
agent can't perform: web search is present only with a ``TAVILY_API_KEY``, and the docs
64-
tools are best-effort (absent when the docs host is unreachable).
76+
agent can't perform: web search is present only with a ``FIRECRAWL_API_KEY``, and the
77+
docs tools are best-effort (absent when the docs host is unreachable).
6578
"""
6679
names = {tool.name for tool in tools}
6780
capabilities: list[str] = []
@@ -74,15 +87,35 @@ def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]:
7487
return capabilities
7588

7689

77-
def build_system_prompt(persona: str, *, tools: Sequence[BaseTool]) -> str:
90+
def _extra_capability(extra_tools: Sequence[BaseTool]) -> str | None:
91+
"""The spoken-capability phrase for user-configured MCP tools, listing them by name.
92+
93+
The deepagents graph already shows the model each tool's schema, so this only has to
94+
name the tools so the guidance doesn't claim "no external tools" when MCP tools are
95+
bound — and so the model knows to reach for them.
96+
"""
97+
names = sorted(tool.name for tool in extra_tools)
98+
if not names:
99+
return None
100+
return f"use your connected tools ({', '.join(names)})"
101+
102+
103+
def build_system_prompt(
104+
persona: str, *, tools: Sequence[BaseTool], extra_tools: Sequence[BaseTool] = ()
105+
) -> str:
78106
"""The live agent's system prompt: the user's persona plus tool guidance.
79107
80-
The guidance is tailored to ``tools`` so the model is only told about capabilities it
81-
actually has — advertising a missing tool (web search without a ``TAVILY_API_KEY``) made
82-
the agent announce an action it then couldn't take, leaving the turn hanging with no
83-
answer. With no tools at all the model is told to answer from its own knowledge.
108+
The guidance is tailored to the bound tools so the model is only told about
109+
capabilities it actually has — advertising a missing tool (web search without a
110+
``FIRECRAWL_API_KEY``) made the agent announce an action it then couldn't take, leaving
111+
the turn hanging with no answer. ``tools`` are the built-in legs (web search, URL
112+
fetch, AssemblyAI docs); ``extra_tools`` are user-configured MCP tools, advertised
113+
generically by name. With no tools at all the model answers from its own knowledge.
84114
"""
85115
capabilities = _tool_capabilities(tools)
116+
extra = _extra_capability(extra_tools)
117+
if extra is not None:
118+
capabilities.append(extra)
86119
if not capabilities:
87120
return f"{persona}\n\n{_NO_TOOLS_GUIDANCE}"
88121
guidance = (
@@ -100,12 +133,12 @@ def build_live_tools() -> list[BaseTool]:
100133
All three are reused from the coding agent's tool modules. Unlike there they are
101134
*not* approval-gated — a spoken turn can't wait for a keyboard confirmation, so the
102135
live agent only gets read-only tools and runs them automatically. Web search is
103-
present only when ``TAVILY_API_KEY`` is set; the docs MCP is best-effort (an empty
136+
present only when ``FIRECRAWL_API_KEY`` is set; the docs MCP is best-effort (an empty
104137
list when the host is unreachable), so neither blocks a session.
105138
"""
106139
from aai_cli.code_agent.docs_mcp import load_docs_tools
107140
from aai_cli.code_agent.fetch_tool import build_fetch_tool
108-
from aai_cli.code_agent.web_search import build_web_search_tool
141+
from aai_cli.code_agent.firecrawl_search import build_web_search_tool
109142

110143
tools: list[BaseTool] = [build_fetch_tool()]
111144
search = build_web_search_tool()
@@ -116,27 +149,36 @@ def build_live_tools() -> list[BaseTool]:
116149

117150

118151
def build_graph(
119-
api_key: str, config: CascadeConfig, *, tools: Sequence[BaseTool] | None = None
152+
api_key: str,
153+
config: CascadeConfig,
154+
*,
155+
tools: Sequence[BaseTool] | None = None,
156+
mcp_tools: Sequence[BaseTool] | None = None,
120157
) -> CompiledAgent:
121158
"""Compile the deepagents graph for one live session over the gateway model.
122159
123160
Reuses the coding agent's gateway-bound ``ChatOpenAI`` (so the live agent can only
124161
ever reach AssemblyAI), threading the cascade's ``--max-tokens``/``--llm-config``
125-
through it. ``tools`` defaults to :func:`build_live_tools`; tests pass an explicit
126-
(possibly empty) list to skip the network-touching docs probe.
162+
through it. ``tools`` defaults to :func:`build_live_tools`; ``mcp_tools`` defaults to
163+
the tools of the servers in ``config.mcp_servers``. The two are kept apart so the
164+
system prompt advertises the built-in legs and the MCP tools differently, but the
165+
model is bound to both. Tests pass explicit (possibly empty) lists to skip the
166+
network-touching docs/MCP probes.
127167
"""
128168
from deepagents import create_deep_agent
129169

170+
from aai_cli.agent_cascade.mcp_tools import load_mcp_tools
130171
from aai_cli.code_agent.model import build_model
131172

132173
model = build_model(
133174
api_key, model=config.model, max_tokens=config.max_tokens, extra=config.llm_extra
134175
)
135-
resolved = build_live_tools() if tools is None else list(tools)
176+
builtin = build_live_tools() if tools is None else list(tools)
177+
extra = load_mcp_tools(config.mcp_servers) if mcp_tools is None else list(mcp_tools)
136178
return create_deep_agent(
137179
model=model,
138-
tools=resolved,
139-
system_prompt=build_system_prompt(config.system_prompt, tools=resolved),
180+
tools=builtin + extra,
181+
system_prompt=build_system_prompt(config.system_prompt, tools=builtin, extra_tools=extra),
140182
)
141183

142184

@@ -147,18 +189,79 @@ def build_completer(
147189
148190
The cascade prepends its own ``system`` message to the history each turn; the graph
149191
already owns the system prompt, so we drop it before invoking. The graph runs the
150-
full tool loop and we return its final spoken text. ``graph`` is injected in tests
151-
so the per-turn wiring runs against a fake with no network.
192+
full tool loop and we return its final spoken text. Under ``-v`` the loop is streamed
193+
so each tool call/result is logged as it lands (see :func:`_run_graph`). ``graph`` is
194+
injected in tests so the per-turn wiring runs against a fake with no network.
152195
"""
153196
resolved = build_graph(api_key, config) if graph is None else graph
154197

155198
def complete_reply(messages: list[ChatCompletionMessageParam]) -> str:
156199
conversation = [message for message in messages if message.get("role") != "system"]
157-
return _reply_text(resolved.invoke({"messages": conversation}))
200+
return _reply_text(_run_graph(resolved, conversation))
158201

159202
return complete_reply
160203

161204

205+
def _run_graph(
206+
graph: CompiledAgent, conversation: list[ChatCompletionMessageParam]
207+
) -> dict[str, object]:
208+
"""Run one turn through the graph, returning its end state.
209+
210+
Normally a single ``invoke`` (the whole tool loop runs internally). Under verbose
211+
mode, and when the graph can stream, drive it as incremental state snapshots instead
212+
so :func:`_log_flow` can surface each tool call/result on stderr as it happens — which
213+
is what makes a stalled spoken turn debuggable. The test fakes only implement
214+
``invoke``, so they (and the non-verbose path) take the plain branch.
215+
"""
216+
graph_input = {"messages": conversation}
217+
if debuglog.active() and hasattr(graph, "stream"):
218+
last: dict[str, object] = {}
219+
seen = 0
220+
for chunk in graph.stream(graph_input, None, stream_mode="values"):
221+
seen = _log_flow(chunk, seen)
222+
last = chunk
223+
return last
224+
return graph.invoke(graph_input)
225+
226+
227+
def _log_flow(state: dict[str, object], seen: int) -> int:
228+
"""Log the tool calls/results added to ``state`` since the first ``seen`` messages.
229+
230+
Reuses the coding agent's message→event vocabulary so the flow log knows the same
231+
AIMessage/ToolMessage shapes the TUI does. Returns the new high-water message count
232+
so the next snapshot only logs what it added.
233+
"""
234+
from aai_cli.code_agent.events import AssistantText, ToolCall, ToolResult, message_events
235+
236+
messages = state.get("messages")
237+
if not isinstance(messages, list):
238+
return seen
239+
for message in messages[seen:]:
240+
for event in message_events(message, announce_calls=True):
241+
if isinstance(event, ToolCall):
242+
_FLOW_LOG.info("tool call %s args=%s", event.name, event.args)
243+
elif isinstance(event, ToolResult):
244+
_FLOW_LOG.info("tool result %s -> %s", event.name, _clip(event.content))
245+
elif isinstance(event, AssistantText):
246+
_FLOW_LOG.info("llm: %s", event.text)
247+
return len(messages)
248+
249+
250+
def _clip(text: str) -> str:
251+
"""Flatten a tool result onto one line and truncate it for the flow log.
252+
253+
Tool output is untrusted external content (a fetched page, a search payload), so its
254+
whitespace — newlines especially — is collapsed before logging: a result can't then
255+
forge extra ``[aai_cli.…]`` log lines, and each result stays on one readable line. The
256+
length is capped so a multi-KB payload can't bury the rest of the flow. (Secrets are
257+
separately masked by the debuglog formatter across every record.)
258+
"""
259+
flattened = " ".join(text.split())
260+
if len(flattened) <= _RESULT_LOG_CAP:
261+
return flattened
262+
return f"{flattened[:_RESULT_LOG_CAP]}… ({len(flattened)} chars)"
263+
264+
162265
def _reply_text(result: dict[str, object]) -> str:
163266
"""The agent's final spoken reply: the last assistant message that carries text.
164267

0 commit comments

Comments
 (0)