329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""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
|
|
)
|
|
)
|