Improve WebUI streaming and Python tooling
This commit is contained in:
+205
-51
@@ -8,16 +8,20 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import venv
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from threading import Lock, RLock
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -150,8 +154,18 @@ WEBUI_SLASH_COMMAND_DESCRIPTIONS_ZH = {
|
||||
# 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:
|
||||
"""Holds the live agent instance plus a lock for serialized access."""
|
||||
"""Holds account-scoped agent instances, config, and execution locks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -164,15 +178,66 @@ class AgentState:
|
||||
allow_write: bool,
|
||||
session_directory: Path,
|
||||
) -> None:
|
||||
self.cwd = cwd.resolve()
|
||||
self.session_directory = session_directory
|
||||
self._lock = Lock()
|
||||
self._lock = RLock()
|
||||
self._agents: dict[str, LocalCodingAgent] = {}
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.allow_shell = allow_shell
|
||||
self.allow_write = allow_write
|
||||
self._run_locks: dict[str, Lock] = {}
|
||||
self._account_configs: dict[str, AgentInstanceConfig] = {}
|
||||
self._default_config = AgentInstanceConfig(
|
||||
cwd=cwd.resolve(),
|
||||
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:
|
||||
if not account_id:
|
||||
@@ -189,6 +254,7 @@ class AgentState:
|
||||
'scratchpad': self.session_directory,
|
||||
'uploads': self.session_directory,
|
||||
'outputs': self.session_directory,
|
||||
'python_env': base / 'python' / '.venv',
|
||||
}
|
||||
base = self._account_base(account_id)
|
||||
return {
|
||||
@@ -197,26 +263,32 @@ class AgentState:
|
||||
'scratchpad': base / 'sessions',
|
||||
'uploads': base / 'sessions',
|
||||
'outputs': base / 'sessions',
|
||||
'python_env': base / 'python' / '.venv',
|
||||
}
|
||||
|
||||
def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent:
|
||||
config = self._config_for(account_id)
|
||||
paths = self.account_paths(account_id)
|
||||
for directory in paths.values():
|
||||
if directory.name != '.venv':
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._ensure_python_env(paths['python_env'])
|
||||
permissions = AgentPermissions(
|
||||
allow_file_write=self.allow_write,
|
||||
allow_shell_commands=self.allow_shell,
|
||||
allow_file_write=config.allow_write,
|
||||
allow_shell_commands=config.allow_shell,
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
cwd=self.cwd,
|
||||
cwd=config.cwd,
|
||||
permissions=permissions,
|
||||
stream_model_responses=True,
|
||||
session_directory=paths['sessions'],
|
||||
scratchpad_root=paths['scratchpad'],
|
||||
python_env_dir=paths['python_env'],
|
||||
)
|
||||
model_config = ModelConfig(
|
||||
model=self.model,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
model=config.model,
|
||||
base_url=config.base_url,
|
||||
api_key=config.api_key,
|
||||
)
|
||||
return LocalCodingAgent(
|
||||
model_config=model_config,
|
||||
@@ -224,13 +296,23 @@ class AgentState:
|
||||
)
|
||||
|
||||
def agent_for(self, account_id: str | None = None) -> LocalCodingAgent:
|
||||
key = _safe_account_id(account_id) if account_id else '__default__'
|
||||
with self._lock:
|
||||
key = self._account_key(account_id)
|
||||
agent = self._agents.get(key)
|
||||
if agent is None:
|
||||
agent = self._build_agent(account_id)
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -243,45 +325,56 @@ class AgentState:
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
# account_id 只用于让调用方拿到对应账号的 snapshot,不参与全局配置更新。
|
||||
del account_id
|
||||
config = self._config_for(account_id)
|
||||
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:
|
||||
self.base_url = base_url
|
||||
config = replace(config, base_url=base_url)
|
||||
if api_key is not None:
|
||||
self.api_key = api_key
|
||||
config = replace(config, api_key=api_key)
|
||||
if cwd is not None:
|
||||
resolved = Path(cwd).expanduser().resolve()
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f'cwd does not exist: {resolved}')
|
||||
self.cwd = resolved
|
||||
config = replace(config, cwd=resolved)
|
||||
if allow_shell is not None:
|
||||
self.allow_shell = allow_shell
|
||||
config = replace(config, allow_shell=allow_shell)
|
||||
if allow_write is not None:
|
||||
self.allow_write = allow_write
|
||||
self._agents.clear()
|
||||
config = replace(config, allow_write=allow_write)
|
||||
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]:
|
||||
with self._lock:
|
||||
config = self._config_for(account_id)
|
||||
agent = self.agent_for(account_id)
|
||||
paths = self.account_paths(account_id)
|
||||
return {
|
||||
'model': self.model,
|
||||
'base_url': self.base_url,
|
||||
'cwd': str(self.cwd),
|
||||
'model': config.model,
|
||||
'base_url': config.base_url,
|
||||
'cwd': str(config.cwd),
|
||||
'account_id': _safe_account_id(account_id) if account_id else None,
|
||||
'account_directory': str(paths['base']),
|
||||
'session_directory': str(paths['sessions']),
|
||||
'upload_directory': str(paths['uploads']),
|
||||
'output_directory': str(paths['outputs']),
|
||||
'allow_shell': self.allow_shell,
|
||||
'allow_write': self.allow_write,
|
||||
'python_env_directory': str(paths['python_env']),
|
||||
'allow_shell': config.allow_shell,
|
||||
'allow_write': config.allow_write,
|
||||
'active_session_id': agent.active_session_id,
|
||||
}
|
||||
|
||||
def lock(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
|
||||
@@ -308,6 +401,7 @@ class StateUpdate(BaseModel):
|
||||
class ModelListRequest(BaseModel):
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class SessionTitleUpdate(BaseModel):
|
||||
@@ -366,7 +460,8 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
return commands
|
||||
|
||||
@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 [
|
||||
{
|
||||
'name': skill.name,
|
||||
@@ -375,19 +470,21 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
'aliases': list(skill.aliases),
|
||||
'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
|
||||
]
|
||||
|
||||
@app.get('/api/models')
|
||||
async def list_models_get() -> dict[str, Any]:
|
||||
return _list_backend_models(state.base_url, state.api_key)
|
||||
async def list_models_get(account_id: str | None = None) -> dict[str, Any]:
|
||||
config = state.config_for(account_id)
|
||||
return _list_backend_models(config.base_url, config.api_key)
|
||||
|
||||
@app.post('/api/models')
|
||||
async def list_models_post(payload: ModelListRequest) -> dict[str, Any]:
|
||||
config = state.config_for(payload.account_id)
|
||||
return _list_backend_models(
|
||||
payload.base_url or state.base_url,
|
||||
payload.api_key or state.api_key,
|
||||
payload.base_url or config.base_url,
|
||||
payload.api_key or config.api_key,
|
||||
)
|
||||
|
||||
# ------------- sessions --------------------------------------------------
|
||||
@@ -492,16 +589,14 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
)
|
||||
return _serialize_token_budget(snapshot)
|
||||
|
||||
# ------------- chat ------------------------------------------------------
|
||||
@app.post('/api/chat')
|
||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||
prompt = request.prompt.strip()
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
with state.lock():
|
||||
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
|
||||
@@ -523,15 +618,17 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
detail='Session to resume not found',
|
||||
)
|
||||
result = agent.resume(
|
||||
prompt,
|
||||
request.prompt.strip(),
|
||||
stored,
|
||||
runtime_context=request.runtime_context,
|
||||
event_sink=event_sink,
|
||||
)
|
||||
else:
|
||||
result = agent.run(
|
||||
prompt,
|
||||
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)
|
||||
@@ -545,14 +642,24 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
_ensure_session_title(
|
||||
session_directory,
|
||||
result.session_id,
|
||||
model=state.model,
|
||||
base_url=state.base_url,
|
||||
api_key=state.api_key,
|
||||
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 ------------------------------------------------------
|
||||
@app.post('/api/chat')
|
||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||
prompt = request.prompt.strip()
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
return _run_chat_payload(request)
|
||||
|
||||
try:
|
||||
payload = await asyncio.to_thread(_run)
|
||||
except HTTPException:
|
||||
@@ -567,6 +674,53 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
)
|
||||
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')
|
||||
async def clear_state(account_id: str | None = None) -> dict[str, Any]:
|
||||
with state.lock():
|
||||
|
||||
@@ -26,6 +26,22 @@ type ClawChatResponse = {
|
||||
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 = {
|
||||
id?: string;
|
||||
messages: UIMessage[];
|
||||
@@ -75,6 +91,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const streamState = { textStarted: false, textStreamed: false };
|
||||
writer.write({ type: "start" });
|
||||
writer.write({ type: "start-step" });
|
||||
|
||||
@@ -85,12 +102,14 @@ export async function POST(req: Request) {
|
||||
id: "reasoning-1",
|
||||
delta: "思考中,正在判断是否需要调用工具。",
|
||||
});
|
||||
const payload = await callClawBackend(
|
||||
const payload = await callClawBackendStream(
|
||||
userPrompt,
|
||||
runtimeContext,
|
||||
account.id,
|
||||
sessionId,
|
||||
resumeId,
|
||||
writer,
|
||||
streamState,
|
||||
);
|
||||
const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt;
|
||||
const text = formatClawResponse(payload);
|
||||
@@ -102,9 +121,14 @@ export async function POST(req: Request) {
|
||||
});
|
||||
writer.write({ type: "reasoning-end", id: "reasoning-1" });
|
||||
writeToolTrace(writer, payload.transcript);
|
||||
if (!streamState.textStreamed) {
|
||||
writer.write({ type: "text-start", 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",
|
||||
@@ -124,8 +148,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
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 writeToolTrace(
|
||||
@@ -276,15 +305,17 @@ function safeFilename(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment";
|
||||
}
|
||||
|
||||
async function callClawBackend(
|
||||
async function callClawBackendStream(
|
||||
prompt: string,
|
||||
runtimeContext: string,
|
||||
accountId: string,
|
||||
sessionId: string,
|
||||
resumeSessionId?: string,
|
||||
writer?: UIMessageStreamWriter<UIMessage>,
|
||||
streamState?: { textStarted: boolean; textStreamed: boolean },
|
||||
): Promise<ClawChatResponse> {
|
||||
try {
|
||||
const response = await fetch(`${CLAW_API_URL}/api/chat`, {
|
||||
const response = await fetch(`${CLAW_API_URL}/api/chat/stream`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -295,8 +326,10 @@ async function callClawBackend(
|
||||
...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}),
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as ClawChatResponse;
|
||||
if (!response.ok) {
|
||||
const payload = (await response
|
||||
.json()
|
||||
.catch(() => ({}))) as ClawChatResponse;
|
||||
return {
|
||||
error:
|
||||
payload.detail ??
|
||||
@@ -304,7 +337,9 @@ async function callClawBackend(
|
||||
`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) {
|
||||
return {
|
||||
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) {
|
||||
if (payload.error) return payload.error;
|
||||
return payload.final_output ?? "";
|
||||
|
||||
@@ -24,10 +24,14 @@ async function proxyModelsRequest(
|
||||
if (!account) return Response.json({ models: [] }, { status: 401 });
|
||||
|
||||
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,
|
||||
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",
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
@@ -6,7 +6,9 @@ export async function GET() {
|
||||
const account = await getCurrentAccount();
|
||||
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",
|
||||
});
|
||||
const payload = await response.text();
|
||||
|
||||
@@ -1061,15 +1061,30 @@ const ActivityChainSummary: FC<{
|
||||
partIndex: number | undefined;
|
||||
}> = ({ messageId, partIndex }) => {
|
||||
const { openItem } = useActivityPanel();
|
||||
const startedAtRef = useRef(Date.now());
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const label = useAuiState((s) => {
|
||||
const message = s.message;
|
||||
return summarizeActivityChainLabel(message.content, message.status?.type);
|
||||
return summarizeActivityChainLabel(
|
||||
message.content,
|
||||
message.status?.type,
|
||||
elapsedMs,
|
||||
);
|
||||
});
|
||||
const active = useAuiState((s) => {
|
||||
const message = s.message;
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1085,14 +1100,22 @@ const ActivityChainSummary: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
type ActivitySummaryPart = {
|
||||
type: string;
|
||||
text?: string;
|
||||
result?: unknown;
|
||||
args?: unknown;
|
||||
toolName?: string;
|
||||
};
|
||||
|
||||
function summarizeActivityChainLabel(
|
||||
content: readonly { type: string; text?: string; result?: unknown }[],
|
||||
content: readonly ActivitySummaryPart[],
|
||||
status: string | undefined,
|
||||
elapsedMs = 0,
|
||||
) {
|
||||
const toolCount = content.filter((part) => part.type === "tool-call").length;
|
||||
const runningToolCount = content.filter(
|
||||
(part) => part.type === "tool-call" && part.result === undefined,
|
||||
).length;
|
||||
const toolParts = content.filter((part) => part.type === "tool-call");
|
||||
const runningTool = toolParts.findLast((part) => part.result === undefined);
|
||||
const activeTool = runningTool ?? toolParts.at(-1);
|
||||
const reasoningText =
|
||||
content.find((part) => part.type === "reasoning")?.text ?? "";
|
||||
const lastReasoningLine = reasoningText
|
||||
@@ -1101,19 +1124,64 @@ function summarizeActivityChainLabel(
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
const stageNote = activeTool ? getToolStageNote(activeTool.args) : "";
|
||||
const activeDetail =
|
||||
stageNote ||
|
||||
(activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") ||
|
||||
(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);
|
||||
|
||||
if (status === "running") {
|
||||
return runningToolCount > 0 ? "调用工具中" : "思考中";
|
||||
const elapsed = `用时 ${formatActivityDuration(elapsedMs)}`;
|
||||
if (activeDetail) {
|
||||
return `${compactActivityLabel(activeDetail, 15)},${elapsed}`;
|
||||
}
|
||||
return `思考中,${elapsed}`;
|
||||
}
|
||||
|
||||
const label = compactActivityLabel(lastReasoningLine || "思考完成");
|
||||
return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label;
|
||||
const label = compactActivityLabel(
|
||||
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();
|
||||
if (normalized.length <= 36) return normalized;
|
||||
return `${normalized.slice(0, 35)}…`;
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, Math.max(1, maxLength - 1))}…`;
|
||||
}
|
||||
|
||||
const AssistantActionBar: FC = () => {
|
||||
@@ -1251,10 +1319,13 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
const [localText, setLocalText] = useState(storeText);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
const justSubmittedRef = useRef(false);
|
||||
const isDisabled = runtimeDisabled || disabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isComposingRef.current) {
|
||||
if (justSubmittedRef.current && storeText) return;
|
||||
if (!storeText) justSubmittedRef.current = false;
|
||||
setLocalText(storeText);
|
||||
}
|
||||
}, [storeText]);
|
||||
@@ -1277,6 +1348,19 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
}, [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(
|
||||
(value: string) => {
|
||||
if (!aui.composer().getState().isEditing) return;
|
||||
@@ -1324,6 +1408,7 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
event.preventDefault();
|
||||
syncComposerText(localText);
|
||||
aui.composer().send();
|
||||
justSubmittedRef.current = true;
|
||||
setLocalText("");
|
||||
};
|
||||
|
||||
@@ -1336,6 +1421,7 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
onChange={(event) => {
|
||||
onChange?.(event);
|
||||
const nextText = event.currentTarget.value;
|
||||
justSubmittedRef.current = false;
|
||||
setLocalText(nextText);
|
||||
const isNativeComposing =
|
||||
(event.nativeEvent as { isComposing?: boolean }).isComposing === true;
|
||||
|
||||
+20
-3
@@ -22,6 +22,7 @@ class PromptContext:
|
||||
is_git_repo: bool
|
||||
is_git_worktree: bool
|
||||
scratchpad_directory: str | None = None
|
||||
python_env_directory: str | None = None
|
||||
additional_working_directories: tuple[str, ...] = ()
|
||||
user_context: dict[str, str] = field(default_factory=dict)
|
||||
system_context: dict[str, str] = field(default_factory=dict)
|
||||
@@ -56,6 +57,11 @@ def build_prompt_context(
|
||||
is_git_repo=snapshot.is_git_repo,
|
||||
is_git_worktree=snapshot.is_git_worktree,
|
||||
scratchpad_directory=snapshot.scratchpad_directory,
|
||||
python_env_directory=(
|
||||
str(runtime_config.python_env_dir.resolve())
|
||||
if runtime_config.python_env_dir is not None
|
||||
else None
|
||||
),
|
||||
additional_working_directories=snapshot.additional_working_directories,
|
||||
user_context=snapshot.user_context,
|
||||
system_context=snapshot.system_context,
|
||||
@@ -154,7 +160,9 @@ def get_doing_tasks_section() -> str:
|
||||
'除非确实需要新文件,否则优先编辑现有文件。',
|
||||
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
||||
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python。',
|
||||
'一次性脚本放到临时目录或任务专属目录;只有用户需要长期复用时,才加入项目代码。',
|
||||
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件。',
|
||||
'如果确实需要临时脚本、缓存或中间产物,必须写入当前会话 scratchpad 目录,或写入明确的任务产物目录;禁止在项目根目录创建 analyze_*.py、tmp_*.py、scratch_*.py 等临时脚本。',
|
||||
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
|
||||
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
|
||||
'注意不要引入命令注入、SQL 注入、XSS 或不安全 shell 行为等安全问题。',
|
||||
'如实汇报结果。如果没有运行某个验证步骤,需要明确说明。',
|
||||
@@ -201,10 +209,17 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
|
||||
'需要结构化文件分析、批量数据处理、JSON/JSONL 转换、抽样、校验或快速计算时,必须优先使用 python_exec,而不是通过 bash 手写 python 命令。'
|
||||
)
|
||||
items.append(
|
||||
'python_exec 默认使用项目 .venv/bin/python;不要用它安装依赖。缺少依赖时先说明缺失包并向用户确认包管理方式。'
|
||||
'python_exec 默认使用当前用户独立 Python venv,不使用项目 .venv;不要用 bash 执行 python 或 pip。'
|
||||
)
|
||||
items.append(
|
||||
'如果已经用 write_file 生成了临时 Python 脚本,下一步也应使用 python_exec 的 script_path 执行该脚本,不要改用 bash。'
|
||||
'python_exec 会注入 PYTHON_EXEC_SCRATCHPAD 环境变量,指向当前用户当前会话隔离的 scratchpad。一次性脚本、缓存和中间输出都应写入这里。'
|
||||
)
|
||||
if 'python_package' in enabled_tool_names:
|
||||
items.append(
|
||||
'当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。'
|
||||
)
|
||||
items.append(
|
||||
'不要用 write_file 在项目根目录生成临时 Python 脚本;如果用户明确要求保留脚本,才写入合适的项目路径,并用 python_exec 的 script_path 执行。'
|
||||
)
|
||||
if 'bash' in enabled_tool_names:
|
||||
items.append(
|
||||
@@ -444,6 +459,8 @@ def compute_simple_env_info(prompt_context: PromptContext) -> str:
|
||||
items.append(list(prompt_context.additional_working_directories))
|
||||
if prompt_context.scratchpad_directory:
|
||||
items.append(f'会话 scratchpad 目录: {prompt_context.scratchpad_directory}')
|
||||
if prompt_context.python_env_directory:
|
||||
items.append(f'当前用户 Python venv: {prompt_context.python_env_directory}')
|
||||
items.extend(
|
||||
[
|
||||
f'平台: {prompt_context.platform_name}',
|
||||
|
||||
+61
-7
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from .account_runtime import AccountRuntime
|
||||
@@ -68,6 +68,43 @@ from .tokenizer_runtime import describe_token_counter
|
||||
from .workflow_runtime import WorkflowRuntime
|
||||
from .worktree_runtime import WorktreeRuntime
|
||||
from .session_env_vars import clear_session_env_vars
|
||||
|
||||
RuntimeEventSink = Callable[[dict[str, object]], None]
|
||||
|
||||
|
||||
class _RuntimeEventBuffer(list[dict[str, object]]):
|
||||
def __init__(self, event_sink: RuntimeEventSink | None = None) -> None:
|
||||
super().__init__()
|
||||
self._event_sink = event_sink
|
||||
|
||||
def append(self, event: dict[str, object]) -> None: # type: ignore[override]
|
||||
super().append(event)
|
||||
if self._event_sink is not None:
|
||||
self._event_sink(dict(event))
|
||||
|
||||
def extend(self, events: Any) -> None: # type: ignore[override]
|
||||
for event in events:
|
||||
self.append(event)
|
||||
|
||||
|
||||
def _append_final_text_stream_events(
|
||||
stream_events: list[dict[str, object]],
|
||||
text: str,
|
||||
) -> None:
|
||||
if not text:
|
||||
return
|
||||
stream_events.append({'type': 'final_text_start'})
|
||||
chunk_size = 96
|
||||
for start in range(0, len(text), chunk_size):
|
||||
stream_events.append(
|
||||
{
|
||||
'type': 'final_text_delta',
|
||||
'delta': text[start : start + chunk_size],
|
||||
}
|
||||
)
|
||||
stream_events.append({'type': 'final_text_end'})
|
||||
|
||||
|
||||
from .session_store import (
|
||||
StoredAgentSession,
|
||||
load_agent_session,
|
||||
@@ -375,6 +412,7 @@ class LocalCodingAgent:
|
||||
session_id: str | None = None,
|
||||
*,
|
||||
runtime_context: str | None = None,
|
||||
event_sink: RuntimeEventSink | None = None,
|
||||
) -> AgentRunResult:
|
||||
self.managed_agent_id = None
|
||||
self.resume_source_session_id = None
|
||||
@@ -389,6 +427,7 @@ class LocalCodingAgent:
|
||||
scratchpad_directory=scratchpad_directory,
|
||||
existing_file_history=(),
|
||||
runtime_context=runtime_context,
|
||||
event_sink=event_sink,
|
||||
)
|
||||
self._accumulate_usage(result)
|
||||
self._finalize_managed_agent(result)
|
||||
@@ -400,6 +439,7 @@ class LocalCodingAgent:
|
||||
stored_session: StoredAgentSession,
|
||||
*,
|
||||
runtime_context: str | None = None,
|
||||
event_sink: RuntimeEventSink | None = None,
|
||||
) -> AgentRunResult:
|
||||
self.managed_agent_id = None
|
||||
self.resume_source_session_id = stored_session.session_id
|
||||
@@ -435,6 +475,7 @@ class LocalCodingAgent:
|
||||
scratchpad_directory=scratchpad_directory,
|
||||
existing_file_history=stored_session.file_history,
|
||||
runtime_context=runtime_context,
|
||||
event_sink=event_sink,
|
||||
)
|
||||
self._accumulate_usage(result)
|
||||
self._finalize_managed_agent(result)
|
||||
@@ -449,6 +490,7 @@ class LocalCodingAgent:
|
||||
scratchpad_directory: Path | None,
|
||||
existing_file_history: tuple[dict[str, object], ...],
|
||||
runtime_context: str | None = None,
|
||||
event_sink: RuntimeEventSink | None = None,
|
||||
) -> AgentRunResult:
|
||||
slash_result = preprocess_slash_command(self, prompt)
|
||||
if slash_result.handled and not slash_result.should_query:
|
||||
@@ -495,6 +537,10 @@ class LocalCodingAgent:
|
||||
session.append_user(effective_prompt, model_content=model_prompt)
|
||||
self.last_session = session
|
||||
self.active_session_id = session_id
|
||||
self.tool_context = replace(
|
||||
self.tool_context,
|
||||
scratchpad_directory=scratchpad_directory,
|
||||
)
|
||||
tool_specs = [tool.to_openai_tool() for tool in self.tool_registry.values()]
|
||||
starting_usage = UsageStats()
|
||||
starting_cost_usd = 0.0
|
||||
@@ -525,7 +571,7 @@ class LocalCodingAgent:
|
||||
total_usage = starting_usage
|
||||
total_cost_usd = starting_cost_usd
|
||||
file_history = list(existing_file_history)
|
||||
stream_events: list[dict[str, object]] = []
|
||||
stream_events: list[dict[str, object]] = _RuntimeEventBuffer(event_sink)
|
||||
assistant_response_segments: list[str] = []
|
||||
delegated_tasks = sum(
|
||||
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent')
|
||||
@@ -715,8 +761,10 @@ class LocalCodingAgent:
|
||||
)
|
||||
last_content = ''.join(assistant_response_segments)
|
||||
continue
|
||||
final_output = ''.join(assistant_response_segments)
|
||||
_append_final_text_stream_events(stream_events, final_output)
|
||||
result = AgentRunResult(
|
||||
final_output=''.join(assistant_response_segments),
|
||||
final_output=final_output,
|
||||
turns=turn_index,
|
||||
tool_calls=tool_calls,
|
||||
transcript=session.transcript(),
|
||||
@@ -816,8 +864,10 @@ class LocalCodingAgent:
|
||||
)
|
||||
last_content = ''.join(assistant_response_segments)
|
||||
continue
|
||||
final_output = ''.join(assistant_response_segments)
|
||||
_append_final_text_stream_events(stream_events, final_output)
|
||||
result = AgentRunResult(
|
||||
final_output=''.join(assistant_response_segments),
|
||||
final_output=final_output,
|
||||
turns=turn_index,
|
||||
tool_calls=tool_calls,
|
||||
transcript=session.transcript(),
|
||||
@@ -896,6 +946,8 @@ class LocalCodingAgent:
|
||||
'type': 'tool_start',
|
||||
'tool_name': tool_call.name,
|
||||
'tool_call_id': tool_call.id,
|
||||
'arguments': dict(tool_call.arguments),
|
||||
'assistant_content': turn.content,
|
||||
'message_id': session.messages[tool_message_index].message_id,
|
||||
}
|
||||
)
|
||||
@@ -1194,11 +1246,13 @@ class LocalCodingAgent:
|
||||
self.last_run_result = result
|
||||
return result
|
||||
|
||||
result = AgentRunResult(
|
||||
final_output=(
|
||||
final_output = (
|
||||
last_content
|
||||
or 'Stopped: max turns reached before the model produced a final answer.'
|
||||
),
|
||||
)
|
||||
_append_final_text_stream_events(stream_events, final_output)
|
||||
result = AgentRunResult(
|
||||
final_output=final_output,
|
||||
turns=self.runtime_config.max_turns,
|
||||
tool_calls=tool_calls,
|
||||
transcript=session.transcript(),
|
||||
|
||||
+164
-6
@@ -76,6 +76,8 @@ class ToolExecutionContext:
|
||||
command_timeout_seconds: float
|
||||
max_output_chars: int
|
||||
permissions: AgentPermissions
|
||||
scratchpad_directory: Path | None = None
|
||||
python_env_dir: Path | None = None
|
||||
extra_env: dict[str, str] = field(default_factory=dict)
|
||||
tool_registry: dict[str, 'AgentTool'] | None = None
|
||||
search_runtime: 'SearchRuntime | None' = None
|
||||
@@ -152,6 +154,8 @@ class ToolStreamUpdate:
|
||||
def build_tool_context(
|
||||
config: AgentRuntimeConfig,
|
||||
*,
|
||||
scratchpad_directory: Path | None = None,
|
||||
python_env_dir: Path | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
tool_registry: dict[str, AgentTool] | None = None,
|
||||
search_runtime: 'SearchRuntime | None' = None,
|
||||
@@ -173,6 +177,14 @@ def build_tool_context(
|
||||
command_timeout_seconds=config.command_timeout_seconds,
|
||||
max_output_chars=config.max_output_chars,
|
||||
permissions=config.permissions,
|
||||
scratchpad_directory=scratchpad_directory.resolve() if scratchpad_directory else None,
|
||||
python_env_dir=(
|
||||
python_env_dir.resolve()
|
||||
if python_env_dir
|
||||
else config.python_env_dir.resolve()
|
||||
if config.python_env_dir
|
||||
else None
|
||||
),
|
||||
extra_env=dict(extra_env or {}),
|
||||
tool_registry=tool_registry,
|
||||
search_runtime=search_runtime,
|
||||
@@ -339,20 +351,22 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
name='python_exec',
|
||||
description=(
|
||||
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用项目 .venv/bin/python;'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;'
|
||||
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
|
||||
'缺包时应向用户确认后再处理依赖。'
|
||||
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;如需写临时文件,'
|
||||
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
|
||||
'不要在项目根目录创建临时 .py 脚本。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'code': {
|
||||
'type': 'string',
|
||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一。',
|
||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
|
||||
},
|
||||
'script_path': {
|
||||
'type': 'string',
|
||||
'description': '工作区内已有 Python 脚本路径。code 和 script_path 必须二选一。',
|
||||
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
|
||||
},
|
||||
'args': {
|
||||
'type': 'array',
|
||||
@@ -377,12 +391,49 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
},
|
||||
handler=_run_python_exec,
|
||||
),
|
||||
AgentTool(
|
||||
name='python_package',
|
||||
description=(
|
||||
'在当前用户独立 Python venv 中检查或安装 Python 包。'
|
||||
'用于 python_exec 缺少 pandas、pyarrow、openpyxl 等分析依赖时;'
|
||||
'不要通过 bash 执行 pip,也不要安装到项目 .venv。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'action': {
|
||||
'type': 'string',
|
||||
'enum': ['show', 'install'],
|
||||
'description': 'show 查看当前 Python 环境和 pip;install 安装 packages。',
|
||||
},
|
||||
'packages': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': '要安装的包名或 pip requirement spec,例如 pandas、pyarrow==15.0.0。',
|
||||
},
|
||||
'timeout_seconds': {
|
||||
'type': 'number',
|
||||
'minimum': 1,
|
||||
'maximum': 600,
|
||||
'description': '安装超时时间,默认 120 秒。',
|
||||
},
|
||||
'max_output_chars': {
|
||||
'type': 'integer',
|
||||
'minimum': 1000,
|
||||
'maximum': 50000,
|
||||
},
|
||||
},
|
||||
'required': ['action'],
|
||||
},
|
||||
handler=_run_python_package,
|
||||
),
|
||||
AgentTool(
|
||||
name='bash',
|
||||
description=(
|
||||
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
||||
'不要用 bash 执行 python、python3 或 .venv/bin/python;结构化文件分析、'
|
||||
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
||||
'Python 包安装必须优先使用 python_package。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -2604,6 +2655,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
payload = [
|
||||
'timed_out=true',
|
||||
f'timeout_seconds={timeout_seconds:g}',
|
||||
f'scratchpad={context.scratchpad_directory or ""}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
@@ -2615,6 +2668,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
'action': 'python_exec',
|
||||
'mode': mode,
|
||||
'interpreter': interpreter,
|
||||
'scratchpad_directory': str(context.scratchpad_directory) if context.scratchpad_directory else '',
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'timed_out': True,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
@@ -2628,6 +2683,8 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
payload = [
|
||||
f'exit_code={completed.returncode}',
|
||||
f'interpreter={interpreter}',
|
||||
f'scratchpad={context.scratchpad_directory or ""}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
@@ -2639,6 +2696,94 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
'action': 'python_exec',
|
||||
'mode': mode,
|
||||
'interpreter': interpreter,
|
||||
'scratchpad_directory': str(context.scratchpad_directory) if context.scratchpad_directory else '',
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'exit_code': completed.returncode,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_process_execution_allowed(context, 'Python package management')
|
||||
action = _require_string(arguments, 'action')
|
||||
interpreter = _resolve_python_interpreter(context)
|
||||
timeout_seconds = _coerce_float(
|
||||
arguments,
|
||||
'timeout_seconds',
|
||||
min(context.command_timeout_seconds * 4, 120.0),
|
||||
)
|
||||
max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars)
|
||||
if action == 'show':
|
||||
command = [interpreter, '-m', 'pip', '--version']
|
||||
elif action == 'install':
|
||||
packages = arguments.get('packages')
|
||||
if not isinstance(packages, list) or not packages:
|
||||
raise ToolExecutionError('packages must be a non-empty array for action=install')
|
||||
package_args = [str(package).strip() for package in packages if str(package).strip()]
|
||||
if not package_args:
|
||||
raise ToolExecutionError('packages must contain at least one non-empty package name')
|
||||
command = [interpreter, '-m', 'pip', 'install', *package_args]
|
||||
else:
|
||||
raise ToolExecutionError('action must be "show" or "install"')
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=context.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
env=_build_subprocess_env(context),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout if isinstance(exc.stdout, str) else ''
|
||||
stderr = exc.stderr if isinstance(exc.stderr, str) else ''
|
||||
payload = [
|
||||
'timed_out=true',
|
||||
f'timeout_seconds={timeout_seconds:g}',
|
||||
f'interpreter={interpreter}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
stderr.rstrip(),
|
||||
]
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
'action': 'python_package',
|
||||
'package_action': action,
|
||||
'interpreter': interpreter,
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'timed_out': True,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
)
|
||||
|
||||
stdout = completed.stdout or ''
|
||||
stderr = completed.stderr or ''
|
||||
payload = [
|
||||
f'exit_code={completed.returncode}',
|
||||
f'interpreter={interpreter}',
|
||||
f'python_env={context.python_env_dir or ""}',
|
||||
'[stdout]',
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
stderr.rstrip(),
|
||||
]
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
'action': 'python_package',
|
||||
'package_action': action,
|
||||
'interpreter': interpreter,
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'exit_code': completed.returncode,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
@@ -2648,7 +2793,11 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
|
||||
|
||||
def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
|
||||
# 优先使用项目虚拟环境,保证数据处理依赖和 agent 运行环境一致。
|
||||
# 优先使用当前用户独立 Python 环境,避免包安装污染项目 .venv 或系统环境。
|
||||
if context.python_env_dir is not None:
|
||||
user_python = context.python_env_dir / 'bin' / 'python'
|
||||
if user_python.exists() and os.access(user_python, os.X_OK):
|
||||
return str(user_python)
|
||||
venv_python = context.root / '.venv' / 'bin' / 'python'
|
||||
if venv_python.exists() and os.access(venv_python, os.X_OK):
|
||||
return str(venv_python)
|
||||
@@ -4395,6 +4544,15 @@ def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]:
|
||||
for key, value in get_session_env_vars().items():
|
||||
env[key] = value
|
||||
env.update(context.extra_env)
|
||||
if context.scratchpad_directory is not None:
|
||||
env['PYTHON_EXEC_SCRATCHPAD'] = str(context.scratchpad_directory)
|
||||
if context.python_env_dir is not None:
|
||||
env['VIRTUAL_ENV'] = str(context.python_env_dir)
|
||||
env['PYTHON_EXEC_VENV'] = str(context.python_env_dir)
|
||||
env['PYTHONNOUSERSITE'] = '1'
|
||||
bin_dir = str(context.python_env_dir / 'bin')
|
||||
existing_path = env.get('PATH', '')
|
||||
env['PATH'] = f'{bin_dir}{os.pathsep}{existing_path}' if existing_path else bin_dir
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ class AgentRuntimeConfig:
|
||||
output_schema: OutputSchemaConfig | None = None
|
||||
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
|
||||
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
|
||||
python_env_dir: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -186,6 +186,11 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict:
|
||||
),
|
||||
'session_directory': str(runtime_config.session_directory),
|
||||
'scratchpad_root': str(runtime_config.scratchpad_root),
|
||||
'python_env_dir': (
|
||||
str(runtime_config.python_env_dir)
|
||||
if runtime_config.python_env_dir is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +235,11 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
|
||||
output_schema=_deserialize_output_schema(output_schema_payload),
|
||||
session_directory=Path(str(payload.get('session_directory', DEFAULT_AGENT_SESSION_DIR))).resolve(),
|
||||
scratchpad_root=Path(str(payload.get('scratchpad_root', DEFAULT_SESSION_DIR / 'scratchpad'))).resolve(),
|
||||
python_env_dir=(
|
||||
Path(str(payload['python_env_dir'])).resolve()
|
||||
if payload.get('python_env_dir') is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,35 @@ class GuiServerTests(unittest.TestCase):
|
||||
self.assertEqual(payload['account_id'], 'alice')
|
||||
self.assertIn('/accounts/alice/sessions', payload['session_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:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
@@ -132,6 +161,28 @@ class GuiServerTests(unittest.TestCase):
|
||||
payload = response.json()
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
@@ -29,6 +30,97 @@ class PythonExecToolTests(TestCase):
|
||||
self.assertEqual(result.metadata.get('action'), 'python_exec')
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = AgentRuntimeConfig(cwd=Path(tmp_dir))
|
||||
@@ -67,4 +159,3 @@ class PythonExecToolTests(TestCase):
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('a|b', result.content)
|
||||
self.assertEqual(result.metadata.get('mode'), 'script')
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ class TestRuntimeConfigSerialization(unittest.TestCase):
|
||||
),
|
||||
session_directory=Path('/sessions'),
|
||||
scratchpad_root=Path('/scratch'),
|
||||
python_env_dir=Path('/python/.venv'),
|
||||
)
|
||||
payload = serialize_runtime_config(config)
|
||||
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.schema, config.output_schema.schema)
|
||||
self.assertTrue(restored.output_schema.strict)
|
||||
self.assertEqual(restored.python_env_dir, Path('/python/.venv'))
|
||||
|
||||
def test_round_trip_none_output_schema(self) -> None:
|
||||
config = AgentRuntimeConfig(
|
||||
|
||||
Reference in New Issue
Block a user