chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
"""Claude Agent SDK backend for the In-Chat HITL (useHumanInTheLoop) demo.
|
||||
|
||||
The `book_call` tool is defined on the FRONTEND via `useHumanInTheLoop`,
|
||||
so there is no backend tool here. The agent simply responds in chat and
|
||||
relies on the standard frontend-tool / tool-call lifecycle to invoke
|
||||
`book_call` when the user asks to book.
|
||||
|
||||
Mirrors the langgraph-python `hitl_in_chat_agent.py` reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You help users book an onboarding call with the sales team. When they
|
||||
ask to book a call, call the frontend-provided `book_call` tool with a
|
||||
short topic and the user's name (use a sensible placeholder like
|
||||
'Alice from Sales' if no attendee was specified). Keep any chat reply
|
||||
to one short sentence.
|
||||
""").strip()
|
||||
|
||||
|
||||
async def run_hitl_in_chat_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
|
||||
"""Stream a Claude response that may call the frontend `book_call` tool.
|
||||
|
||||
`book_call` is defined on the frontend via `useHumanInTheLoop`. AG-UI
|
||||
forwards frontend tool definitions in `input_data.tools`, so we just
|
||||
pass them straight to Claude and let the standard tool-call lifecycle
|
||||
resolve the user's choice back through CopilotKit.
|
||||
"""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
# Convert AG-UI messages to Anthropic format.
|
||||
#
|
||||
# AG-UI delivers three message roles:
|
||||
# - "user" → plain user text
|
||||
# - "assistant" → assistant text + optional tool_use blocks
|
||||
# - "tool" → tool result from a resolved frontend tool
|
||||
#
|
||||
# When the CopilotKit runtime re-invokes this agent after the user
|
||||
# resolves a frontend tool (e.g. picks a time slot in the book_call
|
||||
# HITL UI), the messages array includes:
|
||||
# 1. assistant message with tool_use content (the original tool call)
|
||||
# 2. tool message with the resolved result
|
||||
#
|
||||
# Anthropic's Messages API represents tool results as a "user" role
|
||||
# message with content blocks of type "tool_result". We must convert
|
||||
# AG-UI "tool" messages into that shape, and assistant messages with
|
||||
# tool_use content into Anthropic's structured format, so the LLM
|
||||
# sees the full conversation and aimock's ``hasToolResult`` matcher
|
||||
# fires correctly.
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
# Handle tool result messages from AG-UI (resolved frontend tools).
|
||||
if role == "tool":
|
||||
tool_call_id = getattr(msg, "tool_call_id", None) or (
|
||||
getattr(msg, "toolCallId", None)
|
||||
)
|
||||
raw = getattr(msg, "content", None)
|
||||
result_text = ""
|
||||
if isinstance(raw, str):
|
||||
result_text = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
parts_text = "".join(parts)
|
||||
if parts_text:
|
||||
result_text = parts_text
|
||||
else:
|
||||
result_text = json.dumps(raw)
|
||||
if tool_call_id:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call_id,
|
||||
"content": result_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
|
||||
raw = getattr(msg, "content", None)
|
||||
|
||||
# For assistant messages, check for tool calls (AG-UI's
|
||||
# AssistantMessage stores them in `tool_calls`, not in `content`).
|
||||
# Anthropic requires tool_use blocks in the assistant content so
|
||||
# the subsequent tool_result can pair with them.
|
||||
if role == "assistant":
|
||||
msg_tool_calls = getattr(msg, "tool_calls", None)
|
||||
text_content = ""
|
||||
if isinstance(raw, str):
|
||||
text_content = raw
|
||||
elif isinstance(raw, list):
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
text_content += part.text
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
text_content += part["text"]
|
||||
|
||||
if msg_tool_calls:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if text_content:
|
||||
content_blocks.append({"type": "text", "text": text_content})
|
||||
for tc in msg_tool_calls:
|
||||
tc_id = getattr(tc, "id", None) or (
|
||||
tc.get("id") if isinstance(tc, dict) else None
|
||||
)
|
||||
func = getattr(tc, "function", None) or (
|
||||
tc.get("function") if isinstance(tc, dict) else None
|
||||
)
|
||||
if func:
|
||||
tc_name = getattr(func, "name", None) or (
|
||||
func.get("name") if isinstance(func, dict) else "unknown"
|
||||
)
|
||||
tc_args_str = getattr(func, "arguments", None) or (
|
||||
func.get("arguments", "{}")
|
||||
if isinstance(func, dict)
|
||||
else "{}"
|
||||
)
|
||||
else:
|
||||
tc_name = "unknown"
|
||||
tc_args_str = "{}"
|
||||
try:
|
||||
tc_args = (
|
||||
json.loads(tc_args_str)
|
||||
if isinstance(tc_args_str, str)
|
||||
else tc_args_str
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
tc_args = {}
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc_id or "unknown",
|
||||
"name": tc_name,
|
||||
"input": tc_args,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": content_blocks})
|
||||
continue
|
||||
elif text_content:
|
||||
messages.append({"role": "assistant", "content": text_content})
|
||||
continue
|
||||
|
||||
content = ""
|
||||
if isinstance(raw, str):
|
||||
content = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
# Forward frontend-defined tools (including useHumanInTheLoop's `book_call`)
|
||||
# to Claude. AG-UI sends them in `input_data.tools` with JSON-Schema
|
||||
# parameters; Claude expects `input_schema` of the same shape.
|
||||
tools: list[dict[str, Any]] = []
|
||||
for t in input_data.tools or []:
|
||||
# AG-UI Tool schema: { name, description, parameters }
|
||||
name = getattr(t, "name", None) or (
|
||||
t.get("name") if isinstance(t, dict) else None
|
||||
)
|
||||
description = getattr(t, "description", None) or (
|
||||
t.get("description", "") if isinstance(t, dict) else ""
|
||||
)
|
||||
parameters = getattr(t, "parameters", None) or (
|
||||
t.get("parameters", {}) if isinstance(t, dict) else {}
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
tools.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"input_schema": parameters or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
msg_id = f"msg-{run_id}-0"
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
stream_kwargs: dict[str, Any] = {
|
||||
"model": normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")
|
||||
),
|
||||
"max_tokens": 1024,
|
||||
"system": SYSTEM_PROMPT,
|
||||
"messages": messages,
|
||||
}
|
||||
if tools:
|
||||
stream_kwargs["tools"] = tools # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
async with client.messages.stream(**stream_kwargs) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
except Exception:
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Frontend (`useHumanInTheLoop`) resolves `book_call` and the runtime
|
||||
# injects the resolution back into a follow-up turn. Each turn is its
|
||||
# own POST so we don't loop here — emitting RUN_FINISHED returns control
|
||||
# to the runtime, which will re-invoke us with the resolved tool result.
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user