Files
LangBot/plugins/Runner/coze-agent/components/runner/default.py

588 lines
22 KiB
Python

"""Coze Agent default runner implementation.
Real Coze API integration supporting streaming responses with multimodal input.
"""
from __future__ import annotations
import base64
import json
import logging
import typing
from urllib.parse import urlsplit
from langbot_plugin.api.definition.components.runner.runner import Runner
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
from langbot_plugin.api.entities.builtin.runner import (
RunnerContext,
RunnerResult,
)
from pkg.asset_gateway import register_assets
from pkg.coze_client import (
AsyncCozeClient,
CozeAPIError,
CozeConfigError,
)
from pkg.reasoning import ResponseBudget, ThinkingFilter, positive_timeout, strict_bool
from pkg.scoped_identity import scoped_identity
logger = logging.getLogger(__name__)
DEFAULT_LANGBOT_ASSET_TOKEN_INPUT = "langbot_asset_run_token"
def _to_bool(value: typing.Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int | float):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return default
def _to_int(value: typing.Any, default: int) -> int:
if value is None or isinstance(value, bool):
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _to_float(value: typing.Any, default: float) -> float:
if value is None or isinstance(value, bool):
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _attachment_get(attachment: typing.Any, key: str, default: typing.Any = None) -> typing.Any:
if isinstance(attachment, dict):
return attachment.get(key, default)
return getattr(attachment, key, default)
def _content_get(content: typing.Any, key: str, default: typing.Any = None) -> typing.Any:
if isinstance(content, dict):
return content.get(key, default)
return getattr(content, key, default)
def _content_type_from_base64(value: typing.Any, default: str) -> str:
if isinstance(value, str) and value.startswith("data:") and ";base64," in value:
return value[5 : value.find(";base64,")] or default
return default
def _decode_content(value: typing.Any) -> bytes | None:
max_bytes = 10 * 1024 * 1024
def check_size(size: int) -> None:
if size > max_bytes:
raise CozeAPIError("Input attachment exceeds the 10 MiB size limit", code="coze.input_error")
if isinstance(value, (bytes, bytearray)):
check_size(len(value))
return bytes(value)
if isinstance(value, str):
start = value.find(",") + 1 if value.startswith("data:") else 0
# Check before slicing or decoding: decoding itself allocates memory.
if len(value) - start > 4 * ((max_bytes + 2) // 3) + 4:
check_size(max_bytes + 1)
payload = value[start:]
try:
decoded = base64.b64decode(payload, validate=True)
except (ValueError, base64.binascii.Error):
# Retain the existing plain-text attachment fallback, also bounded.
check_size(len(value))
decoded = value.encode("utf-8")
check_size(len(decoded))
return decoded
return None
def _attachments_from_contents(contents: list[typing.Any]) -> list[dict[str, typing.Any]]:
attachments: list[dict[str, typing.Any]] = []
for item in contents or []:
item_type = _content_get(item, "type")
if item_type == "image_base64":
content = _content_get(item, "image_base64")
attachments.append(
{
"type": "image",
"name": "image.png",
"content": content,
"content_type": _content_type_from_base64(content, "image/jpeg"),
}
)
elif item_type == "file_base64":
content = _content_get(item, "file_base64")
attachments.append(
{
"type": "file",
"name": _content_get(item, "file_name") or "file",
"content": content,
"content_type": _content_type_from_base64(content, "application/octet-stream"),
}
)
return attachments
def _int_or_none(value: typing.Any) -> int | None:
if value is None or isinstance(value, bool):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _first_int(mapping: dict[str, typing.Any], *keys: str) -> int | None:
for key in keys:
value = _int_or_none(mapping.get(key))
if value is not None:
return value
return None
def _usage_from_payload(payload: typing.Any) -> dict[str, typing.Any] | None:
if not isinstance(payload, dict):
return None
usage = payload.get("usage")
if not isinstance(usage, dict):
metadata = payload.get("metadata")
if isinstance(metadata, dict):
usage = metadata.get("usage")
if not isinstance(usage, dict):
data = payload.get("data")
if isinstance(data, dict):
usage = data.get("usage")
token_keys = {
"prompt_tokens",
"completion_tokens",
"total_tokens",
"input_tokens",
"output_tokens",
"input_count",
"output_count",
"token_count",
"total_count",
}
if not isinstance(usage, dict) and token_keys.intersection(payload):
usage = payload
if not isinstance(usage, dict):
return None
normalized = dict(usage)
prompt_tokens = _first_int(usage, "prompt_tokens", "input_tokens", "input_count", "inputTokenCount")
completion_tokens = _first_int(
usage,
"completion_tokens",
"output_tokens",
"output_count",
"outputTokenCount",
)
total_tokens = _first_int(usage, "total_tokens", "token_count", "total_count", "totalTokenCount")
if prompt_tokens is not None:
normalized["prompt_tokens"] = prompt_tokens
if completion_tokens is not None:
normalized["completion_tokens"] = completion_tokens
if total_tokens is None and (prompt_tokens is not None or completion_tokens is not None):
total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
if total_tokens is not None:
normalized["total_tokens"] = total_tokens
return normalized or None
class DefaultRunner(Runner):
"""Real Runner for Coze API.
Supports:
- Streaming chat responses
- Multimodal input (text, image, file)
- Thinking/reasoning content with 🤔...💬 tags
- Stateful session via conversation_id
Configuration (static, from ctx.config):
- bot-id: Coze Bot ID
- api-key: Coze API Key
- api-base: Coze API base URL (default: https://api.coze.cn)
- timeout: Request timeout in seconds
- auto-save-history: Whether to save chat history
Runtime state (from ctx.state):
- external.conversation_id: Coze conversation ID for stateful sessions
"""
def _validate_config(self, ctx: RunnerContext) -> dict[str, typing.Any]:
"""Validate and return static configuration.
Raises CozeConfigError on missing required fields.
"""
config = ctx.config or {}
self._get_user_id(ctx) # Validate identity before constructing any upstream client.
try:
remove_think = strict_bool(config, "remove-think")
timeout = positive_timeout(config)
auto_save_history = strict_bool(config, "auto-save-history", True)
except ValueError as exc:
raise CozeConfigError(str(exc), code="coze.config_invalid") from None
api_base = config.get("api-base", "https://api.coze.cn")
try:
if not isinstance(api_base, str) or any(c.isspace() or ord(c) < 32 for c in api_base):
raise ValueError
parsed = urlsplit(api_base)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError
_ = parsed.port # Validate port without echoing the URL.
except (ValueError, TypeError):
raise CozeConfigError(
"api-base must be an HTTP(S) URL without credentials, query or fragment", code="coze.config_invalid"
) from None
api_key = config.get("api-key", "")
if not api_key:
raise CozeConfigError("api-key is required", code="coze.config_invalid")
bot_id = config.get("bot-id", "")
if not bot_id:
raise CozeConfigError("bot-id is required", code="coze.config_invalid")
return {
"api_key": api_key,
"bot_id": bot_id,
"api_base": api_base,
"timeout": timeout,
"remove_think": remove_think,
"auto_save_history": auto_save_history,
"langbot_assets_enabled": _to_bool(config.get("langbot-assets-enabled"), False),
"asset_gateway_host": str(config.get("langbot-assets-gateway-host") or "0.0.0.0"),
"asset_gateway_port": _to_int(config.get("langbot-assets-gateway-port"), 8765),
"asset_gateway_request_timeout": _to_float(config.get("langbot-assets-gateway-request-timeout"), 60.0),
"asset_gateway_token_ttl": _to_float(config.get("langbot-assets-token-ttl"), 3600.0),
"asset_gateway_input_name": str(
config.get("langbot-assets-input-name") or DEFAULT_LANGBOT_ASSET_TOKEN_INPUT
),
}
async def _create_asset_gateway_registration(
self,
ctx: RunnerContext,
config: dict[str, typing.Any],
):
"""Register a run-scoped LangBot asset token in the shared MCP gateway.
The token is injected into Coze ``custom_variables`` so the bot prompt can
reference it (``{{langbot_asset_run_token}}``) and pass it as the
``run_token`` argument on LangBot Asset Gateway MCP tool calls. The
registration must be stopped when the run ends.
"""
return await register_assets(self, self.get_run_api(ctx), ctx, config)
def _get_user_id(self, ctx: RunnerContext) -> str:
"""Get user identifier for Coze API."""
source = ctx.config.get("user-id-source", "sender")
if source not in ("sender", "legacy-session"):
raise CozeConfigError("user-id-source is invalid", code="coze.config_invalid")
if source == "legacy-session":
conversation = ctx.conversation
if conversation and conversation.launcher_type in ("group", "person"):
launcher_id = conversation.launcher_id
if isinstance(launcher_id, str) and launcher_id:
return scoped_identity(self, ctx, f"{conversation.launcher_type}_{launcher_id}")
raise CozeConfigError("user-id-source requires trusted Host identity", code="coze.identity_unavailable")
actor = ctx.actor
if actor and actor.actor_id:
return scoped_identity(self, ctx, f"{actor.actor_type}_{actor.actor_id}")
raise CozeConfigError(
"user-id-source=sender requires a trusted Host actor identity",
code="coze.identity_unavailable",
)
def _get_external_conversation_id(self, ctx: RunnerContext) -> str | None:
"""Get external conversation ID from state or context.
Priority:
1. ctx.state.conversation["external.conversation_id"]
2. None (start new conversation)
"""
# Priority 1: State (persistent external conversation ID)
external_conv_id = ctx.state.conversation.get("external.conversation_id")
if external_conv_id:
return external_conv_id
# Start a new Coze provider conversation. Do not send LangBot-local
# conversation ids as provider conversation ids.
return None
async def _build_additional_messages(
self,
ctx: RunnerContext,
client: AsyncCozeClient,
) -> list[dict[str, typing.Any]]:
"""Build Coze message format from input.
Returns:
List of Coze message objects with role, content, content_type
"""
content_parts: list[dict[str, typing.Any]] = []
# Process attachments first
attachments = list(ctx.input.attachments or [])
if not any(_attachment_get(attachment, "content") for attachment in attachments):
attachments.extend(_attachments_from_contents(ctx.input.contents))
for attachment in attachments:
try:
attachment_type = _attachment_get(attachment, "type") or _attachment_get(attachment, "artifact_type")
content_type = (
_attachment_get(attachment, "content_type")
or _attachment_get(attachment, "mime_type")
or "application/octet-stream"
)
if attachment_type == "image_base64" or (
attachment_type == "image" and content_type.startswith("image/")
):
# Upload image to get file_id
file_bytes = _decode_content(_attachment_get(attachment, "content"))
if not file_bytes:
raise CozeAPIError(
f"Input image {_attachment_get(attachment, 'name', 'image.png')} has no uploadable content",
code="coze.input_error",
)
file_id = await client.upload_file(file_bytes, _attachment_get(attachment, "name") or "image.png")
content_parts.append({"type": "image", "file_id": file_id})
# Handle file type
elif attachment_type == "file":
file_bytes = _decode_content(_attachment_get(attachment, "content"))
if not file_bytes:
raise CozeAPIError(
f"Input file {_attachment_get(attachment, 'name', 'file')} has no uploadable content",
code="coze.input_error",
)
file_id = await client.upload_file(file_bytes, _attachment_get(attachment, "name") or "file")
content_parts.append({"type": "file", "file_id": file_id})
# Handle image type (direct bytes)
elif attachment_type == "image":
file_bytes = _decode_content(_attachment_get(attachment, "content"))
if not file_bytes:
raise CozeAPIError(
f"Input image {_attachment_get(attachment, 'name', 'image.png')} has no uploadable content",
code="coze.input_error",
)
file_id = await client.upload_file(file_bytes, _attachment_get(attachment, "name") or "image.png")
content_parts.append({"type": "image", "file_id": file_id})
except CozeAPIError:
raise
except Exception as e:
raise CozeAPIError(
f"Failed to upload input attachment {_attachment_get(attachment, 'name', 'attachment')}: {e}",
code="coze.input_error",
) from None
# Add text content
text = ctx.input.to_text()
if text:
content_parts.insert(0, {"type": "text", "text": text})
if not content_parts:
# Empty input, return empty messages list
return []
# Build message format
if len(content_parts) == 1 and content_parts[0].get("type") == "text":
# Simple text message
return [
{
"role": "user",
"content": content_parts[0].get("text", ""),
"content_type": "text",
}
]
else:
# Multimodal message with object_string format
return [
{
"role": "user",
"content": json.dumps(content_parts),
"content_type": "object_string",
}
]
async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]:
"""Run the Coze agent.
Streams RunnerResult.message_delta chunks and final run_completed.
"""
try:
config = self._validate_config(ctx)
except CozeConfigError as e:
yield RunnerResult.run_failed(
ctx.run_id,
error=e.message,
code=e.code,
)
return
client = AsyncCozeClient(
api_key=config["api_key"],
api_base=config["api_base"],
timeout=config["timeout"],
)
user_id = self._get_user_id(ctx)
conversation_id = self._get_external_conversation_id(ctx)
auto_save_history = config["auto_save_history"]
bot_id = config["bot_id"]
# Build additional messages from input
try:
additional_messages = await self._build_additional_messages(ctx, client)
except Exception as e:
logger.exception(f"Failed to build Coze messages: {e}")
yield RunnerResult.run_failed(
ctx.run_id,
error=f"Failed to prepare input: {e}",
code="coze.input_error",
)
return
final_conversation_id = conversation_id
budget = ResponseBudget(CozeAPIError, "coze.response_limit")
full_content = ""
text_filter = ThinkingFilter(config["remove_think"], (("🤔", "💬"),))
full_reasoning = ""
has_response = False
usage: dict[str, typing.Any] | None = None
# Optionally register a run-scoped LangBot asset token and pass it to the
# Coze bot through custom_variables. The bot prompt references it as
# {{langbot_asset_run_token}} and passes it as the run_token argument on
# LangBot Asset Gateway MCP tool calls. Stopped in finally when run ends.
asset_registration = None
custom_variables: dict[str, typing.Any] = {}
if config["langbot_assets_enabled"]:
asset_registration = await self._create_asset_gateway_registration(ctx, config)
custom_variables[config["asset_gateway_input_name"]] = asset_registration.token
try:
async for chunk in client.chat_messages(
bot_id=bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
auto_save_history=auto_save_history,
custom_variables=custom_variables or None,
):
event_type = chunk.get("event", "")
data = chunk.get("data", {})
logger.debug(f"Coze event: {event_type}")
usage = _usage_from_payload(data) or usage
if event_type == "conversation.message.delta":
budget.add(data.get("reasoning_content", ""), data.get("content", ""))
# Collect reasoning content
if "reasoning_content" in data:
reasoning = data.get("reasoning_content", "")
if reasoning:
has_response = True
if not config["remove_think"]:
full_reasoning += reasoning
# Collect main content
if "content" in data:
content_delta = data.get("content", "")
if content_delta:
full_content += text_filter.feed(content_delta)
has_response = True
elif event_type.endswith(".done") or event_type == "conversation.chat.completed":
# Track conversation_id for stateful session
if data.get("conversation_id"):
final_conversation_id = data["conversation_id"]
elif event_type == "error":
error_msg = data.get("message", "Unknown Coze API error")
yield RunnerResult.run_failed(
ctx.run_id,
error=f"Coze API error: {error_msg}",
code="coze.api_error",
)
return
# Build final response content
final_content = full_content + text_filter.feed("", final=True)
# Add reasoning with think tags if present
if full_reasoning:
final_content = f"🤔\n{full_reasoning}\n💬\n{final_content}".strip()
if not has_response:
yield RunnerResult.run_failed(
ctx.run_id,
error="Coze API returned no response",
code="coze.empty_response",
)
return
budget.check_rendered(final_content)
# Yield final message
yield RunnerResult.message_delta(
ctx.run_id, MessageChunk(role="assistant", content=final_content, is_final=True)
)
except CozeAPIError as e:
yield RunnerResult.run_failed(
ctx.run_id,
error=e.message,
code=e.code,
)
return
except Exception as e:
logger.exception(f"Coze runner unexpected error: {e}")
yield RunnerResult.run_failed(
ctx.run_id,
error=f"Coze runner error: {e}",
code="coze.unexpected_error",
)
return
finally:
await client.close()
if asset_registration is not None:
await asset_registration.stop()
# Update state with conversation_id for next run (scoped state)
if final_conversation_id:
yield RunnerResult.state_updated(
ctx.run_id,
"external.conversation_id",
final_conversation_id,
scope="conversation",
)
yield RunnerResult.run_completed(ctx.run_id, usage=usage)