"""Application service for agent entry. 这层的职责是把“接口层如何调用 AgentLoop”统一收口。 接口层以后不应该各自做这些事情: 1. 自己 new `AgentLoop` 2. 自己决定何时 `boot()` 3. 自己处理 direct run 的同步/异步包装 统一放在 `AgentService` 后,CLI / Web / Gateway 才能共享同一条运行主链。 """ from __future__ import annotations import asyncio from pathlib import Path from time import perf_counter from typing import Any from uuid import uuid4 from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader from beaver.engine.providers import make_provider_bundle from beaver.foundation.events import InboundMessage, OutboundMessage from beaver.foundation.models import CronJob, CronRunRecord from beaver.prompts.main_agent import normalize_main_agent_prompt_locale from beaver.tasks import ( MainAgentRouter, TaskRecord, ) from beaver.tasks.attempt_orchestrator import TaskAttemptOrchestrator from beaver.tasks.service import normalize_acceptance_type NOTIFICATION_SESSION_ID = "notify:default:scheduled" class AgentService: """面向 interfaces 的统一 agent 运行入口。 这里明确区分两种调用模式: 1. direct mode - 不启动后台运行循环 - 直接调用 `process_direct()` / `run_direct()` 2. running mode - 先 `await start()` - 之后所有外部任务都必须走 `submit_direct()` - 不允许再直接调用 `process_direct()` """ def __init__( self, *, workspace: str | Path | None = None, config_path: str | Path | None = None, profile: AgentProfile | None = None, loader: EngineLoader | None = None, ) -> None: self.profile = profile or AgentProfile() self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path) self._apply_configured_profile_defaults() self._loop: AgentLoop | None = None self._run_task: asyncio.Task[None] | None = None self._main_agent_router = MainAgentRouter() self._runtime_services: dict[str, Any] = {} def _apply_configured_profile_defaults(self) -> None: defaults = self.loader.config.agents_defaults self.profile.max_tokens = None self.profile.temperature = 0.2 self.profile.max_context_messages = 1000 self.profile.max_tool_iterations = 30 if defaults.max_tokens is not None: self.profile.max_tokens = max(1, defaults.max_tokens) if defaults.temperature is not None: self.profile.temperature = defaults.temperature if defaults.max_context_messages is not None: self.profile.max_context_messages = max(1, defaults.max_context_messages) if defaults.max_tool_iterations is not None: self.profile.max_tool_iterations = max(0, defaults.max_tool_iterations) def create_loop(self) -> AgentLoop: """创建并缓存当前 service 使用的 AgentLoop。""" if self._loop is None: self._loop = AgentLoop(profile=self.profile, loader=self.loader) self._loop.runtime_services.update(self._runtime_services) self._loop.boot() return self._loop def create_isolated_loop(self) -> AgentLoop: loop = AgentLoop(profile=self.profile, loader=self.loader) loop.runtime_services.update(self._runtime_services) return loop def register_runtime_service(self, name: str, service: Any) -> None: """Expose process-level services to tools during agent runs.""" self._runtime_services[name] = service if self._loop is not None: self._loop.runtime_services[name] = service @property def has_loop(self) -> bool: """当前 service 是否已经创建过 loop。""" return self._loop is not None @property def is_running(self) -> bool: """当前 service 是否处于 running mode。""" return self._run_task is not None and not self._run_task.done() def close(self) -> None: """关闭当前 service 持有的 runtime。""" if self._run_task is not None and not self._run_task.done(): raise RuntimeError("AgentService.close() requires stop() before closing a running loop") self._run_task = None if self._loop is None: return try: self._loop.close() finally: self._loop = None async def start(self) -> None: """启动后台运行循环,进入 running mode。 进入 running mode 后: - 外部任务必须通过 `submit_direct()` 提交 - `process_direct()` 不再允许直接调用 """ if self._run_task is not None and not self._run_task.done(): return loop = self.create_loop() self._run_task = asyncio.create_task(loop.run()) while not loop.is_running: if self._run_task.done(): await self._run_task break await asyncio.sleep(0) async def _stop_impl( self, *, timeout_seconds: float | None = None, force: bool = False, ) -> None: """内部停止实现,支持 graceful timeout 和可选 force cancel。""" if self._run_task is None: return run_task = self._run_task loop = self.create_loop() try: await loop.stop() if timeout_seconds is None: await run_task else: try: await asyncio.wait_for(asyncio.shield(run_task), timeout=timeout_seconds) except asyncio.TimeoutError as exc: if force: run_task.cancel() try: await run_task except asyncio.CancelledError: pass else: raise TimeoutError( f"AgentService.stop() timed out after {timeout_seconds} seconds while draining queued tasks" ) from exc finally: if run_task.done(): self._run_task = None async def stop( self, *, timeout_seconds: float | None = None, force: bool = False, ) -> None: """停止后台运行循环并等待退出。 参数: - `timeout_seconds`: graceful drain 的最长等待时间;`None` 表示一直等 - `force`: 超时后是否 cancel 掉运行循环 task """ await self._stop_impl(timeout_seconds=timeout_seconds, force=force) async def shutdown( self, *, timeout_seconds: float | None = None, force: bool = False, ) -> None: """先停运行循环,再释放 runtime。""" await self._stop_impl(timeout_seconds=timeout_seconds, force=force) self.close() async def process_direct( self, message: str, **kwargs: Any, ) -> AgentRunResult: """异步 direct run 入口。 仅在 direct mode 下可用。 如果 service 已经 `start()` 进入 running mode, 调用方必须改用 `submit_direct()`,不能绕过运行队列直接执行。 """ if self._run_task is not None and not self._run_task.done(): raise RuntimeError( "AgentService.process_direct() is unavailable while the service is running; " "use 'await AgentService.submit_direct(...)' after start()." ) loop = self.create_loop() return await self._process_with_main_agent(message, runner=loop.process_direct, kwargs=kwargs) async def submit_direct( self, message: str, **kwargs: Any, ) -> AgentRunResult: """向 running mode 下的 loop 提交 direct task。 这是 `start()` 之后唯一合法的外部任务入口。 """ loop = self.create_loop() return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs) async def run_scheduled_task( self, message: str, *, session_id: str, cron_job_id: str, cron_job_name: str, scheduled_run_id: str | None = None, requires_followup: bool = False, ) -> AgentRunResult: """Run a cron trigger as a normal internal Task. Scheduled jobs are product-level Tasks, not hidden one-off agent turns. This entry bypasses the main-agent classifier and forces Task mode so every trigger produces a TaskRecord, evidence, acceptance state, and a run_id that the scheduled-task history can link to. """ loaded = self.create_loop().boot() task_service = self._require_loaded(loaded, "task_service") loop = self.create_loop() task = task_service.create_task( session_id=session_id, description=message, creator="cron", metadata={ "source": "scheduled_cron", "cron_job_id": cron_job_id, "cron_job_name": cron_job_name, "scheduled_run_id": scheduled_run_id, "user_engaged": False, "requires_followup": requires_followup, }, ) execution_context = ( "This turn was triggered automatically by a scheduled task.\n\n" f"Cron Job ID: {cron_job_id}\n" f"Cron Job Name: {cron_job_name}\n" f"Scheduled Run ID: {scheduled_run_id or 'unknown'}\n" "Run it as a normal Beaver Task. Do not ask the user for confirmation; " "execute the task and report the concrete outcome." ) runner = loop.submit_direct if self.is_running else loop.process_direct result = await self._run_task_mode( message, runner=runner, task=task, kwargs={ "session_id": session_id, "source": "cron", "user_id": "cron", "title": cron_job_name, "execution_context": execution_context, }, ) loaded = self.create_loop().boot() session_manager = self._require_loaded(loaded, "session_manager") session_manager.update_latest_assistant_event_payload( result.session_id, result.run_id, { "message_type": "scheduled_reply", "scheduled_job_id": cron_job_id, "scheduled_run_id": scheduled_run_id, "cron_job_name": cron_job_name, "mode": "notification", }, ) return result async def run_scheduled_notification( self, message: str, *, session_id: str = NOTIFICATION_SESSION_ID, cron_job_id: str, cron_job_name: str, scheduled_run_id: str, ) -> AgentRunResult: """Run a cron trigger as a notification result, not as an active Task.""" loop = self.create_loop() loaded = loop.boot() session_manager = self._require_loaded(loaded, "session_manager") runner = loop.submit_direct if self.is_running else loop.process_direct execution_context = ( "This turn was triggered automatically by a scheduled notification.\n\n" f"Cron Job ID: {cron_job_id}\n" f"Cron Job Name: {cron_job_name}\n" f"Scheduled Run ID: {scheduled_run_id}\n" "Generate the notification content directly for the user. Do not ask for confirmation." ) result = await runner( message, session_id=session_id, source="notification", user_id="cron", title=cron_job_name, execution_context=execution_context, ) session_manager.update_latest_assistant_event_payload( result.session_id, result.run_id, { "message_type": "scheduled_result", "scheduled_job_id": cron_job_id, "scheduled_run_id": scheduled_run_id, "cron_job_name": cron_job_name, "mode": "notification", }, ) return result def engage_scheduled_run( self, *, job: CronJob, run: CronRunRecord, intent: str = "revise_once", thinking_enabled: bool | None = None, ) -> TaskRecord: """Create or mark the Task that lets the user work on a scheduled result.""" loaded = self.create_loop().boot() task_service = self._require_loaded(loaded, "task_service") if run.task_id: existing = task_service.get_task(run.task_id) if existing is not None: existing.metadata["user_engaged"] = True existing.metadata["engage_intent"] = intent task_service.store.upsert_task(existing) return existing task = task_service.create_task( session_id=run.notification_session_id or NOTIFICATION_SESSION_ID, description=f"修改定时通知:{job.name}", creator="cron", metadata={ "source": "scheduled_run", "cron_job_id": job.id, "cron_job_name": job.name, "scheduled_run_id": run.scheduled_run_id, "scheduled_output": run.output, "user_engaged": True, "engage_intent": intent, }, ) return task async def submit_scheduled_reply( self, message: str, *, job: CronJob, run: CronRunRecord, intent: str = "revise_once", ) -> AgentRunResult: task = self.engage_scheduled_run(job=job, run=run, intent=intent) loop = self.create_loop() runner = loop.submit_direct if self.is_running else loop.process_direct execution_context = ( "The user is replying to a scheduled notification result.\n\n" f"Cron Job ID: {job.id}\n" f"Cron Job Name: {job.name}\n" f"Scheduled Run ID: {run.scheduled_run_id}\n" f"Engagement intent: {intent}\n" f"Original scheduled instruction: {job.payload.message}\n" f"Original notification output:\n{run.output or ''}\n\n" "Handle this as a Task continuation. If the intent is update_future, explain the durable change " "that should apply to future notifications." ) return await self._run_task_mode( message, runner=runner, task=task, kwargs={ "session_id": task.session_id, "source": "notification", "user_id": "web", "title": job.name, "execution_context": execution_context, "thinking_enabled": thinking_enabled, }, ) async def submit_acceptance( self, *, session_id: str, run_id: str, acceptance_type: str, comment: str | None = None, ) -> dict[str, Any]: """Record user acceptance for the internal task linked to a run.""" loaded = self.create_loop().boot() task_service = self._require_loaded(loaded, "task_service") task = task_service.get_task_by_run_id(run_id) if task is None or task.session_id != session_id: raise ValueError(f"No internal task found for run_id={run_id!r}") normalized = normalize_acceptance_type(acceptance_type) legacy_feedback_type = "satisfied" if normalized == "accept" else normalized already_recorded = any( item.get("run_id") == run_id and item.get("acceptance_type") == normalized for item in task.feedback ) conflicting_acceptance = next( ( item for item in task.feedback if item.get("run_id") == run_id and item.get("acceptance_type") != normalized ), None, ) if conflicting_acceptance is not None: raise ValueError( f"Acceptance for run_id={run_id!r} was already recorded as " f"{conflicting_acceptance.get('acceptance_type')!r}" ) if task.status in {"closed", "abandoned"} and not already_recorded: raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}") updated = task if already_recorded else task_service.add_acceptance( task.task_id, acceptance_type=normalized, comment=comment, run_id=run_id, ) session_manager = self._require_loaded(loaded, "session_manager") session_manager.update_latest_assistant_event_payload( session_id, run_id, { "task_id": updated.task_id, "task_status": updated.status, "acceptance_state": normalized, "feedback_state": legacy_feedback_type, }, ) if not already_recorded: session_manager.append_message( session_id, run_id=run_id, role="system", event_type="task_acceptance_recorded", event_payload={ "task_id": task.task_id, "acceptance_type": normalized, "feedback_type": legacy_feedback_type, "comment": comment, "task_status": updated.status, }, content=comment, context_visible=False, ) generated_candidates = [] if not already_recorded: run_memory_store = self._require_loaded(loaded, "run_memory_store") acceptance_payload = { "acceptance_type": normalized, "feedback_type": legacy_feedback_type, "comment": comment or "", "task_status": updated.status, "final_accepted_run_id": updated.metadata.get("final_accepted_run_id"), } run_memory_store.update_run_record( run_id, success=normalized == "accept", feedback=acceptance_payload, ) run_memory_store.update_skill_effects_for_run( run_id, success=normalized == "accept", feedback_score=self._acceptance_score_for_learning(normalized), notes=(comment or normalized).strip(), ) skill_learning_service = self._require_loaded(loaded, "skill_learning_service") skill_learning_service.rescore_skill_versions() if already_recorded: generated_candidates = [] elif normalized == "accept": generated_candidates = [ item.to_dict() for item in skill_learning_service.build_learning_candidates_for_task( updated.task_id, final_accepted_run_id=run_id, ) ] elif normalized == "abandon": session_manager.append_message( session_id, run_id=run_id, role="system", event_type="task_failure_evidence_recorded", event_payload={ "task_id": updated.task_id, "acceptance_type": normalized, "feedback_type": legacy_feedback_type, "comment": comment or "", "task_status": updated.status, "durable_memory_written": False, }, content=(comment or "Task abandoned; retained as run/session failure evidence."), context_visible=False, ) return { "session_id": session_id, "run_id": run_id, "task_id": updated.task_id, "task_status": updated.status, "acceptance_type": normalized, "feedback_type": legacy_feedback_type, "learning_candidates": generated_candidates, } async def submit_feedback( self, *, session_id: str, run_id: str, feedback_type: str, comment: str | None = None, ) -> dict[str, Any]: """Backward-compatible wrapper for older clients.""" return await self.submit_acceptance( session_id=session_id, run_id=run_id, acceptance_type=feedback_type, comment=comment, ) async def _process_with_main_agent( self, message: str, *, runner: Any, kwargs: dict[str, Any], ) -> AgentRunResult: loaded = self.create_loop().boot() task_service = self._require_loaded(loaded, "task_service") session_manager = self._require_loaded(loaded, "session_manager") session_id = kwargs.get("session_id") or uuid4().hex kwargs = dict(kwargs) kwargs["session_id"] = session_id provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs) kwargs["provider_bundle"] = provider_bundle router_provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider router_runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime active_task = task_service.get_latest_open_task(session_id) router_started = perf_counter() try: decision = await self._main_agent_router.classify( message, active_task=active_task, provider=router_provider, model=getattr(router_runtime, "model", None), recent_messages=session_manager.get_messages_as_conversation(session_id), intent_skill=self._load_intent_agent_skill(loaded), thinking_enabled=kwargs.get("thinking_enabled"), ) finally: kwargs["pre_run_latency_ms"] = self._merge_latency_ms( kwargs.get("pre_run_latency_ms"), {"router_ms": (perf_counter() - router_started) * 1000}, ) kwargs["intent_agent_decision"] = self._intent_decision_payload( decision, active_task=active_task, ) if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"): active_task.metadata["short_title"] = decision.short_title task_service.store.upsert_task(active_task) if active_task is not None and (decision.action == "simple_chat" or decision.starts_new_task): await self._accept_active_task_for_new_topic(active_task) if active_task is not None and decision.closes_task: task_service.close_task(active_task.task_id, reason=decision.reason) return await runner(message, **kwargs) if active_task is not None and decision.abandons_task: task_service.abandon_task(active_task.task_id, reason=decision.reason) return await runner(message, **kwargs) if not decision.is_task: kwargs["include_skill_assembly"] = False kwargs["include_tools"] = False return await runner(message, **kwargs) task = ( task_service.create_task( session_id=session_id, description=message, metadata={ "prompt_locale": normalize_main_agent_prompt_locale(kwargs.get("prompt_locale")), "router_reason": decision.reason, **({"short_title": decision.short_title} if decision.short_title else {}), }, ) if active_task is None or decision.starts_new_task else active_task ) if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id: task = self._record_revision_acceptance_for_task( loaded, task=task, session_id=session_id, comment=message, ) return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task) async def _accept_active_task_for_new_topic(self, task: TaskRecord) -> None: """Accept a completed active Task before routing an unrelated new topic.""" if task.status != "awaiting_acceptance": return run_id = next((item for item in reversed(task.run_ids) if item), None) if not run_id: return await self.submit_acceptance( session_id=task.session_id, run_id=run_id, acceptance_type="accept", ) def _record_revision_acceptance_for_task( self, loaded: Any, *, task: TaskRecord, session_id: str, comment: str, ) -> TaskRecord: """Mark the latest acceptance-eligible run as revised before continuing a task.""" if task.status not in {"awaiting_acceptance", "needs_revision"}: return task run_id = next((item for item in reversed(task.run_ids) if item), None) if not run_id: return task existing = next((item for item in task.feedback if item.get("run_id") == run_id), None) if existing is not None: if existing.get("acceptance_type") != "revise": return task updated = task already_recorded = True else: task_service = self._require_loaded(loaded, "task_service") updated = task_service.add_acceptance( task.task_id, acceptance_type="revise", comment=comment, run_id=run_id, ) already_recorded = False session_manager = self._require_loaded(loaded, "session_manager") session_manager.update_latest_assistant_event_payload( session_id, run_id, { "task_id": updated.task_id, "task_status": updated.status, "acceptance_state": "revise", "feedback_state": "revise", }, ) if already_recorded: return updated session_manager.append_message( session_id, run_id=run_id, role="system", event_type="task_acceptance_recorded", event_payload={ "task_id": updated.task_id, "acceptance_type": "revise", "feedback_type": "revise", "comment": comment, "task_status": updated.status, "auto_recorded": True, }, content=comment, context_visible=False, ) run_memory_store = self._require_loaded(loaded, "run_memory_store") run_memory_store.update_run_record( run_id, success=False, feedback={ "acceptance_type": "revise", "feedback_type": "revise", "comment": comment, "task_status": updated.status, }, ) run_memory_store.update_skill_effects_for_run( run_id, success=False, feedback_score=self._acceptance_score_for_learning("revise"), notes=comment.strip() or "revise", ) skill_learning_service = self._require_loaded(loaded, "skill_learning_service") skill_learning_service.rescore_skill_versions() return updated async def _run_task_mode( self, message: str, *, runner: Any, kwargs: dict[str, Any], task: TaskRecord, ) -> AgentRunResult: loaded = self.create_loop().boot() return await self._build_task_attempt_orchestrator(loaded).run( message=message, runner=runner, kwargs=kwargs, task=task, ) def _build_task_attempt_orchestrator(self, loaded: Any) -> TaskAttemptOrchestrator: return TaskAttemptOrchestrator( loaded=loaded, create_loop=self.create_loop, make_provider_bundle_for_task=self._make_provider_bundle_for_task, ) @staticmethod def _require_loaded(loaded: Any, field_name: str) -> Any: value = getattr(loaded, field_name) if value is None: raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}") return value @staticmethod def _load_intent_agent_skill(loaded: Any) -> str | None: skills_loader = getattr(loaded, "skills_loader", None) if skills_loader is None: return None return skills_loader.load_skill("intent-agent-router") @staticmethod def _intent_decision_payload(decision: Any, *, active_task: TaskRecord | None) -> dict[str, Any]: action = decision.action or ("create_task" if decision.is_task and active_task is None else decision.mode) return { "agent": "intent_agent", "choice": action, "mode": "task" if decision.is_task else "simple", "reason": decision.reason, "active_task_id": active_task.task_id if active_task is not None else None, "starts_new_task": bool(decision.starts_new_task or (decision.is_task and active_task is None)), "closes_task": bool(decision.closes_task), "abandons_task": bool(decision.abandons_task), "short_title": decision.short_title, } @staticmethod def _merge_latency_ms(current: Any, updates: dict[str, float]) -> dict[str, float]: merged: dict[str, float] = {} if isinstance(current, dict): for key, value in current.items(): if isinstance(value, (int, float)): merged[str(key)] = float(value) for key, value in updates.items(): merged[key] = merged.get(key, 0.0) + float(value) return merged @staticmethod def _acceptance_score_for_learning(acceptance_type: str) -> float: if acceptance_type == "accept": return 1.0 if acceptance_type == "revise": return 0.5 return 0.0 def _make_provider_bundle_for_task(self, loaded: Any, kwargs: dict[str, Any]) -> Any: config = loaded.config configured_provider = config.resolve_provider_target( model=kwargs.get("model"), provider_name=kwargs.get("provider_name"), ) resolved_model = configured_provider.get("model") or self.profile.default_model resolved_provider_name = configured_provider.get("provider_name") or kwargs.get("provider_name") return make_provider_bundle( model=resolved_model, provider_name=resolved_provider_name, api_key=kwargs.get("api_key") or configured_provider.get("api_key"), api_base=kwargs.get("api_base") or configured_provider.get("api_base"), request_timeout_seconds=configured_provider.get("request_timeout_seconds"), extra_headers=kwargs.get("extra_headers") or configured_provider.get("extra_headers"), routing=kwargs.get("routing"), fallback_target=kwargs.get("fallback_target"), auxiliary_target=kwargs.get("auxiliary_target"), embedding_target=kwargs.get("embedding_target") or config.resolve_embedding_target(), embedding_model=kwargs.get("embedding_model") or config.default_embedding_model, ) async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage: """把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。""" channel_identity = inbound.channel_identity try: runner = self.submit_direct if self.is_running else self.process_direct result = await runner( inbound.content, session_id=inbound.session_id, source=f"gateway:{inbound.channel}", user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None), title=inbound.title, execution_context=inbound.execution_context, model=inbound.model, provider_name=inbound.provider_name, embedding_model=inbound.embedding_model, channel_identity=channel_identity, ) except Exception as exc: return self.build_outbound_error( inbound, detail=str(exc), finish_reason=self._classify_inbound_failure(exc), ) return self.build_outbound_message(inbound, result) @staticmethod def _classify_inbound_failure(exc: Exception) -> str: """把 runtime 异常收口为更稳定的 bus finish reason。""" if isinstance(exc, RuntimeError): detail = str(exc) if ( "requires an active run() loop" in detail or "not accepting new tasks after stop()" in detail ): return "stopped" return "error" @staticmethod def build_outbound_message(inbound: InboundMessage, result: AgentRunResult) -> OutboundMessage: """把一次 runtime 正常结果转成 bus outbound。""" return OutboundMessage( message_id=inbound.message_id, channel=inbound.channel, session_id=result.session_id, run_id=result.run_id, content=result.output_text, finish_reason=result.finish_reason, provider_name=result.provider_name, model=result.model, content_type=inbound.content_type, channel_identity=inbound.channel_identity, usage=dict(result.usage), metadata={ "inbound_metadata": dict(inbound.metadata), "task_id": getattr(result, "task_id", None), "task_status": getattr(result, "task_status", None), "evidence_status": "recorded" if getattr(result, "task_id", None) else None, "validation_result": None, }, ) @staticmethod def build_outbound_error( inbound: InboundMessage, *, detail: str, finish_reason: str = "error", ) -> OutboundMessage: """把 inbound 处理失败转换成结构化 outbound 错误消息。""" return OutboundMessage( message_id=inbound.message_id, channel=inbound.channel, session_id=inbound.session_id, content=detail, finish_reason=finish_reason, content_type=inbound.content_type, channel_identity=inbound.channel_identity, metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)}, ) def run_direct( self, message: str, **kwargs: Any, ) -> AgentRunResult: """同步 direct run 包装。 主要给当前 CLI 或简单脚本使用。真正的长期方向仍然是让 interfaces 在 direct mode 下直接走 `await process_direct(...)`。 """ try: asyncio.get_running_loop() except RuntimeError: pass else: raise RuntimeError( "AgentService.run_direct() cannot be used inside an active event loop; " "use 'await AgentService.process_direct(...)' instead." ) return asyncio.run(self.process_direct(message, **kwargs))