service.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. from collections.abc import Callable, Iterator
  2. from dataclasses import dataclass
  3. from datetime import datetime
  4. from secrets import randbelow
  5. from typing import Any
  6. from app.core.errors import AppError
  7. from app.core.identifiers import new_ulid
  8. from app.domains.agent.models import AgentMessage, AgentRun, AgentThread
  9. from app.domains.agent.repository import AgentRepository
  10. from app.domains.agent.runtime import AgentRuntime
  11. from app.domains.identity.models import AdminUser, H5User
  12. from app.harness.events import RuntimeEvent
  13. from app.harness.kernel import AgentInvocation, ConversationTurn
  14. from app.harness.schemas import AgentReply
  15. from app.infrastructure.redis.agent_state import (
  16. AgentStateStore,
  17. InMemoryAgentStateStore,
  18. )
  19. @dataclass(frozen=True)
  20. class PreparedMessage:
  21. thread: AgentThread
  22. text: str
  23. run_id: str
  24. service_no: str
  25. started_at: datetime
  26. history: tuple[ConversationTurn, ...]
  27. h5_user: H5User | None
  28. admin_user: AdminUser | None
  29. class AgentThreadService:
  30. """客户与运营人格共用的会话、消息、运行事件和限流服务。"""
  31. def __init__(
  32. self,
  33. repository: AgentRepository,
  34. clock: Callable[[], datetime],
  35. runtime: AgentRuntime,
  36. state_store: AgentStateStore | None = None,
  37. ) -> None:
  38. self._repository = repository
  39. self._clock = clock
  40. self._runtime = runtime
  41. self._state_store = state_store or InMemoryAgentStateStore()
  42. def create_customer_thread(
  43. self,
  44. user: H5User,
  45. *,
  46. title: str | None,
  47. ) -> dict[str, Any]:
  48. return self._create_thread(
  49. owner_type="H5_USER",
  50. owner_id=user.id,
  51. persona="customer",
  52. title=title or "新的保障咨询",
  53. )
  54. def create_operation_thread(
  55. self,
  56. user: AdminUser,
  57. *,
  58. title: str | None,
  59. ) -> dict[str, Any]:
  60. return self._create_thread(
  61. owner_type="ADMIN_USER",
  62. owner_id=user.id,
  63. persona="operation",
  64. title=title or "新的运营分析",
  65. )
  66. def send_customer_message(
  67. self,
  68. user: H5User,
  69. *,
  70. thread_id: str,
  71. text: str,
  72. ) -> dict[str, Any]:
  73. prepared = self._prepare_message(
  74. self._owned_thread("H5_USER", user.id, thread_id),
  75. text,
  76. h5_user=user,
  77. )
  78. return self._execute(prepared)
  79. def stream_customer_message(
  80. self,
  81. user: H5User,
  82. *,
  83. thread_id: str,
  84. text: str,
  85. ) -> Iterator[RuntimeEvent]:
  86. prepared = self._prepare_message(
  87. self._owned_thread("H5_USER", user.id, thread_id),
  88. text,
  89. h5_user=user,
  90. )
  91. yield self._emit(
  92. prepared,
  93. "run.started",
  94. 1,
  95. {"persona": prepared.thread.persona},
  96. )
  97. try:
  98. reply = self._invoke(prepared)
  99. result = self._complete(prepared, reply)
  100. except AppError as error:
  101. self._save_failed_run(prepared, error)
  102. yield self._emit(
  103. prepared,
  104. "run.failed",
  105. 2,
  106. {
  107. "code": error.code,
  108. "message": error.message,
  109. "retryable": error.retryable,
  110. },
  111. )
  112. return
  113. sequence = 2
  114. for tool_name in reply.invoked_tools:
  115. yield self._emit(
  116. prepared,
  117. "tool.completed",
  118. sequence,
  119. {"tool_name": tool_name},
  120. )
  121. sequence += 1
  122. if reply.cards or reply.actions:
  123. yield self._emit(
  124. prepared,
  125. "ui.ready",
  126. sequence,
  127. {
  128. "cards": reply.cards,
  129. "actions": reply.actions,
  130. },
  131. )
  132. sequence += 1
  133. yield self._emit(
  134. prepared,
  135. "run.completed",
  136. sequence,
  137. {"result": result},
  138. )
  139. def send_operation_message(
  140. self,
  141. user: AdminUser,
  142. *,
  143. thread_id: str,
  144. text: str,
  145. ) -> dict[str, Any]:
  146. prepared = self._prepare_message(
  147. self._owned_thread("ADMIN_USER", user.id, thread_id),
  148. text,
  149. admin_user=user,
  150. )
  151. return self._execute(prepared)
  152. def list_customer_messages(
  153. self,
  154. user: H5User,
  155. *,
  156. thread_id: str,
  157. ) -> dict[str, Any]:
  158. self._owned_thread("H5_USER", user.id, thread_id)
  159. return self._message_data(thread_id)
  160. def list_operation_messages(
  161. self,
  162. user: AdminUser,
  163. *,
  164. thread_id: str,
  165. ) -> dict[str, Any]:
  166. self._owned_thread("ADMIN_USER", user.id, thread_id)
  167. return self._message_data(thread_id)
  168. def get_customer_run(self, user: H5User, *, run_id: str) -> dict[str, Any]:
  169. return self._get_run("H5_USER", user.id, run_id)
  170. def get_operation_run(
  171. self,
  172. user: AdminUser,
  173. *,
  174. run_id: str,
  175. ) -> dict[str, Any]:
  176. return self._get_run("ADMIN_USER", user.id, run_id)
  177. def list_operation_runs(
  178. self,
  179. user: AdminUser,
  180. *,
  181. limit: int = 20,
  182. ) -> dict[str, Any]:
  183. threads = self._repository.list_threads("ADMIN_USER", user.id)
  184. runs = self._repository.list_runs(
  185. tuple(thread.id for thread in threads),
  186. limit,
  187. )
  188. return {
  189. "items": [
  190. {
  191. "id": run.id,
  192. "thread_id": run.thread_id,
  193. "persona": run.persona,
  194. "status": run.status,
  195. "trace_id": run.trace_id,
  196. "trace_url": self._resolve_trace_url(run),
  197. "input": run.input,
  198. "error_code": run.error_code,
  199. "created_at": run.created_at,
  200. "completed_at": run.completed_at,
  201. }
  202. for run in runs
  203. ],
  204. "total": len(runs),
  205. }
  206. def list_customer_run_events(
  207. self,
  208. user: H5User,
  209. *,
  210. run_id: str,
  211. ) -> list[RuntimeEvent]:
  212. self._get_run("H5_USER", user.id, run_id)
  213. return self._state_store.list_events(run_id)
  214. def _create_thread(
  215. self,
  216. *,
  217. owner_type: str,
  218. owner_id: str,
  219. persona: str,
  220. title: str,
  221. ) -> dict[str, Any]:
  222. now = self._clock()
  223. thread = AgentThread(
  224. id=new_ulid(),
  225. owner_type=owner_type,
  226. owner_id=owner_id,
  227. persona=persona,
  228. title=title.strip(),
  229. status="ACTIVE",
  230. created_at=now,
  231. updated_at=now,
  232. )
  233. self._repository.save_thread(thread)
  234. self._state_store.touch_thread(
  235. thread.id,
  236. owner_type=thread.owner_type,
  237. owner_id=thread.owner_id,
  238. persona=thread.persona,
  239. )
  240. return {
  241. "id": thread.id,
  242. "persona": thread.persona,
  243. "title": thread.title,
  244. "status": thread.status,
  245. }
  246. def _prepare_message(
  247. self,
  248. thread: AgentThread,
  249. text: str,
  250. *,
  251. h5_user: H5User | None = None,
  252. admin_user: AdminUser | None = None,
  253. ) -> PreparedMessage:
  254. principal = h5_user or admin_user
  255. if principal is None:
  256. raise AppError("AGENT_PRINCIPAL_REQUIRED", "Agent缺少登录身份", 401)
  257. if not self._state_store.allow_request(principal.id):
  258. raise AppError(
  259. "AGENT_RATE_LIMITED",
  260. "请求过于频繁,请稍后再试",
  261. 429,
  262. retryable=True,
  263. )
  264. history = tuple(
  265. ConversationTurn(
  266. role="assistant" if message.role == "ASSISTANT" else "user",
  267. content=message.content,
  268. )
  269. for message in self._repository.list_messages(thread.id)[-12:]
  270. )
  271. now = self._clock()
  272. run_id = new_ulid()
  273. service_no = f"ZBT-{now:%Y%m%d}-{randbelow(10_000):04d}"
  274. self._repository.save_message(
  275. AgentMessage(
  276. id=new_ulid(),
  277. thread_id=thread.id,
  278. role="USER",
  279. content=text,
  280. metadata={"run_id": run_id, "service_no": service_no},
  281. created_at=now,
  282. )
  283. )
  284. self._state_store.touch_thread(
  285. thread.id,
  286. owner_type=thread.owner_type,
  287. owner_id=thread.owner_id,
  288. persona=thread.persona,
  289. )
  290. return PreparedMessage(
  291. thread=thread,
  292. text=text,
  293. run_id=run_id,
  294. service_no=service_no,
  295. started_at=now,
  296. history=history,
  297. h5_user=h5_user,
  298. admin_user=admin_user,
  299. )
  300. def _execute(self, prepared: PreparedMessage) -> dict[str, Any]:
  301. self._emit(
  302. prepared,
  303. "run.started",
  304. 1,
  305. {"persona": prepared.thread.persona},
  306. )
  307. try:
  308. reply = self._invoke(prepared)
  309. result = self._complete(prepared, reply)
  310. except AppError as error:
  311. self._save_failed_run(prepared, error)
  312. self._emit(
  313. prepared,
  314. "run.failed",
  315. 2,
  316. {
  317. "code": error.code,
  318. "message": error.message,
  319. "retryable": error.retryable,
  320. },
  321. )
  322. raise
  323. sequence = 2
  324. for tool_name in reply.invoked_tools:
  325. self._emit(
  326. prepared,
  327. "tool.completed",
  328. sequence,
  329. {"tool_name": tool_name},
  330. )
  331. sequence += 1
  332. if reply.cards or reply.actions:
  333. self._emit(
  334. prepared,
  335. "ui.ready",
  336. sequence,
  337. {"cards": reply.cards, "actions": reply.actions},
  338. )
  339. sequence += 1
  340. self._emit(
  341. prepared,
  342. "run.completed",
  343. sequence,
  344. {"result": result},
  345. )
  346. return result
  347. def _invoke(self, prepared: PreparedMessage) -> AgentReply:
  348. try:
  349. return self._runtime.reply(
  350. AgentInvocation(
  351. persona=prepared.thread.persona,
  352. message=prepared.text,
  353. h5_user=prepared.h5_user,
  354. admin_user=prepared.admin_user,
  355. history=prepared.history,
  356. )
  357. )
  358. except AppError:
  359. raise
  360. except Exception as error:
  361. raise AppError(
  362. "AGENT_RUNTIME_FAILED",
  363. "智能体服务暂不可用,请稍后重试",
  364. 502,
  365. retryable=True,
  366. ) from error
  367. def _complete(
  368. self,
  369. prepared: PreparedMessage,
  370. reply: AgentReply,
  371. ) -> dict[str, Any]:
  372. assistant_message = {
  373. "type": "text",
  374. "text": reply.text,
  375. "cards": reply.cards,
  376. "actions": reply.actions,
  377. "service_no": prepared.service_no,
  378. }
  379. completed_at = self._clock()
  380. self._repository.save_message(
  381. AgentMessage(
  382. id=new_ulid(),
  383. thread_id=prepared.thread.id,
  384. role="ASSISTANT",
  385. content=reply.text,
  386. metadata={
  387. "run_id": prepared.run_id,
  388. "service_no": prepared.service_no,
  389. "cards": reply.cards,
  390. "actions": reply.actions,
  391. "invoked_tools": list(reply.invoked_tools),
  392. "trace_url": reply.trace_url,
  393. },
  394. created_at=completed_at,
  395. )
  396. )
  397. self._repository.save_run(
  398. AgentRun(
  399. id=prepared.run_id,
  400. request_id=f"agent-{new_ulid()}",
  401. thread_id=prepared.thread.id,
  402. trace_id=reply.trace_id,
  403. persona=prepared.thread.persona,
  404. status="COMPLETED",
  405. input={"type": "text", "text": prepared.text},
  406. output={**assistant_message, "trace_url": reply.trace_url},
  407. error_code=None,
  408. created_at=prepared.started_at,
  409. completed_at=completed_at,
  410. )
  411. )
  412. return {
  413. "run_id": prepared.run_id,
  414. "thread_id": prepared.thread.id,
  415. "status": "COMPLETED",
  416. "assistant_message": assistant_message,
  417. "service_no": prepared.service_no,
  418. "trace_id": reply.trace_id,
  419. "trace_url": reply.trace_url,
  420. "invoked_tools": list(reply.invoked_tools),
  421. }
  422. def _save_failed_run(
  423. self,
  424. prepared: PreparedMessage,
  425. error: AppError,
  426. ) -> None:
  427. self._repository.save_run(
  428. AgentRun(
  429. id=prepared.run_id,
  430. request_id=f"agent-{new_ulid()}",
  431. thread_id=prepared.thread.id,
  432. trace_id=None,
  433. persona=prepared.thread.persona,
  434. status="FAILED",
  435. input={"type": "text", "text": prepared.text},
  436. output=None,
  437. error_code=error.code,
  438. created_at=prepared.started_at,
  439. completed_at=self._clock(),
  440. )
  441. )
  442. def _emit(
  443. self,
  444. prepared: PreparedMessage,
  445. event: str,
  446. sequence: int,
  447. data: dict[str, Any],
  448. ) -> RuntimeEvent:
  449. runtime_event = RuntimeEvent.model_validate(
  450. {
  451. "event": event,
  452. "run_id": prepared.run_id,
  453. "sequence": sequence,
  454. "occurred_at": self._clock(),
  455. "data": data,
  456. }
  457. )
  458. self._state_store.append_event(runtime_event)
  459. return runtime_event
  460. def _message_data(self, thread_id: str) -> dict[str, Any]:
  461. messages = self._repository.list_messages(thread_id)
  462. return {
  463. "items": [
  464. {
  465. "id": message.id,
  466. "role": message.role,
  467. "content": message.content,
  468. "metadata": message.metadata,
  469. "created_at": message.created_at,
  470. }
  471. for message in messages
  472. ]
  473. }
  474. def _get_run(
  475. self,
  476. owner_type: str,
  477. owner_id: str,
  478. run_id: str,
  479. ) -> dict[str, Any]:
  480. run = self._repository.get_run(run_id)
  481. if run is None:
  482. raise AppError("AGENT_RUN_NOT_FOUND", "未找到智能体运行", 404)
  483. self._owned_thread(owner_type, owner_id, run.thread_id)
  484. trace_url = self._resolve_trace_url(run)
  485. return {
  486. "id": run.id,
  487. "thread_id": run.thread_id,
  488. "persona": run.persona,
  489. "status": run.status,
  490. "trace_id": run.trace_id,
  491. "trace_url": trace_url,
  492. "output": run.output,
  493. "error_code": run.error_code,
  494. "completed_at": run.completed_at,
  495. }
  496. def _resolve_trace_url(self, run: AgentRun) -> str | None:
  497. trace_url = run.output.get("trace_url") if run.output is not None else None
  498. if not trace_url and run.trace_id:
  499. resolver = getattr(self._runtime, "trace_url", None)
  500. if callable(resolver):
  501. resolved = resolver(run.trace_id)
  502. trace_url = str(resolved) if resolved else None
  503. return str(trace_url) if trace_url else None
  504. def _owned_thread(
  505. self,
  506. owner_type: str,
  507. owner_id: str,
  508. thread_id: str,
  509. ) -> AgentThread:
  510. thread = self._repository.get_thread(thread_id)
  511. if thread is None or thread.owner_type != owner_type or thread.owner_id != owner_id:
  512. raise AppError("AGENT_THREAD_NOT_FOUND", "未找到会话", 404)
  513. return thread