business_tasks.py 19 KB

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