688 lines
26 KiB
Python
688 lines
26 KiB
Python
"""Transportless Pipecat runner for one persisted fixed-text test case."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Awaitable, Callable
|
|
from uuid import uuid4
|
|
|
|
from loguru import logger
|
|
from models import AssistantConfig
|
|
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
|
from pipecat.frames.frames import (
|
|
BotStoppedSpeakingFrame,
|
|
CancelFrame,
|
|
EndFrame,
|
|
ErrorFrame,
|
|
FunctionCallInProgressFrame,
|
|
FunctionCallResultFrame,
|
|
LLMContextFrame,
|
|
LLMFullResponseEndFrame,
|
|
LLMFullResponseStartFrame,
|
|
LLMMessagesAppendFrame,
|
|
ManuallySwitchServiceFrame,
|
|
TTSSpeakFrame,
|
|
)
|
|
from pipecat.pipeline.pipeline import Pipeline
|
|
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
|
from pipecat.processors.aggregators.llm_response_universal import (
|
|
LLMContextAggregatorPair,
|
|
)
|
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|
from pipecat.services.llm_service import FunctionCallParams
|
|
from pipecat.workers.runner import WorkerRunner
|
|
|
|
from db.session import SessionLocal
|
|
from services.brains import BrainRuntime, build_brain
|
|
from services.knowledge import search as search_knowledge
|
|
from services.pipecat.call_lifecycle import (
|
|
CallEndCoordinator,
|
|
FixedSpeechPlaybackMarkerFrame,
|
|
)
|
|
from services.pipecat.processors import (
|
|
KnowledgeRetrievalProcessor,
|
|
UserTurnRoutingProcessor,
|
|
)
|
|
from services.pipecat.service_factory import config_with_resource
|
|
from services.pipecat.workflow_services import build_workflow_llm_switcher
|
|
from services.test_runs.errors import TestExecutionError
|
|
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
|
|
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition
|
|
|
|
|
|
IDLE_SETTLE_SECONDS = 0.2
|
|
WORKER_START_TIMEOUT_SECONDS = 5.0
|
|
INTERNAL_CANCEL_REASON = "text_test_case_complete"
|
|
AUTOMATIC_KNOWLEDGE_HINT = (
|
|
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
|
|
"回答资料事实时只根据检索内容,资料不足要明确说明。"
|
|
)
|
|
ON_DEMAND_KNOWLEDGE_HINT = (
|
|
"你已连接内部知识库。当用户问题涉及可能存在于业务知识库中的事实时,"
|
|
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
|
|
"资料不足要明确说明。"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class RawToolCall:
|
|
id: str
|
|
function_name: str
|
|
arguments: Any
|
|
result: Any = None
|
|
outcome: str = "error"
|
|
duration_ms: int = 0
|
|
_started_at: float = field(default_factory=time.monotonic, repr=False)
|
|
|
|
def as_result(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"functionName": self.function_name,
|
|
"argumentsJson": json.dumps(
|
|
self.arguments, ensure_ascii=False, indent=2, default=str
|
|
),
|
|
"resultJson": json.dumps(
|
|
self.result, ensure_ascii=False, indent=2, default=str
|
|
),
|
|
"outcome": self.outcome,
|
|
"durationMs": self.duration_ms,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class RawTurnResult:
|
|
id: str
|
|
index: int
|
|
user_input: str
|
|
assistant_reply: str
|
|
tool_calls: list[RawToolCall]
|
|
|
|
|
|
@dataclass
|
|
class TextCaseResult:
|
|
turns: list[RawTurnResult]
|
|
transcript: list[dict[str, str]]
|
|
|
|
|
|
@dataclass
|
|
class _ResponseWindow:
|
|
active_llm: int = 0
|
|
active_assistant: int = 0
|
|
pending_tools: set[str] = field(default_factory=set)
|
|
activity_count: int = 0
|
|
last_activity_at: float = field(default_factory=time.monotonic)
|
|
|
|
def touch(self) -> None:
|
|
self.activity_count += 1
|
|
self.last_activity_at = time.monotonic()
|
|
|
|
@property
|
|
def idle(self) -> bool:
|
|
return self.active_llm == 0 and self.active_assistant == 0 and not self.pending_tools
|
|
|
|
|
|
class _TextCaptureProcessor(FrameProcessor):
|
|
"""Capture assistant/tool output and expose a reliable response boundary."""
|
|
|
|
def __init__(self, context: LLMContext):
|
|
super().__init__()
|
|
self.context = context
|
|
self.window = _ResponseWindow()
|
|
self.outputs: list[str] = []
|
|
self.tool_calls: list[RawToolCall] = []
|
|
self._tool_by_id: dict[str, RawToolCall] = {}
|
|
self.error: TestExecutionError | None = None
|
|
self.on_fixed_speech_complete: Callable[[], Awaitable[None]] | None = None
|
|
|
|
async def process_frame(self, frame, direction: FrameDirection):
|
|
await super().process_frame(frame, direction)
|
|
|
|
if isinstance(frame, TTSSpeakFrame):
|
|
text = frame.text.strip()
|
|
if text:
|
|
self.outputs.append(text)
|
|
if frame.append_to_context:
|
|
self.context.add_message({"role": "assistant", "content": text})
|
|
self.window.touch()
|
|
if self.on_fixed_speech_complete is not None:
|
|
await self.on_fixed_speech_complete()
|
|
return
|
|
|
|
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
|
|
await frame.completion.mark_played()
|
|
self.window.touch()
|
|
return
|
|
|
|
if isinstance(frame, LLMFullResponseStartFrame):
|
|
self.window.active_llm += 1
|
|
self.window.touch()
|
|
elif isinstance(frame, LLMFullResponseEndFrame):
|
|
self.window.active_llm = max(0, self.window.active_llm - 1)
|
|
self.window.touch()
|
|
await self.push_frame(frame, direction)
|
|
# There is no TTS/output transport. This releases provider/tool
|
|
# follow-up logic that normally waits for a speaking boundary.
|
|
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
|
|
return
|
|
elif isinstance(frame, FunctionCallInProgressFrame):
|
|
call = RawToolCall(
|
|
id=frame.tool_call_id,
|
|
function_name=frame.function_name,
|
|
arguments=frame.arguments or {},
|
|
)
|
|
self.tool_calls.append(call)
|
|
self._tool_by_id[call.id] = call
|
|
self.window.pending_tools.add(call.id)
|
|
self.window.touch()
|
|
elif isinstance(frame, FunctionCallResultFrame):
|
|
call = self._tool_by_id.get(frame.tool_call_id)
|
|
if call is None:
|
|
call = RawToolCall(
|
|
id=frame.tool_call_id,
|
|
function_name=frame.function_name,
|
|
arguments=frame.arguments or {},
|
|
)
|
|
self.tool_calls.append(call)
|
|
self._tool_by_id[call.id] = call
|
|
call.result = frame.result
|
|
call.duration_ms = max(0, round((time.monotonic() - call._started_at) * 1000))
|
|
status = frame.result.get("status") if isinstance(frame.result, dict) else None
|
|
call.outcome = "error" if status in {"error", "failed", "timeout"} else "success"
|
|
self.window.pending_tools.discard(call.id)
|
|
self.window.touch()
|
|
elif isinstance(frame, ErrorFrame):
|
|
self.error = TestExecutionError(
|
|
code="PIPELINE_ERROR",
|
|
message=frame.error or "Pipeline 执行失败",
|
|
stage="model" if frame.processor else "pipeline",
|
|
retryable=True,
|
|
)
|
|
self.window.touch()
|
|
elif isinstance(frame, (EndFrame, CancelFrame)):
|
|
self.window.touch()
|
|
|
|
await self.push_frame(frame, direction)
|
|
|
|
|
|
def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
|
|
messages: list[dict[str, Any]] = []
|
|
latest_call_by_name: dict[str, str] = {}
|
|
for index, turn in enumerate(context_turns):
|
|
if turn.role in {"agent", "user"}:
|
|
messages.append(
|
|
{
|
|
"role": "assistant" if turn.role == "agent" else "user",
|
|
"content": turn.content,
|
|
}
|
|
)
|
|
continue
|
|
|
|
call_id = turn.tool_call_id or latest_call_by_name.get(turn.tool_name or "")
|
|
call_id = call_id or f"context_tool_{index}_{uuid4().hex[:8]}"
|
|
if turn.role == "tool_call":
|
|
latest_call_by_name[turn.tool_name or ""] = call_id
|
|
messages.append(
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": {
|
|
"name": turn.tool_name,
|
|
"arguments": json.dumps(
|
|
json.loads(turn.content), ensure_ascii=False
|
|
),
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
else:
|
|
result_payload = json.loads(turn.content)
|
|
if turn.is_error:
|
|
result_payload = (
|
|
{**result_payload, "status": "error"}
|
|
if isinstance(result_payload, dict)
|
|
else {"status": "error", "data": result_payload}
|
|
)
|
|
messages.append(
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": call_id,
|
|
"content": json.dumps(result_payload, ensure_ascii=False),
|
|
}
|
|
)
|
|
return messages
|
|
|
|
|
|
async def _wait_for_quiescence(
|
|
capture: _TextCaptureProcessor,
|
|
runner_task: asyncio.Task[None],
|
|
activity_marker: int,
|
|
*,
|
|
require_activity: bool = True,
|
|
) -> None:
|
|
while True:
|
|
if capture.error is not None:
|
|
raise capture.error
|
|
if runner_task.done():
|
|
await runner_task
|
|
return
|
|
has_activity = capture.window.activity_count > activity_marker
|
|
settled = time.monotonic() - capture.window.last_activity_at >= IDLE_SETTLE_SECONDS
|
|
if capture.window.idle and settled and (has_activity or not require_activity):
|
|
return
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
|
class TextPipelineRunner:
|
|
"""Execute all fixed input turns in one isolated, transportless pipeline."""
|
|
|
|
async def run(
|
|
self,
|
|
cfg: AssistantConfig,
|
|
definition: TestCaseDefinition,
|
|
*,
|
|
timeout_seconds: int,
|
|
) -> TextCaseResult:
|
|
try:
|
|
async with asyncio.timeout(timeout_seconds):
|
|
return await self._run(cfg, definition)
|
|
except TimeoutError as exc:
|
|
raise TestExecutionError(
|
|
code="PIPELINE_TIMEOUT",
|
|
message=f"测试用例执行超过 {timeout_seconds} 秒",
|
|
stage="pipeline",
|
|
retryable=True,
|
|
) from exc
|
|
|
|
async def _run(
|
|
self,
|
|
cfg: AssistantConfig,
|
|
definition: TestCaseDefinition,
|
|
) -> TextCaseResult:
|
|
if cfg.runtimeMode != "pipeline":
|
|
raise TestExecutionError(
|
|
code="UNSUPPORTED_RUNTIME_MODE",
|
|
message="第一版文本测试只支持 Pipeline 运行模式",
|
|
stage="pipeline",
|
|
)
|
|
if cfg.type not in {"prompt", "workflow"}:
|
|
raise TestExecutionError(
|
|
code="UNSUPPORTED_ASSISTANT_TYPE",
|
|
message=f"第一版文本测试暂不支持 {cfg.type} 类型助手",
|
|
stage="pipeline",
|
|
)
|
|
|
|
brain = build_brain(cfg)
|
|
seed_messages = _context_messages(definition.context_turns)
|
|
knowledge_config = cfg.knowledge_retrieval_config or {}
|
|
knowledge_mode = str(knowledge_config.get("mode") or "automatic")
|
|
|
|
def with_knowledge_hint(text: str) -> str:
|
|
if cfg.type != "prompt" or not cfg.knowledge_base_id:
|
|
return text
|
|
hint = (
|
|
AUTOMATIC_KNOWLEDGE_HINT
|
|
if knowledge_mode == "automatic"
|
|
else ON_DEMAND_KNOWLEDGE_HINT
|
|
)
|
|
return "\n\n".join(part for part in (text, hint) if part)
|
|
|
|
system_prompt = with_knowledge_hint(brain.system_prompt(cfg))
|
|
context = LLMContext(
|
|
messages=(
|
|
seed_messages
|
|
if cfg.type == "workflow"
|
|
else [{"role": "system", "content": system_prompt}, *seed_messages]
|
|
)
|
|
)
|
|
|
|
graph_settings = cfg.graph.get("settings") or {}
|
|
default_resource = cfg.workflow_model_resources.get(
|
|
str(graph_settings.get("defaultLlmResourceId") or "")
|
|
)
|
|
llm_cfg = (
|
|
config_with_resource(cfg, default_resource)
|
|
if cfg.type == "workflow" and default_resource is not None
|
|
else cfg
|
|
)
|
|
llm = brain.build_llm(llm_cfg, context)
|
|
llm_services: dict[str, FrameProcessor] = {}
|
|
current_llm_service: FrameProcessor = llm
|
|
if cfg.type == "workflow":
|
|
llm, llm_services, current_llm_service = build_workflow_llm_switcher(cfg, llm)
|
|
|
|
aggregators = LLMContextAggregatorPair(context)
|
|
user_aggregator = aggregators.user()
|
|
assistant_aggregator = aggregators.assistant()
|
|
capture = _TextCaptureProcessor(context)
|
|
knowledge = KnowledgeRetrievalProcessor(
|
|
cfg.knowledge_base_id if knowledge_mode == "automatic" else None,
|
|
top_n=int(knowledge_config.get("top_n", knowledge_config.get("topN", 5))),
|
|
score_threshold=float(
|
|
knowledge_config.get(
|
|
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
|
|
)
|
|
),
|
|
)
|
|
pipeline = Pipeline(
|
|
[
|
|
user_aggregator,
|
|
UserTurnRoutingProcessor(brain),
|
|
knowledge,
|
|
llm,
|
|
capture,
|
|
assistant_aggregator,
|
|
]
|
|
)
|
|
worker = PipelineWorker(
|
|
pipeline,
|
|
params=PipelineParams(enable_metrics=False),
|
|
enable_rtvi=False,
|
|
enable_turn_tracking=False,
|
|
idle_timeout_secs=None,
|
|
)
|
|
runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
|
|
worker_started = asyncio.Event()
|
|
|
|
@worker.event_handler("on_pipeline_started")
|
|
async def on_pipeline_started(_worker, _frame):
|
|
worker_started.set()
|
|
|
|
executor_holder: dict[str, MockToolExecutor] = {}
|
|
|
|
def create_test_executor(store) -> MockToolExecutor:
|
|
executor = MockToolExecutor(store)
|
|
executor_holder["executor"] = executor
|
|
return executor
|
|
|
|
async def queue_call_end(reason: str) -> None:
|
|
logger.debug(f"文本测试收到会话结束请求: {reason}")
|
|
await worker.queue_frame(EndFrame(reason=reason))
|
|
|
|
call_end = CallEndCoordinator(queue_call_end)
|
|
|
|
async def finish_fixed_speech_if_ending() -> None:
|
|
if call_end.ending:
|
|
await call_end.finish()
|
|
|
|
capture.on_fixed_speech_complete = finish_fixed_speech_if_ending
|
|
current_service = current_llm_service
|
|
|
|
async def switch_services(
|
|
llm_resource_id: str | None,
|
|
_asr_resource_id: str | None,
|
|
_tts_resource_id: str | None,
|
|
) -> None:
|
|
nonlocal current_service
|
|
target = (
|
|
llm_services.get(llm_resource_id)
|
|
if llm_resource_id
|
|
else current_llm_service
|
|
)
|
|
if target is None:
|
|
raise ValueError(f"Workflow LLM 资源未加载:{llm_resource_id}")
|
|
if target is current_service:
|
|
return
|
|
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
|
current_service = target
|
|
|
|
def set_system_prompt(text: str) -> None:
|
|
messages = context.get_messages()
|
|
text = with_knowledge_hint(text)
|
|
if messages and messages[0].get("role") == "system":
|
|
messages[0] = {"role": "system", "content": text}
|
|
else:
|
|
messages.insert(0, {"role": "system", "content": text})
|
|
|
|
knowledge_schema: FunctionSchema | None = None
|
|
if (
|
|
cfg.type == "prompt"
|
|
and cfg.knowledge_base_id
|
|
and knowledge_mode == "on_demand"
|
|
):
|
|
knowledge_schema = FunctionSchema(
|
|
name="search_knowledge_base",
|
|
description=(
|
|
"在当前助手绑定的知识库中检索资料。"
|
|
f"知识库:{cfg.knowledge_base_name}。"
|
|
f"{cfg.knowledge_base_description}"
|
|
),
|
|
properties={
|
|
"query": {
|
|
"type": "string",
|
|
"description": "完整问题或检索关键词",
|
|
}
|
|
},
|
|
required=["query"],
|
|
)
|
|
|
|
async def search_bound_knowledge(params: FunctionCallParams) -> None:
|
|
query = str(params.arguments.get("query") or "").strip()
|
|
if not query:
|
|
await params.result_callback(
|
|
{"status": "error", "message": "检索问题为空"}
|
|
)
|
|
return
|
|
try:
|
|
async with SessionLocal() as session:
|
|
results = await search_knowledge(
|
|
session,
|
|
cfg.knowledge_base_id or "",
|
|
query,
|
|
top_k=int(
|
|
knowledge_config.get(
|
|
"top_n",
|
|
knowledge_config.get("topN", 5),
|
|
)
|
|
),
|
|
score_threshold=float(
|
|
knowledge_config.get(
|
|
"score_threshold",
|
|
knowledge_config.get("scoreThreshold", 0.0),
|
|
)
|
|
),
|
|
)
|
|
await params.result_callback(
|
|
{"status": "ok", "results": results}
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - return tool errors to LLM
|
|
logger.warning(f"文本测试知识库检索失败: {exc}")
|
|
await params.result_callback(
|
|
{"status": "error", "message": "知识库检索暂时不可用"}
|
|
)
|
|
|
|
llm.register_function("search_knowledge_base", search_bound_knowledge)
|
|
|
|
def set_tools(schemas=None) -> None:
|
|
visible = list(schemas or [])
|
|
if knowledge_schema is not None:
|
|
visible.append(knowledge_schema)
|
|
if visible:
|
|
context.set_tools(ToolsSchema(standard_tools=visible))
|
|
else:
|
|
context.set_tools()
|
|
|
|
await brain.setup(
|
|
cfg,
|
|
BrainRuntime(
|
|
context=context,
|
|
llm=llm,
|
|
queue_frame=worker.queue_frame,
|
|
set_system_prompt=set_system_prompt,
|
|
set_tools=set_tools,
|
|
call_end=call_end,
|
|
session_id=f"test_{uuid4().hex}",
|
|
client_tools=TextClientToolPort(),
|
|
worker=worker,
|
|
context_aggregator=aggregators,
|
|
transport=None,
|
|
switch_services=switch_services,
|
|
set_knowledge_scope=knowledge.set_scope,
|
|
set_vision_scope=lambda _scope: None,
|
|
vision_function=None,
|
|
set_input_enabled=lambda _enabled: None,
|
|
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
|
|
flow_global_functions=[],
|
|
tool_executor_factory=create_test_executor,
|
|
),
|
|
)
|
|
executor = executor_holder["executor"]
|
|
|
|
@assistant_aggregator.event_handler("on_assistant_turn_started")
|
|
async def on_assistant_turn_started(_aggregator):
|
|
capture.window.active_assistant += 1
|
|
capture.window.touch()
|
|
await brain.on_assistant_text_start(uuid4().hex)
|
|
|
|
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
|
|
async def on_assistant_turn_stopped(_aggregator, message):
|
|
content = str(message.content or "").strip()
|
|
capture.window.active_assistant = max(
|
|
0, capture.window.active_assistant - 1
|
|
)
|
|
if content:
|
|
capture.outputs.append(content)
|
|
capture.window.touch()
|
|
await brain.on_assistant_text_end(
|
|
uuid4().hex,
|
|
content,
|
|
bool(getattr(message, "interrupted", False)),
|
|
)
|
|
if call_end.ending:
|
|
# Text tests have no audio transport. A fully aggregated
|
|
# response is therefore the equivalent of playback ending.
|
|
await call_end.finish()
|
|
|
|
all_tool_behaviors = [
|
|
behavior
|
|
for turn in definition.turns
|
|
for behavior in turn.behaviors
|
|
if behavior.type == "tool_call"
|
|
]
|
|
executor.set_turn_behaviors(all_tool_behaviors)
|
|
|
|
runner_task: asyncio.Task[None] | None = None
|
|
try:
|
|
await brain.run_preflight()
|
|
if executor.unmocked_calls:
|
|
raise TestExecutionError(
|
|
code="UNMOCKED_TOOL_CALL",
|
|
message="启动阶段调用了未配置 Mock 的工具: "
|
|
+ "、".join(executor.unmocked_calls),
|
|
stage="tool",
|
|
)
|
|
|
|
await runner.add_workers(worker)
|
|
runner_task = asyncio.create_task(
|
|
runner.run(), name=f"text-test-{uuid4().hex[:12]}"
|
|
)
|
|
await asyncio.wait_for(
|
|
worker_started.wait(), timeout=WORKER_START_TIMEOUT_SECONDS
|
|
)
|
|
|
|
marker = capture.window.activity_count
|
|
await brain.on_connected(greeting_pending=False)
|
|
await brain.on_client_ready()
|
|
await _wait_for_quiescence(
|
|
capture,
|
|
runner_task,
|
|
marker,
|
|
require_activity=False,
|
|
)
|
|
# Startup messages initialize the session but are not a scripted
|
|
# user turn result in the fixed-input editor contract.
|
|
capture.outputs.clear()
|
|
capture.tool_calls.clear()
|
|
|
|
results: list[RawTurnResult] = []
|
|
for index, turn in enumerate(definition.turns):
|
|
if worker.has_finished():
|
|
raise TestExecutionError(
|
|
code="PIPELINE_ENDED_EARLY",
|
|
message=f"Pipeline 在第 {index + 1} 轮输入前已经结束",
|
|
stage="pipeline",
|
|
retryable=False,
|
|
)
|
|
result = await self._run_turn(
|
|
worker,
|
|
runner_task,
|
|
capture,
|
|
executor,
|
|
turn,
|
|
index,
|
|
)
|
|
results.append(result)
|
|
|
|
transcript = [
|
|
{
|
|
"role": str(message.get("role") or ""),
|
|
"content": str(message.get("content") or ""),
|
|
}
|
|
for message in context.get_messages()
|
|
if message.get("role") in {"user", "assistant"}
|
|
and str(message.get("content") or "").strip()
|
|
]
|
|
return TextCaseResult(turns=results, transcript=transcript)
|
|
except TestExecutionError:
|
|
raise
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
raise TestExecutionError(
|
|
code="PIPELINE_EXECUTION_FAILED",
|
|
message=str(exc) or type(exc).__name__,
|
|
stage="model",
|
|
retryable=True,
|
|
) from exc
|
|
finally:
|
|
if not worker.has_finished():
|
|
await worker.cancel(reason=INTERNAL_CANCEL_REASON)
|
|
if runner_task is not None:
|
|
await asyncio.gather(runner_task, return_exceptions=True)
|
|
|
|
async def _run_turn(
|
|
self,
|
|
worker: PipelineWorker,
|
|
runner_task: asyncio.Task[None],
|
|
capture: _TextCaptureProcessor,
|
|
executor: MockToolExecutor,
|
|
turn: FixedInputTurn,
|
|
index: int,
|
|
) -> RawTurnResult:
|
|
tool_behaviors = [
|
|
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
|
|
]
|
|
executor.set_turn_behaviors(tool_behaviors)
|
|
activity_marker = capture.window.activity_count
|
|
output_marker = len(capture.outputs)
|
|
tool_marker = len(capture.tool_calls)
|
|
await worker.queue_frame(
|
|
LLMMessagesAppendFrame(
|
|
messages=[{"role": "user", "content": turn.user_input}],
|
|
run_llm=True,
|
|
)
|
|
)
|
|
await _wait_for_quiescence(capture, runner_task, activity_marker)
|
|
if executor.unmocked_calls:
|
|
raise TestExecutionError(
|
|
code="UNMOCKED_TOOL_CALL",
|
|
message="本轮调用了未配置 Mock 的工具: "
|
|
+ "、".join(executor.unmocked_calls),
|
|
stage="tool",
|
|
)
|
|
return RawTurnResult(
|
|
id=turn.id,
|
|
index=index,
|
|
user_input=turn.user_input,
|
|
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
|
|
tool_calls=list(capture.tool_calls[tool_marker:]),
|
|
)
|