Improve WebUI streaming and Python tooling

This commit is contained in:
武阳
2026-05-06 20:37:17 +08:00
parent a0da3c2423
commit 9ca675be0d
13 changed files with 930 additions and 147 deletions
+20 -3
View File
@@ -22,6 +22,7 @@ class PromptContext:
is_git_repo: bool
is_git_worktree: bool
scratchpad_directory: str | None = None
python_env_directory: str | None = None
additional_working_directories: tuple[str, ...] = ()
user_context: dict[str, str] = field(default_factory=dict)
system_context: dict[str, str] = field(default_factory=dict)
@@ -56,6 +57,11 @@ def build_prompt_context(
is_git_repo=snapshot.is_git_repo,
is_git_worktree=snapshot.is_git_worktree,
scratchpad_directory=snapshot.scratchpad_directory,
python_env_directory=(
str(runtime_config.python_env_dir.resolve())
if runtime_config.python_env_dir is not None
else None
),
additional_working_directories=snapshot.additional_working_directories,
user_context=snapshot.user_context,
system_context=snapshot.system_context,
@@ -154,7 +160,9 @@ def get_doing_tasks_section() -> str:
'除非确实需要新文件,否则优先编辑现有文件。',
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python。',
'一次性脚本放到临时目录或任务专属目录;只有用户需要长期复用时,才加入项目代码',
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件',
'如果确实需要临时脚本、缓存或中间产物,必须写入当前会话 scratchpad 目录,或写入明确的任务产物目录;禁止在项目根目录创建 analyze_*.py、tmp_*.py、scratch_*.py 等临时脚本。',
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
'注意不要引入命令注入、SQL 注入、XSS 或不安全 shell 行为等安全问题。',
'如实汇报结果。如果没有运行某个验证步骤,需要明确说明。',
@@ -201,10 +209,17 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
'需要结构化文件分析、批量数据处理、JSON/JSONL 转换、抽样、校验或快速计算时,必须优先使用 python_exec,而不是通过 bash 手写 python 命令。'
)
items.append(
'python_exec 默认使用项目 .venv/bin/python;不要用它安装依赖。缺少依赖时先说明缺失包并向用户确认包管理方式'
'python_exec 默认使用当前用户独立 Python venv,不使用项目 .venv;不要用 bash 执行 python 或 pip'
)
items.append(
'如果已经用 write_file 生成了临时 Python 脚本,下一步也应使用 python_exec 的 script_path 执行该脚本,不要改用 bash'
'python_exec 会注入 PYTHON_EXEC_SCRATCHPAD 环境变量,指向当前用户当前会话隔离的 scratchpad。一次性脚本、缓存和中间输出都应写入这里'
)
if 'python_package' in enabled_tool_names:
items.append(
'当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。'
)
items.append(
'不要用 write_file 在项目根目录生成临时 Python 脚本;如果用户明确要求保留脚本,才写入合适的项目路径,并用 python_exec 的 script_path 执行。'
)
if 'bash' in enabled_tool_names:
items.append(
@@ -444,6 +459,8 @@ def compute_simple_env_info(prompt_context: PromptContext) -> str:
items.append(list(prompt_context.additional_working_directories))
if prompt_context.scratchpad_directory:
items.append(f'会话 scratchpad 目录: {prompt_context.scratchpad_directory}')
if prompt_context.python_env_directory:
items.append(f'当前用户 Python venv: {prompt_context.python_env_directory}')
items.extend(
[
f'平台: {prompt_context.platform_name}',
+62 -8
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
from typing import Any, Callable
from uuid import uuid4
from .account_runtime import AccountRuntime
@@ -68,6 +68,43 @@ from .tokenizer_runtime import describe_token_counter
from .workflow_runtime import WorkflowRuntime
from .worktree_runtime import WorktreeRuntime
from .session_env_vars import clear_session_env_vars
RuntimeEventSink = Callable[[dict[str, object]], None]
class _RuntimeEventBuffer(list[dict[str, object]]):
def __init__(self, event_sink: RuntimeEventSink | None = None) -> None:
super().__init__()
self._event_sink = event_sink
def append(self, event: dict[str, object]) -> None: # type: ignore[override]
super().append(event)
if self._event_sink is not None:
self._event_sink(dict(event))
def extend(self, events: Any) -> None: # type: ignore[override]
for event in events:
self.append(event)
def _append_final_text_stream_events(
stream_events: list[dict[str, object]],
text: str,
) -> None:
if not text:
return
stream_events.append({'type': 'final_text_start'})
chunk_size = 96
for start in range(0, len(text), chunk_size):
stream_events.append(
{
'type': 'final_text_delta',
'delta': text[start : start + chunk_size],
}
)
stream_events.append({'type': 'final_text_end'})
from .session_store import (
StoredAgentSession,
load_agent_session,
@@ -375,6 +412,7 @@ class LocalCodingAgent:
session_id: str | None = None,
*,
runtime_context: str | None = None,
event_sink: RuntimeEventSink | None = None,
) -> AgentRunResult:
self.managed_agent_id = None
self.resume_source_session_id = None
@@ -389,6 +427,7 @@ class LocalCodingAgent:
scratchpad_directory=scratchpad_directory,
existing_file_history=(),
runtime_context=runtime_context,
event_sink=event_sink,
)
self._accumulate_usage(result)
self._finalize_managed_agent(result)
@@ -400,6 +439,7 @@ class LocalCodingAgent:
stored_session: StoredAgentSession,
*,
runtime_context: str | None = None,
event_sink: RuntimeEventSink | None = None,
) -> AgentRunResult:
self.managed_agent_id = None
self.resume_source_session_id = stored_session.session_id
@@ -435,6 +475,7 @@ class LocalCodingAgent:
scratchpad_directory=scratchpad_directory,
existing_file_history=stored_session.file_history,
runtime_context=runtime_context,
event_sink=event_sink,
)
self._accumulate_usage(result)
self._finalize_managed_agent(result)
@@ -449,6 +490,7 @@ class LocalCodingAgent:
scratchpad_directory: Path | None,
existing_file_history: tuple[dict[str, object], ...],
runtime_context: str | None = None,
event_sink: RuntimeEventSink | None = None,
) -> AgentRunResult:
slash_result = preprocess_slash_command(self, prompt)
if slash_result.handled and not slash_result.should_query:
@@ -495,6 +537,10 @@ class LocalCodingAgent:
session.append_user(effective_prompt, model_content=model_prompt)
self.last_session = session
self.active_session_id = session_id
self.tool_context = replace(
self.tool_context,
scratchpad_directory=scratchpad_directory,
)
tool_specs = [tool.to_openai_tool() for tool in self.tool_registry.values()]
starting_usage = UsageStats()
starting_cost_usd = 0.0
@@ -525,7 +571,7 @@ class LocalCodingAgent:
total_usage = starting_usage
total_cost_usd = starting_cost_usd
file_history = list(existing_file_history)
stream_events: list[dict[str, object]] = []
stream_events: list[dict[str, object]] = _RuntimeEventBuffer(event_sink)
assistant_response_segments: list[str] = []
delegated_tasks = sum(
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent')
@@ -715,8 +761,10 @@ class LocalCodingAgent:
)
last_content = ''.join(assistant_response_segments)
continue
final_output = ''.join(assistant_response_segments)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=''.join(assistant_response_segments),
final_output=final_output,
turns=turn_index,
tool_calls=tool_calls,
transcript=session.transcript(),
@@ -816,8 +864,10 @@ class LocalCodingAgent:
)
last_content = ''.join(assistant_response_segments)
continue
final_output = ''.join(assistant_response_segments)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=''.join(assistant_response_segments),
final_output=final_output,
turns=turn_index,
tool_calls=tool_calls,
transcript=session.transcript(),
@@ -896,6 +946,8 @@ class LocalCodingAgent:
'type': 'tool_start',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'arguments': dict(tool_call.arguments),
'assistant_content': turn.content,
'message_id': session.messages[tool_message_index].message_id,
}
)
@@ -1194,11 +1246,13 @@ class LocalCodingAgent:
self.last_run_result = result
return result
final_output = (
last_content
or 'Stopped: max turns reached before the model produced a final answer.'
)
_append_final_text_stream_events(stream_events, final_output)
result = AgentRunResult(
final_output=(
last_content
or 'Stopped: max turns reached before the model produced a final answer.'
),
final_output=final_output,
turns=self.runtime_config.max_turns,
tool_calls=tool_calls,
transcript=session.transcript(),
+164 -6
View File
@@ -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 环境和 pipinstall 安装 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
+1
View File
@@ -167,6 +167,7 @@ class AgentRuntimeConfig:
output_schema: OutputSchemaConfig | None = None
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
python_env_dir: Path | None = None
@dataclass(frozen=True)
+10
View File
@@ -186,6 +186,11 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict:
),
'session_directory': str(runtime_config.session_directory),
'scratchpad_root': str(runtime_config.scratchpad_root),
'python_env_dir': (
str(runtime_config.python_env_dir)
if runtime_config.python_env_dir is not None
else None
),
}
@@ -230,6 +235,11 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
output_schema=_deserialize_output_schema(output_schema_payload),
session_directory=Path(str(payload.get('session_directory', DEFAULT_AGENT_SESSION_DIR))).resolve(),
scratchpad_root=Path(str(payload.get('scratchpad_root', DEFAULT_SESSION_DIR / 'scratchpad'))).resolve(),
python_env_dir=(
Path(str(payload['python_env_dir'])).resolve()
if payload.get('python_env_dir') is not None
else None
),
)