"""Anthropic Messages adapter for agent rollouts.
Exposes /v1/messages and /v1/messages/count_tokens. Each Anthropic message
history is rendered with the served model's chat template, sent to sglang
/generate as input_ids, and fed into a shared TrajectoryManager keyed by session
id. finish_session(sid) drains a session's trajectory into a list of Sample.
The per-sid tree inside TrajectoryManager handles sub-agent and compaction
patterns automatically: any divergence in the prompt prefix forks into a new
leaf, so we do not track explicit chains here.
This module mirrors slime.agent.adapters.openai; the section layout (adapter
class -> translation -> reply building -> request framing) is shared between
them. See BaseAdapter for the hooks to fill.
"""
from __future__ import annotations
import json
import logging
import secrets
from typing import Any
from aiohttp import web
from slime.agent.adapters.common import (
BaseAdapter,
Reply,
flatten_content,
manager_finish_reason,
sid_from_bearer,
tool_call_dict,
)
from slime.agent.parsing import ParsedModelOutput
logger = logging.getLogger(__name__)
class AnthropicAdapter(BaseAdapter):
"""Anthropic Messages-compatible HTTP adapter: wire translation and reply
framing only; the turn machinery is inherited from BaseAdapter."""
logger = logger
log_prefix = "anthropic_adapter"
max_token_keys = ("max_tokens",)
stop_keys = ("stop_sequences",)
def _register_routes(self, app: web.Application) -> None:
app.router.add_post("/v1/messages", self._run_turn)
app.router.add_post("/v1/messages/count_tokens", _count_tokens)
def _session_id(self, request: web.Request, body: dict) -> str:
return _request_session_id(request)
def _preprocess_body(self, body: dict) -> None:
_fold_mid_list_system_into_user(body)
def _translate(self, body: dict) -> tuple[list[dict], list[dict] | None]:
translated = _translate_messages(body.get("messages") or [], body.get("system"))
tools_schema = _tools_to_chat_tools(body.get("tools"))
return translated, tools_schema
def _build_reply(self, parsed, raw_finish, translated, tools_schema) -> Reply:
blocks, stop_reason, manager_message = _build_reply_parts(parsed, raw_finish)
return Reply(
manager_message=manager_message,
finish_reason=manager_finish_reason(parsed.tool_uses, raw_finish),
wire=(blocks, stop_reason),
)
async def _respond(self, request, body, reply, in_tok, out_tok, stream) -> web.StreamResponse:
blocks, stop_reason = reply.wire
if stream:
return await _render_stream(request, blocks, stop_reason, in_tok, out_tok)
return web.json_response(_render_response(body, blocks, stop_reason, in_tok, out_tok))
# --- Translation (Anthropic wire -> chat-template messages) ---
def _translate_messages(msgs: list[dict], system: Any) -> list[dict]:
"""Anthropic messages + system -> chat-template messages. Pure function."""
translated: list[dict] = []
if system:
translated.append({"role": "system", "content": flatten_content(system)})
for m in msgs:
if not isinstance(m, dict):
continue
role, content = m.get("role"), m.get("content")
if role == "user":
blocks = content if isinstance(content, list) else [{"type": "text", "text": flatten_content(content)}]
for b in blocks:
if isinstance(b, dict) and b.get("type") == "tool_result":
translated.append({"role": "tool", "content": flatten_content(b.get("content"))})
elif isinstance(b, dict) and b.get("type") == "text":
translated.append({"role": "user", "content": b.get("text", "")})
else:
translated.append({"role": "user", "content": flatten_content(b)})
elif role == "assistant":
texts, thinkings, tcs = [], [], []
blocks = content if isinstance(content, list) else [{"type": "text", "text": flatten_content(content)}]
for b in blocks:
if not isinstance(b, dict):
continue
if b.get("type") == "text":
texts.append(b.get("text", ""))
elif b.get("type") == "thinking":
thinkings.append(b.get("thinking", ""))
elif b.get("type") == "tool_use":
# drop the wire-only id; tool_call_dict keeps arguments a dict
tcs.append(tool_call_dict(b.get("name", "tool"), b.get("input")))
mo: dict[str, Any] = {"role": "assistant", "content": "".join(texts)}
if thinkings:
mo["reasoning_content"] = "".join(thinkings)
if tcs:
mo["tool_calls"] = tcs
translated.append(mo)
elif role == "system":
translated.append({"role": "system", "content": flatten_content(content)})
return translated
def _tools_to_chat_tools(anth_tools: list[dict] | None) -> list[dict] | None:
"""Convert Anthropic tools to tokenizer chat-template tool schema."""
if not anth_tools:
return None
ts: list[dict] = []
for t in anth_tools:
if not isinstance(t, dict) or "name" not in t:
continue
ts.append(
{
"type": "function",
"function": {
"name": t["name"],
"description": t.get("description", ""),
"parameters": t.get("input_schema") or t.get("parameters") or {"type": "object", "properties": {}},
},
}
)
return ts or None
# --- Reply building: parsed output -> Anthropic blocks + manager_message ---
def _build_reply_parts(
parsed: ParsedModelOutput,
finish: str,
) -> tuple[list[dict], str, dict[str, Any]]:
"""Return (anthropic blocks, wire stop_reason, manager_message).
The tool_calls inside manager_message use canonical args (tool_call_dict) so
this assistant turn compares equal (dict equality) to the same turn replayed
as history on the next request.
"""
blocks: list[dict] = []
if parsed.reasoning:
blocks.append({"type": "thinking", "thinking": parsed.reasoning})
if parsed.text:
blocks.append({"type": "text", "text": parsed.text})
manager_tcs: list[dict] = []
for tu in parsed.tool_uses:
tu_id = f"toolu_{secrets.token_hex(8)}"
blocks.append({"type": "tool_use", "id": tu_id, "name": tu["name"], "input": tu["input"]})
# tu_id is wire-only; tool_call_dict drops it so the leaf matches its echo
manager_tcs.append(tool_call_dict(tu["name"], tu.get("input")))
if not blocks:
blocks.append({"type": "text", "text": ""})
if parsed.tool_uses:
stop_reason = "tool_use"
elif finish == "length":
stop_reason = "max_tokens"
else:
stop_reason = "end_turn"
manager_message: dict[str, Any] = {"role": "assistant", "content": parsed.text or ""}
if parsed.reasoning:
manager_message["reasoning_content"] = parsed.reasoning
if manager_tcs:
manager_message["tool_calls"] = manager_tcs
return blocks, stop_reason, manager_message
# --- Request framing: session id + wire response/stream rendering ---
def _request_session_id(request: web.Request) -> str:
# Anthropic auth lands in Authorization: Bearer or X-Api-Key; the Messages
# body carries no sid hint. Bearer wins when both are present.
return sid_from_bearer(request) or (request.headers.get("X-Api-Key") or "").strip() or "default"
def _render_response(body: dict, blocks: list[dict], stop_reason: str, in_tok: int, out_tok: int) -> dict:
return {
"id": f"msg_{secrets.token_hex(12)}",
"type": "message",
"role": "assistant",
"model": body.get("model", "slime-actor"),
"content": blocks,
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
}
async def _render_stream(request, blocks, stop_reason, in_tok, out_tok) -> web.StreamResponse:
"""Stream blocks back as an Anthropic Messages SSE response: message_start,
(content_block_start, content_block_delta, content_block_stop)*N,
message_delta, message_stop."""
out = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await out.prepare(request)
ms_data = {
"type": "message_start",
"message": {
"id": f"msg_{secrets.token_hex(12)}",
"type": "message",
"role": "assistant",
"model": "slime-actor",
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": in_tok, "output_tokens": 0},
},
}
await out.write(f"event: message_start\ndata: {json.dumps(ms_data, ensure_ascii=False)}\n\n".encode())
for idx, block in enumerate(blocks):
bt = block["type"]
if bt == "thinking":
start = {"type": "thinking", "thinking": ""}
delta = {"type": "thinking_delta", "thinking": block["thinking"]}
elif bt == "text":
start = {"type": "text", "text": ""}
delta = {"type": "text_delta", "text": block["text"]}
else: # tool_use
start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
delta = {
"type": "input_json_delta",
"partial_json": json.dumps(block["input"], ensure_ascii=False),
}
cbs_data = {"type": "content_block_start", "index": idx, "content_block": start}
await out.write(f"event: content_block_start\ndata: {json.dumps(cbs_data, ensure_ascii=False)}\n\n".encode())
cbd_data = {"type": "content_block_delta", "index": idx, "delta": delta}
await out.write(f"event: content_block_delta\ndata: {json.dumps(cbd_data, ensure_ascii=False)}\n\n".encode())
cbe_data = {"type": "content_block_stop", "index": idx}
await out.write(f"event: content_block_stop\ndata: {json.dumps(cbe_data, ensure_ascii=False)}\n\n".encode())
md_data = {
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
}
await out.write(f"event: message_delta\ndata: {json.dumps(md_data, ensure_ascii=False)}\n\n".encode())
mst_data = {"type": "message_stop"}
await out.write(f"event: message_stop\ndata: {json.dumps(mst_data, ensure_ascii=False)}\n\n".encode())
return out
# count_tokens runs every turn but the client uses it only as a hint, not a
# hard budget, so returning 0 is fine.
async def _count_tokens(request: web.Request) -> web.Response:
await request.read()
return web.json_response({"input_tokens": 0})
# --- Anthropic-specific quirks: mid-list system folding ---
_MID_SYSTEM_WRAP_PREFIX = "\n"
_MID_SYSTEM_WRAP_SUFFIX = "\n\n"
def _fold_mid_list_system_into_user(body_obj: dict) -> bool:
"""Fold non-leading role:system messages into a neighbouring user message as
a text block. Mutates body_obj in place; returns True iff
any fold happened.
Some clients insert a system message in the middle of the message list, but
many chat templates reject any system message past index 0. Attaching the
wrapped reminder to the preceding user message (or the next one, if there is
no prior user message) keeps the history acceptable to the template.
"""
msgs = body_obj.get("messages")
if not isinstance(msgs, list) or not msgs:
return False
system_idx = [i for i, m in enumerate(msgs) if isinstance(m, dict) and m.get("role") == "system" and i > 0]
if not system_idx:
return False
def _promote_to_list(msg: dict) -> list:
c = msg.get("content")
if isinstance(c, list):
return c
msg["content"] = [{"type": "text", "text": c if isinstance(c, str) else ""}]
return msg["content"]
def _wrap(text: str) -> dict:
return {
"type": "text",
"text": _MID_SYSTEM_WRAP_PREFIX + text + _MID_SYSTEM_WRAP_SUFFIX,
}
changed = False
TOMBSTONE: dict = {"__folded__": True}
for i in system_idx:
sys_msg = msgs[i]
wrapped = _wrap(flatten_content(sys_msg.get("content")))
target = None
for j in range(i - 1, -1, -1):
cand = msgs[j]
if isinstance(cand, dict) and cand.get("role") == "user":
target = cand
_promote_to_list(target).append(wrapped)
break
if target is None:
for j in range(i + 1, len(msgs)):
cand = msgs[j]
if isinstance(cand, dict) and cand.get("role") == "user":
target = cand
_promote_to_list(target).insert(0, wrapped)
break
if target is None:
msgs[i] = {"role": "user", "content": [wrapped]}
changed = True
continue
msgs[i] = TOMBSTONE
changed = True
if changed:
body_obj["messages"] = [m for m in msgs if m is not TOMBSTONE]
return changed