Improve WebUI streaming and Python tooling
This commit is contained in:
+258
-104
@@ -8,16 +8,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import venv
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock, RLock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -150,8 +154,18 @@ WEBUI_SLASH_COMMAND_DESCRIPTIONS_ZH = {
|
|||||||
# Agent state holder
|
# Agent state holder
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentInstanceConfig:
|
||||||
|
cwd: Path
|
||||||
|
model: str
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
allow_shell: bool
|
||||||
|
allow_write: bool
|
||||||
|
|
||||||
|
|
||||||
class AgentState:
|
class AgentState:
|
||||||
"""Holds the live agent instance plus a lock for serialized access."""
|
"""Holds account-scoped agent instances, config, and execution locks."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -164,15 +178,66 @@ class AgentState:
|
|||||||
allow_write: bool,
|
allow_write: bool,
|
||||||
session_directory: Path,
|
session_directory: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.cwd = cwd.resolve()
|
|
||||||
self.session_directory = session_directory
|
self.session_directory = session_directory
|
||||||
self._lock = Lock()
|
self._lock = RLock()
|
||||||
self._agents: dict[str, LocalCodingAgent] = {}
|
self._agents: dict[str, LocalCodingAgent] = {}
|
||||||
self.model = model
|
self._run_locks: dict[str, Lock] = {}
|
||||||
self.base_url = base_url
|
self._account_configs: dict[str, AgentInstanceConfig] = {}
|
||||||
self.api_key = api_key
|
self._default_config = AgentInstanceConfig(
|
||||||
self.allow_shell = allow_shell
|
cwd=cwd.resolve(),
|
||||||
self.allow_write = allow_write
|
model=model,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
allow_shell=allow_shell,
|
||||||
|
allow_write=allow_write,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cwd(self) -> Path:
|
||||||
|
return self._default_config.cwd
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
return self._default_config.model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return self._default_config.base_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_key(self) -> str:
|
||||||
|
return self._default_config.api_key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allow_shell(self) -> bool:
|
||||||
|
return self._default_config.allow_shell
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allow_write(self) -> bool:
|
||||||
|
return self._default_config.allow_write
|
||||||
|
|
||||||
|
def _account_key(self, account_id: str | None) -> str:
|
||||||
|
return _safe_account_id(account_id) if account_id else '__default__'
|
||||||
|
|
||||||
|
def _config_for(self, account_id: str | None) -> AgentInstanceConfig:
|
||||||
|
if not account_id:
|
||||||
|
return self._default_config
|
||||||
|
key = self._account_key(account_id)
|
||||||
|
config = self._account_configs.get(key)
|
||||||
|
if config is None:
|
||||||
|
config = replace(self._default_config)
|
||||||
|
self._account_configs[key] = config
|
||||||
|
return config
|
||||||
|
|
||||||
|
def config_for(self, account_id: str | None) -> AgentInstanceConfig:
|
||||||
|
with self._lock:
|
||||||
|
return self._config_for(account_id)
|
||||||
|
|
||||||
|
def _set_config_for(self, account_id: str | None, config: AgentInstanceConfig) -> None:
|
||||||
|
if not account_id:
|
||||||
|
self._default_config = config
|
||||||
|
return
|
||||||
|
self._account_configs[self._account_key(account_id)] = config
|
||||||
|
|
||||||
def _account_base(self, account_id: str | None) -> Path:
|
def _account_base(self, account_id: str | None) -> Path:
|
||||||
if not account_id:
|
if not account_id:
|
||||||
@@ -189,6 +254,7 @@ class AgentState:
|
|||||||
'scratchpad': self.session_directory,
|
'scratchpad': self.session_directory,
|
||||||
'uploads': self.session_directory,
|
'uploads': self.session_directory,
|
||||||
'outputs': self.session_directory,
|
'outputs': self.session_directory,
|
||||||
|
'python_env': base / 'python' / '.venv',
|
||||||
}
|
}
|
||||||
base = self._account_base(account_id)
|
base = self._account_base(account_id)
|
||||||
return {
|
return {
|
||||||
@@ -197,26 +263,32 @@ class AgentState:
|
|||||||
'scratchpad': base / 'sessions',
|
'scratchpad': base / 'sessions',
|
||||||
'uploads': base / 'sessions',
|
'uploads': base / 'sessions',
|
||||||
'outputs': base / 'sessions',
|
'outputs': base / 'sessions',
|
||||||
|
'python_env': base / 'python' / '.venv',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent:
|
def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent:
|
||||||
|
config = self._config_for(account_id)
|
||||||
paths = self.account_paths(account_id)
|
paths = self.account_paths(account_id)
|
||||||
for directory in paths.values():
|
for directory in paths.values():
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
if directory.name != '.venv':
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._ensure_python_env(paths['python_env'])
|
||||||
permissions = AgentPermissions(
|
permissions = AgentPermissions(
|
||||||
allow_file_write=self.allow_write,
|
allow_file_write=config.allow_write,
|
||||||
allow_shell_commands=self.allow_shell,
|
allow_shell_commands=config.allow_shell,
|
||||||
)
|
)
|
||||||
runtime_config = AgentRuntimeConfig(
|
runtime_config = AgentRuntimeConfig(
|
||||||
cwd=self.cwd,
|
cwd=config.cwd,
|
||||||
permissions=permissions,
|
permissions=permissions,
|
||||||
|
stream_model_responses=True,
|
||||||
session_directory=paths['sessions'],
|
session_directory=paths['sessions'],
|
||||||
scratchpad_root=paths['scratchpad'],
|
scratchpad_root=paths['scratchpad'],
|
||||||
|
python_env_dir=paths['python_env'],
|
||||||
)
|
)
|
||||||
model_config = ModelConfig(
|
model_config = ModelConfig(
|
||||||
model=self.model,
|
model=config.model,
|
||||||
base_url=self.base_url,
|
base_url=config.base_url,
|
||||||
api_key=self.api_key,
|
api_key=config.api_key,
|
||||||
)
|
)
|
||||||
return LocalCodingAgent(
|
return LocalCodingAgent(
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
@@ -224,12 +296,22 @@ class AgentState:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def agent_for(self, account_id: str | None = None) -> LocalCodingAgent:
|
def agent_for(self, account_id: str | None = None) -> LocalCodingAgent:
|
||||||
key = _safe_account_id(account_id) if account_id else '__default__'
|
with self._lock:
|
||||||
agent = self._agents.get(key)
|
key = self._account_key(account_id)
|
||||||
if agent is None:
|
agent = self._agents.get(key)
|
||||||
agent = self._build_agent(account_id)
|
if agent is None:
|
||||||
self._agents[key] = agent
|
agent = self._build_agent(account_id)
|
||||||
return agent
|
self._agents[key] = agent
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def run_lock_for(self, account_id: str | None = None) -> Lock:
|
||||||
|
key = self._account_key(account_id)
|
||||||
|
with self._lock:
|
||||||
|
lock = self._run_locks.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = Lock()
|
||||||
|
self._run_locks[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
@@ -243,45 +325,56 @@ class AgentState:
|
|||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# account_id 只用于让调用方拿到对应账号的 snapshot,不参与全局配置更新。
|
config = self._config_for(account_id)
|
||||||
del account_id
|
|
||||||
if model is not None:
|
if model is not None:
|
||||||
self.model = _normalize_chat_model_name(model)
|
config = replace(config, model=_normalize_chat_model_name(model))
|
||||||
if base_url is not None:
|
if base_url is not None:
|
||||||
self.base_url = base_url
|
config = replace(config, base_url=base_url)
|
||||||
if api_key is not None:
|
if api_key is not None:
|
||||||
self.api_key = api_key
|
config = replace(config, api_key=api_key)
|
||||||
if cwd is not None:
|
if cwd is not None:
|
||||||
resolved = Path(cwd).expanduser().resolve()
|
resolved = Path(cwd).expanduser().resolve()
|
||||||
if not resolved.is_dir():
|
if not resolved.is_dir():
|
||||||
raise ValueError(f'cwd does not exist: {resolved}')
|
raise ValueError(f'cwd does not exist: {resolved}')
|
||||||
self.cwd = resolved
|
config = replace(config, cwd=resolved)
|
||||||
if allow_shell is not None:
|
if allow_shell is not None:
|
||||||
self.allow_shell = allow_shell
|
config = replace(config, allow_shell=allow_shell)
|
||||||
if allow_write is not None:
|
if allow_write is not None:
|
||||||
self.allow_write = allow_write
|
config = replace(config, allow_write=allow_write)
|
||||||
self._agents.clear()
|
self._set_config_for(account_id, config)
|
||||||
|
self._agents.pop(self._account_key(account_id), None)
|
||||||
|
|
||||||
def snapshot(self, account_id: str | None = None) -> dict[str, Any]:
|
def snapshot(self, account_id: str | None = None) -> dict[str, Any]:
|
||||||
agent = self.agent_for(account_id)
|
with self._lock:
|
||||||
paths = self.account_paths(account_id)
|
config = self._config_for(account_id)
|
||||||
return {
|
agent = self.agent_for(account_id)
|
||||||
'model': self.model,
|
paths = self.account_paths(account_id)
|
||||||
'base_url': self.base_url,
|
return {
|
||||||
'cwd': str(self.cwd),
|
'model': config.model,
|
||||||
'account_id': _safe_account_id(account_id) if account_id else None,
|
'base_url': config.base_url,
|
||||||
'account_directory': str(paths['base']),
|
'cwd': str(config.cwd),
|
||||||
'session_directory': str(paths['sessions']),
|
'account_id': _safe_account_id(account_id) if account_id else None,
|
||||||
'upload_directory': str(paths['uploads']),
|
'account_directory': str(paths['base']),
|
||||||
|
'session_directory': str(paths['sessions']),
|
||||||
|
'upload_directory': str(paths['uploads']),
|
||||||
'output_directory': str(paths['outputs']),
|
'output_directory': str(paths['outputs']),
|
||||||
'allow_shell': self.allow_shell,
|
'python_env_directory': str(paths['python_env']),
|
||||||
'allow_write': self.allow_write,
|
'allow_shell': config.allow_shell,
|
||||||
'active_session_id': agent.active_session_id,
|
'allow_write': config.allow_write,
|
||||||
}
|
'active_session_id': agent.active_session_id,
|
||||||
|
}
|
||||||
|
|
||||||
def lock(self) -> Lock:
|
def lock(self) -> Lock:
|
||||||
return self._lock
|
return self._lock
|
||||||
|
|
||||||
|
def _ensure_python_env(self, env_dir: Path) -> None:
|
||||||
|
python_bin = env_dir / 'bin' / 'python'
|
||||||
|
pip_bin = env_dir / 'bin' / 'pip'
|
||||||
|
if python_bin.exists() and pip_bin.exists():
|
||||||
|
return
|
||||||
|
env_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
venv.EnvBuilder(with_pip=True, symlinks=False).create(env_dir)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request models
|
# Request models
|
||||||
@@ -308,6 +401,7 @@ class StateUpdate(BaseModel):
|
|||||||
class ModelListRequest(BaseModel):
|
class ModelListRequest(BaseModel):
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
|
account_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SessionTitleUpdate(BaseModel):
|
class SessionTitleUpdate(BaseModel):
|
||||||
@@ -366,7 +460,8 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
return commands
|
return commands
|
||||||
|
|
||||||
@app.get('/api/skills')
|
@app.get('/api/skills')
|
||||||
async def list_skills() -> list[dict[str, Any]]:
|
async def list_skills(account_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
config = state.config_for(account_id)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'name': skill.name,
|
'name': skill.name,
|
||||||
@@ -375,19 +470,21 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
'aliases': list(skill.aliases),
|
'aliases': list(skill.aliases),
|
||||||
'allowed_tools': list(skill.allowed_tools),
|
'allowed_tools': list(skill.allowed_tools),
|
||||||
}
|
}
|
||||||
for skill in get_bundled_skills(state.cwd)
|
for skill in get_bundled_skills(config.cwd)
|
||||||
if skill.user_invocable
|
if skill.user_invocable
|
||||||
]
|
]
|
||||||
|
|
||||||
@app.get('/api/models')
|
@app.get('/api/models')
|
||||||
async def list_models_get() -> dict[str, Any]:
|
async def list_models_get(account_id: str | None = None) -> dict[str, Any]:
|
||||||
return _list_backend_models(state.base_url, state.api_key)
|
config = state.config_for(account_id)
|
||||||
|
return _list_backend_models(config.base_url, config.api_key)
|
||||||
|
|
||||||
@app.post('/api/models')
|
@app.post('/api/models')
|
||||||
async def list_models_post(payload: ModelListRequest) -> dict[str, Any]:
|
async def list_models_post(payload: ModelListRequest) -> dict[str, Any]:
|
||||||
|
config = state.config_for(payload.account_id)
|
||||||
return _list_backend_models(
|
return _list_backend_models(
|
||||||
payload.base_url or state.base_url,
|
payload.base_url or config.base_url,
|
||||||
payload.api_key or state.api_key,
|
payload.api_key or config.api_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------- sessions --------------------------------------------------
|
# ------------- sessions --------------------------------------------------
|
||||||
@@ -492,6 +589,67 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
)
|
)
|
||||||
return _serialize_token_budget(snapshot)
|
return _serialize_token_budget(snapshot)
|
||||||
|
|
||||||
|
def _run_chat_payload(
|
||||||
|
request: ChatRequest,
|
||||||
|
event_sink: Any | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
run_lock = state.run_lock_for(request.account_id)
|
||||||
|
with run_lock:
|
||||||
|
agent = state.agent_for(request.account_id)
|
||||||
|
config = state.config_for(request.account_id)
|
||||||
|
session_directory = state.account_paths(request.account_id)['sessions']
|
||||||
|
requested_session_id = _safe_session_id(
|
||||||
|
request.resume_session_id or request.session_id
|
||||||
|
)
|
||||||
|
previous_title = _read_session_title(
|
||||||
|
session_directory,
|
||||||
|
requested_session_id,
|
||||||
|
)
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
if request.resume_session_id is not None:
|
||||||
|
try:
|
||||||
|
stored = load_agent_session(
|
||||||
|
request.resume_session_id,
|
||||||
|
directory=session_directory,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail='Session to resume not found',
|
||||||
|
)
|
||||||
|
result = agent.resume(
|
||||||
|
request.prompt.strip(),
|
||||||
|
stored,
|
||||||
|
runtime_context=request.runtime_context,
|
||||||
|
event_sink=event_sink,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = agent.run(
|
||||||
|
request.prompt.strip(),
|
||||||
|
session_id=_safe_session_id(request.session_id),
|
||||||
|
runtime_context=request.runtime_context,
|
||||||
|
event_sink=event_sink,
|
||||||
|
)
|
||||||
|
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
||||||
|
payload = _serialize_run_result(result)
|
||||||
|
payload['elapsed_ms'] = elapsed_ms
|
||||||
|
if result.session_id:
|
||||||
|
_annotate_last_assistant_elapsed(
|
||||||
|
session_directory,
|
||||||
|
result.session_id,
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
_ensure_session_title(
|
||||||
|
session_directory,
|
||||||
|
result.session_id,
|
||||||
|
model=config.model,
|
||||||
|
base_url=config.base_url,
|
||||||
|
api_key=config.api_key,
|
||||||
|
previous_title=previous_title,
|
||||||
|
)
|
||||||
|
_annotate_transcript_elapsed(payload, elapsed_ms)
|
||||||
|
return payload
|
||||||
|
|
||||||
# ------------- chat ------------------------------------------------------
|
# ------------- chat ------------------------------------------------------
|
||||||
@app.post('/api/chat')
|
@app.post('/api/chat')
|
||||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||||
@@ -500,58 +658,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||||||
|
|
||||||
def _run() -> dict[str, Any]:
|
def _run() -> dict[str, Any]:
|
||||||
with state.lock():
|
return _run_chat_payload(request)
|
||||||
agent = state.agent_for(request.account_id)
|
|
||||||
session_directory = state.account_paths(request.account_id)['sessions']
|
|
||||||
requested_session_id = _safe_session_id(
|
|
||||||
request.resume_session_id or request.session_id
|
|
||||||
)
|
|
||||||
previous_title = _read_session_title(
|
|
||||||
session_directory,
|
|
||||||
requested_session_id,
|
|
||||||
)
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
if request.resume_session_id is not None:
|
|
||||||
try:
|
|
||||||
stored = load_agent_session(
|
|
||||||
request.resume_session_id,
|
|
||||||
directory=session_directory,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail='Session to resume not found',
|
|
||||||
)
|
|
||||||
result = agent.resume(
|
|
||||||
prompt,
|
|
||||||
stored,
|
|
||||||
runtime_context=request.runtime_context,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = agent.run(
|
|
||||||
prompt,
|
|
||||||
session_id=_safe_session_id(request.session_id),
|
|
||||||
runtime_context=request.runtime_context,
|
|
||||||
)
|
|
||||||
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
|
||||||
payload = _serialize_run_result(result)
|
|
||||||
payload['elapsed_ms'] = elapsed_ms
|
|
||||||
if result.session_id:
|
|
||||||
_annotate_last_assistant_elapsed(
|
|
||||||
session_directory,
|
|
||||||
result.session_id,
|
|
||||||
elapsed_ms,
|
|
||||||
)
|
|
||||||
_ensure_session_title(
|
|
||||||
session_directory,
|
|
||||||
result.session_id,
|
|
||||||
model=state.model,
|
|
||||||
base_url=state.base_url,
|
|
||||||
api_key=state.api_key,
|
|
||||||
previous_title=previous_title,
|
|
||||||
)
|
|
||||||
_annotate_transcript_elapsed(payload, elapsed_ms)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(_run)
|
payload = await asyncio.to_thread(_run)
|
||||||
@@ -567,6 +674,53 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
@app.post('/api/chat/stream')
|
||||||
|
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||||
|
prompt = request.prompt.strip()
|
||||||
|
if not prompt:
|
||||||
|
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||||||
|
|
||||||
|
event_queue: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
||||||
|
|
||||||
|
def emit_event(event: dict[str, object]) -> None:
|
||||||
|
event_queue.put({'kind': 'event', 'event': event})
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
payload = _run_chat_payload(request, event_sink=emit_event)
|
||||||
|
except HTTPException as exc:
|
||||||
|
event_queue.put(
|
||||||
|
{
|
||||||
|
'kind': 'error',
|
||||||
|
'status_code': exc.status_code,
|
||||||
|
'detail': exc.detail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
event_queue.put(
|
||||||
|
{
|
||||||
|
'kind': 'error',
|
||||||
|
'status_code': 500,
|
||||||
|
'error': str(exc),
|
||||||
|
'error_type': type(exc).__name__,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
event_queue.put({'kind': 'result', 'payload': payload})
|
||||||
|
finally:
|
||||||
|
event_queue.put(None)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def generate() -> Any:
|
||||||
|
while True:
|
||||||
|
item = event_queue.get()
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
yield json.dumps(item, ensure_ascii=False) + '\n'
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type='application/x-ndjson')
|
||||||
|
|
||||||
@app.post('/api/clear')
|
@app.post('/api/clear')
|
||||||
async def clear_state(account_id: str | None = None) -> dict[str, Any]:
|
async def clear_state(account_id: str | None = None) -> dict[str, Any]:
|
||||||
with state.lock():
|
with state.lock():
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ type ClawChatResponse = {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClawRuntimeEvent = {
|
||||||
|
type?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
assistant_content?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
ok?: boolean;
|
||||||
|
delta?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClawStreamItem =
|
||||||
|
| { kind: "event"; event?: ClawRuntimeEvent }
|
||||||
|
| { kind: "result"; payload?: ClawChatResponse }
|
||||||
|
| { kind: "error"; error?: string; detail?: string; status_code?: number };
|
||||||
|
|
||||||
type ChatRequestBody = {
|
type ChatRequestBody = {
|
||||||
id?: string;
|
id?: string;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
@@ -75,6 +91,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const stream = createUIMessageStream({
|
const stream = createUIMessageStream({
|
||||||
execute: async ({ writer }) => {
|
execute: async ({ writer }) => {
|
||||||
|
const streamState = { textStarted: false, textStreamed: false };
|
||||||
writer.write({ type: "start" });
|
writer.write({ type: "start" });
|
||||||
writer.write({ type: "start-step" });
|
writer.write({ type: "start-step" });
|
||||||
|
|
||||||
@@ -85,12 +102,14 @@ export async function POST(req: Request) {
|
|||||||
id: "reasoning-1",
|
id: "reasoning-1",
|
||||||
delta: "思考中,正在判断是否需要调用工具。",
|
delta: "思考中,正在判断是否需要调用工具。",
|
||||||
});
|
});
|
||||||
const payload = await callClawBackend(
|
const payload = await callClawBackendStream(
|
||||||
userPrompt,
|
userPrompt,
|
||||||
runtimeContext,
|
runtimeContext,
|
||||||
account.id,
|
account.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
resumeId,
|
resumeId,
|
||||||
|
writer,
|
||||||
|
streamState,
|
||||||
);
|
);
|
||||||
const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt;
|
const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt;
|
||||||
const text = formatClawResponse(payload);
|
const text = formatClawResponse(payload);
|
||||||
@@ -102,9 +121,14 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
writer.write({ type: "reasoning-end", id: "reasoning-1" });
|
writer.write({ type: "reasoning-end", id: "reasoning-1" });
|
||||||
writeToolTrace(writer, payload.transcript);
|
writeToolTrace(writer, payload.transcript);
|
||||||
writer.write({ type: "text-start", id: "text-1" });
|
if (!streamState.textStreamed) {
|
||||||
writer.write({ type: "text-delta", id: "text-1", delta: text });
|
writer.write({ type: "text-start", id: "text-1" });
|
||||||
writer.write({ type: "text-end", id: "text-1" });
|
writer.write({ type: "text-delta", id: "text-1", delta: text });
|
||||||
|
writer.write({ type: "text-end", id: "text-1" });
|
||||||
|
} else if (streamState.textStarted) {
|
||||||
|
writer.write({ type: "text-end", id: "text-1" });
|
||||||
|
streamState.textStarted = false;
|
||||||
|
}
|
||||||
writer.write({ type: "finish-step" });
|
writer.write({ type: "finish-step" });
|
||||||
writer.write({
|
writer.write({
|
||||||
type: "finish",
|
type: "finish",
|
||||||
@@ -124,8 +148,13 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number) {
|
function formatDuration(ms: number) {
|
||||||
if (ms < 1000) return `${ms}ms`;
|
const safeMs = Math.max(0, Math.round(ms));
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
if (safeMs < 1000) return `${safeMs}ms`;
|
||||||
|
const totalSeconds = Math.round(safeMs / 1000);
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeToolTrace(
|
function writeToolTrace(
|
||||||
@@ -276,15 +305,17 @@ function safeFilename(value: string) {
|
|||||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment";
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callClawBackend(
|
async function callClawBackendStream(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
runtimeContext: string,
|
runtimeContext: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
|
writer?: UIMessageStreamWriter<UIMessage>,
|
||||||
|
streamState?: { textStarted: boolean; textStreamed: boolean },
|
||||||
): Promise<ClawChatResponse> {
|
): Promise<ClawChatResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CLAW_API_URL}/api/chat`, {
|
const response = await fetch(`${CLAW_API_URL}/api/chat/stream`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -295,8 +326,10 @@ async function callClawBackend(
|
|||||||
...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}),
|
...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as ClawChatResponse;
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const payload = (await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({}))) as ClawChatResponse;
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
payload.detail ??
|
payload.detail ??
|
||||||
@@ -304,7 +337,9 @@ async function callClawBackend(
|
|||||||
`Claw backend returned ${response.status}`,
|
`Claw backend returned ${response.status}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return payload;
|
if (!response.body)
|
||||||
|
return { error: "Claw backend returned an empty stream" };
|
||||||
|
return await consumeClawStream(response.body, writer, streamState);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
@@ -315,6 +350,124 @@ async function callClawBackend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function consumeClawStream(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
writer?: UIMessageStreamWriter<UIMessage>,
|
||||||
|
streamState?: { textStarted: boolean; textStreamed: boolean },
|
||||||
|
) {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let finalPayload: ClawChatResponse | undefined;
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (value) {
|
||||||
|
buffer += decoder.decode(value, { stream: !done });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const payload = parseStreamLine(line);
|
||||||
|
if (!payload) continue;
|
||||||
|
if (payload.kind === "event" && payload.event && writer) {
|
||||||
|
writeRuntimeEvent(writer, payload.event, streamState);
|
||||||
|
}
|
||||||
|
if (payload.kind === "result") {
|
||||||
|
finalPayload = payload.payload;
|
||||||
|
}
|
||||||
|
if (payload.kind === "error") {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
payload.detail ??
|
||||||
|
payload.error ??
|
||||||
|
`Claw backend returned ${payload.status_code ?? 500}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const payload = parseStreamLine(buffer);
|
||||||
|
if (payload?.kind === "result") finalPayload = payload.payload;
|
||||||
|
if (payload?.kind === "error") {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
payload.detail ??
|
||||||
|
payload.error ??
|
||||||
|
`Claw backend returned ${payload.status_code ?? 500}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
finalPayload ?? { error: "Claw backend stream ended without a result" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStreamLine(line: string): ClawStreamItem | undefined {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as ClawStreamItem;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRuntimeEvent(
|
||||||
|
writer: UIMessageStreamWriter<UIMessage>,
|
||||||
|
event: ClawRuntimeEvent,
|
||||||
|
streamState?: { textStarted: boolean; textStreamed: boolean },
|
||||||
|
) {
|
||||||
|
if (event.type === "final_text_start") {
|
||||||
|
if (!streamState?.textStarted) {
|
||||||
|
writer.write({ type: "text-start", id: "text-1" });
|
||||||
|
if (streamState) streamState.textStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === "final_text_delta" && event.delta) {
|
||||||
|
if (!streamState?.textStarted) {
|
||||||
|
writer.write({ type: "text-start", id: "text-1" });
|
||||||
|
if (streamState) streamState.textStarted = true;
|
||||||
|
}
|
||||||
|
writer.write({ type: "text-delta", id: "text-1", delta: event.delta });
|
||||||
|
if (streamState) streamState.textStreamed = true;
|
||||||
|
}
|
||||||
|
if (event.type === "final_text_end") {
|
||||||
|
if (streamState?.textStarted) {
|
||||||
|
writer.write({ type: "text-end", id: "text-1" });
|
||||||
|
streamState.textStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === "tool_start" && event.tool_call_id && event.tool_name) {
|
||||||
|
writer.write({
|
||||||
|
type: "tool-input-available",
|
||||||
|
toolCallId: event.tool_call_id,
|
||||||
|
toolName: event.tool_name,
|
||||||
|
input: attachStageNote(event.arguments ?? {}, event.assistant_content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.type === "tool_delta" && event.tool_call_id && event.delta) {
|
||||||
|
writer.write({
|
||||||
|
type: "reasoning-delta",
|
||||||
|
id: "reasoning-1",
|
||||||
|
delta: `\n${event.tool_name ?? "tool"} 输出中...`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.type === "tool_result" && event.tool_call_id) {
|
||||||
|
const preview = event.metadata?.output_preview;
|
||||||
|
writer.write({
|
||||||
|
type: "tool-output-available",
|
||||||
|
toolCallId: event.tool_call_id,
|
||||||
|
output:
|
||||||
|
typeof preview === "string"
|
||||||
|
? preview
|
||||||
|
: event.ok === false
|
||||||
|
? "工具执行失败,等待最终结果汇总。"
|
||||||
|
: "工具调用完成,等待最终结果汇总。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatClawResponse(payload: ClawChatResponse) {
|
function formatClawResponse(payload: ClawChatResponse) {
|
||||||
if (payload.error) return payload.error;
|
if (payload.error) return payload.error;
|
||||||
return payload.final_output ?? "";
|
return payload.final_output ?? "";
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ async function proxyModelsRequest(
|
|||||||
if (!account) return Response.json({ models: [] }, { status: 401 });
|
if (!account) return Response.json({ models: [] }, { status: 401 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CLAW_API_URL}/api/models`, {
|
const url = new URL(`${CLAW_API_URL}/api/models`);
|
||||||
|
if (method === "GET") url.searchParams.set("account_id", account.id);
|
||||||
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: payload ? { "content-type": "application/json" } : undefined,
|
headers: payload ? { "content-type": "application/json" } : undefined,
|
||||||
body: payload ? JSON.stringify(payload) : undefined,
|
body: payload
|
||||||
|
? JSON.stringify({ ...payload, account_id: account.id })
|
||||||
|
: undefined,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export async function GET() {
|
|||||||
const account = await getCurrentAccount();
|
const account = await getCurrentAccount();
|
||||||
if (!account) return Response.json([], { status: 401 });
|
if (!account) return Response.json([], { status: 401 });
|
||||||
|
|
||||||
const response = await fetch(`${CLAW_API_URL}/api/skills`, {
|
const url = new URL(`${CLAW_API_URL}/api/skills`);
|
||||||
|
url.searchParams.set("account_id", account.id);
|
||||||
|
const response = await fetch(url, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const payload = await response.text();
|
const payload = await response.text();
|
||||||
|
|||||||
@@ -1061,15 +1061,30 @@ const ActivityChainSummary: FC<{
|
|||||||
partIndex: number | undefined;
|
partIndex: number | undefined;
|
||||||
}> = ({ messageId, partIndex }) => {
|
}> = ({ messageId, partIndex }) => {
|
||||||
const { openItem } = useActivityPanel();
|
const { openItem } = useActivityPanel();
|
||||||
|
const startedAtRef = useRef(Date.now());
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
const label = useAuiState((s) => {
|
const label = useAuiState((s) => {
|
||||||
const message = s.message;
|
const message = s.message;
|
||||||
return summarizeActivityChainLabel(message.content, message.status?.type);
|
return summarizeActivityChainLabel(
|
||||||
|
message.content,
|
||||||
|
message.status?.type,
|
||||||
|
elapsedMs,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const active = useAuiState((s) => {
|
const active = useAuiState((s) => {
|
||||||
const message = s.message;
|
const message = s.message;
|
||||||
return message.status?.type === "running";
|
return message.status?.type === "running";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setElapsedMs(Date.now() - startedAtRef.current);
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setElapsedMs(Date.now() - startedAtRef.current);
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1085,14 +1100,22 @@ const ActivityChainSummary: FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActivitySummaryPart = {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
result?: unknown;
|
||||||
|
args?: unknown;
|
||||||
|
toolName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function summarizeActivityChainLabel(
|
function summarizeActivityChainLabel(
|
||||||
content: readonly { type: string; text?: string; result?: unknown }[],
|
content: readonly ActivitySummaryPart[],
|
||||||
status: string | undefined,
|
status: string | undefined,
|
||||||
|
elapsedMs = 0,
|
||||||
) {
|
) {
|
||||||
const toolCount = content.filter((part) => part.type === "tool-call").length;
|
const toolParts = content.filter((part) => part.type === "tool-call");
|
||||||
const runningToolCount = content.filter(
|
const runningTool = toolParts.findLast((part) => part.result === undefined);
|
||||||
(part) => part.type === "tool-call" && part.result === undefined,
|
const activeTool = runningTool ?? toolParts.at(-1);
|
||||||
).length;
|
|
||||||
const reasoningText =
|
const reasoningText =
|
||||||
content.find((part) => part.type === "reasoning")?.text ?? "";
|
content.find((part) => part.type === "reasoning")?.text ?? "";
|
||||||
const lastReasoningLine = reasoningText
|
const lastReasoningLine = reasoningText
|
||||||
@@ -1101,19 +1124,64 @@ function summarizeActivityChainLabel(
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.at(-1);
|
.at(-1);
|
||||||
|
const stageNote = activeTool ? getToolStageNote(activeTool.args) : "";
|
||||||
|
const activeDetail =
|
||||||
|
stageNote ||
|
||||||
|
(activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") ||
|
||||||
|
(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);
|
||||||
|
|
||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
return runningToolCount > 0 ? "调用工具中" : "思考中";
|
const elapsed = `用时 ${formatActivityDuration(elapsedMs)}`;
|
||||||
|
if (activeDetail) {
|
||||||
|
return `${compactActivityLabel(activeDetail, 15)},${elapsed}`;
|
||||||
|
}
|
||||||
|
return `思考中,${elapsed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = compactActivityLabel(lastReasoningLine || "思考完成");
|
const label = compactActivityLabel(
|
||||||
return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label;
|
activeDetail || lastReasoningLine || "思考完成",
|
||||||
|
15,
|
||||||
|
);
|
||||||
|
const finalDuration = extractDurationLabel(lastReasoningLine);
|
||||||
|
const elapsed = finalDuration || formatActivityDuration(elapsedMs);
|
||||||
|
return `${label},用时 ${elapsed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactActivityLabel(label: string) {
|
function getToolStageNote(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||||
|
const note = (value as Record<string, unknown>).__claw_stage_note;
|
||||||
|
return typeof note === "string" ? note.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenericActivityLine(value?: string) {
|
||||||
|
if (!value) return true;
|
||||||
|
return (
|
||||||
|
value.startsWith("思考中") ||
|
||||||
|
value.startsWith("思考并执行完成") ||
|
||||||
|
value.endsWith("输出中...")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDurationLabel(value?: string) {
|
||||||
|
if (!value) return "";
|
||||||
|
const match = value.match(/用时\s*([^。,.,\s]+)/);
|
||||||
|
return match?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityDuration(ms: number) {
|
||||||
|
const safeMs = Math.max(0, Math.round(ms));
|
||||||
|
if (safeMs < 1000) return `${safeMs}ms`;
|
||||||
|
const totalSeconds = Math.round(safeMs / 1000);
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactActivityLabel(label: string, maxLength = 15) {
|
||||||
const normalized = label.replace(/\s+/g, " ").trim();
|
const normalized = label.replace(/\s+/g, " ").trim();
|
||||||
if (normalized.length <= 36) return normalized;
|
if (normalized.length <= maxLength) return normalized;
|
||||||
return `${normalized.slice(0, 35)}…`;
|
return `${normalized.slice(0, Math.max(1, maxLength - 1))}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantActionBar: FC = () => {
|
const AssistantActionBar: FC = () => {
|
||||||
@@ -1251,10 +1319,13 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
const [localText, setLocalText] = useState(storeText);
|
const [localText, setLocalText] = useState(storeText);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isComposingRef = useRef(false);
|
const isComposingRef = useRef(false);
|
||||||
|
const justSubmittedRef = useRef(false);
|
||||||
const isDisabled = runtimeDisabled || disabled;
|
const isDisabled = runtimeDisabled || disabled;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isComposingRef.current) {
|
if (!isComposingRef.current) {
|
||||||
|
if (justSubmittedRef.current && storeText) return;
|
||||||
|
if (!storeText) justSubmittedRef.current = false;
|
||||||
setLocalText(storeText);
|
setLocalText(storeText);
|
||||||
}
|
}
|
||||||
}, [storeText]);
|
}, [storeText]);
|
||||||
@@ -1277,6 +1348,19 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||||
}, [autoFocus, isDisabled]);
|
}, [autoFocus, isDisabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clearAfterSubmit = () => {
|
||||||
|
justSubmittedRef.current = true;
|
||||||
|
setLocalText("");
|
||||||
|
};
|
||||||
|
const unsubscribeComposer = aui.on("composer.send", clearAfterSubmit);
|
||||||
|
const unsubscribeRun = aui.on("thread.runStart", clearAfterSubmit);
|
||||||
|
return () => {
|
||||||
|
unsubscribeComposer();
|
||||||
|
unsubscribeRun();
|
||||||
|
};
|
||||||
|
}, [aui]);
|
||||||
|
|
||||||
const syncComposerText = useCallback(
|
const syncComposerText = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
if (!aui.composer().getState().isEditing) return;
|
if (!aui.composer().getState().isEditing) return;
|
||||||
@@ -1324,6 +1408,7 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
syncComposerText(localText);
|
syncComposerText(localText);
|
||||||
aui.composer().send();
|
aui.composer().send();
|
||||||
|
justSubmittedRef.current = true;
|
||||||
setLocalText("");
|
setLocalText("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1336,6 +1421,7 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onChange?.(event);
|
onChange?.(event);
|
||||||
const nextText = event.currentTarget.value;
|
const nextText = event.currentTarget.value;
|
||||||
|
justSubmittedRef.current = false;
|
||||||
setLocalText(nextText);
|
setLocalText(nextText);
|
||||||
const isNativeComposing =
|
const isNativeComposing =
|
||||||
(event.nativeEvent as { isComposing?: boolean }).isComposing === true;
|
(event.nativeEvent as { isComposing?: boolean }).isComposing === true;
|
||||||
|
|||||||
+20
-3
@@ -22,6 +22,7 @@ class PromptContext:
|
|||||||
is_git_repo: bool
|
is_git_repo: bool
|
||||||
is_git_worktree: bool
|
is_git_worktree: bool
|
||||||
scratchpad_directory: str | None = None
|
scratchpad_directory: str | None = None
|
||||||
|
python_env_directory: str | None = None
|
||||||
additional_working_directories: tuple[str, ...] = ()
|
additional_working_directories: tuple[str, ...] = ()
|
||||||
user_context: dict[str, str] = field(default_factory=dict)
|
user_context: dict[str, str] = field(default_factory=dict)
|
||||||
system_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_repo=snapshot.is_git_repo,
|
||||||
is_git_worktree=snapshot.is_git_worktree,
|
is_git_worktree=snapshot.is_git_worktree,
|
||||||
scratchpad_directory=snapshot.scratchpad_directory,
|
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,
|
additional_working_directories=snapshot.additional_working_directories,
|
||||||
user_context=snapshot.user_context,
|
user_context=snapshot.user_context,
|
||||||
system_context=snapshot.system_context,
|
system_context=snapshot.system_context,
|
||||||
@@ -154,7 +160,9 @@ def get_doing_tasks_section() -> str:
|
|||||||
'除非确实需要新文件,否则优先编辑现有文件。',
|
'除非确实需要新文件,否则优先编辑现有文件。',
|
||||||
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
||||||
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/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 行为等安全问题。',
|
'注意不要引入命令注入、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 命令。'
|
'需要结构化文件分析、批量数据处理、JSON/JSONL 转换、抽样、校验或快速计算时,必须优先使用 python_exec,而不是通过 bash 手写 python 命令。'
|
||||||
)
|
)
|
||||||
items.append(
|
items.append(
|
||||||
'python_exec 默认使用项目 .venv/bin/python;不要用它安装依赖。缺少依赖时先说明缺失包并向用户确认包管理方式。'
|
'python_exec 默认使用当前用户独立 Python venv,不使用项目 .venv;不要用 bash 执行 python 或 pip。'
|
||||||
)
|
)
|
||||||
items.append(
|
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:
|
if 'bash' in enabled_tool_names:
|
||||||
items.append(
|
items.append(
|
||||||
@@ -444,6 +459,8 @@ def compute_simple_env_info(prompt_context: PromptContext) -> str:
|
|||||||
items.append(list(prompt_context.additional_working_directories))
|
items.append(list(prompt_context.additional_working_directories))
|
||||||
if prompt_context.scratchpad_directory:
|
if prompt_context.scratchpad_directory:
|
||||||
items.append(f'会话 scratchpad 目录: {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(
|
items.extend(
|
||||||
[
|
[
|
||||||
f'平台: {prompt_context.platform_name}',
|
f'平台: {prompt_context.platform_name}',
|
||||||
|
|||||||
+62
-8
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field, replace
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .account_runtime import AccountRuntime
|
from .account_runtime import AccountRuntime
|
||||||
@@ -68,6 +68,43 @@ from .tokenizer_runtime import describe_token_counter
|
|||||||
from .workflow_runtime import WorkflowRuntime
|
from .workflow_runtime import WorkflowRuntime
|
||||||
from .worktree_runtime import WorktreeRuntime
|
from .worktree_runtime import WorktreeRuntime
|
||||||
from .session_env_vars import clear_session_env_vars
|
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 (
|
from .session_store import (
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
load_agent_session,
|
load_agent_session,
|
||||||
@@ -375,6 +412,7 @@ class LocalCodingAgent:
|
|||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
*,
|
*,
|
||||||
runtime_context: str | None = None,
|
runtime_context: str | None = None,
|
||||||
|
event_sink: RuntimeEventSink | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
self.managed_agent_id = None
|
self.managed_agent_id = None
|
||||||
self.resume_source_session_id = None
|
self.resume_source_session_id = None
|
||||||
@@ -389,6 +427,7 @@ class LocalCodingAgent:
|
|||||||
scratchpad_directory=scratchpad_directory,
|
scratchpad_directory=scratchpad_directory,
|
||||||
existing_file_history=(),
|
existing_file_history=(),
|
||||||
runtime_context=runtime_context,
|
runtime_context=runtime_context,
|
||||||
|
event_sink=event_sink,
|
||||||
)
|
)
|
||||||
self._accumulate_usage(result)
|
self._accumulate_usage(result)
|
||||||
self._finalize_managed_agent(result)
|
self._finalize_managed_agent(result)
|
||||||
@@ -400,6 +439,7 @@ class LocalCodingAgent:
|
|||||||
stored_session: StoredAgentSession,
|
stored_session: StoredAgentSession,
|
||||||
*,
|
*,
|
||||||
runtime_context: str | None = None,
|
runtime_context: str | None = None,
|
||||||
|
event_sink: RuntimeEventSink | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
self.managed_agent_id = None
|
self.managed_agent_id = None
|
||||||
self.resume_source_session_id = stored_session.session_id
|
self.resume_source_session_id = stored_session.session_id
|
||||||
@@ -435,6 +475,7 @@ class LocalCodingAgent:
|
|||||||
scratchpad_directory=scratchpad_directory,
|
scratchpad_directory=scratchpad_directory,
|
||||||
existing_file_history=stored_session.file_history,
|
existing_file_history=stored_session.file_history,
|
||||||
runtime_context=runtime_context,
|
runtime_context=runtime_context,
|
||||||
|
event_sink=event_sink,
|
||||||
)
|
)
|
||||||
self._accumulate_usage(result)
|
self._accumulate_usage(result)
|
||||||
self._finalize_managed_agent(result)
|
self._finalize_managed_agent(result)
|
||||||
@@ -449,6 +490,7 @@ class LocalCodingAgent:
|
|||||||
scratchpad_directory: Path | None,
|
scratchpad_directory: Path | None,
|
||||||
existing_file_history: tuple[dict[str, object], ...],
|
existing_file_history: tuple[dict[str, object], ...],
|
||||||
runtime_context: str | None = None,
|
runtime_context: str | None = None,
|
||||||
|
event_sink: RuntimeEventSink | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
slash_result = preprocess_slash_command(self, prompt)
|
slash_result = preprocess_slash_command(self, prompt)
|
||||||
if slash_result.handled and not slash_result.should_query:
|
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)
|
session.append_user(effective_prompt, model_content=model_prompt)
|
||||||
self.last_session = session
|
self.last_session = session
|
||||||
self.active_session_id = session_id
|
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()]
|
tool_specs = [tool.to_openai_tool() for tool in self.tool_registry.values()]
|
||||||
starting_usage = UsageStats()
|
starting_usage = UsageStats()
|
||||||
starting_cost_usd = 0.0
|
starting_cost_usd = 0.0
|
||||||
@@ -525,7 +571,7 @@ class LocalCodingAgent:
|
|||||||
total_usage = starting_usage
|
total_usage = starting_usage
|
||||||
total_cost_usd = starting_cost_usd
|
total_cost_usd = starting_cost_usd
|
||||||
file_history = list(existing_file_history)
|
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] = []
|
assistant_response_segments: list[str] = []
|
||||||
delegated_tasks = sum(
|
delegated_tasks = sum(
|
||||||
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent')
|
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)
|
last_content = ''.join(assistant_response_segments)
|
||||||
continue
|
continue
|
||||||
|
final_output = ''.join(assistant_response_segments)
|
||||||
|
_append_final_text_stream_events(stream_events, final_output)
|
||||||
result = AgentRunResult(
|
result = AgentRunResult(
|
||||||
final_output=''.join(assistant_response_segments),
|
final_output=final_output,
|
||||||
turns=turn_index,
|
turns=turn_index,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
transcript=session.transcript(),
|
transcript=session.transcript(),
|
||||||
@@ -816,8 +864,10 @@ class LocalCodingAgent:
|
|||||||
)
|
)
|
||||||
last_content = ''.join(assistant_response_segments)
|
last_content = ''.join(assistant_response_segments)
|
||||||
continue
|
continue
|
||||||
|
final_output = ''.join(assistant_response_segments)
|
||||||
|
_append_final_text_stream_events(stream_events, final_output)
|
||||||
result = AgentRunResult(
|
result = AgentRunResult(
|
||||||
final_output=''.join(assistant_response_segments),
|
final_output=final_output,
|
||||||
turns=turn_index,
|
turns=turn_index,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
transcript=session.transcript(),
|
transcript=session.transcript(),
|
||||||
@@ -896,6 +946,8 @@ class LocalCodingAgent:
|
|||||||
'type': 'tool_start',
|
'type': 'tool_start',
|
||||||
'tool_name': tool_call.name,
|
'tool_name': tool_call.name,
|
||||||
'tool_call_id': tool_call.id,
|
'tool_call_id': tool_call.id,
|
||||||
|
'arguments': dict(tool_call.arguments),
|
||||||
|
'assistant_content': turn.content,
|
||||||
'message_id': session.messages[tool_message_index].message_id,
|
'message_id': session.messages[tool_message_index].message_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1194,11 +1246,13 @@ class LocalCodingAgent:
|
|||||||
self.last_run_result = result
|
self.last_run_result = result
|
||||||
return 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(
|
result = AgentRunResult(
|
||||||
final_output=(
|
final_output=final_output,
|
||||||
last_content
|
|
||||||
or 'Stopped: max turns reached before the model produced a final answer.'
|
|
||||||
),
|
|
||||||
turns=self.runtime_config.max_turns,
|
turns=self.runtime_config.max_turns,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
transcript=session.transcript(),
|
transcript=session.transcript(),
|
||||||
|
|||||||
+164
-6
@@ -76,6 +76,8 @@ class ToolExecutionContext:
|
|||||||
command_timeout_seconds: float
|
command_timeout_seconds: float
|
||||||
max_output_chars: int
|
max_output_chars: int
|
||||||
permissions: AgentPermissions
|
permissions: AgentPermissions
|
||||||
|
scratchpad_directory: Path | None = None
|
||||||
|
python_env_dir: Path | None = None
|
||||||
extra_env: dict[str, str] = field(default_factory=dict)
|
extra_env: dict[str, str] = field(default_factory=dict)
|
||||||
tool_registry: dict[str, 'AgentTool'] | None = None
|
tool_registry: dict[str, 'AgentTool'] | None = None
|
||||||
search_runtime: 'SearchRuntime | None' = None
|
search_runtime: 'SearchRuntime | None' = None
|
||||||
@@ -152,6 +154,8 @@ class ToolStreamUpdate:
|
|||||||
def build_tool_context(
|
def build_tool_context(
|
||||||
config: AgentRuntimeConfig,
|
config: AgentRuntimeConfig,
|
||||||
*,
|
*,
|
||||||
|
scratchpad_directory: Path | None = None,
|
||||||
|
python_env_dir: Path | None = None,
|
||||||
extra_env: dict[str, str] | None = None,
|
extra_env: dict[str, str] | None = None,
|
||||||
tool_registry: dict[str, AgentTool] | None = None,
|
tool_registry: dict[str, AgentTool] | None = None,
|
||||||
search_runtime: 'SearchRuntime | None' = None,
|
search_runtime: 'SearchRuntime | None' = None,
|
||||||
@@ -173,6 +177,14 @@ def build_tool_context(
|
|||||||
command_timeout_seconds=config.command_timeout_seconds,
|
command_timeout_seconds=config.command_timeout_seconds,
|
||||||
max_output_chars=config.max_output_chars,
|
max_output_chars=config.max_output_chars,
|
||||||
permissions=config.permissions,
|
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 {}),
|
extra_env=dict(extra_env or {}),
|
||||||
tool_registry=tool_registry,
|
tool_registry=tool_registry,
|
||||||
search_runtime=search_runtime,
|
search_runtime=search_runtime,
|
||||||
@@ -339,20 +351,22 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
name='python_exec',
|
name='python_exec',
|
||||||
description=(
|
description=(
|
||||||
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
|
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
|
||||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用项目 .venv/bin/python;'
|
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;'
|
||||||
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
|
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
|
||||||
'缺包时应向用户确认后再处理依赖。'
|
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;如需写临时文件,'
|
||||||
|
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
|
||||||
|
'不要在项目根目录创建临时 .py 脚本。'
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
'code': {
|
'code': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一。',
|
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
|
||||||
},
|
},
|
||||||
'script_path': {
|
'script_path': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': '工作区内已有 Python 脚本路径。code 和 script_path 必须二选一。',
|
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
|
||||||
},
|
},
|
||||||
'args': {
|
'args': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
@@ -377,12 +391,49 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
},
|
},
|
||||||
handler=_run_python_exec,
|
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(
|
AgentTool(
|
||||||
name='bash',
|
name='bash',
|
||||||
description=(
|
description=(
|
||||||
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
||||||
'不要用 bash 执行 python、python3 或 .venv/bin/python;结构化文件分析、'
|
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
|
||||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
||||||
|
'Python 包安装必须优先使用 python_package。'
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -2604,6 +2655,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
|||||||
payload = [
|
payload = [
|
||||||
'timed_out=true',
|
'timed_out=true',
|
||||||
f'timeout_seconds={timeout_seconds:g}',
|
f'timeout_seconds={timeout_seconds:g}',
|
||||||
|
f'scratchpad={context.scratchpad_directory or ""}',
|
||||||
|
f'python_env={context.python_env_dir or ""}',
|
||||||
'[stdout]',
|
'[stdout]',
|
||||||
stdout.rstrip(),
|
stdout.rstrip(),
|
||||||
'[stderr]',
|
'[stderr]',
|
||||||
@@ -2615,6 +2668,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
|||||||
'action': 'python_exec',
|
'action': 'python_exec',
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'interpreter': interpreter,
|
'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,
|
'timed_out': True,
|
||||||
'timeout_seconds': timeout_seconds,
|
'timeout_seconds': timeout_seconds,
|
||||||
'stdout_preview': _snapshot_text(stdout),
|
'stdout_preview': _snapshot_text(stdout),
|
||||||
@@ -2628,6 +2683,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
|||||||
payload = [
|
payload = [
|
||||||
f'exit_code={completed.returncode}',
|
f'exit_code={completed.returncode}',
|
||||||
f'interpreter={interpreter}',
|
f'interpreter={interpreter}',
|
||||||
|
f'scratchpad={context.scratchpad_directory or ""}',
|
||||||
|
f'python_env={context.python_env_dir or ""}',
|
||||||
'[stdout]',
|
'[stdout]',
|
||||||
stdout.rstrip(),
|
stdout.rstrip(),
|
||||||
'[stderr]',
|
'[stderr]',
|
||||||
@@ -2639,6 +2696,94 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
|||||||
'action': 'python_exec',
|
'action': 'python_exec',
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'interpreter': interpreter,
|
'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,
|
'exit_code': completed.returncode,
|
||||||
'stdout_preview': _snapshot_text(stdout),
|
'stdout_preview': _snapshot_text(stdout),
|
||||||
'stderr_preview': _snapshot_text(stderr),
|
'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:
|
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'
|
venv_python = context.root / '.venv' / 'bin' / 'python'
|
||||||
if venv_python.exists() and os.access(venv_python, os.X_OK):
|
if venv_python.exists() and os.access(venv_python, os.X_OK):
|
||||||
return str(venv_python)
|
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():
|
for key, value in get_session_env_vars().items():
|
||||||
env[key] = value
|
env[key] = value
|
||||||
env.update(context.extra_env)
|
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
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class AgentRuntimeConfig:
|
|||||||
output_schema: OutputSchemaConfig | None = None
|
output_schema: OutputSchemaConfig | None = None
|
||||||
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
|
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
|
||||||
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
|
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
|
||||||
|
python_env_dir: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict:
|
|||||||
),
|
),
|
||||||
'session_directory': str(runtime_config.session_directory),
|
'session_directory': str(runtime_config.session_directory),
|
||||||
'scratchpad_root': str(runtime_config.scratchpad_root),
|
'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),
|
output_schema=_deserialize_output_schema(output_schema_payload),
|
||||||
session_directory=Path(str(payload.get('session_directory', DEFAULT_AGENT_SESSION_DIR))).resolve(),
|
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(),
|
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
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,35 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload['account_id'], 'alice')
|
self.assertEqual(payload['account_id'], 'alice')
|
||||||
self.assertIn('/accounts/alice/sessions', payload['session_directory'])
|
self.assertIn('/accounts/alice/sessions', payload['session_directory'])
|
||||||
self.assertIn('/accounts/alice/sessions', payload['upload_directory'])
|
self.assertIn('/accounts/alice/sessions', payload['upload_directory'])
|
||||||
|
self.assertIn('/accounts/alice/python/.venv', payload['python_env_directory'])
|
||||||
|
self.assertTrue((Path(payload['python_env_directory']) / 'bin' / 'python').exists())
|
||||||
|
|
||||||
|
def test_state_update_is_scoped_to_account(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
client, _ = _build_client(Path(d))
|
||||||
|
updated = client.post(
|
||||||
|
'/api/state',
|
||||||
|
json={'account_id': 'alice', 'allow_shell': True, 'model': 'alice-model'},
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200)
|
||||||
|
self.assertTrue(updated.json()['allow_shell'])
|
||||||
|
self.assertEqual(updated.json()['model'], 'alice-model')
|
||||||
|
|
||||||
|
alice = client.get('/api/state', params={'account_id': 'alice'}).json()
|
||||||
|
bob = client.get('/api/state', params={'account_id': 'bob'}).json()
|
||||||
|
default = client.get('/api/state').json()
|
||||||
|
self.assertEqual(alice['model'], 'alice-model')
|
||||||
|
self.assertTrue(alice['allow_shell'])
|
||||||
|
self.assertEqual(bob['model'], 'test-model')
|
||||||
|
self.assertFalse(bob['allow_shell'])
|
||||||
|
self.assertEqual(default['model'], 'test-model')
|
||||||
|
self.assertFalse(default['allow_shell'])
|
||||||
|
|
||||||
|
def test_run_locks_are_scoped_to_account(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
_, state = _build_client(Path(d))
|
||||||
|
self.assertIs(state.run_lock_for('alice'), state.run_lock_for('alice'))
|
||||||
|
self.assertIsNot(state.run_lock_for('alice'), state.run_lock_for('bob'))
|
||||||
|
|
||||||
def test_state_update_rejects_missing_cwd(self) -> None:
|
def test_state_update_rejects_missing_cwd(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
@@ -132,6 +161,28 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
self.assertIsNone(payload['session_id'])
|
self.assertIsNone(payload['session_id'])
|
||||||
|
|
||||||
|
def test_chat_uses_account_scoped_model_config(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
client, _ = _build_client(Path(d))
|
||||||
|
client.post(
|
||||||
|
'/api/state',
|
||||||
|
json={'account_id': 'alice', 'model': 'alice-model'},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/chat',
|
||||||
|
json={'prompt': '/status', 'account_id': 'alice'},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('- Model: alice-model', response.json()['final_output'])
|
||||||
|
|
||||||
|
bob = client.post(
|
||||||
|
'/api/chat',
|
||||||
|
json={'prompt': '/status', 'account_id': 'bob'},
|
||||||
|
)
|
||||||
|
self.assertEqual(bob.status_code, 200)
|
||||||
|
self.assertIn('- Model: test-model', bob.json()['final_output'])
|
||||||
|
|
||||||
def test_agent_session_store_uses_session_subdirectory(self) -> None:
|
def test_agent_session_store_uses_session_subdirectory(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
root = Path(d)
|
root = Path(d)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
@@ -29,6 +30,97 @@ class PythonExecToolTests(TestCase):
|
|||||||
self.assertEqual(result.metadata.get('action'), 'python_exec')
|
self.assertEqual(result.metadata.get('action'), 'python_exec')
|
||||||
self.assertEqual(result.metadata.get('mode'), 'code')
|
self.assertEqual(result.metadata.get('mode'), 'code')
|
||||||
|
|
||||||
|
def test_python_exec_exposes_session_scratchpad(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
root = Path(tmp_dir)
|
||||||
|
scratchpad = (root / 'session' / 'scratchpad').resolve()
|
||||||
|
scratchpad.mkdir(parents=True)
|
||||||
|
config = AgentRuntimeConfig(
|
||||||
|
cwd=root,
|
||||||
|
permissions=AgentPermissions(allow_shell_commands=True),
|
||||||
|
)
|
||||||
|
context = build_tool_context(config, scratchpad_directory=scratchpad)
|
||||||
|
result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'python_exec',
|
||||||
|
{'code': 'import os\nprint(os.environ["PYTHON_EXEC_SCRATCHPAD"])'},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertIn(str(scratchpad), result.content)
|
||||||
|
self.assertEqual(result.metadata.get('scratchpad_directory'), str(scratchpad))
|
||||||
|
|
||||||
|
def test_python_exec_prefers_user_python_env(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
root = Path(tmp_dir)
|
||||||
|
user_env = (root / 'account' / 'python' / '.venv').resolve()
|
||||||
|
bin_dir = user_env / 'bin'
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
python_bin = bin_dir / 'python'
|
||||||
|
try:
|
||||||
|
python_bin.symlink_to(sys.executable)
|
||||||
|
except OSError:
|
||||||
|
python_bin.write_text(
|
||||||
|
'#!/bin/sh\nexec "$PYTHON_EXEC_TEST_REAL_PYTHON" "$@"\n',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
python_bin.chmod(0o755)
|
||||||
|
config = AgentRuntimeConfig(
|
||||||
|
cwd=root,
|
||||||
|
permissions=AgentPermissions(allow_shell_commands=True),
|
||||||
|
python_env_dir=user_env,
|
||||||
|
)
|
||||||
|
context = build_tool_context(
|
||||||
|
config,
|
||||||
|
extra_env={'PYTHON_EXEC_TEST_REAL_PYTHON': sys.executable},
|
||||||
|
)
|
||||||
|
result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'python_exec',
|
||||||
|
{'code': 'import os, sys\nprint(sys.executable)\nprint(os.environ["PYTHON_EXEC_VENV"])'},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertIn(str(user_env), result.content)
|
||||||
|
self.assertEqual(result.metadata.get('python_env_dir'), str(user_env))
|
||||||
|
|
||||||
|
def test_python_package_show_uses_user_python_env(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
root = Path(tmp_dir)
|
||||||
|
user_env = (root / 'account' / 'python' / '.venv').resolve()
|
||||||
|
bin_dir = user_env / 'bin'
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
python_bin = bin_dir / 'python'
|
||||||
|
try:
|
||||||
|
python_bin.symlink_to(sys.executable)
|
||||||
|
except OSError:
|
||||||
|
python_bin.write_text(
|
||||||
|
'#!/bin/sh\nexec "$PYTHON_EXEC_TEST_REAL_PYTHON" "$@"\n',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
python_bin.chmod(0o755)
|
||||||
|
config = AgentRuntimeConfig(
|
||||||
|
cwd=root,
|
||||||
|
permissions=AgentPermissions(allow_shell_commands=True),
|
||||||
|
python_env_dir=user_env,
|
||||||
|
)
|
||||||
|
context = build_tool_context(
|
||||||
|
config,
|
||||||
|
extra_env={'PYTHON_EXEC_TEST_REAL_PYTHON': sys.executable},
|
||||||
|
)
|
||||||
|
result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'python_package',
|
||||||
|
{'action': 'show'},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertIn(str(user_env), result.content)
|
||||||
|
self.assertEqual(result.metadata.get('python_env_dir'), str(user_env))
|
||||||
|
|
||||||
def test_python_exec_is_blocked_without_shell_permission(self) -> None:
|
def test_python_exec_is_blocked_without_shell_permission(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
config = AgentRuntimeConfig(cwd=Path(tmp_dir))
|
config = AgentRuntimeConfig(cwd=Path(tmp_dir))
|
||||||
@@ -67,4 +159,3 @@ class PythonExecToolTests(TestCase):
|
|||||||
self.assertTrue(result.ok)
|
self.assertTrue(result.ok)
|
||||||
self.assertIn('a|b', result.content)
|
self.assertIn('a|b', result.content)
|
||||||
self.assertEqual(result.metadata.get('mode'), 'script')
|
self.assertEqual(result.metadata.get('mode'), 'script')
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ class TestRuntimeConfigSerialization(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
session_directory=Path('/sessions'),
|
session_directory=Path('/sessions'),
|
||||||
scratchpad_root=Path('/scratch'),
|
scratchpad_root=Path('/scratch'),
|
||||||
|
python_env_dir=Path('/python/.venv'),
|
||||||
)
|
)
|
||||||
payload = serialize_runtime_config(config)
|
payload = serialize_runtime_config(config)
|
||||||
restored = deserialize_runtime_config(payload)
|
restored = deserialize_runtime_config(payload)
|
||||||
@@ -344,6 +345,7 @@ class TestRuntimeConfigSerialization(unittest.TestCase):
|
|||||||
self.assertEqual(restored.output_schema.name, 'test_schema')
|
self.assertEqual(restored.output_schema.name, 'test_schema')
|
||||||
self.assertEqual(restored.output_schema.schema, config.output_schema.schema)
|
self.assertEqual(restored.output_schema.schema, config.output_schema.schema)
|
||||||
self.assertTrue(restored.output_schema.strict)
|
self.assertTrue(restored.output_schema.strict)
|
||||||
|
self.assertEqual(restored.python_env_dir, Path('/python/.venv'))
|
||||||
|
|
||||||
def test_round_trip_none_output_schema(self) -> None:
|
def test_round_trip_none_output_schema(self) -> None:
|
||||||
config = AgentRuntimeConfig(
|
config = AgentRuntimeConfig(
|
||||||
|
|||||||
Reference in New Issue
Block a user