Files

279 lines
10 KiB
Python

"""A Pipecat processor that puts Claude Code in the LLM slot of a voice pipeline.
This drives the Claude Agent SDK rather than the raw Anthropic API, so the
conversation runs through the same `claude` CLI (and the same auth) that a
terminal session uses, and Claude keeps its tools.
The SDK owns conversation history, so the Pipecat context is used only for turn
detection: this reads the newest user message off each `LLMContextFrame` and
emits the `LLMFullResponseStartFrame` / `LLMTextFrame` /
`LLMFullResponseEndFrame` sequence the TTS service downstream expects.
"""
import asyncio
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
StreamEvent,
TextBlock,
ToolUseBlock,
)
from loguru import logger
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InterruptionFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMTextFrame,
StartFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
# Whisper hallucinates these on silence or breath noise. Treating them as speech
# makes the agent answer questions nobody asked.
_NOISE_TRANSCRIPTS = {
"",
".",
"thank you.",
"thanks for watching!",
"you",
"bye.",
"okay.",
"[blank_audio]",
"[silence]",
}
class ClaudeCodeLLM(FrameProcessor):
"""Runs each user utterance through a persistent Claude Code session."""
def __init__(
self, *, options: ClaudeAgentOptions, observer=None, working_phrase: str | None = "One moment.", **kwargs
):
super().__init__(**kwargs)
# Said once per turn if a tool runs before any answer has begun. Silence
# while Claude works reads as a crash: in one recorded session four of
# nine turns went unanswered because tool-heavy turns took over a minute
# with no sound, and speaking again to check cancels the turn in flight.
self._working_phrase = working_phrase
self._said_working = False
self._options = options
self._client: ClaudeSDKClient | None = None
self._turn_task: asyncio.Task | None = None
# Called with each completed reply. What Claude is talking about
# predicts what the user will say next, so this feeds the recognizer.
#
# Not named _observer: FrameProcessor owns that attribute and reassigns
# it during pipeline setup, so it would be silently replaced by
# pipecat's own observer and then called with the wrong signature.
self._on_reply = observer
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
await self.push_frame(frame, direction)
await self._connect()
elif isinstance(frame, (EndFrame, CancelFrame)):
await self._cancel_turn()
await self._disconnect()
await self.push_frame(frame, direction)
elif isinstance(frame, InterruptionFrame):
await self._cancel_turn()
await self.push_frame(frame, direction)
elif isinstance(frame, LLMContextFrame):
await self._maybe_start_turn(_latest_user_text(frame.context))
else:
await self.push_frame(frame, direction)
def start_turn_direct(self, text: str):
utterance = text.strip()
if not utterance:
return
self.create_task(self._maybe_start_turn(utterance))
async def _connect(self):
if self._client:
return
logger.info("Starting Claude Code session...")
client = ClaudeSDKClient(options=self._options)
try:
await client.connect()
except Exception as e:
self._client = None
logger.error(
f"Could not start the Claude CLI: {e}\n"
"Exit code -9 means the CLI was killed rather than failing on its own. "
"On a managed Mac that is usually either the binary still awaiting "
"approval, or its sandbox failing to apply because this process is "
"already inside one — in which case run `./talk` from a normal terminal."
)
return
self._client = client
logger.info("Claude Code session ready.")
async def _disconnect(self):
if not self._client:
return
await self._client.disconnect()
self._client = None
async def _maybe_start_turn(self, text: str):
utterance = text.strip()
if utterance.lower() in _NOISE_TRANSCRIPTS or len(utterance) < 2:
logger.debug(f"Ignoring noise transcript: {utterance!r}")
return
if not self._client:
logger.warning("Transcript arrived before the Claude session was ready; dropping it.")
return
# A new utterance supersedes whatever Claude was in the middle of saying.
await self._cancel_turn()
logger.info(f"You: {utterance}")
self._turn_task = self.create_task(self._run_turn(utterance))
async def _cancel_turn(self):
if not self._turn_task:
return
task, self._turn_task = self._turn_task, None
if self._client:
try:
await self._client.interrupt()
except Exception as e:
logger.debug(f"Interrupt failed (session may be idle): {e}")
await self.cancel_task(task)
async def _say_working(self, spoken: list[str]):
"""Break the silence before a slow tool call, once per turn."""
if self._said_working or spoken or not self._working_phrase:
return
self._said_working = True
await self.push_frame(TTSSpeakFrame(self._working_phrase))
async def _run_turn(self, utterance: str):
await self.push_frame(LLMFullResponseStartFrame())
self._said_working = False
spoken: list[str] = []
try:
await self._client.query(utterance)
async for message in self._client.receive_response():
# Subagent chatter carries a parent tool id; only speak the main thread.
if getattr(message, "parent_tool_use_id", None):
continue
if isinstance(message, StreamEvent):
text = _text_delta(message)
if text:
spoken.append(text)
await self.push_frame(LLMTextFrame(text))
elif isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
logger.info(f" [tool] {block.name}")
try:
import web_server
web_server.broadcast_event("tool", {"name": block.name, "detail": str(getattr(block, "input", ""))})
except Exception:
pass
await self._say_working(spoken)
elif isinstance(block, TextBlock) and not self._streams_partials():
spoken.append(block.text)
await self.push_frame(LLMTextFrame(block.text))
elif isinstance(message, ResultMessage):
if message.is_error:
_log_result_error(message)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception(f"Claude turn failed: {e}")
finally:
if spoken:
reply = "".join(spoken)
logger.info(f"Claude: {reply}")
if self._on_reply:
# Isolated deliberately: this frame ends the turn for the
# TTS and the aggregator, so nothing optional may prevent
# it being pushed.
try:
self._on_reply(reply)
except Exception as e:
logger.warning(f"Reply observer failed: {e}")
await self.push_frame(LLMFullResponseEndFrame())
def _streams_partials(self) -> bool:
return self._options.include_partial_messages
def _log_result_error(message: ResultMessage):
"""Report a failed turn using whichever field actually says something.
``result`` is often None on a failure — it holds the reply text, and a turn
that failed has none — so logging it alone produced "Claude returned an
error: None" and told us nothing. The diagnosis lives in the other fields.
"""
details = {
field: value
for field in (
"subtype",
"stop_reason",
"terminal_reason",
"api_error_status",
"errors",
"permission_denials",
"result",
)
if (value := getattr(message, field, None))
}
# Cutting Claude off mid-answer is a normal part of talking, and the CLI
# reports it the same way it reports a genuine failure. Don't cry wolf.
if details.get("terminal_reason") in (
"interrupted",
"cancelled",
# What the CLI reports when interrupt() lands mid-stream, which is
# simply what talking over Claude looks like from its side.
"aborted_streaming",
):
logger.debug(f"Turn interrupted: {details}")
return
logger.error(f"Claude turn failed: {details or 'no detail reported'}")
def _latest_user_text(context: LLMContext) -> str:
"""Return the text of the most recent user message in the context."""
for message in reversed(context.get_messages()):
if not isinstance(message, dict) or message.get("role") != "user":
continue
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return ""
def _text_delta(event: StreamEvent) -> str | None:
"""Pull assistant text out of a raw stream event, ignoring thinking and tool input."""
raw = event.event or {}
if raw.get("type") != "content_block_delta":
return None
delta = raw.get("delta") or {}
if delta.get("type") != "text_delta":
return None
return delta.get("text") or None