946 lines
35 KiB
Python
946 lines
35 KiB
Python
"""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))
|