785 lines
31 KiB
Python
785 lines
31 KiB
Python
"""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 ``<user-preferences>`` 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
|