Improve WebUI streaming and Python tooling
This commit is contained in:
+164
-6
@@ -76,6 +76,8 @@ class ToolExecutionContext:
|
||||
command_timeout_seconds: float
|
||||
max_output_chars: int
|
||||
permissions: AgentPermissions
|
||||
scratchpad_directory: Path | None = None
|
||||
python_env_dir: Path | None = None
|
||||
extra_env: dict[str, str] = field(default_factory=dict)
|
||||
tool_registry: dict[str, 'AgentTool'] | None = None
|
||||
search_runtime: 'SearchRuntime | None' = None
|
||||
@@ -152,6 +154,8 @@ class ToolStreamUpdate:
|
||||
def build_tool_context(
|
||||
config: AgentRuntimeConfig,
|
||||
*,
|
||||
scratchpad_directory: Path | None = None,
|
||||
python_env_dir: Path | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
tool_registry: dict[str, AgentTool] | None = None,
|
||||
search_runtime: 'SearchRuntime | None' = None,
|
||||
@@ -173,6 +177,14 @@ def build_tool_context(
|
||||
command_timeout_seconds=config.command_timeout_seconds,
|
||||
max_output_chars=config.max_output_chars,
|
||||
permissions=config.permissions,
|
||||
scratchpad_directory=scratchpad_directory.resolve() if scratchpad_directory else None,
|
||||
python_env_dir=(
|
||||
python_env_dir.resolve()
|
||||
if python_env_dir
|
||||
else config.python_env_dir.resolve()
|
||||
if config.python_env_dir
|
||||
else None
|
||||
),
|
||||
extra_env=dict(extra_env or {}),
|
||||
tool_registry=tool_registry,
|
||||
search_runtime=search_runtime,
|
||||
@@ -339,20 +351,22 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
name='python_exec',
|
||||
description=(
|
||||
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用项目 .venv/bin/python;'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;'
|
||||
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
|
||||
'缺包时应向用户确认后再处理依赖。'
|
||||
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;如需写临时文件,'
|
||||
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
|
||||
'不要在项目根目录创建临时 .py 脚本。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'code': {
|
||||
'type': 'string',
|
||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一。',
|
||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
|
||||
},
|
||||
'script_path': {
|
||||
'type': 'string',
|
||||
'description': '工作区内已有 Python 脚本路径。code 和 script_path 必须二选一。',
|
||||
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
|
||||
},
|
||||
'args': {
|
||||
'type': 'array',
|
||||
@@ -377,12 +391,49 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
},
|
||||
handler=_run_python_exec,
|
||||
),
|
||||
AgentTool(
|
||||
name='python_package',
|
||||
description=(
|
||||
'在当前用户独立 Python venv 中检查或安装 Python 包。'
|
||||
'用于 python_exec 缺少 pandas、pyarrow、openpyxl 等分析依赖时;'
|
||||
'不要通过 bash 执行 pip,也不要安装到项目 .venv。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'action': {
|
||||
'type': 'string',
|
||||
'enum': ['show', 'install'],
|
||||
'description': 'show 查看当前 Python 环境和 pip;install 安装 packages。',
|
||||
},
|
||||
'packages': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': '要安装的包名或 pip requirement spec,例如 pandas、pyarrow==15.0.0。',
|
||||
},
|
||||
'timeout_seconds': {
|
||||
'type': 'number',
|
||||
'minimum': 1,
|
||||
'maximum': 600,
|
||||
'description': '安装超时时间,默认 120 秒。',
|
||||
},
|
||||
'max_output_chars': {
|
||||
'type': 'integer',
|
||||
'minimum': 1000,
|
||||
'maximum': 50000,
|
||||
},
|
||||
},
|
||||
'required': ['action'],
|
||||
},
|
||||
handler=_run_python_package,
|
||||
),
|
||||
AgentTool(
|
||||
name='bash',
|
||||
description=(
|
||||
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
||||
'不要用 bash 执行 python、python3 或 .venv/bin/python;结构化文件分析、'
|
||||
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
||||
'Python 包安装必须优先使用 python_package。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -2604,6 +2655,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
payload = [
|
||||
'timed_out=true',
|
||||
f'timeout_seconds={timeout_seconds:g}',
|
||||
f'scratchpad={context.scratchpad_directory or ""}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
@@ -2615,6 +2668,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
'action': 'python_exec',
|
||||
'mode': mode,
|
||||
'interpreter': interpreter,
|
||||
'scratchpad_directory': str(context.scratchpad_directory) if context.scratchpad_directory else '',
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'timed_out': True,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
@@ -2628,6 +2683,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
payload = [
|
||||
f'exit_code={completed.returncode}',
|
||||
f'interpreter={interpreter}',
|
||||
f'scratchpad={context.scratchpad_directory or ""}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
@@ -2639,6 +2696,94 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
'action': 'python_exec',
|
||||
'mode': mode,
|
||||
'interpreter': interpreter,
|
||||
'scratchpad_directory': str(context.scratchpad_directory) if context.scratchpad_directory else '',
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'exit_code': completed.returncode,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_process_execution_allowed(context, 'Python package management')
|
||||
action = _require_string(arguments, 'action')
|
||||
interpreter = _resolve_python_interpreter(context)
|
||||
timeout_seconds = _coerce_float(
|
||||
arguments,
|
||||
'timeout_seconds',
|
||||
min(context.command_timeout_seconds * 4, 120.0),
|
||||
)
|
||||
max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars)
|
||||
if action == 'show':
|
||||
command = [interpreter, '-m', 'pip', '--version']
|
||||
elif action == 'install':
|
||||
packages = arguments.get('packages')
|
||||
if not isinstance(packages, list) or not packages:
|
||||
raise ToolExecutionError('packages must be a non-empty array for action=install')
|
||||
package_args = [str(package).strip() for package in packages if str(package).strip()]
|
||||
if not package_args:
|
||||
raise ToolExecutionError('packages must contain at least one non-empty package name')
|
||||
command = [interpreter, '-m', 'pip', 'install', *package_args]
|
||||
else:
|
||||
raise ToolExecutionError('action must be "show" or "install"')
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=context.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
env=_build_subprocess_env(context),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout if isinstance(exc.stdout, str) else ''
|
||||
stderr = exc.stderr if isinstance(exc.stderr, str) else ''
|
||||
payload = [
|
||||
'timed_out=true',
|
||||
f'timeout_seconds={timeout_seconds:g}',
|
||||
f'interpreter={interpreter}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
stderr.rstrip(),
|
||||
]
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
'action': 'python_package',
|
||||
'package_action': action,
|
||||
'interpreter': interpreter,
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'timed_out': True,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
)
|
||||
|
||||
stdout = completed.stdout or ''
|
||||
stderr = completed.stderr or ''
|
||||
payload = [
|
||||
f'exit_code={completed.returncode}',
|
||||
f'interpreter={interpreter}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
stderr.rstrip(),
|
||||
]
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
'action': 'python_package',
|
||||
'package_action': action,
|
||||
'interpreter': interpreter,
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'exit_code': completed.returncode,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
@@ -2648,7 +2793,11 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
|
||||
|
||||
def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
|
||||
# 优先使用项目虚拟环境,保证数据处理依赖和 agent 运行环境一致。
|
||||
# 优先使用当前用户独立 Python 环境,避免包安装污染项目 .venv 或系统环境。
|
||||
if context.python_env_dir is not None:
|
||||
user_python = context.python_env_dir / 'bin' / 'python'
|
||||
if user_python.exists() and os.access(user_python, os.X_OK):
|
||||
return str(user_python)
|
||||
venv_python = context.root / '.venv' / 'bin' / 'python'
|
||||
if venv_python.exists() and os.access(venv_python, os.X_OK):
|
||||
return str(venv_python)
|
||||
@@ -4395,6 +4544,15 @@ def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]:
|
||||
for key, value in get_session_env_vars().items():
|
||||
env[key] = value
|
||||
env.update(context.extra_env)
|
||||
if context.scratchpad_directory is not None:
|
||||
env['PYTHON_EXEC_SCRATCHPAD'] = str(context.scratchpad_directory)
|
||||
if context.python_env_dir is not None:
|
||||
env['VIRTUAL_ENV'] = str(context.python_env_dir)
|
||||
env['PYTHON_EXEC_VENV'] = str(context.python_env_dir)
|
||||
env['PYTHONNOUSERSITE'] = '1'
|
||||
bin_dir = str(context.python_env_dir / 'bin')
|
||||
existing_path = env.get('PATH', '')
|
||||
env['PATH'] = f'{bin_dir}{os.pathsep}{existing_path}' if existing_path else bin_dir
|
||||
return env
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user