exploration_tools.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. """业务只保留网关取数;准备、沙箱执行和提交是宿主基础设施。"""
  2. import asyncio
  3. from functools import wraps
  4. import inspect
  5. import json
  6. import subprocess
  7. from typing import Annotated, Any
  8. from weakref import WeakValueDictionary
  9. from langchain.tools import ToolRuntime
  10. from langchain_core.messages import ToolMessage
  11. from langgraph.types import Command
  12. from pydantic import BeforeValidator, ValidationError
  13. from extensions import define_tool
  14. from extensions.main_fan import exploration
  15. from runtime import python_sandbox
  16. _locks = WeakValueDictionary()
  17. def _list_argument(value):
  18. # 部分兼容模型把数组参数再次编码成 JSON 字符串;保持工具 schema 为数组,容忍这层编码。
  19. if isinstance(value, str):
  20. value = json.loads(value)
  21. return value
  22. def _workspace_argument(value):
  23. if isinstance(value, str) and value and not value.startswith('/'):
  24. return '/workspace/' + value.removeprefix('./')
  25. return value
  26. WorkspacePath = Annotated[str, BeforeValidator(_workspace_argument)]
  27. WorkspacePaths = Annotated[list[WorkspacePath], BeforeValidator(_list_argument)]
  28. StringList = Annotated[list[str], BeforeValidator(_list_argument)]
  29. def _identity(runtime):
  30. identity = runtime.config.get('configurable', {})
  31. if not identity.get('workspace_user') or not identity.get('workspace_session'):
  32. raise ValueError('探索工具缺少服务端会话身份')
  33. return identity, identity['workspace_user'], identity['workspace_session']
  34. def _action(function):
  35. """同会话串行写入;可纠正的交付错误交还 Agent 继续探索。"""
  36. signature = inspect.signature(function)
  37. @wraps(function)
  38. async def wrapped(*args, **kwargs):
  39. try:
  40. runtime = signature.bind(*args, **kwargs).arguments['runtime']
  41. _, user, session = _identity(runtime)
  42. key = (user, session)
  43. lock = _locks.get(key)
  44. if lock is None:
  45. lock = _locks[key] = asyncio.Lock()
  46. async with lock:
  47. return await function(*args, **kwargs)
  48. except (ValueError, OSError, subprocess.SubprocessError) as exc:
  49. detail = (json.dumps(exc.errors(include_input=False, include_url=False), ensure_ascii=False, default=str)
  50. if isinstance(exc, ValidationError) else str(exc))
  51. return {'ok': False, 'error_type': type(exc).__name__, 'message': detail[:3000],
  52. 'next_action': '读取反馈、修正代码或补取原始数据后重试;不能把本次失败写成已完成。'}
  53. return wrapped
  54. @define_tool(group='自主探索', label='准备任务与可信输入')
  55. @_action
  56. async def prepare_main_fan_task(target: dict[str, Any] | str, runtime: ToolRuntime) -> dict:
  57. """绑定任务身份,导出本轮原始输入、原件路径、规则、身份和交付 schema;不解析文档或计算。
  58. target 传用户原目标或明确给出的输入。队列任务使用服务端已校验输入,不自行重造确认或时序。
  59. 后续文档解析、数值分析和报告生成均由你按 Skill 写 Python,在沙箱中执行。
  60. """
  61. identity, user, session = _identity(runtime)
  62. trusted = identity.get('business_task_input') if identity.get('business_agent') == 'main_fan' else None
  63. return await asyncio.to_thread(exploration.prepare, target, user, session, trusted,
  64. identity.get('business_task_id'))
  65. @define_tool(group='主通风机', label='取得本次网关资料')
  66. @_action
  67. async def fetch_main_fan_data(workflow_id: str, runtime: ToolRuntime) -> dict:
  68. """use_gateway=true 时取得已配置网关的设备、原件和时序,更新 input.json/data.json;不解析或分析。"""
  69. _, user, session = _identity(runtime)
  70. return await exploration.fetch(workflow_id, user, session)
  71. @define_tool(group='自主探索', label='在沙箱执行 Agent 编写的 Python')
  72. @_action
  73. async def run_python(script_path: WorkspacePath, runtime: ToolRuntime, arguments: StringList | None = None,
  74. output_paths: WorkspacePaths | None = None, timeout_seconds: int = 60,
  75. workflow_id: str | None = None) -> dict:
  76. """执行你用 write_file/edit_file 编写的 /workspace/*.py,返回真实 stdout/stderr、退出码和 run_id。
  77. 代码当前目录为本会话 workspace,使用相对路径。可用标准库、numpy、pypdfium2、Pillow、cv2;
  78. 无网络、不传服务端凭证、不支持临时安装依赖。自己编写 PDF 文本提取/渲染、计算及报告生成代码。
  79. 对扫描件可输出 PNG 并用 read_file 看图,之后自行编写提取与分析脚本,不调用旧业务算法。
  80. 必须传 workflow_id 关联本轮交付,声明 output_paths 保存输出哈希;原件与可信输入不要修改。
  81. 报告 Markdown、研判 JSON 和附件都须由成功脚本生成,并在提交时引用相应 run_id。
  82. """
  83. _, user, session = _identity(runtime)
  84. if workflow_id:
  85. workflow = await asyncio.to_thread(exploration._load, workflow_id, user, session)
  86. await asyncio.to_thread(exploration.validate_inputs, workflow, user, session)
  87. receipt = await python_sandbox.run(user, session, script_path, arguments or (), output_paths or (), timeout_seconds)
  88. if workflow_id:
  89. await asyncio.to_thread(exploration.record_python, workflow_id, user, session, receipt)
  90. receipt['python_paths'] = exploration.python_paths(workflow)
  91. if not receipt['ok']:
  92. receipt['next_action'] = '代码执行失败;先按 stderr 最后的异常类型和行号修正代码,再重新运行。'
  93. if 'FileNotFoundError' in receipt['stderr'] or 'PermissionError' in receipt['stderr']:
  94. receipt['next_action'] += ('代码内使用 python_paths 中的相对路径;不要用 /workspace/ 绝对路径,'
  95. '也不要把 exploration/任务ID/input.json 缩成 input.json。')
  96. elif 'AttributeError' in receipt['stderr']:
  97. receipt['next_action'] += '文档库 API 用法见 Skill 的 references/document-reading.md,先核对实际可用方法。'
  98. elif receipt['missing_outputs'] or receipt['unchanged_outputs']:
  99. receipt['next_action'] = ('脚本退出码为0,但部分声明文件未生成或本次未改写。核对 stdout 中实际保存位置,'
  100. '将正确的 /workspace/ 输出路径传入 output_paths 后重新运行生成代码,再提交。')
  101. return receipt
  102. @define_tool(group='自主探索', label='核验沙箱交付并归档')
  103. @_action
  104. async def submit_main_fan_report(workflow_id: str, report_path: WorkspacePath, calculations_path: WorkspacePath,
  105. runtime: ToolRuntime, python_run_ids: StringList,
  106. artifacts: WorkspacePaths | None = None) -> Any:
  107. """提交沙箱脚本实际生成的 Markdown 和 AgentAssessment JSON,不重跑文档解析、业务计算或报告模板。
  108. 格式见准备工具返回的 submission_schema_path。宿主只核验身份、原件指纹、来源引用、文件格式和
  109. 本轮成功执行输出。python_run_ids 必填,报告、JSON 和附件必须都在相应成功执行的 output_paths 中。
  110. 不生成 MainFanResult/analysis_id。失败可修正重提;成功提交作为最后操作,须单独调用。
  111. """
  112. _, user, session = _identity(runtime)
  113. calls = getattr(runtime.state.get('messages', [])[-1], 'tool_calls', []) if runtime.state.get('messages') else []
  114. if len(calls) > 1:
  115. raise ValueError('提交报告应单独调用,不能与其他工具并行执行')
  116. result = await asyncio.to_thread(exploration.submit, workflow_id, user, session, report_path,
  117. calculations_path, python_run_ids, artifacts or ())
  118. receipt = {'ok': True, 'status': result['status'], 'analysis_id': result['analysis_id'],
  119. 'workflow_id': workflow_id, 'sources': result['sources'], 'message': '报告已归档,本轮研判完成。'}
  120. return Command(update={'structured_response': result, 'messages': [ToolMessage(
  121. content=json.dumps(receipt, ensure_ascii=False), tool_call_id=runtime.tool_call_id,
  122. name='submit_main_fan_report')]})