business_tasks.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. """持久化业务任务及结果投递;取数网关与结果接收平台各用独立连接。"""
  2. import asyncio
  3. from datetime import timedelta
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import re
  8. import config
  9. from agents.workspace import session_root
  10. from db import business_store as store, chat_store, db_conn
  11. from extensions.registry import load_extensions
  12. from obs import get_logger
  13. from runtime import business, execution
  14. from runtime.business_models import RemoteFile, platform_result
  15. from runtime.result_models import PlatformResultPayload
  16. logger = get_logger(__name__)
  17. _controller = None
  18. def get_controller():
  19. if _controller is None:
  20. raise RuntimeError("业务任务执行器未启动,请在 RuntimeEnvironment(business_jobs=True) 内使用")
  21. return _controller
  22. def submit(name, data, *, user_name="default", files=(), request_id=None, publish=False, source="manual"):
  23. spec = load_extensions().business(name)
  24. business.ensure_enabled(name)
  25. validated = spec.input_schema.model_validate(data).model_dump(mode="json")
  26. sources = [RemoteFile.model_validate(f).model_dump(mode="json") for f in files]
  27. needs_gateway = bool(sources or validated.get("use_gateway"))
  28. if needs_gateway and not spec.gateway.agent_code:
  29. raise ValueError("取数需要先配置本业务的网关调用身份")
  30. if needs_gateway and not config.get_bool("SHANXI_GATEWAY_ENABLED", False):
  31. raise ValueError("网关连接尚未启用")
  32. if publish and not spec.result_binding.agent_code:
  33. raise ValueError("结果推送需要配置本业务的结果平台身份")
  34. if publish and not config.get_bool("RESULT_PLATFORM_ENABLED", False):
  35. raise ValueError("结果接收平台尚未启用")
  36. return store.enqueue(name, user_name, validated, sources, request_id=request_id, source=source, publish=publish)[0]
  37. def public_job(job):
  38. return {key: value for key, value in job.items() if key not in {"payload", "push_payload"}}
  39. class BusinessTaskController:
  40. def __init__(self, gateway=None, *, publisher=None, executor=None):
  41. self.gateway = gateway
  42. self.publisher = publisher
  43. self.executor = executor or execution.execute
  44. self.catalog = load_extensions()
  45. self.tasks = []
  46. self.running_jobs = {}
  47. self.closed = False
  48. async def start(self):
  49. global _controller
  50. if _controller is not None:
  51. raise RuntimeError("业务任务执行器已经启动")
  52. await asyncio.to_thread(store.recover)
  53. for spec in self.catalog.business_agents.values():
  54. await asyncio.to_thread(store.ensure_state, spec.name)
  55. _controller = self
  56. for spec in self.catalog.business_agents.values():
  57. for index in range(spec.max_concurrency):
  58. self.tasks.append(asyncio.create_task(self._worker(spec.name), name=f"business-worker-{spec.name}-{index}"))
  59. if self.publisher:
  60. self.tasks.append(asyncio.create_task(self._publish_loop(), name="business-publisher"))
  61. async def close(self):
  62. global _controller
  63. self.closed = True
  64. for task in self.tasks:
  65. task.cancel()
  66. await asyncio.gather(*self.tasks, return_exceptions=True)
  67. self.tasks.clear()
  68. if _controller is self:
  69. _controller = None
  70. async def _worker(self, name):
  71. while not self.closed:
  72. try:
  73. await asyncio.to_thread(business.ensure_enabled, name)
  74. job = await asyncio.to_thread(store.claim, name)
  75. if job:
  76. # 单次任务放在子 task,业务停止只取消该任务,worker 仍可恢复。
  77. task = asyncio.create_task(self.run_job(job), name=f"business-job-{job['id']}")
  78. self.running_jobs[job['id']] = task
  79. try:
  80. await task
  81. except asyncio.CancelledError:
  82. if self.closed:
  83. raise
  84. finally:
  85. self.running_jobs.pop(job['id'], None)
  86. except business.BusinessStopped:
  87. pass
  88. except asyncio.CancelledError:
  89. raise
  90. except Exception as exc:
  91. logger.error("业务队列处理异常 agent=%s type=%s", name, type(exc).__name__)
  92. await asyncio.sleep(1)
  93. async def run_job(self, job):
  94. name, job_id = job["agent_name"], job["id"]
  95. with business.track(name):
  96. try:
  97. spec = self.catalog.business(name)
  98. await asyncio.to_thread(business.ensure_enabled, name)
  99. payload = json.loads(job["payload"])
  100. if not job.get("session_id"):
  101. session = await asyncio.to_thread(chat_store.create_session, job["user_name"], "full", spec.display_name)
  102. job["session_id"] = session["id"]
  103. await asyncio.to_thread(store.update, job_id, session_id=session["id"])
  104. data = spec.input_schema.model_validate(payload["input"]).model_dump(mode="json")
  105. downloaded = []
  106. for raw in payload["files"]:
  107. if self.gateway is None:
  108. raise ValueError("网关未启用,不能下载平台文件")
  109. source = RemoteFile.model_validate(raw)
  110. filename = re.sub(r"[^\w.\-\u4e00-\u9fff]", "_", Path(source.filename).name)
  111. key = hashlib.sha256(f"{source.file_id}\0{source.version}".encode()).hexdigest()[:12]
  112. root = session_root(job["user_name"], job["session_id"])
  113. target = root / "workspace" / "uploads" / f"{key}_{filename}"
  114. metadata = await self.gateway.download(spec.gateway.agent_code, source, target)
  115. await asyncio.to_thread(store.record_file, job_id, metadata)
  116. downloaded.append({"file_id": source.file_id, "version": source.version,
  117. "path": f"/workspace/uploads/{target.name}"})
  118. # 文件路径以额外上下文传递,避免修改各业务输入 schema。
  119. message = "请执行本业务任务,按业务结果 schema 返回。\n输入:" + json.dumps(data, ensure_ascii=False)
  120. if downloaded:
  121. if isinstance(data.get("materials"), list):
  122. data["materials"] = [*data["materials"], *downloaded]
  123. message = "请执行本业务任务,按业务结果 schema 返回。\n输入:" + json.dumps(data, ensure_ascii=False)
  124. message += "\n已下载附件:" + json.dumps(downloaded, ensure_ascii=False)
  125. task_config = {"business_task_input": data, "business_task_id": job_id}
  126. if spec.skill_path:
  127. # 大量时序已经通过服务端配置传给工具,不再作为聊天上下文重复展开。
  128. summary = {key: data.get(key) for key in ('mine_code','mine_name','fan_id','fan_name','fan_pair','materials')}
  129. summary.update(fan_state_count=len(data.get('fan_state_records',[])), flow_series_count=len(data.get('operating_series',[])))
  130. message = ('请先读取 '+spec.skill_path+',按 Skill 自主规划本次研判。'
  131. '完整输入已经服务端校验并注入 business_task_input;先准备任务,按需使用网关取数。'
  132. '文档解析、计算及报告生成都由你自行编写代码并在沙箱执行,根据真实输出调整计划;'
  133. '最后提交脚本生成的 Markdown、研判 JSON 和成功执行ID。不要调用旧业务算法、使用历史报告或虚构原件事实。'
  134. '\n本次输入摘要:'+json.dumps(summary,ensure_ascii=False))
  135. if job['source'] == 'test_pipeline':
  136. result = await self._run_test_pipeline(spec, job, data)
  137. else:
  138. result = await self.executor(agent_name=name, user_name=job["user_name"],
  139. session_id=job["session_id"], message=message, kind="business",
  140. graph_config={"recursion_limit": config.get_int(
  141. 'AGENT_EXPLORATION_RECURSION_LIMIT' if spec.skill_path else 'BUSINESS_RECURSION_LIMIT',
  142. 480 if spec.skill_path else 40),
  143. "configurable": task_config})
  144. await self.complete(job, result)
  145. except asyncio.CancelledError:
  146. await asyncio.shield(asyncio.to_thread(store.update, job_id, status="cancelled", error="cancelled", finished_at=db_conn.fmt_dt()))
  147. raise
  148. except business.BusinessBusy:
  149. await asyncio.to_thread(store.update, job_id, status="queued", error=None)
  150. except business.BusinessStopped:
  151. await asyncio.to_thread(store.update, job_id, status="queued", error=None)
  152. except Exception as exc:
  153. error = type(exc).__name__
  154. if job['source'].startswith('test_') and isinstance(exc, ValueError):
  155. error += ': ' + str(exc)[:300]
  156. await asyncio.to_thread(store.update, job_id, status="failed", error=error, finished_at=db_conn.fmt_dt())
  157. logger.error("业务任务失败 id=%s agent=%s type=%s", job_id, name, type(exc).__name__)
  158. async def _run_test_pipeline(self, spec, job, data):
  159. """测试业务工具本身;沿用生产准入、验证、归档与持久化队列。"""
  160. from runtime.engine import ExecutionResult
  161. if spec.test_runner is None:
  162. raise ValueError('该智能体未声明业务流程测试执行器,请选择完整智能体模式')
  163. # 纯规则样例无需模型计费配置;文档识别在真正调用模型前自行准入及计费。
  164. admission = await execution.admit(job['user_name'], job['session_id'], check_billing=False)
  165. lease = None
  166. try:
  167. lease = business.BusinessLease(spec.name)
  168. lease.bind()
  169. await asyncio.to_thread(store.bind_session, job['session_id'], spec.name)
  170. result = await spec.test_runner(data, job['user_name'], job['session_id'],
  171. gateway=self.gateway, job_id=job['id'],
  172. document_audit_dir=session_root(job['user_name'], job['session_id'])/'workspace/test_audits'/job['id'])
  173. text = f"# {result.get('result_title', spec.display_name)}\n\n{result.get('result','')}"
  174. await asyncio.to_thread(chat_store.save_message, job['session_id'], 'user', '智能体业务流程测试')
  175. await asyncio.to_thread(chat_store.save_message, job['session_id'], 'assistant', text, citations=result.get('sources', []))
  176. return ExecutionResult('', job['session_id'], 'success', text, '', {}, result.get('sources', []), [], result)
  177. finally:
  178. if lease:
  179. lease.release()
  180. admission.release()
  181. async def cancel_job(self, job_id):
  182. task = self.running_jobs.get(job_id)
  183. if task and not task.done():
  184. task.cancel()
  185. await asyncio.gather(task, return_exceptions=True)
  186. else:
  187. await asyncio.to_thread(db_conn.execute,
  188. "UPDATE business_jobs SET status='cancelled',finished_at=?,updated_at=? WHERE id=? AND status='queued'",
  189. (db_conn.fmt_dt(), db_conn.fmt_dt(), job_id))
  190. async def complete(self, job, result):
  191. fields = {"run_id": result.run_id, "error": result.error or None}
  192. if result.status != "success":
  193. fields.update(status=result.status, finished_at=None if result.status == "waiting_input" else db_conn.fmt_dt())
  194. else:
  195. spec = self.catalog.business(job["agent_name"])
  196. raw = result.structured_output
  197. if raw is None:
  198. if spec.skill_path:
  199. raise ValueError('自主探索未完成正式提交,请查看工具反馈;需要通过报告提交工具归档后才能完成任务')
  200. try:
  201. raw = json.loads(result.output)
  202. except json.JSONDecodeError as exc:
  203. raise ValueError(
  204. "业务智能体未交回有效的结构化结果,返回正文无法解析为 JSON;"
  205. "请检查模型的思考模式及结果必填字段"
  206. ) from exc
  207. if spec.result_validator:
  208. raw = await asyncio.to_thread(spec.result_validator, raw, job["user_name"], job["session_id"])
  209. checked = spec.result_schema.model_validate(raw)
  210. normalized = platform_result(checked.model_dump(mode="json"))
  211. source_input = spec.input_schema.model_validate(json.loads(job["payload"])["input"]).model_dump(mode="json")
  212. for key in ("mine_code", "mine_name"):
  213. if key in source_input and getattr(normalized, key) != source_input[key]:
  214. raise ValueError("结果矿井身份与任务输入不一致")
  215. if normalized.platform_payload:
  216. exported = normalized.platform_payload
  217. if exported.taskId != job["id"] or (exported.mineCode, exported.mineName) != (normalized.mine_code, normalized.mine_name):
  218. raise ValueError("结果平台报文身份与当前任务不一致")
  219. fields.update(status=normalized.status, result_json=checked.model_dump_json(),
  220. finished_at=normalized.platform_payload.checkedAt if normalized.platform_payload else db_conn.fmt_dt())
  221. await asyncio.to_thread(store.update, job["id"], **fields)
  222. if fields.get("status") == "completed" and json.loads(job["payload"])["publish"]:
  223. try:
  224. await self.queue_publish(await asyncio.to_thread(store.get, job["id"]))
  225. except Exception as exc:
  226. # 推送配置或映射错误单独记录,已经完成的业务结果仍保留。
  227. await asyncio.to_thread(store.update, job["id"], push_status="failed", error=type(exc).__name__)
  228. async def on_resume(self, session_id, result):
  229. rows = await asyncio.to_thread(db_conn.query,
  230. "SELECT * FROM business_jobs WHERE session_id = ? AND status = 'waiting_input'", (session_id,))
  231. for job in rows:
  232. try:
  233. await self.complete(job, result)
  234. except Exception as exc:
  235. await asyncio.to_thread(store.update, job["id"], status="failed", error=type(exc).__name__)
  236. async def _prepare_push(self, job):
  237. spec = self.catalog.business(job["agent_name"])
  238. if self.publisher is None:
  239. raise ValueError("结果接收平台尚未启用")
  240. result = json.loads(job["result_json"])
  241. if result["status"] != "completed":
  242. raise ValueError("未完成的业务结果不能作为正式研判上报")
  243. if not result.get("platform_payload"):
  244. raise ValueError("历史结果没有新格式报文,请重新执行研判")
  245. payload = PlatformResultPayload.model_validate(result["platform_payload"]).model_dump(mode="json")
  246. if payload["taskId"] != job["id"] or (payload["mineCode"], payload["mineName"]) != (result["mine_code"], result["mine_name"]):
  247. raise ValueError("推送结果身份与任务不一致")
  248. if payload["agentCode"] != spec.result_binding.agent_code or payload["agentName"] != spec.result_binding.agent_name:
  249. raise ValueError("结果平台身份已改变,不能改写历史结果")
  250. return await asyncio.to_thread(store.freeze_push, job["id"], payload, self.publisher.settings.url)
  251. async def queue_publish(self, job):
  252. if job["push_status"] == "pushed":
  253. return
  254. await self._prepare_push(job)
  255. await asyncio.to_thread(store.update, job["id"], push_status="pending", push_attempts=0, next_push_at=None, error=None)
  256. async def publish_job(self, job):
  257. with business.track(job["agent_name"]):
  258. await self._publish_job_once(job)
  259. async def _publish_job_once(self, job):
  260. name = job["agent_name"]
  261. try:
  262. await asyncio.to_thread(business.ensure_enabled, name)
  263. if self.publisher is None:
  264. raise ValueError("结果接收平台尚未启用")
  265. payload = PlatformResultPayload.model_validate(json.loads(job["push_payload"])).model_dump(mode="json") if job.get("push_payload") else await self._prepare_push(job)
  266. # 推送重试沿用首次生成的身份和 taskId,禁止身份变化后误归属。
  267. spec = self.catalog.business(name)
  268. if (payload["agentCode"], payload["agentName"]) != (spec.result_binding.agent_code, spec.result_binding.agent_name):
  269. raise ValueError("平台身份已改变,请人工确认待推送记录")
  270. if await asyncio.to_thread(store.push_target, job["id"]) != self.publisher.settings.url:
  271. raise ValueError("结果接收平台地址已改变,不能改投历史结果")
  272. receipt = await self.publisher.push(payload)
  273. await asyncio.to_thread(store.update, job["id"], push_status="pushed", receipt_json=json.dumps(receipt, ensure_ascii=False), next_push_at=None, error=None)
  274. except business.BusinessStopped:
  275. return
  276. except asyncio.CancelledError:
  277. raise
  278. except Exception as exc:
  279. from extensions.result_platform.client import ResultPlatformError
  280. attempts = job["push_attempts"] + 1
  281. retryable = isinstance(exc, ResultPlatformError) and exc.retryable
  282. delay = min(2 ** min(attempts, 10), 600)
  283. if isinstance(exc, ResultPlatformError) and exc.retry_after is not None:
  284. delay = max(delay, exc.retry_after)
  285. next_at = db_conn.fmt_dt(db_conn.now() + timedelta(seconds=delay))
  286. await asyncio.to_thread(store.update, job["id"], push_status="pending" if retryable and attempts < 10 else "failed",
  287. push_attempts=attempts, next_push_at=next_at, error=type(exc).__name__)
  288. logger.warning("结果推送失败 id=%s agent=%s type=%s", job["id"], name, type(exc).__name__)
  289. async def _publish_loop(self):
  290. while not self.closed:
  291. try:
  292. for job in await asyncio.to_thread(store.pending_pushes, db_conn.fmt_dt()):
  293. task = asyncio.create_task(self.publish_job(job), name=f"business-push-{job['id']}")
  294. try:
  295. await task
  296. except asyncio.CancelledError:
  297. if self.closed:
  298. raise
  299. except asyncio.CancelledError:
  300. raise
  301. except Exception as exc:
  302. logger.error("推送队列异常 type=%s", type(exc).__name__)
  303. await asyncio.sleep(1)