mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
573 lines
22 KiB
Python
573 lines
22 KiB
Python
"""DashScope Agent default runner implementation.
|
|
|
|
Real Aliyun DashScope (百炼) API integration supporting agent and workflow app types.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import typing
|
|
from contextlib import aclosing
|
|
|
|
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.dashscope_client import (
|
|
DashScopeAPIError,
|
|
DashScopeClient,
|
|
DashScopeConfigError,
|
|
extract_references_from_chunk,
|
|
replace_references,
|
|
)
|
|
from pkg.reasoning import ResponseBudget, ThinkingFilter, positive_timeout, strict_bool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Thinking block markers (special Unicode characters used by DashScope)
|
|
THINK_START = "႑"
|
|
THINK_END = "႐"
|
|
|
|
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 _get_adapter_params(ctx: RunnerContext) -> dict[str, typing.Any]:
|
|
"""Read single-run business params from adapter.extra.params."""
|
|
if ctx.adapter is None:
|
|
return {}
|
|
params = (ctx.adapter.extra or {}).get("params")
|
|
return dict(params) if isinstance(params, dict) else {}
|
|
|
|
|
|
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(*payloads: typing.Any) -> dict[str, typing.Any] | None:
|
|
for payload in payloads:
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
|
|
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):
|
|
continue
|
|
|
|
normalized = dict(usage)
|
|
prompt_tokens = _first_int(usage, "prompt_tokens", "input_tokens", "input_count")
|
|
completion_tokens = _first_int(usage, "completion_tokens", "output_tokens", "output_count")
|
|
total_tokens = _first_int(usage, "total_tokens", "token_count", "total_count")
|
|
|
|
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
|
|
return None
|
|
|
|
|
|
class DefaultRunner(Runner):
|
|
"""Real Runner for DashScope (阿里云百炼) API.
|
|
|
|
Supports two app types:
|
|
- agent: Agent with thinking/reasoning capability
|
|
- workflow: Workflow execution with message format streaming
|
|
|
|
Configuration (static, from ctx.config):
|
|
- app-type: Application type (agent/workflow)
|
|
- api-key: DashScope API key
|
|
- app-id: DashScope application ID
|
|
- references_quote: Prefix for reference text (default: "参考资料来自:")
|
|
|
|
Runtime state (from ctx.state):
|
|
- external.conversation_id: DashScope session_id for stateful sessions
|
|
"""
|
|
|
|
def _validate_config(self, ctx: RunnerContext) -> dict[str, typing.Any]:
|
|
"""Validate and return static configuration.
|
|
|
|
Raises DashScopeConfigError on missing required fields.
|
|
"""
|
|
config = ctx.config or {}
|
|
try:
|
|
remove_think = strict_bool(config, "remove-think")
|
|
timeout = positive_timeout(config)
|
|
except ValueError as exc:
|
|
raise DashScopeConfigError(str(exc), code="dashscope.config_invalid") from None
|
|
|
|
app_type = config.get("app-type", "agent")
|
|
valid_types = ["agent", "workflow"]
|
|
if app_type not in valid_types:
|
|
raise DashScopeConfigError(
|
|
f"Invalid app-type: {app_type}. Must be one of {valid_types}",
|
|
code="dashscope.config_invalid",
|
|
)
|
|
|
|
api_key = config.get("api-key", "")
|
|
if not api_key:
|
|
raise DashScopeConfigError("api-key is required", code="dashscope.config_invalid")
|
|
|
|
app_id = config.get("app-id", "")
|
|
if not app_id:
|
|
raise DashScopeConfigError("app-id is required", code="dashscope.config_invalid")
|
|
|
|
return {
|
|
"app_type": app_type,
|
|
"api_key": api_key,
|
|
"app_id": app_id,
|
|
"references_quote": config.get("references_quote", "参考资料来自:"),
|
|
"timeout": timeout,
|
|
"remove_think": remove_think,
|
|
"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 DashScope ``biz_params`` so the 百炼 app can
|
|
reference it 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_session_id(self, ctx: RunnerContext) -> str:
|
|
"""Get session ID from state for multi-turn conversation.
|
|
|
|
Priority:
|
|
1. ctx.state.conversation["external.conversation_id"]
|
|
2. Empty string (start new session)
|
|
"""
|
|
external_conv_id = ctx.state.conversation.get("external.conversation_id")
|
|
if external_conv_id:
|
|
return external_conv_id
|
|
return ""
|
|
|
|
def _get_input_text(self, ctx: RunnerContext) -> str:
|
|
"""Get text input from context."""
|
|
return ctx.input.to_text()
|
|
|
|
def _get_biz_params(self, ctx: RunnerContext) -> dict[str, typing.Any]:
|
|
"""Get business parameters for workflow from adapter params."""
|
|
return _get_adapter_params(ctx)
|
|
|
|
async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]:
|
|
"""Run the DashScope agent.
|
|
|
|
Streams RunnerResult.message_delta chunks and final run_completed.
|
|
"""
|
|
try:
|
|
config = self._validate_config(ctx)
|
|
except DashScopeConfigError as e:
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=e.message,
|
|
code=e.code,
|
|
)
|
|
return
|
|
|
|
client = DashScopeClient(
|
|
api_key=config["api_key"],
|
|
app_id=config["app_id"],
|
|
app_type=config["app_type"],
|
|
references_quote=config["references_quote"],
|
|
timeout=config["timeout"],
|
|
)
|
|
|
|
input_text = self._get_input_text(ctx)
|
|
session_id = self._get_session_id(ctx)
|
|
app_type = config["app_type"]
|
|
|
|
# Optionally register a run-scoped LangBot asset token and pass it to the
|
|
# 百炼 app through biz_params. The app references it (e.g. ${biz_params.X})
|
|
# and passes it as the run_token argument on LangBot Asset Gateway MCP
|
|
# tool calls. The token is stopped in finally when the run ends.
|
|
asset_registration = None
|
|
asset_biz_params: dict[str, typing.Any] = {}
|
|
if config["langbot_assets_enabled"]:
|
|
asset_registration = await self._create_asset_gateway_registration(ctx, config)
|
|
asset_biz_params[config["asset_gateway_input_name"]] = asset_registration.token
|
|
|
|
try:
|
|
run = self._run_workflow if app_type == "workflow" else self._run_runner
|
|
# Own every delegated generator through downstream backpressure/close.
|
|
async with aclosing(run(ctx, client, input_text, session_id, asset_biz_params)) as results:
|
|
async for result in results:
|
|
yield result
|
|
except DashScopeAPIError as e:
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=e.message,
|
|
code=e.code,
|
|
retryable=getattr(e, "retryable", False),
|
|
)
|
|
return
|
|
except Exception as e:
|
|
logger.exception(f"DashScope runner unexpected error: {e}")
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=f"DashScope runner error: {e}",
|
|
code="dashscope.unexpected_error",
|
|
)
|
|
return
|
|
finally:
|
|
if asset_registration is not None:
|
|
await asset_registration.stop()
|
|
|
|
async def _run_runner(
|
|
self,
|
|
ctx: RunnerContext,
|
|
client: DashScopeClient,
|
|
input_text: str,
|
|
session_id: str,
|
|
extra_biz_params: dict[str, typing.Any] | None = None,
|
|
) -> typing.AsyncGenerator[RunnerResult, None]:
|
|
"""Run agent mode.
|
|
|
|
Streams message_delta chunks with thinking content support.
|
|
"""
|
|
budget = ResponseBudget(DashScopeAPIError, "dashscope.response_limit")
|
|
pending_content = ""
|
|
remove_think = strict_bool(ctx.config, "remove-think")
|
|
text_filter = ThinkingFilter(remove_think, ((THINK_START, THINK_END),))
|
|
saw_output = False
|
|
last_final = False
|
|
references_dict: dict[str, str] = {}
|
|
final_session_id = session_id
|
|
|
|
think_start = False
|
|
think_end = False
|
|
usage: dict[str, typing.Any] | None = None
|
|
has_response = False
|
|
|
|
# Match native request flags as well as filtering provider output.
|
|
enable_thinking = not remove_think
|
|
|
|
async with aclosing(
|
|
client.iter_agent(
|
|
prompt=input_text,
|
|
session_id=session_id,
|
|
enable_thinking=enable_thinking,
|
|
biz_params=extra_biz_params or None,
|
|
)
|
|
) as chunks:
|
|
async for chunk in chunks:
|
|
if not chunk:
|
|
continue
|
|
# Check for API errors
|
|
status_code = chunk.get("status_code")
|
|
if status_code != 200:
|
|
raise DashScopeAPIError(
|
|
f"DashScope API error: status_code={status_code} "
|
|
f"message={chunk.get('message')} request_id={chunk.get('request_id')}",
|
|
code="dashscope.api_error",
|
|
)
|
|
|
|
if not chunk:
|
|
continue
|
|
|
|
stream_output = chunk.get("output", {})
|
|
usage = _usage_from_payload(chunk, stream_output) or usage
|
|
|
|
# Track session_id for stateful session
|
|
if stream_output.get("session_id"):
|
|
final_session_id = stream_output["session_id"]
|
|
|
|
# Handle thinking/reasoning content
|
|
stream_think = stream_output.get("thoughts") or []
|
|
budget.add(stream_output.get("text", ""), *(item.get("thought", "") for item in stream_think))
|
|
if stream_think and stream_think[0].get("thought"):
|
|
saw_output = True
|
|
if not remove_think and stream_think and stream_think[0].get("thought"):
|
|
if not think_start:
|
|
think_start = True
|
|
pending_content += f"{THINK_START}\n{stream_think[0].get('thought')}"
|
|
else:
|
|
# Continue outputting reasoning_content
|
|
pending_content += stream_think[0].get("thought")
|
|
elif think_start and (not stream_think or stream_think[0].get("thought") == "") and not think_end:
|
|
think_end = True
|
|
pending_content += f"\n{THINK_END}\n"
|
|
|
|
# Handle text content
|
|
if stream_output.get("text"):
|
|
saw_output = True
|
|
pending_content += text_filter.feed(stream_output["text"])
|
|
|
|
# Check if this is the final chunk
|
|
finish_reason = stream_output.get("finish_reason")
|
|
is_final = finish_reason != "null" if finish_reason else False
|
|
if is_final:
|
|
pending_content += text_filter.feed("", final=True)
|
|
|
|
# Extract and accumulate references
|
|
chunk_refs = extract_references_from_chunk(stream_output)
|
|
references_dict.update(chunk_refs)
|
|
|
|
# Replace references in content
|
|
if references_dict:
|
|
pending_content = replace_references(
|
|
pending_content,
|
|
references_dict,
|
|
client.references_quote,
|
|
)
|
|
|
|
budget.check_rendered(pending_content)
|
|
|
|
# Yield periodically or on final chunk
|
|
if pending_content or (is_final and saw_output):
|
|
has_response = True
|
|
last_final = is_final
|
|
yield RunnerResult.message_delta(
|
|
ctx.run_id,
|
|
MessageChunk(
|
|
role="assistant",
|
|
content=pending_content,
|
|
is_final=is_final,
|
|
),
|
|
)
|
|
if is_final:
|
|
pending_content = ""
|
|
|
|
# Providers may omit finish_reason; emit a final snapshot at EOF,
|
|
# including an empty snapshot when only hidden reasoning was received.
|
|
tail = text_filter.feed("", final=True)
|
|
pending_content += tail
|
|
budget.check_rendered(pending_content)
|
|
if saw_output and (not last_final or tail):
|
|
has_response = True
|
|
yield RunnerResult.message_delta(
|
|
ctx.run_id, MessageChunk(role="assistant", content=pending_content, is_final=True)
|
|
)
|
|
|
|
if not has_response:
|
|
raise DashScopeAPIError(
|
|
"DashScope API returned no response",
|
|
code="dashscope.empty_response",
|
|
)
|
|
|
|
# Update state with session_id for next run
|
|
if final_session_id:
|
|
yield RunnerResult.state_updated(
|
|
ctx.run_id,
|
|
"external.conversation_id",
|
|
final_session_id,
|
|
scope="conversation",
|
|
)
|
|
|
|
yield RunnerResult.run_completed(ctx.run_id, usage=usage)
|
|
|
|
async def _run_workflow(
|
|
self,
|
|
ctx: RunnerContext,
|
|
client: DashScopeClient,
|
|
input_text: str,
|
|
session_id: str,
|
|
extra_biz_params: dict[str, typing.Any] | None = None,
|
|
) -> typing.AsyncGenerator[RunnerResult, None]:
|
|
"""Run workflow mode.
|
|
|
|
Streams message_delta chunks from workflow message format output.
|
|
"""
|
|
budget = ResponseBudget(DashScopeAPIError, "dashscope.response_limit")
|
|
pending_content = ""
|
|
remove_think = strict_bool(ctx.config, "remove-think")
|
|
text_filter = ThinkingFilter(remove_think, ((THINK_START, THINK_END),))
|
|
saw_output = False
|
|
last_final = False
|
|
references_dict: dict[str, str] = {}
|
|
final_session_id = session_id
|
|
usage: dict[str, typing.Any] | None = None
|
|
has_response = False
|
|
|
|
# Get business parameters from context, merging the LangBot asset token.
|
|
biz_params = self._get_biz_params(ctx)
|
|
if extra_biz_params:
|
|
biz_params = {**biz_params, **extra_biz_params}
|
|
|
|
async with aclosing(
|
|
client.iter_workflow(
|
|
prompt=input_text,
|
|
session_id=session_id,
|
|
biz_params=biz_params,
|
|
)
|
|
) as chunks:
|
|
async for chunk in chunks:
|
|
if not chunk:
|
|
continue
|
|
# Check for API errors
|
|
status_code = chunk.get("status_code")
|
|
if status_code != 200:
|
|
raise DashScopeAPIError(
|
|
f"DashScope API error: status_code={status_code} "
|
|
f"message={chunk.get('message')} request_id={chunk.get('request_id')}",
|
|
code="dashscope.api_error",
|
|
)
|
|
|
|
if not chunk:
|
|
continue
|
|
|
|
stream_output = chunk.get("output", {})
|
|
usage = _usage_from_payload(chunk, stream_output) or usage
|
|
|
|
# Track session_id for stateful session
|
|
if stream_output.get("session_id"):
|
|
final_session_id = stream_output["session_id"]
|
|
|
|
# Handle workflow message format output
|
|
workflow_message = stream_output.get("workflow_message")
|
|
if workflow_message is not None:
|
|
content = (workflow_message.get("message") or {}).get("content", "")
|
|
else:
|
|
# Native non-streaming workflows use output.text. Do not add
|
|
# both representations when a provider includes both.
|
|
content = stream_output.get("text", "")
|
|
if content:
|
|
budget.add(content)
|
|
saw_output = True
|
|
pending_content += text_filter.feed(content)
|
|
|
|
# Check if this is the final chunk
|
|
finish_reason = stream_output.get("finish_reason")
|
|
is_final = finish_reason != "null" if finish_reason else False
|
|
if is_final:
|
|
pending_content += text_filter.feed("", final=True)
|
|
|
|
# Extract and accumulate references
|
|
chunk_refs = extract_references_from_chunk(stream_output)
|
|
references_dict.update(chunk_refs)
|
|
|
|
# Replace references in content
|
|
if references_dict:
|
|
pending_content = replace_references(
|
|
pending_content,
|
|
references_dict,
|
|
client.references_quote,
|
|
)
|
|
|
|
budget.check_rendered(pending_content)
|
|
|
|
# Yield periodically or on final chunk
|
|
if pending_content or (is_final and saw_output):
|
|
has_response = True
|
|
last_final = is_final
|
|
yield RunnerResult.message_delta(
|
|
ctx.run_id,
|
|
MessageChunk(
|
|
role="assistant",
|
|
content=pending_content,
|
|
is_final=is_final,
|
|
),
|
|
)
|
|
if is_final:
|
|
pending_content = ""
|
|
|
|
# Providers may omit finish_reason; emit a final snapshot at EOF,
|
|
# including an empty snapshot when only hidden reasoning was received.
|
|
tail = text_filter.feed("", final=True)
|
|
pending_content += tail
|
|
budget.check_rendered(pending_content)
|
|
if saw_output and (not last_final or tail):
|
|
has_response = True
|
|
yield RunnerResult.message_delta(
|
|
ctx.run_id, MessageChunk(role="assistant", content=pending_content, is_final=True)
|
|
)
|
|
|
|
if not has_response:
|
|
raise DashScopeAPIError(
|
|
"DashScope workflow returned no response",
|
|
code="dashscope.empty_response",
|
|
)
|
|
|
|
# Update state with session_id for next run
|
|
if final_session_id:
|
|
yield RunnerResult.state_updated(
|
|
ctx.run_id,
|
|
"external.conversation_id",
|
|
final_session_id,
|
|
scope="conversation",
|
|
)
|
|
|
|
yield RunnerResult.run_completed(ctx.run_id, usage=usage)
|