"""Raycast backend adapter. Translates between Anthropic's ``/v1/messages`` wire vocabulary (incoming ``MessageParam`` history, outgoing ``MessageStreamEvent`` SSE) and the ``raycast-api`` SDK (``Message`` history, ``ChatStreamChunk`` SSE). Two halves live here: * :func:`_to_raycast_messages` — pure conversion of an Anthropic message list into ``list[raycast_api.Message]``. ``tool_result`` blocks carry no tool name in Anthropic; we recover it by remembering each ``tool_use`` id we saw upstream. * :meth:`RaycastBackend.complete` — opens a ``client.chat.stream`` and walks chunks through a tiny block-state machine. The state machine exists only because Raycast streams ``tool_calls`` in three phases (open with id+name, deltas with empty id, final summary with the full ``arguments``) — Anthropic wants one ``content_block_start`` → deltas → ``content_block_stop`` per block, so we de-duplicate the final summary against the per-delta increments already emitted. MCP wiring: Raycast has no native MCP concept, so when an agent declares ``expose_mcps`` we splice each MCP's tools into the wire request as ``Tool.local(name=f"{mcp}__{tool}", ...)`` and run a gateway-internal loop. Every time the model emits a tool_call for one of those local tools we route it back to the underlying MCP in-process, append the result as a ``tool`` message, and re-issue the stream — all inside one Anthropic envelope (one ``message_start`` … one ``message_stop``). The tool_use blocks DO surface to the caller (mirrors what ``ClaudeSdkBackend`` does), but tool_results stay internal. """ from __future__ import annotations import fnmatch import json import logging import uuid from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, cast from raycast_api import Message as RaycastMessage from raycast_api import RemoteTool, Tool, ToolCall from beaver_gateway.agents.raycast import RaycastAgent from beaver_gateway.core.events import ( StopReason, build_content_block_stop, build_input_json_delta, build_message_delta, build_message_start, build_message_stop, build_text_block_start, build_text_delta, build_thinking_block_start, build_thinking_delta, build_tool_use_block_start, ) if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable, Mapping, Sequence from anthropic.types import MessageParam from fastmcp import FastMCP from fastmcp.tools.base import Tool as FastMCPTool from raycast_api import ChatStreamChunk, Client from beaver_gateway.agents.base import BaseAgent from beaver_gateway.core.events import MessageStreamEvent else: from collections.abc import Mapping __all__ = ["RaycastBackend"] _log = logging.getLogger("beaver_gateway.backends.raycast") # Cap on consecutive tool-call turns inside one Anthropic envelope. # Real conversations rarely chain more than a handful; the limit only # fires on a model that loops, and surfaces as a clean ``end_turn`` # with an error tool_result rather than a hang. _MAX_TOOL_TURNS = 20 _RAYCAST_TO_ANTHROPIC_STOP: dict[str, StopReason] = { "stop": "end_turn", "STOP": "end_turn", "end_turn": "end_turn", "tool_calls": "tool_use", "tool_use": "tool_use", "length": "max_tokens", "max_tokens": "max_tokens", "stop_sequence": "stop_sequence", } def _first_set[T](*values: T | None) -> T | None: """Return the first value that isn't ``None``, else ``None``. Used to layer per-request options over per-agent defaults: a real ``0.0`` temperature on the request must override the agent's ``None``, but the agent's value must take effect when the request omits it. ``or``-chaining is wrong here because ``0.0`` / ``""`` are legitimate values and falsy. """ for v in values: if v is not None: return v return None def _map_stop_reason(raw: str | None) -> StopReason: """Map Raycast ``finish_reason`` strings into Anthropic stop reasons. Unknown values collapse to ``end_turn`` — Anthropic clients treat that as a clean finish, which is the right user-visible behaviour when the upstream simply went off-vocabulary. """ if raw is None: return "end_turn" return _RAYCAST_TO_ANTHROPIC_STOP.get(raw, "end_turn") def _as_mapping(block: object) -> Mapping[str, Any] | None: """Narrow a block param (TypedDict | dict | anything) to a read-only mapping. ty refuses to assign a TypedDict to ``dict[str, Any]`` (TypedDicts are not freely-mutable dicts in its model), and our access pattern is strictly read-only — so we go through ``Mapping``. """ if isinstance(block, Mapping): return cast("Mapping[str, Any]", block) return None def _extract_tool_result_text(content: object) -> str: """Flatten an Anthropic ``tool_result`` block's content into plain text. Anthropic accepts either a string or a list of typed blocks (text / image / etc.). Raycast only carries text in tool results, so we keep text blocks and JSON-encode anything richer rather than dropping it. """ if isinstance(content, str): return content if isinstance(content, list): parts: list[str] = [] for block in content: block_map = _as_mapping(block) if block_map is not None and block_map.get("type") == "text": parts.append(str(block_map.get("text", ""))) else: parts.append( json.dumps(block, separators=(",", ":"), ensure_ascii=False) ) return "\n".join(parts) return json.dumps(content, separators=(",", ":"), ensure_ascii=False) def _to_raycast_messages(messages: Iterable[MessageParam]) -> list[RaycastMessage]: """Convert an Anthropic message history into a Raycast one. ``tool_use_id → name`` is tracked across the iteration so that Raycast ``tool`` messages — which require a tool name the Anthropic side does not carry on ``tool_result`` blocks — can be reconstructed. """ out: list[RaycastMessage] = [] tool_use_names: dict[str, str] = {} for msg in messages: role = msg["role"] content = msg.get("content", "") if isinstance(content, str): if role == "user": out.append(RaycastMessage.user(content)) else: out.append(RaycastMessage.assistant(text=content)) continue if role == "user": text_parts: list[str] = [] tool_results: list[Mapping[str, Any]] = [] for block in content: block_map = _as_mapping(block) if block_map is None: continue btype = block_map.get("type") if btype == "text": text_parts.append(str(block_map.get("text", ""))) elif btype == "tool_result": tool_results.append(block_map) if text_parts: out.append(RaycastMessage.user("\n".join(text_parts))) for tr in tool_results: tool_use_id = str(tr.get("tool_use_id", "")) out.append( RaycastMessage.tool( tool_call_id=tool_use_id, name=tool_use_names.get(tool_use_id, ""), result=_extract_tool_result_text(tr.get("content", "")), ) ) continue text_parts = [] tool_calls: list[ToolCall] = [] for block in content: block_map = _as_mapping(block) if block_map is None: continue btype = block_map.get("type") if btype == "text": text_parts.append(str(block_map.get("text", ""))) elif btype == "tool_use": tu_id = str(block_map.get("id", "")) tu_name = str(block_map.get("name", "")) tool_use_names[tu_id] = tu_name tool_calls.append( ToolCall( id=tu_id, name=tu_name, arguments=json.dumps( block_map.get("input", {}), separators=(",", ":"), ensure_ascii=False, ), ) ) out.append( RaycastMessage.assistant( text="\n".join(text_parts), tool_calls=tool_calls or None ) ) return out @dataclass(frozen=True, slots=True) class _AgentToolCatalog: """Per-agent MCP tool catalog + routing map. Cached on :class:`RaycastBackend` keyed by agent name. ``tools`` is the fully-rendered ``tools`` argument for ``chat.stream`` (local MCP tools first, native remote tools last). ``mcp_routing`` maps each wire-name we registered back to the underlying ``(mcp, original)`` pair so we can dispatch a tool_call without re-parsing the prefix. """ tools: tuple[Tool, ...] mcp_routing: Mapping[str, tuple[str, str]] _EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}} def _sanitize_schema(schema: Mapping[str, Any] | None) -> dict[str, Any]: """Strip JSON-Schema meta keys that Raycast/Gemini's tool API rejects. MCP servers ship draft-07 schemas including ``$schema``/``$id``/etc. OpenAI-style function-calling expects a leaner subset — leaving the meta keys in causes the upstream provider to reject the entire request with an opaque ``unknown_api_error``. We drop the document-level meta keys and keep the structural keys (``type``/``properties``/``required``/...). """ if not schema: return dict(_EMPTY_SCHEMA) out: dict[str, Any] = {} for key, value in schema.items(): if key.startswith("$"): continue out[key] = value if "type" not in out: out["type"] = "object" if out.get("type") == "object" and "properties" not in out: out["properties"] = {} return out def _build_agent_catalog( agent: RaycastAgent, mcp_tools: Mapping[str, list[FastMCPTool]] ) -> _AgentToolCatalog: """Resolve ``agent.expose_mcps`` against the prefetched MCP tool lists. Each exposed MCP contributes one ``Tool.local`` per tool, named ``{mcp}__{tool}`` (mirrors claude-code's wire convention, so a model that has seen one style sees a familiar one here). ``ExposedMcp.tools`` optionally filters to a subset by original (unprefixed) name. A missing MCP entry (broken at startup, see ``cli._prefetch_mcp_tools``) silently contributes nothing — surfaces in logs at start, doesn't crash request-time. """ routing: dict[str, tuple[str, str]] = {} local_tools: list[Tool] = [] for em in agent.expose_mcps: for mt in mcp_tools.get(em.name, []): if em.tools is not None and mt.name not in em.tools: continue if any(fnmatch.fnmatchcase(mt.name, pat) for pat in em.deny): continue wire_name = f"{em.name}__{mt.name}" routing[wire_name] = (em.name, mt.name) local_tools.append( Tool.local( name=wire_name, description=mt.description or "", parameters=_sanitize_schema(mt.parameters), ) ) remote_tools = [Tool.remote(n) for n in agent.available_native_tools] return _AgentToolCatalog( tools=tuple(local_tools + remote_tools), mcp_routing=routing ) def _render_mcp_tool_result(result: Any) -> str: """Flatten a ``fastmcp.ToolResult`` into the text Raycast carries. Raycast ``tool`` messages only hold a string. MCP results can carry text, images, structured payloads, etc. — we keep text blocks verbatim, JSON-encode anything else, and fall back to ``structured_content`` if the content list is empty. """ parts: list[str] = [] for block in getattr(result, "content", None) or []: text = getattr(block, "text", None) if isinstance(text, str): parts.append(text) continue dump = getattr(block, "model_dump", None) if callable(dump): parts.append(json.dumps(dump(), ensure_ascii=False)) else: parts.append(str(block)) if not parts: structured = getattr(result, "structured_content", None) if structured is not None: parts.append(json.dumps(structured, ensure_ascii=False)) return "\n".join(parts) @dataclass(slots=True) class _TurnAccumulator: """Pieces collected from one Raycast stream needed to feed the next. ``text_parts`` is the assistant's text portion (joined and replayed on the next ``chat.stream`` call so the model sees its own prior reply). ``tool_*`` mirrors the same data the wire-state already emitted, but kept in raw form so we can build ``ToolCall`` objects and dispatch them through the gateway. """ text_parts: list[str] = field(default_factory=list) tool_order: list[str] = field(default_factory=list) tool_names: dict[str, str] = field(default_factory=dict) tool_args: dict[str, list[str]] = field(default_factory=dict) finish_reason: str | None = None usage: dict[str, int] | None = None def add_tool_phase1(self, tool_id: str, name: str) -> None: if tool_id in self.tool_names: return self.tool_order.append(tool_id) self.tool_names[tool_id] = name self.tool_args.setdefault(tool_id, []) def add_tool_args(self, tool_id: str, fragment: str) -> None: self.tool_args.setdefault(tool_id, []).append(fragment) class _BlockState: """Tracks the currently-open Anthropic content block, if any. Anthropic events are sequential per block (``content_block_start`` → deltas → ``content_block_stop``). Raycast streams text and tool_calls interleaved across chunks; we keep one slot open at a time and close it whenever the kind changes. Tool-call routing needs two side tables because Raycast streams ids in phase 1 only and ``index`` in every chunk: ``tool_id_to_block`` keys by Raycast tool-call id, ``tool_idx_to_id`` resolves chunk indices back to that id for the no-id delta chunks. State spans the entire Anthropic envelope (all Raycast turns) so block indices grow monotonically across the tool-call loop. The per-turn ``_TurnAccumulator`` carries the volatile bits. """ __slots__ = ("index", "kind", "tool_id_to_block", "tool_idx_to_id") def __init__(self) -> None: self.index: int = -1 self.kind: str | None = None # "text" | "thinking" | "tool_use" | None self.tool_id_to_block: dict[str, int] = {} self.tool_idx_to_id: dict[int, str] = {} def reset_turn_indexing(self) -> None: """Drop chunk-index → id routing between turns. Raycast chunk ``index`` fields restart at 0 inside each fresh ``chat.stream``; reusing the previous turn's table would alias new tool_calls onto old ids. ``tool_id_to_block`` we keep — ids are stream-unique and new ones won't collide. """ self.tool_idx_to_id = {} class RaycastBackend: """Adapter from ``raycast-api`` chat streams to Anthropic stream events. Construction takes a long-lived :class:`raycast_api.Client` (one per gateway — bearer + device_id are process-wide) plus the in-process MCP server map and the prefetched per-MCP tool list. Each :meth:`complete` call opens one or more ``chat.stream`` calls — one per agent "turn" inside the gateway-internal tool-call loop — and yields a single Anthropic stream envelope spanning all of them. """ def __init__( self, client: Client, *, mcp_servers: Mapping[str, FastMCP] | None = None, mcp_tools: Mapping[str, list[FastMCPTool]] | None = None, ) -> None: self._client = client self._mcp_servers: Mapping[str, FastMCP] = mcp_servers or {} self._mcp_tools: Mapping[str, list[FastMCPTool]] = mcp_tools or {} # Cached per-agent catalog — agents are immutable, so a single # render at first use covers the gateway's lifetime. self._agent_catalog: dict[str, _AgentToolCatalog] = {} def _catalog_for(self, agent: RaycastAgent) -> _AgentToolCatalog: cached = self._agent_catalog.get(agent.name) if cached is not None: return cached cat = _build_agent_catalog(agent, self._mcp_tools) self._agent_catalog[agent.name] = cat return cat async def complete( self, *, agent: BaseAgent, messages: Iterable[MessageParam], system: str | None = None, **options: Any, ) -> AsyncIterator[MessageStreamEvent]: if not isinstance(agent, RaycastAgent): msg = f"RaycastBackend requires RaycastAgent, got {type(agent).__name__}" raise TypeError(msg) raycast_messages = _to_raycast_messages(messages) catalog = self._catalog_for(agent) # Native remote tools + spliced MCP locals. ``None`` keeps the # SDK from sending a ``tools`` field at all when neither is # declared. tools_arg: list[Tool | RemoteTool | str] | None = ( list(catalog.tools) if catalog.tools else None ) # On the wire Raycast uses ``system_instructions`` as a format # marker (``"markdown"`` for AI_CHAT, ``"plain"`` otherwise — # filled in by the SDK from the source default when we pass # ``None``) and ``additional_system_instructions`` as the actual # prompt content. So our ``system_prompt`` (or the per-request # Anthropic ``system``, if present) flows into the *additional* # slot. The SDK still prepends ```` to whatever # we hand it via ``_build_preamble``. prompt_content = system if system is not None else agent.system_prompt # Per-request options win over agent defaults; agent defaults # win over Raycast SDK defaults. ``None`` means "fall back". async for event in self._stream( agent=agent, raycast_messages=raycast_messages, tools=tools_arg, mcp_routing=catalog.mcp_routing, prompt_content=prompt_content, temperature=_first_set(options.get("temperature"), agent.temperature), reasoning_effort=_first_set( options.get("reasoning_effort"), agent.reasoning_effort ), tool_choice=_first_set(options.get("tool_choice"), agent.tool_choice), ): yield event async def _stream( self, *, agent: RaycastAgent, raycast_messages: Sequence[RaycastMessage], tools: list[Tool | RemoteTool | str] | None, mcp_routing: Mapping[str, tuple[str, str]], prompt_content: str | None, temperature: float | None, reasoning_effort: str | None, tool_choice: str | None, ) -> AsyncIterator[MessageStreamEvent]: message_id = f"msg_{uuid.uuid4().hex}" yield build_message_start(message_id=message_id, model=agent.model) state = _BlockState() # ``working_messages`` is the rolling history fed back to # Raycast as we resolve MCP tool_calls turn by turn. working_messages = list(raycast_messages) last_usage: dict[str, int] | None = None last_finish: str | None = None for _turn in range(_MAX_TOOL_TURNS): acc = _TurnAccumulator() state.reset_turn_indexing() stream = self._client.chat.stream( model=agent.model, messages=working_messages, source=agent.source, # ``system_instructions=None`` → SDK substitutes the # source default (``"markdown"`` / ``"plain"``). Real # prompt goes into ``additional_system_instructions``. additional_system_instructions=prompt_content, user_preferences=agent.user_preferences, tools=tools, tool_choice=tool_choice, temperature=temperature, reasoning_effort=reasoning_effort, ) async for chunk in stream: for event in self._handle_chunk(chunk, state, acc): yield event if chunk.finish_reason: acc.finish_reason = chunk.finish_reason if chunk.usage: acc.usage = chunk.usage last_finish = acc.finish_reason if acc.usage: last_usage = acc.usage # Figure out which (if any) tool_calls land on our MCP # routing table. Anything not in the table is left to bubble # out of the envelope as a regular tool_use block — the # Anthropic caller can then respond with a tool_result the # usual way, and the next ``complete`` invocation will # carry it back in. pending_mcp_calls = [ tid for tid in acc.tool_order if acc.tool_names.get(tid) in mcp_routing ] if not pending_mcp_calls: break # Close whatever block is still open before we step into # tool execution — the next turn's chunks start a fresh # block sequence. if state.kind is not None: yield build_content_block_stop(state.index) state.kind = None # Echo the assistant turn so Raycast sees its own reply + # tool_calls in subsequent context. assistant_text = "".join(acc.text_parts) assistant_tool_calls = [ ToolCall( id=tid, name=acc.tool_names.get(tid, ""), arguments="".join(acc.tool_args.get(tid, [])) or "{}", ) for tid in acc.tool_order ] working_messages.append( RaycastMessage.assistant( text=assistant_text, tool_calls=assistant_tool_calls or None ) ) # Dispatch each MCP-routed call and append a ``tool`` # message. Calls not in the routing table get a placeholder # error so the model can correct itself rather than the # gateway hanging the conversation. for tid in acc.tool_order: tool_name = acc.tool_names.get(tid, "") args_str = "".join(acc.tool_args.get(tid, [])) or "{}" if tool_name in mcp_routing: result_text = await self._dispatch_mcp_call( tool_name=tool_name, args_json=args_str, mcp_routing=mcp_routing ) else: # Non-MCP tool — shouldn't really happen because we # haven't surfaced any other locals, but defend. result_text = f"Tool {tool_name!r} is not handled by the gateway." working_messages.append( RaycastMessage.tool( tool_call_id=tid, name=tool_name, result=result_text ) ) # Loop: re-stream with the new history. else: _log.warning( "raycast tool-call loop hit %d-turn cap for agent %r; " "closing the envelope", _MAX_TOOL_TURNS, agent.name, ) # Close whatever block is still open before the final delta. if state.kind is not None: yield build_content_block_stop(state.index) state.kind = None yield build_message_delta( stop_reason=_map_stop_reason(last_finish), usage=last_usage ) yield build_message_stop() async def _dispatch_mcp_call( self, *, tool_name: str, args_json: str, mcp_routing: Mapping[str, tuple[str, str]], ) -> str: """Route one tool_call to the matching MCP and render its result. Errors (missing server, JSON parse, tool exception) surface as text in the ``tool`` message rather than crashing the stream — the model gets a chance to recover or apologize. """ mcp_name, original_name = mcp_routing[tool_name] server = self._mcp_servers.get(mcp_name) if server is None: return f"Error: MCP {mcp_name!r} is not registered." try: args = json.loads(args_json) if args_json else {} except json.JSONDecodeError as exc: return f"Error: tool arguments are not valid JSON ({exc})." if not isinstance(args, dict): return "Error: tool arguments must be a JSON object." try: result = await server.call_tool(original_name, args) except Exception as exc: # noqa: BLE001 _log.exception( "MCP call failed: %s.%s args=%r", mcp_name, original_name, args ) return f"Error calling {tool_name}: {exc}" return _render_mcp_tool_result(result) def _handle_chunk( self, chunk: ChatStreamChunk, state: _BlockState, acc: _TurnAccumulator ) -> Iterable[MessageStreamEvent]: """Translate one Raycast chunk into zero or more Anthropic events. Branch order matters: we close the previous block kind before opening a new one (text → tool_use, tool_use → text, etc.) and we intentionally fall through ``tool_calls`` only if there's a real delta — the final-summary chunk re-sends the full arguments string we've already streamed delta-by-delta. """ events: list[MessageStreamEvent] = [] is_final_summary = chunk.finish_reason is not None if chunk.text: events.extend(self._ensure_kind(state, "text")) events.append(build_text_delta(state.index, chunk.text)) acc.text_parts.append(chunk.text) if chunk.reasoning: events.extend(self._ensure_kind(state, "thinking")) events.append(build_thinking_delta(state.index, chunk.reasoning)) if chunk.tool_calls: events.extend( self._handle_tool_calls( chunk, state, acc, is_final_summary=is_final_summary ) ) return events def _ensure_kind( self, state: _BlockState, kind: str ) -> Iterable[MessageStreamEvent]: """Open a block of ``kind``, closing any current block first.""" if state.kind == kind: return [] events: list[MessageStreamEvent] = [] if state.kind is not None: events.append(build_content_block_stop(state.index)) state.index += 1 state.kind = kind if kind == "text": events.append(build_text_block_start(state.index)) elif kind == "thinking": events.append(build_thinking_block_start(state.index)) else: msg = f"unexpected block kind: {kind!r}" raise ValueError(msg) return events def _handle_tool_calls( self, chunk: ChatStreamChunk, state: _BlockState, acc: _TurnAccumulator, *, is_final_summary: bool, ) -> Iterable[MessageStreamEvent]: """Translate one chunk's ``tool_calls`` payload into Anthropic events. Mirrors the keying logic of ``raycast_api.ChatResult._merge_tool_calls`` so the same dedupe behaviour lands here: tool calls are tracked by id when present, otherwise by their wire ``index`` field, and the final-summary chunk's arguments string is dropped because the deltas already streamed it. Side-effects on ``acc`` mirror what gets emitted to the wire so the gateway can rebuild a full ``ToolCall`` for the next Raycast turn (it needs the joined ``arguments`` JSON string, which the wire never gives us as a whole — only in fragments). """ events: list[MessageStreamEvent] = [] raw_tcs = chunk.raw.get("tool_calls") or [] for i, tc in enumerate(chunk.tool_calls or []): raw_tc = raw_tcs[i] if i < len(raw_tcs) else {} idx_field = raw_tc.get("index") if isinstance(raw_tc, dict) else None # Resolve this entry to a tool-id key, mirroring # `raycast_api.ChatResult._merge_tool_calls`. Phase 1 carries # id+index, phase 2 only index, phase 3 only id. tool_id: str | None = None if tc.id: tool_id = tc.id if isinstance(idx_field, int): state.tool_idx_to_id[idx_field] = tc.id elif isinstance(idx_field, int): tool_id = state.tool_idx_to_id.get(idx_field) if tool_id is None: continue block_idx = state.tool_id_to_block.get(tool_id) if block_idx is None: # New tool_use block. Close any open text/thinking block first. if state.kind is not None: events.append(build_content_block_stop(state.index)) state.index += 1 state.kind = "tool_use" block_idx = state.index state.tool_id_to_block[tool_id] = block_idx acc.add_tool_phase1(tool_id, tc.name or "") events.append( build_tool_use_block_start( block_idx, tool_use_id=tool_id, name=tc.name or "" ) ) # Streaming providers (GPT) deliver arguments as deltas # across chunks and re-state the full string in a final # summary chunk; non-streaming-args providers (Gemini) # only send args in the final summary. Emit args in # both cases when this is the first appearance — final # summary then IS the full string. if tc.arguments: events.append(build_input_json_delta(block_idx, tc.arguments)) acc.add_tool_args(tool_id, tc.arguments) continue # Existing block. Final summary chunks restate the full args # string; if we already streamed deltas, that restatement is # a duplicate (skip). If we streamed nothing (the streaming # provider didn't send mid-arg deltas — Gemini path again), # the summary IS the args — emit it once. if is_final_summary: if tc.arguments and not acc.tool_args.get(tool_id): events.append(build_input_json_delta(block_idx, tc.arguments)) acc.add_tool_args(tool_id, tc.arguments) continue if tc.arguments: events.append(build_input_json_delta(block_idx, tc.arguments)) acc.add_tool_args(tool_id, tc.arguments) return events