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
+258 -104
View File
@@ -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():
+163 -10
View File
@@ -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 ?? "";
+6 -2
View File
@@ -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();
+3 -1
View File
@@ -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();
+98 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 环境和 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( 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
+1
View File
@@ -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)
+10
View File
@@ -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
),
) )
+51
View File
@@ -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)
+92 -1
View File
@@ -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')
+2
View File
@@ -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(