Files
zk-data-agent/backend/api/server.py
T
2026-06-12 20:17:20 +08:00

9099 lines
337 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI server backing the local web GUI.
Wraps a single global :class:`LocalCodingAgent`, exposes JSON endpoints for
chat, slash commands, and saved sessions, and serves the static SPA.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
import csv
from datetime import datetime, timedelta
import sys
import hashlib
import shlex
import json
import os
import pwd
import queue
import re
import shutil
import signal
import subprocess
import threading
import time
import venv
from dataclasses import dataclass, field, replace
from pathlib import Path
from threading import Lock, RLock
from typing import Any, Callable, Iterable
from urllib import error, request
from urllib.parse import quote, unquote, urlparse
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from src.agent_session import AgentSessionState
from src.agent_runtime import LocalCodingAgent
from src.agent_slash_commands import get_slash_command_specs
from src.agent_types import (
AgentPermissions,
AgentRuntimeConfig,
ModelConfig,
)
from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills
from src.data_agent_inputs import DataAgentInputError, load_input_sources
from src.jupyter_runtime import (
DEFAULT_JUPYTER_WORKSPACE_ROOT,
JupyterRuntimeError,
JupyterRuntimeManager,
JupyterRuntimeSession,
)
from src.mcp_runtime import MCPRuntime, MCPServerProfile
from src.openai_compat import OpenAICompatClient, OpenAICompatError
from src.personal_memory import (
SKILL_MEMORY_DIRNAME,
USER_MEMORY_FILENAME,
PersonalMemoryManager,
)
from src.bash_bg_store import (
ACTIVE_BG_STATUSES,
TERMINAL_BG_STATUSES,
BashBgStore,
BgTaskSpec,
BgTaskStatus,
)
from src.run_state_store import ACTIVE_RUN_STATUSES, RunStateStore
from src.session_store import (
DEFAULT_AGENT_SESSION_DIR,
StoredAgentSession,
append_agent_display_message,
agent_session_delta,
cancel_session_input_queue,
consume_next_session_input,
consume_session_guidance,
delete_agent_session,
deserialize_runtime_config,
enqueue_session_input,
list_agent_sessions,
list_session_input_queue,
load_agent_session,
save_agent_session,
serialize_model_config,
serialize_runtime_config,
update_session_input_queue_item,
)
from src.token_budget import calculate_token_budget
LINUX_ACCOUNT_WORKSPACE_NAME = 'zk-agent'
LINUX_ACCOUNT_ENV = 'CLAW_ENABLE_LINUX_ACCOUNTS'
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
API_TOOL_CONTENT_MAX_CHARS = 20000
FEISHU_NPM_REGISTRY = 'https://pkgs.d.xiaomi.net/artifactory/api/npm/mi-npm/'
FEISHU_MCP_PACKAGE = '@mi/feishu-mcp-pro@latest'
FEISHU_MCP_SERVER_NAME = 'feishu-mcp-pro'
FEISHU_DOCUMENT_SUFFIXES = {'.docx', '.md', '.txt'}
FEISHU_SPREADSHEET_SUFFIXES = {'.csv', '.xlsx'}
FEISHU_SUPPORTED_DOCUMENT_SUFFIXES = FEISHU_DOCUMENT_SUFFIXES | FEISHU_SPREADSHEET_SUFFIXES
FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES = {'.json', '.jsonl'}
FEISHU_DOC_MARKDOWN_MAX_CHARS = 100_000
FEISHU_SPREADSHEET_MAX_ROWS = 5000
FEISHU_SPREADSHEET_MAX_COLS = 200
FEISHU_SPREADSHEET_MAX_CELL_CHARS = 5000
FEISHU_SPREADSHEET_WRITE_BATCH_ROWS = 500
FEISHU_ONLINE_DOCS_FILENAME = 'online-docs.json'
JUPYTER_WORKSPACE_FILENAME = 'jupyter_workspace.json'
JUPYTER_WORKSPACES_REGISTRY_FILENAME = 'workspaces.json'
JUPYTER_STREAM_CHUNK_BYTES = 1024 * 1024
FEISHU_REMOTE_DOC_MAX_BYTES = 50 * 1024 * 1024
FEISHU_REMOTE_SHEET_MAX_BYTES = 100 * 1024 * 1024
@dataclass
class FeishuLoginProcess:
process: subprocess.Popen[str]
started_at: float
output: list[str] = field(default_factory=list)
login_url: str | None = None
user_code: str | None = None
_FEISHU_LOGIN_PROCESSES: dict[str, FeishuLoginProcess] = {}
_FEISHU_LOGIN_LOCK = threading.Lock()
_FEISHU_DOC_MAP_LOCK = threading.Lock()
VALIDATED_CHAT_MODEL_PROVIDERS = {
# 这些 provider 已验证可以在当前 WebUI 中使用。
# 部分模型族会在客户端按模型 id 切换到专用兼容路由。
'azure_openai',
'hunyuan',
'minimax',
'moonshot',
'ppio',
'siliconflow',
'tongyi',
'vertex_ai',
'volcengine_maas',
'wenxin',
'xiaomi',
'zhipuai',
}
UNSUPPORTED_TOOL_CHAT_MODEL_IDS = {
# 这些模型来自 /models,但带 tools/tool_choice 的最小 Agent 请求实测失败。
'azure_openai/gpt-5.5',
'azure_openai/grok-3',
'azure_openai/grok-3-mini',
'minimax/abab6.5t-chat',
'ppio/gemini-2.0-flash-20250609',
'siliconflow/deepseek-ai/deepseek-ocr',
'siliconflow/deepseek-ai/deepseek-r1-distill-qwen-14b',
'siliconflow/deepseek-ai/deepseek-r1-distill-qwen-32b',
'siliconflow/deepseek-ai/deepseek-r1-distill-qwen-7b',
'siliconflow/qwen/qwen2.5-coder-32b-instruct',
'siliconflow/qwen/qwen2.5-vl-32b-instruct',
'siliconflow/qwen/qwen2.5-vl-72b-instruct',
'siliconflow/qwen/qwen3-235b-a22b',
'siliconflow/qwen/qwen3-235b-a22b-thinking-2507',
'siliconflow/qwen/qwen3-30b-a3b-instruct-2507',
'siliconflow/qwen/qwen3-vl-8b-instruct',
'siliconflow/qwen/qwen3-vl-8b-thinking',
'siliconflow/thudm/glm-4-9b-chat',
'siliconflow/thudm/glm-4.1v-9b-thinking',
'siliconflow/zai-org/glm-4.5',
'tongyi/deepseek-r1-distill-qwen-14b',
'tongyi/deepseek-r1-distill-qwen-32b',
'tongyi/qwen-mt-plus',
'tongyi/qwen-mt-turbo',
'tongyi/qwen-plus-0919',
'tongyi/qwen2.5-0.5b-instruct',
'tongyi/qwen2.5-1.5b-instruct',
'tongyi/qwen3-0.6b',
'tongyi/qwen3-1.7b',
'tongyi/qwen3-30b-a3b',
'tongyi/qwen3-4b',
'tongyi/qwen3-8b',
'vertex_ai/gemini-2.0-flash-001',
'vertex_ai/gemini-2.0-flash-lite-001',
'vertex_ai/gemini-2.5-computer-use-preview-10-2025',
'vertex_ai/gemini-2.5-flash',
'vertex_ai/gemini-2.5-flash-lite',
'vertex_ai/gemini-2.5-flash-lite-preview-09-2025',
'vertex_ai/gemini-2.5-pro',
'vertex_ai/gemini-3-flash-preview',
'vertex_ai/gemini-3.1-flash-lite-preview',
'vertex_ai/gemini-3.1-pro-preview',
'vertex_ai/gemini-3.1-pro-preview-pt',
'wenxin/ernie-speed-128k',
'wenxin/ernie-speed-8k',
'xiaomi/mi-brag-vl',
'xiaomi/midashenglm-7b-1021-bf16',
'xiaomi/milm2.1-13b-chat',
'xiaomi/mimo-vl-7b-rl',
'xiaomi/mimo-vl-7b-rl-0808',
'xiaomi/paddleocr-vl-0.9b',
'xiaomi/qwen-235b-a22b',
'xiaomi/qwen2.5-72b-instruct-gptq-int4',
'xiaomi/qwen2.5-vl-72b-instruct-awq',
'xiaomi/qwen25-coder-7b',
'xiaomi/qwen3-235b-a22b',
'xiaomi/qwen3-235b-a22b-instruct-2507',
'xiaomi/qwen3-32b',
}
# WebUI 的快速入口只展示可以通过对话输入框安全执行的 slash command。
# 这些命令仍然保留在终端 `/` 列表里,但不适合作为 WebUI 快捷项。
WEBUI_HIDDEN_SLASH_COMMANDS = {
'exit',
'quit',
'feedback',
'bug',
'upgrade',
'stickers',
'chrome',
'install-github-app',
'install-slack-app',
'privacy-settings',
'mobile',
'ios',
'android',
'desktop',
'app',
'vim',
'theme',
}
WEBUI_SLASH_COMMAND_DESCRIPTIONS_ZH = {
'help': '查看内置 slash command 帮助。',
'context': '查看当前会话上下文用量估算。',
'context-raw': '查看原始环境、用户上下文和系统上下文快照。',
'token-budget': '查看当前 token 预算窗口、保留量和提示词长度限制。',
'mcp': '查看本地 MCP 清单和资源数量。',
'search': '查看搜索运行状态、切换搜索提供方,或执行一次网页搜索。',
'remote': '查看远程运行状态,或激活远程目标/配置。',
'worktree': '查看受管 git worktree 状态,或进入/退出当前 worktree 会话。',
'account': '查看账号运行状态或已配置账号档案。',
'ask': '查看 ask-user 运行状态或历史记录。',
'login': '激活本地账号档案或临时身份。',
'logout': '清除当前本地账号会话。',
'config': '查看配置状态、有效配置、配置来源或某个配置值。',
'lsp': '查看 LSP 状态,或执行符号、定义、引用、悬停、调用层级和诊断查询。',
'remotes': '列出本地远程配置。',
'ssh': '激活 SSH 远程目标/配置。',
'teleport': '激活 Teleport 远程目标/配置。',
'direct-connect': '激活直连远程目标/配置。',
'deep-link': '激活 deep-link 远程目标/配置。',
'disconnect': '断开当前远程运行目标。',
'resources': '列出本地 MCP 资源,可按关键词过滤。',
'resource': '按 URI 渲染一个本地 MCP 资源。',
'tasks': '查看本地运行时任务列表,可按状态过滤。',
'workflows': '列出从工作流清单发现的本地 workflows。',
'workflow': '查看或运行一个本地 workflow。',
'triggers': '列出从清单发现的本地远程触发器。',
'trigger': '查看或运行一个远程触发器。',
'teams': '列出本地协作团队配置。',
'team': '查看一个本地协作团队。',
'messages': '查看所有团队或某个团队的协作消息。',
'task-next': '查看本地任务列表中的下一个可执行任务。',
'plan': '查看当前本地运行计划。',
'task': '按 id 查看一个本地任务。',
'prompt': '渲染当前生效的系统提示词。',
'permissions': '查看当前工具权限模式。',
'hooks': '查看已发现的本地 hook 和 policy 清单。',
'trust': '查看 workspace 信任模式、受管设置和安全环境变量。',
'model': '查看或修改当前 Agent 实例使用的模型。',
'tools': '列出已注册工具,以及当前权限是否允许调用。',
'agents': '列出本地 Agent 配置,或查看某个 Agent 定义。',
'memory': '查看当前加载的 CLAUDE.md 记忆包和已发现文件。',
'status': '查看当前运行时和会话状态摘要。',
'clear': '清理当前进程中的临时运行状态。',
'compact': '总结并压缩当前对话,释放上下文空间。',
'cost': '查看当前会话的总耗时和费用估算。',
'diff': '查看当前工作区未提交改动。',
'files': '列出当前会话上下文中已加载的文件。',
'copy': '把上一条助手回复写入临时文件。',
'export': '导出当前对话为文本文件。',
'stats': '查看会话使用统计。',
'tag': '给当前会话添加或移除可搜索标签。',
'rename': '重命名当前对话。',
'branch': '基于当前对话创建一个分支/副本。',
'effort': '查看或设置模型推理强度。',
'doctor': '诊断并验证 claw-code 安装和配置。',
'commit': '创建 git commit。',
'pr-comments': '读取 GitHub PR 评论。',
'resume': '恢复之前的对话。',
'add-dir': '添加新的工作目录。',
'skills': '列出可用 Skills。',
'fast': '切换 fast mode。',
'rewind': '把对话恢复到之前的 checkpoint。',
'output-style': '已废弃:请使用 /config 修改输出风格。',
'release-notes': '查看本地 release notes 或变更日志链接。',
'extra-usage': '查看 extra-usage 配置链接。',
'passes': '查看 Claude Code guest passes 信息。',
'rate-limit-options': '查看账号触发限流时的可选处理方式。',
'reload-plugins': '重新加载本地插件清单并报告数量。',
'voice': '切换当前 workspace 的 voice mode 设置。',
'sandbox-toggle': '查看 sandbox 状态,或排除一条命令模式。',
'keybindings': '打印或创建本地快捷键配置文件。',
'btw': '向模型快速提一个旁路问题,不修改当前任务状态。',
'version': '打印当前 Agent 版本。',
'init': '初始化 CLAUDE.md 代码库说明文件。',
'ide': '查看检测到的 IDE/终端集成状态。',
'plugin': '列出已安装插件,或查看插件子命令用法。',
'remote-env': '列出远程环境,或设置默认配置。',
'bridge': '查看 remote-control bridge 状态。',
'remote-setup': '检查 Claude Code on the web 的准备状态。',
}
# ---------------------------------------------------------------------------
# Agent state holder
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class AgentInstanceConfig:
cwd: Path
model: str
base_url: str
api_key: str
timeout_seconds: float
allow_shell: bool
allow_write: bool
SKILL_SETTINGS_FILENAME = 'skill_settings.json'
class RunProcessRegistry:
def __init__(self) -> None:
self._lock = Lock()
self._processes: set[Any] = set()
def add(self, process: Any) -> None:
with self._lock:
self._processes.add(process)
def discard(self, process: Any) -> None:
with self._lock:
self._processes.discard(process)
def kill_all(self) -> None:
with self._lock:
processes = list(self._processes)
for process in processes:
try:
if process.poll() is None:
self._signal_process_tree(process, signal.SIGTERM)
except OSError:
continue
deadline = time.monotonic() + 1.0
for process in processes:
try:
remaining = max(0.0, deadline - time.monotonic())
process.wait(timeout=remaining)
except Exception:
self._signal_process_tree(process, signal.SIGKILL)
try:
process.wait(timeout=1.0)
except Exception:
pass
@staticmethod
def _signal_process_tree(process: Any, sig: signal.Signals) -> None:
try:
if os.name == 'posix':
pgid = os.getpgid(process.pid)
if pgid == process.pid:
os.killpg(pgid, sig)
return
if sig == signal.SIGTERM:
process.terminate()
else:
process.kill()
except ProcessLookupError:
return
except OSError:
try:
if sig == signal.SIGTERM:
process.terminate()
else:
process.kill()
except OSError:
pass
@dataclass
class RunRecord:
run_id: str
account_key: str
session_id: str
status: str
started_at: float
updated_at: float
cancel_event: threading.Event
process_registry: RunProcessRegistry
pending_prompt: str = ''
current_stage: str = ''
error: str = ''
events: list[dict[str, Any]] = field(default_factory=list)
class RunManager:
def __init__(self) -> None:
self._lock = RLock()
self._runs: dict[str, RunRecord] = {}
self._latest_by_session: dict[tuple[str, str], str] = {}
def start(self, account_key: str, session_id: str, pending_prompt: str = '') -> RunRecord:
now = time.time()
record = RunRecord(
run_id=uuid4().hex,
account_key=account_key,
session_id=session_id,
status='queued',
started_at=now,
updated_at=now,
cancel_event=threading.Event(),
process_registry=RunProcessRegistry(),
pending_prompt=pending_prompt,
)
with self._lock:
self._runs[record.run_id] = record
self._latest_by_session[(account_key, session_id)] = record.run_id
return record
def update(self, run_id: str, *, status: str | None = None, stage: str | None = None, error: str | None = None) -> None:
with self._lock:
record = self._runs.get(run_id)
if record is None:
return
if status is not None:
record.status = status
if stage is not None:
record.current_stage = stage
if error is not None:
record.error = error
record.updated_at = time.time()
def record_event(self, run_id: str, event: dict[str, object]) -> dict[str, Any] | None:
normalized = _normalize_run_event(event)
if normalized is None:
return None
if normalized.get('type') in {'content_delta', 'tool_delta'}:
return None
normalized['recorded_at'] = time.time()
with self._lock:
record = self._runs.get(run_id)
if record is None:
return normalized
record.events.append(normalized)
if len(record.events) > 160:
del record.events[: len(record.events) - 160]
record.updated_at = time.time()
return normalized
def finish(self, run_id: str, status: str) -> None:
self.update(run_id, status=status)
def cancel_latest(self, account_key: str, session_id: str) -> bool:
with self._lock:
record = self._select_session_record(account_key, session_id)
return self.cancel_record(record)
def cancel_run(self, run_id: str) -> bool:
with self._lock:
record = self._runs.get(run_id)
return self.cancel_record(record)
def cancel_session(self, account_key: str, session_id: str) -> list[str]:
"""Cancel every non-terminal run for a session.
The UI can hold a stale run_id after refresh/replay while a newer
request is queued behind the same session lock. Cancelling only the
stale id leaves the queued run alive and makes the composer look
impossible to stop. Session-level cancel is the user-facing intent.
"""
with self._lock:
records = [
record
for record in self._runs.values()
if record.account_key == account_key
and record.session_id == session_id
and record.status not in {'completed', 'failed', 'cancelled'}
]
cancelled: list[str] = []
for record in records:
if self.cancel_record(record):
cancelled.append(record.run_id)
return cancelled
def cancel_record(self, record: RunRecord | None) -> bool:
if record is None or record.status in {'completed', 'failed', 'cancelled'}:
return False
record.cancel_event.set()
record.process_registry.kill_all()
self.update(record.run_id, status='cancelled', stage='用户已取消')
return True
def snapshot_latest(self, account_key: str, session_id: str) -> dict[str, Any] | None:
with self._lock:
record = self._select_session_record(account_key, session_id)
if record is None:
return None
return {
'run_id': record.run_id,
'session_id': record.session_id,
'status': record.status,
'current_stage': record.current_stage,
'started_at': record.started_at,
'updated_at': record.updated_at,
'elapsed_ms': max(0, int((time.time() - record.started_at) * 1000)),
'pending_prompt': record.pending_prompt,
'error': record.error,
'events': [dict(event) for event in record.events],
}
def _select_session_record(
self,
account_key: str,
session_id: str,
) -> RunRecord | None:
records = [
record
for record in self._runs.values()
if record.account_key == account_key and record.session_id == session_id
]
running = [
record
for record in records
if record.status == 'running'
]
if running:
return max(running, key=lambda item: item.updated_at)
queued = [
record
for record in records
if record.status == 'queued'
]
if queued:
return max(queued, key=lambda item: item.started_at)
run_id = self._latest_by_session.get((account_key, session_id))
return self._runs.get(run_id or '')
class AgentState:
"""Holds account-scoped agent instances, config, and execution locks."""
def __init__(
self,
*,
cwd: Path,
model: str,
base_url: str,
api_key: str,
timeout_seconds: float,
allow_shell: bool,
allow_write: bool,
session_directory: Path,
) -> None:
self.session_directory = session_directory
self._lock = RLock()
self._agents: dict[str, LocalCodingAgent] = {}
self._run_locks: dict[str, Lock] = {}
self._account_configs: dict[str, AgentInstanceConfig] = {}
self.run_manager = RunManager()
self.run_state_store = RunStateStore(self.session_directory.parent / 'run_state.db')
self.bash_bg_store = BashBgStore(self.session_directory.parent / 'bash_bg.db')
self.event_loop: 'asyncio.AbstractEventLoop | None' = None
self.jupyter_runtime_manager = JupyterRuntimeManager()
self._default_config = AgentInstanceConfig(
cwd=cwd.resolve(),
model=model,
base_url=base_url,
api_key=api_key,
timeout_seconds=timeout_seconds,
allow_shell=allow_shell,
allow_write=allow_write,
)
self.memory_manager = PersonalMemoryManager(
self.session_directory.parent / 'accounts',
self.model_config_for,
)
self.memory_manager.start()
@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 _session_key(self, account_id: str | None, session_id: str | None = None) -> str:
session_part = _safe_session_id(session_id) or '__shared__'
return f'{self._account_key(account_id)}:{session_part}'
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 model_config_for(self, account_id: str | None) -> ModelConfig:
"""给后台辅助任务使用的模型配置。
AgentInstanceConfig 还包含 cwd、权限等运行态字段;调用模型时需要的是
OpenAICompatClient 支持的 ModelConfig,否则后台记忆整理会缺少 temperature 等字段。
"""
config = self.config_for(account_id)
return ModelConfig(
model=config.model,
base_url=config.base_url,
api_key=config.api_key,
timeout_seconds=config.timeout_seconds,
)
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:
return self.session_directory.parent
safe_id = _safe_account_id(account_id)
if _linux_accounts_enabled():
return _linux_account_workspace(safe_id).resolve()
return (self.session_directory.parent / 'accounts' / safe_id).resolve()
def account_paths(self, account_id: str | None) -> dict[str, Path]:
if not account_id:
base = self.session_directory.parent
return {
'base': base,
'sessions': self.session_directory,
'scratchpad': self.session_directory,
'uploads': self.session_directory,
'outputs': self.session_directory,
'python_env': base / 'python' / '.venv',
'memory': base / 'memory',
}
base = self._account_base(account_id)
return {
'base': base,
'sessions': base / 'sessions',
'scratchpad': base / 'sessions',
'uploads': base / 'sessions',
'outputs': base / 'sessions',
'python_env': base / 'python' / '.venv',
'memory': base / 'memory',
}
def _skill_settings_path(self, account_id: str | None) -> Path:
return self.account_paths(account_id)['base'] / SKILL_SETTINGS_FILENAME
def _load_disabled_skill_names(self, account_id: str | None) -> set[str]:
path = self._skill_settings_path(account_id)
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return set()
disabled = payload.get('disabled_skill_names')
if not isinstance(disabled, list):
return set()
return {
str(name).strip().lower()
for name in disabled
if str(name).strip()
}
def _save_disabled_skill_names(
self,
account_id: str | None,
disabled_skill_names: set[str],
) -> None:
path = self._skill_settings_path(account_id)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
'disabled_skill_names': sorted(
name
for name in disabled_skill_names
if name not in ALWAYS_ENABLED_HIDDEN_SKILL_NAMES
)
}
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + '\n',
encoding='utf-8',
)
def enabled_skill_names(self, account_id: str | None) -> tuple[str, ...]:
config = self._config_for(account_id)
disabled = self._load_disabled_skill_names(account_id)
enabled: list[str] = []
for skill in get_bundled_skills(config.cwd):
if not skill.user_invocable:
continue
lowered = skill.name.lower()
if (
lowered in ALWAYS_ENABLED_HIDDEN_SKILL_NAMES
or lowered not in disabled
):
enabled.append(skill.name)
return tuple(enabled)
def set_skill_enabled(
self,
account_id: str | None,
skill_name: str,
enabled: bool,
) -> None:
with self._lock:
skill_name = skill_name.strip()
if not skill_name:
raise ValueError('skill name must not be empty')
lowered = skill_name.lower()
if lowered in ALWAYS_ENABLED_HIDDEN_SKILL_NAMES:
raise ValueError(
f'skill is always enabled and cannot be changed: {skill_name}'
)
config = self._config_for(account_id)
if not any(
skill.name.lower() == lowered and skill.user_invocable
for skill in get_bundled_skills(config.cwd)
):
raise ValueError(f'unknown skill: {skill_name}')
disabled = self._load_disabled_skill_names(account_id)
if enabled:
disabled.discard(lowered)
else:
disabled.add(lowered)
self._save_disabled_skill_names(account_id, disabled)
account_prefix = f'{self._account_key(account_id)}:'
for key in list(self._agents):
if key.startswith(account_prefix):
self._agents.pop(key, None)
def set_all_skills_enabled(
self,
account_id: str | None,
enabled: bool,
) -> None:
with self._lock:
config = self._config_for(account_id)
configurable_skill_names = {
skill.name.lower()
for skill in get_bundled_skills(config.cwd)
if skill.user_invocable
and skill.name.lower() not in ALWAYS_ENABLED_HIDDEN_SKILL_NAMES
}
disabled = self._load_disabled_skill_names(account_id)
if enabled:
disabled.difference_update(configurable_skill_names)
else:
disabled.update(configurable_skill_names)
self._save_disabled_skill_names(account_id, disabled)
self._clear_agents_for_account(account_id)
def _clear_agents_for_account(self, account_id: str | None) -> None:
account_prefix = f'{self._account_key(account_id)}:'
for key in list(self._agents):
if key.startswith(account_prefix):
self._agents.pop(key, None)
def sync_skills_from_git(self, account_id: str | None) -> dict[str, Any]:
"""Pull the current git branch and refresh account-scoped skill state."""
with self._lock:
config = self._config_for(account_id)
repo = config.cwd
if not (repo / '.git').exists():
raise ValueError(f'当前工作目录不是 git 仓库:{repo}')
git_prefix: list[str] = []
if os.name == 'posix' and hasattr(os, 'geteuid') and os.geteuid() == 0:
try:
repo_owner = pwd.getpwuid(repo.stat().st_uid).pw_name
except (KeyError, OSError):
repo_owner = ''
if repo_owner and repo_owner != 'root':
git_prefix = [
'sudo',
'-H',
'-u',
repo_owner,
'env',
'GIT_TERMINAL_PROMPT=0',
]
def run_git(args: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]:
env = {
**os.environ,
'GIT_TERMINAL_PROMPT': '0',
}
command = [*git_prefix, 'git', *args] if git_prefix else ['git', *args]
proc = subprocess.run(
command,
cwd=repo,
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout or '').strip()
run_as = f'(以 {git_prefix[3]} 用户执行)' if git_prefix else ''
raise ValueError(f'git {" ".join(args)}{run_as} 失败:{detail}')
return proc
dirty = run_git(['status', '--porcelain', '--untracked-files=no']).stdout.strip()
if dirty:
raise ValueError('工作区存在未提交的 tracked 改动,已停止同步 Skill。')
branch = run_git(['branch', '--show-current']).stdout.strip() or 'main'
before = run_git(['rev-parse', 'HEAD']).stdout.strip()
run_git(['fetch', 'origin'], timeout=120)
pull = run_git(['pull', '--ff-only', 'origin', branch], timeout=120)
after = run_git(['rev-parse', 'HEAD']).stdout.strip()
changed_files: list[str] = []
if before != after:
changed_files = [
line.strip()
for line in run_git(
['diff', '--name-only', f'{before}..{after}'],
timeout=60,
).stdout.splitlines()
if line.strip()
]
self._clear_agents_for_account(account_id)
skills = get_bundled_skills(config.cwd)
return {
'branch': branch,
'before': before,
'after': after,
'updated': before != after,
'changed_files': changed_files,
'skill_count': sum(1 for skill in skills if skill.user_invocable),
'message': pull.stdout.strip() or pull.stderr.strip(),
}
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)
runtime_user = (
_safe_account_id(account_id)
if account_id and _linux_accounts_enabled()
else None
)
if runtime_user:
_ensure_linux_user_exists(runtime_user)
_chown_path_for_linux_user(paths['base'], runtime_user, recursive=True)
self._ensure_python_env(paths['python_env'], account_id)
permissions = AgentPermissions(
allow_file_write=config.allow_write,
allow_shell_commands=config.allow_shell,
)
runtime_config = AgentRuntimeConfig(
cwd=config.cwd,
permissions=permissions,
stream_model_responses=True,
session_directory=paths['sessions'],
scratchpad_root=paths['scratchpad'],
python_env_dir=paths['python_env'],
runtime_user=runtime_user,
enabled_skill_names=self.enabled_skill_names(account_id),
auto_compact_threshold_tokens=180_000,
)
model_config = ModelConfig(
model=config.model,
base_url=config.base_url,
api_key=config.api_key,
timeout_seconds=config.timeout_seconds,
)
return LocalCodingAgent(
model_config=model_config,
runtime_config=runtime_config,
)
def agent_for(
self,
account_id: str | None = None,
session_id: str | None = None,
) -> LocalCodingAgent:
with self._lock:
key = self._session_key(account_id, session_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,
session_id: str | None = None,
) -> Lock:
key = self._session_key(account_id, session_id)
with self._lock:
lock = self._run_locks.get(key)
if lock is None:
lock = Lock()
self._run_locks[key] = lock
return lock
def abandon_session_runtime(
self,
account_id: str | None = None,
session_id: str | None = None,
) -> bool:
"""Stop routing future requests through the currently held runtime.
A user cancel can happen while the worker thread is blocked inside a
model HTTP request or a remote runtime call. Python cannot safely
release a Lock owned by another thread, so the correct cancellation
behavior is to abandon that lock object for future turns.
The agent instance is abandoned together with the lock. The old worker
may still mutate its LocalCodingAgent.tool_context during unwind; a new
user turn must not share that object.
"""
key = self._session_key(account_id, session_id)
with self._lock:
removed_lock = self._run_locks.pop(key, None) is not None
removed_agent = self._agents.pop(key, None) is not None
return removed_lock or removed_agent
def update(
self,
*,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
cwd: str | None = None,
allow_shell: bool | None = None,
allow_write: bool | None = None,
account_id: str | None = None,
) -> None:
with self._lock:
config = self._config_for(account_id)
if model is not None:
normalized_model = _normalize_model_id(model)
if normalized_model is None:
raise ValueError('model must not be empty')
config = replace(config, model=normalized_model)
if base_url is not None:
config = replace(config, base_url=base_url)
if api_key is not None:
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}')
config = replace(config, cwd=resolved)
if allow_shell is not None:
config = replace(config, allow_shell=allow_shell)
if allow_write is not None:
config = replace(config, allow_write=allow_write)
self._set_config_for(account_id, config)
account_prefix = f'{self._account_key(account_id)}:'
for key in list(self._agents):
if key.startswith(account_prefix):
self._agents.pop(key, 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': 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']),
'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, account_id: str | None = None) -> 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)
runtime_user = _safe_account_id(account_id) if account_id and _linux_accounts_enabled() else None
if runtime_user:
_ensure_linux_user_exists(runtime_user)
env_dir.parent.mkdir(parents=True, exist_ok=True)
_chown_path_for_linux_user(env_dir.parent, runtime_user, recursive=True)
runtime_python = os.environ.get('CLAW_RUNTIME_PYTHON_BIN') or shutil.which('python3')
if not runtime_python:
raise RuntimeError('Linux runtime requires python3 or CLAW_RUNTIME_PYTHON_BIN')
subprocess.run(
[
'runuser',
'-u',
runtime_user,
'--',
runtime_python,
'-m',
'venv',
str(env_dir),
],
check=True,
capture_output=True,
text=True,
timeout=120,
)
return
venv.EnvBuilder(with_pip=True, symlinks=False).create(env_dir)
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class ChatRequest(BaseModel):
prompt: str = Field(min_length=1)
runtime_context: str | None = None
resume_session_id: str | None = None
account_id: str | None = None
session_id: str | None = None
class RunCancelRequest(BaseModel):
session_id: str = Field(min_length=1)
account_id: str | None = None
run_id: str | None = None
class SessionInputQueueCreate(BaseModel):
content: str = Field(min_length=1, max_length=20000)
kind: str = 'next_turn'
run_id: str | None = None
account_id: str | None = None
metadata: dict[str, Any] | None = None
class SessionInputQueueUpdate(BaseModel):
content: str | None = Field(default=None, max_length=20000)
kind: str | None = None
status: str | None = None
run_id: str | None = None
account_id: str | None = None
class StateUpdate(BaseModel):
model: str | None = None
base_url: str | None = None
api_key: str | None = None
cwd: str | None = None
allow_shell: bool | None = None
allow_write: bool | None = None
account_id: str | None = None
class ModelListRequest(BaseModel):
base_url: str | None = None
api_key: str | None = None
account_id: str | None = None
class JupyterWorkspaceBindRequest(BaseModel):
session_id: str = Field(min_length=1)
base_url: str = Field(min_length=1)
password: str = Field(min_length=1)
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT
account_id: str | None = None
class SavedJupyterWorkspaceBindRequest(BaseModel):
session_id: str = Field(min_length=1)
account_id: str | None = None
class SkillPreferenceUpdate(BaseModel):
skill: str | None = None
enabled: bool
apply_all: bool = False
account_id: str | None = None
class SkillSyncRequest(BaseModel):
account_id: str | None = None
class SessionUpdate(BaseModel):
title: str | None = Field(default=None, max_length=80)
is_training: bool | None = None
class FeishuAccountRequest(BaseModel):
account_id: str | None = None
class FeishuOnlineDocRequest(BaseModel):
path: str = Field(min_length=1)
title: str | None = None
folder_token: str | None = None
account_id: str | None = None
class AdminLoginRequest(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class AdminAccountCreateRequest(BaseModel):
account_id: str = Field(min_length=2)
class MemoryUpdateRequest(BaseModel):
account_id: str = Field(min_length=1)
content: str
def _append_runtime_context(current: str | None, addition: str) -> str:
parts = [part.strip() for part in (current, addition) if part and part.strip()]
return '\n\n'.join(parts)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_app(state: AgentState) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI): # noqa: ARG001
state.event_loop = asyncio.get_running_loop()
_bash_bg_manager.set_chat_runner(_run_chat_payload)
await _bash_bg_manager.recover_from_store(state)
scanner_task = asyncio.create_task(_watcher_scanner_loop(state))
try:
yield
finally:
scanner_task.cancel()
_watcher_manager.cancel_all()
_bash_bg_manager.cancel_all()
state.event_loop = None
app = FastAPI(title='Claw Code GUI', version='1.0', lifespan=lifespan)
# ------------- static + index ------------------------------------------
app.mount(
'/static',
StaticFiles(directory=str(STATIC_DIR)),
name='static',
)
@app.get('/', include_in_schema=False)
async def root() -> FileResponse:
return FileResponse(STATIC_DIR / 'index.html')
# ------------- info ------------------------------------------------------
@app.get('/api/state')
async def get_state(account_id: str | None = None) -> dict[str, Any]:
return state.snapshot(account_id)
@app.post('/api/state')
async def post_state(payload: StateUpdate) -> dict[str, Any]:
try:
state.update(**payload.model_dump(exclude_none=True))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return state.snapshot(payload.account_id)
@app.get('/api/jupyter/session')
async def get_jupyter_session(
session_id: str,
account_id: str | None = None,
) -> dict[str, Any]:
safe_session_id = _safe_session_id(session_id)
if not safe_session_id:
raise HTTPException(status_code=400, detail='session_id is required')
account_key = state._account_key(account_id)
runtime = _jupyter_runtime_for_session(
state,
account_id,
safe_session_id,
)
if runtime is None:
return {
'connected': False,
'account_id': account_key,
'session_id': safe_session_id,
}
return runtime.to_dict()
@app.get('/api/jupyter/files')
async def list_jupyter_files(
session_id: str,
account_id: str | None = None,
) -> dict[str, Any]:
safe_session_id = _safe_session_id(session_id)
if not safe_session_id:
raise HTTPException(status_code=400, detail='session_id is required')
runtime = _jupyter_runtime_for_session(state, account_id, safe_session_id)
if runtime is None:
return {
'connected': False,
'session_id': safe_session_id,
'input': [],
'output': [],
}
try:
return {
'connected': True,
'session_id': safe_session_id,
'workspace_cwd': runtime.binding.workspace_cwd,
'input': runtime.list_files('input', kind='input'),
'output': runtime.list_files('output', kind='output'),
}
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/jupyter/file')
async def download_jupyter_file(
session_id: str,
path: str,
account_id: str | None = None,
) -> Response:
safe_session_id = _safe_session_id(session_id)
if not safe_session_id:
raise HTTPException(status_code=400, detail='session_id is required')
runtime = _jupyter_runtime_for_session(state, account_id, safe_session_id)
if runtime is None:
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
try:
response, filename = runtime.open_file_stream(path)
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def stream_chunks() -> Any:
try:
for chunk in response.iter_content(chunk_size=JUPYTER_STREAM_CHUNK_BYTES):
if chunk:
yield chunk
finally:
response.close()
return StreamingResponse(
stream_chunks(),
media_type=_content_type_for_filename(filename),
headers={
'content-disposition': (
f'attachment; filename="{_ascii_download_filename(filename)}"; '
f"filename*=UTF-8''{_url_quote_filename(filename)}"
)
},
)
@app.post('/api/jupyter/bind-session')
async def bind_jupyter_session(
payload: JupyterWorkspaceBindRequest,
) -> dict[str, Any]:
safe_session_id = _safe_session_id(payload.session_id)
if not safe_session_id:
raise HTTPException(status_code=400, detail='session_id is required')
account_key = state._account_key(payload.account_id)
try:
runtime = await asyncio.to_thread(
state.jupyter_runtime_manager.bind_session,
account_id=account_key,
session_id=safe_session_id,
base_url=payload.base_url,
password=payload.password,
workspace_root=payload.workspace_root,
project_root=state.config_for(payload.account_id).cwd,
)
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=f'Unable to connect Jupyter runtime: {exc}',
) from exc
_save_jupyter_runtime_binding(
state.account_paths(payload.account_id)['sessions'],
runtime,
)
_register_jupyter_workspace(
state,
payload.account_id,
base_url=payload.base_url,
workspace_root=payload.workspace_root,
password=payload.password,
)
return runtime.to_dict()
@app.get('/api/jupyter/workspaces')
async def list_jupyter_workspaces(
account_id: str | None = None,
) -> list[dict[str, Any]]:
entries = _load_jupyter_workspaces_registry(state, account_id)
return [_public_jupyter_workspace_entry(e) for e in entries]
@app.post('/api/jupyter/workspaces/{workspace_id}/bind')
async def bind_saved_jupyter_workspace(
workspace_id: str,
payload: SavedJupyterWorkspaceBindRequest,
) -> dict[str, Any]:
safe_session_id = _safe_session_id(payload.session_id)
if not safe_session_id:
raise HTTPException(status_code=400, detail='session_id is required')
entries = _load_jupyter_workspaces_registry(state, payload.account_id)
match = next((e for e in entries if e.get('id') == workspace_id), None)
if match is None:
raise HTTPException(status_code=404, detail='workspace not found')
password = match.get('password')
base_url = match.get('base_url')
workspace_root = match.get('workspace_root') or DEFAULT_JUPYTER_WORKSPACE_ROOT
if not (isinstance(password, str) and isinstance(base_url, str)):
raise HTTPException(status_code=400, detail='workspace entry is incomplete')
account_key = state._account_key(payload.account_id)
try:
runtime = await asyncio.to_thread(
state.jupyter_runtime_manager.bind_session,
account_id=account_key,
session_id=safe_session_id,
base_url=base_url,
password=password,
workspace_root=workspace_root,
project_root=state.config_for(payload.account_id).cwd,
)
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=f'Unable to connect Jupyter runtime: {exc}',
) from exc
_save_jupyter_runtime_binding(
state.account_paths(payload.account_id)['sessions'],
runtime,
)
_register_jupyter_workspace(
state,
payload.account_id,
base_url=base_url,
workspace_root=workspace_root,
password=password,
label=match.get('label') if isinstance(match.get('label'), str) else None,
)
return runtime.to_dict()
@app.delete('/api/jupyter/workspaces/{workspace_id}')
async def delete_saved_jupyter_workspace(
workspace_id: str,
account_id: str | None = None,
) -> dict[str, Any]:
entries = _load_jupyter_workspaces_registry(state, account_id)
kept = [e for e in entries if e.get('id') != workspace_id]
if len(kept) == len(entries):
raise HTTPException(status_code=404, detail='workspace not found')
_save_jupyter_workspaces_registry(state, account_id, kept)
return {'ok': True}
@app.get('/api/slash-commands')
async def list_slash_commands() -> list[dict[str, Any]]:
commands: list[dict[str, Any]] = []
for spec in get_slash_command_specs():
if any(name in WEBUI_HIDDEN_SLASH_COMMANDS for name in spec.names):
continue
commands.append(
{
'names': list(spec.names),
'primary': spec.names[0],
'description': WEBUI_SLASH_COMMAND_DESCRIPTIONS_ZH.get(
spec.names[0],
spec.description,
),
'source': 'slash_command',
'webui_supported': True,
}
)
return commands
@app.get('/api/skills')
async def list_skills(account_id: str | None = None) -> list[dict[str, Any]]:
config = state.config_for(account_id)
disabled = state._load_disabled_skill_names(account_id)
return [
{
'name': skill.name,
'description': skill.description,
'when_to_use': skill.when_to_use,
'aliases': list(skill.aliases),
'allowed_tools': list(skill.allowed_tools),
'enabled': skill.name.lower() not in disabled,
'configurable': skill.name.lower() not in ALWAYS_ENABLED_HIDDEN_SKILL_NAMES,
}
for skill in get_bundled_skills(config.cwd)
if skill.user_invocable
and skill.name.lower() not in ALWAYS_ENABLED_HIDDEN_SKILL_NAMES
]
@app.patch('/api/skills')
async def update_skill_preference(payload: SkillPreferenceUpdate) -> list[dict[str, Any]]:
try:
if payload.apply_all:
state.set_all_skills_enabled(payload.account_id, payload.enabled)
elif payload.skill:
state.set_skill_enabled(payload.account_id, payload.skill, payload.enabled)
else:
raise ValueError('skill must be provided unless apply_all is true')
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return await list_skills(payload.account_id)
@app.post('/api/skills/sync')
async def sync_skills(payload: SkillSyncRequest) -> dict[str, Any]:
try:
result = await asyncio.to_thread(
state.sync_skills_from_git,
payload.account_id,
)
except (ValueError, subprocess.TimeoutExpired) as exc:
raise HTTPException(status_code=400, detail=str(exc))
result['skills'] = await list_skills(payload.account_id)
account_key = state._account_key(payload.account_id)
config = state.config_for(payload.account_id)
result['remote_runtime_sync'] = await asyncio.to_thread(
state.jupyter_runtime_manager.sync_account,
account_key,
config.cwd,
)
return result
# ------------- memory ----------------------------------------------------
@app.get('/api/memory/user')
async def get_user_memory(account_id: str) -> dict[str, Any]:
try:
return state.memory_manager.list_user_memory(_safe_account_id(account_id))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.put('/api/memory/user')
async def put_user_memory(payload: MemoryUpdateRequest) -> dict[str, Any]:
try:
return state.memory_manager.update_user_memory(
_safe_account_id(payload.account_id),
payload.content,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/memory/skills')
async def get_skill_memories(account_id: str) -> list[dict[str, Any]]:
try:
return state.memory_manager.list_skill_memories(_safe_account_id(account_id))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/memory/events')
async def get_memory_events(account_id: str) -> dict[str, Any]:
try:
safe_account = _safe_account_id(account_id)
return {
'queue': state.memory_manager.queue_snapshot(safe_account),
'events': state.memory_manager.list_recent_events(safe_account, limit=20),
}
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/memory/skills/{skill_name}')
async def get_skill_memory(skill_name: str, account_id: str) -> dict[str, Any]:
try:
return state.memory_manager.read_skill_memory(
_safe_account_id(account_id),
skill_name,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.put('/api/memory/skills/{skill_name}')
async def put_skill_memory(
skill_name: str,
payload: MemoryUpdateRequest,
) -> dict[str, Any]:
try:
return state.memory_manager.update_skill_memory(
_safe_account_id(payload.account_id),
skill_name,
payload.content,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.delete('/api/memory/skills/{skill_name}')
async def delete_skill_memory(skill_name: str, account_id: str) -> dict[str, Any]:
try:
return state.memory_manager.delete_skill_memory(
_safe_account_id(account_id),
skill_name,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/memory/queue')
async def get_memory_queue(account_id: str | None = None) -> dict[str, Any]:
return state.memory_manager.queue_snapshot(
_safe_account_id(account_id) if account_id else None
)
@app.get('/api/memory/events')
async def get_memory_events(
account_id: str,
limit: int = 50,
) -> list[dict[str, Any]]:
return state.memory_manager.list_recent_events(
_safe_account_id(account_id),
limit=limit,
)
# ------------- admin -----------------------------------------------------
@app.post('/api/admin/login')
async def admin_login(payload: AdminLoginRequest) -> dict[str, Any]:
if payload.username == 'admin' and payload.password == 'admin':
return {'token': _admin_token(), 'username': 'admin'}
raise HTTPException(status_code=401, detail='管理员账号或密码错误')
@app.get('/api/admin/summary')
async def admin_summary(token: str, period: str = 'this_month') -> dict[str, Any]:
_require_admin_token(token)
return _build_admin_summary(state, period=period)
@app.get('/api/admin/accounts')
async def admin_accounts(token: str, period: str = 'this_month') -> list[dict[str, Any]]:
_require_admin_token(token)
return _list_admin_accounts(state, period=period)
@app.post('/api/admin/accounts')
async def admin_create_account(
token: str,
payload: AdminAccountCreateRequest,
) -> dict[str, Any]:
_require_admin_token(token)
return _admin_create_account(state, payload.account_id)
@app.delete('/api/admin/accounts/{account_id}')
async def admin_delete_account(account_id: str, token: str) -> dict[str, Any]:
_require_admin_token(token)
return _admin_delete_account(state, account_id)
@app.get('/api/admin/memory')
async def admin_memory(token: str, account_id: str | None = None) -> dict[str, Any]:
_require_admin_token(token)
safe_account = _safe_account_id(account_id) if account_id else None
return {
'queue': state.memory_manager.queue_snapshot(safe_account),
'events': (
state.memory_manager.list_recent_events(safe_account, limit=100)
if safe_account
else []
),
}
@app.get('/api/models')
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 config.base_url,
payload.api_key or config.api_key,
)
# ------------- Feishu integration ---------------------------------------
@app.get('/api/integrations/feishu/status')
async def feishu_status(account_id: str | None = None) -> dict[str, Any]:
return _feishu_status_payload(state, account_id)
@app.post('/api/integrations/feishu/login')
async def feishu_login(payload: FeishuAccountRequest) -> dict[str, Any]:
return _start_feishu_login(state, payload.account_id)
@app.post('/api/integrations/feishu/logout')
async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]:
_stop_feishu_login(payload.account_id)
paths = _feishu_paths(state, payload.account_id)
result = _run_feishu_cli(paths, ['logout'], timeout_seconds=30)
status = _feishu_status_payload(state, payload.account_id)
status['logout_output'] = result['output']
return status
@app.post('/api/files/online-doc')
async def create_feishu_online_doc(payload: FeishuOnlineDocRequest) -> dict[str, Any]:
file_path, online_doc_key = _resolve_feishu_source_file(
state,
payload.account_id,
payload.path,
)
suffix = file_path.suffix.lower()
if suffix in FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES:
raise HTTPException(
status_code=400,
detail='当前先不支持 json/jsonl 转在线文档。',
)
if suffix not in FEISHU_SUPPORTED_DOCUMENT_SUFFIXES:
raise HTTPException(
status_code=400,
detail='当前仅支持 md、txt、csv、docx、xlsx 转在线文档。',
)
status = _feishu_status_payload(state, payload.account_id)
if not status.get('logged_in'):
raise HTTPException(
status_code=409,
detail={
'code': 'feishu_not_logged_in',
'message': '当前账号还没有完成飞书授权,请先授权后再转换。',
'status': status,
},
)
paths = _feishu_paths(state, payload.account_id)
runtime = MCPRuntime(servers=(_feishu_server_profile(paths),))
title = _clean_feishu_doc_title(payload.title or file_path.stem)
kind = 'sheet' if suffix in FEISHU_SPREADSHEET_SUFFIXES else 'doc'
try:
if kind == 'sheet':
rendered, metadata = _create_feishu_online_spreadsheet(
runtime,
file_path,
title=title,
folder_token=payload.folder_token,
)
else:
markdown = _convert_file_to_feishu_markdown(file_path, title=title)
arguments: dict[str, Any] = {'title': title, 'markdown': markdown}
if payload.folder_token and payload.folder_token.strip():
arguments['folder_token'] = payload.folder_token.strip()
rendered, metadata = runtime.call_tool(
'doc_create',
arguments=arguments,
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
except DataAgentInputError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except OSError as exc:
raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}')
except Exception as exc:
target_name = '飞书表格' if kind == 'sheet' else '飞书文档'
raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}')
url = _extract_first_url(rendered)
if url:
_record_feishu_online_doc(
state,
payload.account_id,
file_path=file_path,
map_key=online_doc_key,
title=title,
url=url,
kind=kind,
)
return {
'ok': True,
'title': title,
'url': url,
'kind': kind,
'file_path': str(file_path),
'file_type': suffix.lstrip('.'),
'result': rendered,
'metadata': metadata,
}
# ------------- sessions --------------------------------------------------
@app.get('/api/sessions')
async def list_sessions(
account_id: str | None = None,
include_children: bool = False,
) -> list[dict[str, Any]]:
directory = state.account_paths(account_id)['sessions']
results: list[dict[str, Any]] = []
for stored, updated_at in list_agent_sessions(directory):
session_id = stored.session_id
session_metadata = stored.session_metadata or {}
if (
not include_children
and session_metadata.get('visibility') == 'child'
):
continue
title = session_metadata.get('title')
if not isinstance(title, str) or not title.strip():
title = None
preview = ''
for msg in _stored_display_messages(stored):
if isinstance(msg, dict) and msg.get('role') == 'user':
content = msg.get('content', '')
if isinstance(content, str) and not _is_internal_message(content):
preview = _strip_session_context(content)[:120]
break
results.append(
{
'session_id': session_id,
'turns': stored.turns,
'tool_calls': stored.tool_calls,
'preview': title if isinstance(title, str) and title.strip() else preview,
'modified_at': updated_at,
'model': stored.model_config.get('model'),
'usage': stored.usage,
'is_training': stored.is_training,
'session_metadata': session_metadata,
}
)
return results
@app.get('/api/sessions/{session_id}')
async def get_session(session_id: str, account_id: str | None = None) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=404, detail='Session not found')
try:
stored = load_agent_session(safe_id, directory=directory)
except FileNotFoundError:
raise HTTPException(status_code=404, detail='Session not found')
return _serialize_stored_session(stored)
@app.get('/api/sessions/{session_id}/state')
async def get_session_state_delta(
session_id: str,
account_id: str | None = None,
after_message_seq: int = 0,
after_event_seq: int = 0,
) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
account_key = state._account_key(account_id)
message_delta = agent_session_delta(
safe_id,
directory=directory,
after_message_seq=after_message_seq,
)
event_delta = state.run_state_store.events_since(
account_key,
safe_id,
after_event_seq=after_event_seq,
)
run_status = await latest_run(safe_id, account_id=account_id)
queue_items = list_session_input_queue(safe_id, directory=directory)
return {
'session_id': safe_id,
'session': message_delta.get('session', {'session_id': safe_id}),
'messages': message_delta.get('messages', []),
'latest_message_seq': message_delta.get('latest_message_seq', 0),
'activity_events': event_delta.get('events', []),
'latest_event_seq': event_delta.get('latest_event_seq', 0),
'run': run_status,
'input_queue': queue_items,
}
@app.get('/api/sessions/{session_id}/input-queue')
async def get_session_input_queue(
session_id: str,
account_id: str | None = None,
) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
return {
'session_id': safe_id,
'items': list_session_input_queue(safe_id, directory=directory),
}
@app.post('/api/sessions/{session_id}/input-queue')
async def create_session_input_queue_item(
session_id: str,
payload: SessionInputQueueCreate,
) -> dict[str, Any]:
directory = state.account_paths(payload.account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
content = payload.content.strip()
if not content:
raise HTTPException(status_code=400, detail='Empty input content')
run_id = payload.run_id
if payload.kind == 'guidance' and not run_id:
account_key = state._account_key(payload.account_id)
latest = state.run_state_store.snapshot_latest(account_key, safe_id)
if latest and latest.get('status') in ACTIVE_RUN_STATUSES:
run_id = str(latest.get('run_id') or '')
item = enqueue_session_input(
safe_id,
directory=directory,
run_id=run_id,
kind=payload.kind,
content=content,
metadata=payload.metadata,
)
if item.get('kind') == 'guidance':
_append_guidance_display_message(
safe_id,
directory=directory,
item=item,
)
return {'session_id': safe_id, 'item': item}
@app.patch('/api/sessions/{session_id}/input-queue/{item_id}')
async def update_session_input_queue(
session_id: str,
item_id: int,
payload: SessionInputQueueUpdate,
) -> dict[str, Any]:
directory = state.account_paths(payload.account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
item = update_session_input_queue_item(
safe_id,
item_id,
directory=directory,
content=payload.content.strip() if payload.content is not None else None,
kind=payload.kind,
status=payload.status,
run_id=payload.run_id,
)
if item is None:
raise HTTPException(status_code=404, detail='Queue item not found')
if item.get('kind') == 'guidance' and item.get('status') == 'pending':
_append_guidance_display_message(
safe_id,
directory=directory,
item=item,
)
return {'session_id': safe_id, 'item': item}
@app.delete('/api/sessions/{session_id}/input-queue/{item_id}')
async def delete_session_input_queue(
session_id: str,
item_id: int,
account_id: str | None = None,
) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
item = update_session_input_queue_item(
safe_id,
item_id,
directory=directory,
status='cancelled',
)
if item is None:
raise HTTPException(status_code=404, detail='Queue item not found')
return {'session_id': safe_id, 'item': item}
@app.delete('/api/sessions/{session_id}')
async def delete_session(session_id: str, account_id: str | None = None) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None or not delete_agent_session(safe_id, directory=directory):
raise HTTPException(status_code=404, detail='Session not found')
return {'deleted': True, 'session_id': safe_id}
@app.patch('/api/sessions/{session_id}')
async def update_session(
session_id: str,
payload: SessionUpdate,
account_id: str | None = None,
) -> dict[str, Any]:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
if payload.title is None and payload.is_training is None:
raise HTTPException(status_code=400, detail='Nothing to update')
result: dict[str, Any] = {'session_id': safe_id}
if payload.title is not None:
title = _clean_manual_session_title(payload.title)
if title is None:
raise HTTPException(status_code=400, detail='Invalid session title')
if not _update_session_title(directory, safe_id, title):
raise HTTPException(status_code=404, detail='Session not found')
result['title'] = title
if payload.is_training is not None:
value = bool(payload.is_training)
if not _update_session_training(directory, safe_id, value):
raise HTTPException(status_code=404, detail='Session not found')
result['is_training'] = value
return result
@app.get('/api/training/step-detail')
async def get_training_step_detail(
session_id: str,
step: str,
run_id: str | None = None,
account_id: str | None = None,
) -> Response:
sessions_dir = state.account_paths(account_id)['sessions']
state_path = _session_state_path(sessions_dir, session_id)
state_entries, _ = _read_program_state(state_path)
# Per-round: if frontend passed run_id, use that round's actual runDic
# so historical step views (e.g., R1's augment) read the right
# workflow<N> directory. Fall back to "max in state" only when run_id
# is absent or the round has no resolvable runDic — preserves prior
# behaviour for unbundled callers.
run_dic: int | None = None
if isinstance(run_id, str) and run_id.strip():
rid = run_id.strip()
per_round = _resolve_run_dic_per_round(state_entries)
# 正确标号: R{n}·Train 和 R{n}·Analysis 共用同一 run_id
# per_round[R{n}] 从 Analysis 的 watch/log 中解析到 runDic。
# 兼容旧 session 错误标号: agent 每阶段递增 run_idR7=train, R6=analysis),
# 导致 per_round[R_train] = None,需 fallback 到 R{n-1}。
if step == 'augment':
run_dic = per_round.get(rid)
if run_dic is None:
run_index = _parse_run_index(rid)
if run_index is not None and run_index >= 1:
prev_rid = f'R{run_index - 1}'
run_dic = per_round.get(prev_rid)
if run_dic is None:
run_dic = per_round.get(rid)
if run_dic is None:
run_dic = _resolve_run_dic_from_state(state_entries)
runtime = _jupyter_runtime_for_session(state, account_id, session_id)
artifact_paths = _step_artifact_paths(step, run_dic, runtime)
if not artifact_paths:
payload = {
'step': step,
'run_dic': run_dic,
'format': 'empty',
'content': '',
'source_path': None,
'fetched_at_ms': int(time.time() * 1000),
'error': '该 step 没有定义产物路径',
}
return Response(
content=json.dumps(payload, ensure_ascii=False),
media_type='application/json',
headers={'Cache-Control': 'no-store'},
)
# 多分区表格视图:augmentH1 修改 / H2 仿写)与 dist-analysis(候选 + 报告)共用
# path_indices 指向 _step_artifact_paths 返回的路径列表;多个候选按顺序尝试,
# 用第一个能读到的(让 dist-analysis 的 NFS 主路径 + jupyter pod 本地兜底
# 路径合并成同一个 section,避免出现重复卡片)。
multi_section_specs: dict[str, list[dict[str, Any]]] = {
'augment': [
{'title': 'H1 确认修改(complex 翻转 → 原文件 in-place 改标)', 'max_rows': None, 'path_indices': [0]},
{'title': 'H2 仿写候选 rawGPT 原始输出,未过 sanity', 'max_rows': None, 'path_indices': [1]},
{'title': 'H2 仿写最终(本轮新增训练样本,合入 augment.jsonl', 'max_rows': None, 'path_indices': [2]},
{'title': 'label-master 标签复核(H1+H2 全量 verdict', 'max_rows': None, 'path_indices': [3]},
],
'dist-analysis': [
{'title': '问题分析报告(workflow.md', 'max_rows': None, 'path_indices': [0]},
{'title': 'H1 预计修改候选', 'max_rows': None, 'path_indices': [1, 2]},
],
}
if step == 'dist-analysis':
# 从 iteration_log.jsonl 读 H1 假设短标签,注入到候选集 section 标题
h1_label = await asyncio.to_thread(
_read_iteration_log_h1_label, runtime, run_dic
)
if h1_label:
for spec in multi_section_specs['dist-analysis']:
if spec['title'] == 'H1 预计修改候选':
spec['title'] = f'H1 预计修改候选({h1_label}'
break
if step in multi_section_specs:
section_specs = multi_section_specs[step]
table_sections: list[dict[str, Any]] = []
tried: list[str] = []
errors: list[str] = []
for spec in section_specs:
title = spec['title']
max_rows = spec.get('max_rows')
candidate_paths = [
artifact_paths[i]
for i in spec.get('path_indices', [])
if 0 <= i < len(artifact_paths)
]
if not candidate_paths:
continue
# 多候选按顺序尝试,用第一个能读到的;都读不到则用首个候选作为缺失占位
content: str | None = None
path_str: str = candidate_paths[0]
last_err: str | None = None
for cand in candidate_paths:
tried.append(cand)
content_try, err = await asyncio.to_thread(
_read_artifact, runtime, cand
)
if content_try is not None:
content = content_try
path_str = cand
break
last_err = err
if content is None:
if last_err:
errors.append(f'{title}: {last_err}')
table_sections.append(
{
'title': title,
'source_path': candidate_paths[0],
'kind': 'missing',
'columns': [],
'rows': [],
'total_rows': 0,
'shown_rows': 0,
'error': last_err or '产物文件不存在',
}
)
continue
fmt = _detect_artifact_format(path_str)
try:
if fmt == 'markdown':
# markdown 直接以原文渲染,不走表格
table_sections.append(
{
'title': title,
'source_path': path_str,
'kind': 'markdown',
'columns': [],
'rows': [],
'total_rows': 0,
'shown_rows': 0,
'body': content,
}
)
continue
if fmt == 'csv':
cols, table_rows, total = _csv_to_table(content, max_rows)
kind = 'csv'
elif fmt in {'jsonl', 'json'}:
cols, table_rows, total = _jsonl_to_table(
content, max_rows
)
kind = 'jsonl'
else:
lines = content.splitlines()
total = len(lines)
capped = lines if max_rows is None else lines[:max_rows]
cols = ['line']
table_rows = [[_truncate_cell(line)] for line in capped]
kind = 'text'
except Exception as exc: # noqa: BLE001
errors.append(f'{title}: parse failed: {exc}')
table_sections.append(
{
'title': title,
'source_path': path_str,
'kind': 'error',
'columns': [],
'rows': [],
'total_rows': 0,
'shown_rows': 0,
'error': f'解析失败:{exc}',
}
)
continue
table_sections.append(
{
'title': title,
'source_path': path_str,
'kind': kind,
'columns': cols,
'rows': table_rows,
'total_rows': total,
'shown_rows': len(table_rows),
}
)
if step == 'dist-analysis':
# 软警告:dist-analysis 标 complete 时若 H1 候选 CSV 缺失,把 section
# 的兜底 "产物文件不存在" 换成更明确的提示。某些轮次(hparam 类假设、
# 无标签问题)确实可能没有候选,所以不 fail 这一步、不阻塞 human-check。
target_run = (run_id or '').strip()
last_status: str | None = None
for entry in state_entries:
if not isinstance(entry, dict) or entry.get('step') != 'dist-analysis':
continue
eid = (entry.get('run_id') or '').strip()
if target_run and eid and eid != target_run:
continue
if 'status' in entry:
last_status = entry.get('status')
if last_status == 'complete':
rd_label = str(run_dic) if run_dic is not None else '<runDic>'
for sec in table_sections:
if (
str(sec.get('title', '')).startswith('H1 预计修改候选')
and sec.get('kind') == 'missing'
):
sec['error'] = (
f'未检测到 output/relabel_candidates_{rd_label}.csv —— '
f'若本轮无 H1 候选可忽略;如有候选请按 SKILL.md 「阶段一」'
f'落盘 file,line,query,old_label,是否改(1/0),建议新label。'
)
break
payload = {
'step': step,
'run_dic': run_dic,
'format': 'tables',
'sections': table_sections,
'source_path': artifact_paths[0],
'tried_paths': tried,
'fetched_at_ms': int(time.time() * 1000),
}
if errors:
payload['error'] = '; '.join(errors)
return Response(
content=json.dumps(payload, ensure_ascii=False),
media_type='application/json',
headers={'Cache-Control': 'no-store'},
)
last_error: str | None = None
for path_str in artifact_paths:
content, err = await asyncio.to_thread(
_read_artifact, runtime, path_str
)
if content is not None:
fmt = _detect_artifact_format(path_str)
if fmt == 'jsonl':
# 只取前 50 行,避免超大
content = '\n'.join(content.splitlines()[:50])
payload = {
'step': step,
'run_dic': run_dic,
'format': fmt,
'content': content,
'source_path': path_str,
'fetched_at_ms': int(time.time() * 1000),
}
return Response(
content=json.dumps(payload, ensure_ascii=False),
media_type='application/json',
headers={'Cache-Control': 'no-store'},
)
if err:
last_error = err
payload = {
'step': step,
'run_dic': run_dic,
'format': 'empty',
'content': '',
'source_path': artifact_paths[0] if artifact_paths else None,
'fetched_at_ms': int(time.time() * 1000),
'error': last_error or '产物文件不存在或读取失败',
'tried_paths': artifact_paths,
}
return Response(
content=json.dumps(payload, ensure_ascii=False),
media_type='application/json',
headers={'Cache-Control': 'no-store'},
)
@app.get('/api/training/pipeline')
async def get_training_pipeline(
session_id: str | None = None,
account_id: str | None = None,
) -> Response:
sessions_dir = state.account_paths(account_id)['sessions']
state_path = _session_state_path(sessions_dir, session_id)
binding_path = _jupyter_binding_path(sessions_dir, session_id) if session_id else None
if binding_path and binding_path.is_file():
await _subprocess_sync_remote(binding_path, state_path)
state_entries, _ = _read_program_state(state_path)
trigger = _extract_trigger_from_state(state_entries) or _extract_trigger_from_session(
sessions_dir, session_id
)
_target_set_name = _parse_target_set(trigger, model_config=state.model_config_for(account_id))
iter_count = await asyncio.to_thread(
_read_iteration_log_count,
session_id,
agent_state=state,
account_id=account_id,
)
metrics_history = await asyncio.to_thread(
_read_iteration_log_metrics_history,
session_id,
agent_state=state,
account_id=account_id,
target_set_name=_target_set_name,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
session_id,
sessions_dir=sessions_dir,
state_entries=state_entries,
model_config=state.model_config_for(account_id),
agent_state=state,
account_id=account_id,
iteration_log_count=iter_count,
metrics_history=metrics_history,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
sessions_dir,
session_id,
)
run_active = _is_run_active(state, account_id, session_id)
state_entries = _maybe_inject_gate_fallback(
state_path, state_entries, sessions_dir, session_id, run_active,
)
_apply_program_state(
payload,
state_entries,
iteration_log_count=iter_count or 0,
session_failure_status=failure_status,
run_active=run_active,
session_id=session_id,
)
return Response(
content=json.dumps(payload, ensure_ascii=False),
media_type='application/json',
headers={'Cache-Control': 'no-store'},
)
@app.get('/api/training/pipeline/stream')
async def stream_training_pipeline(
request: Request,
session_id: str | None = None,
account_id: str | None = None,
) -> StreamingResponse:
sessions_dir = state.account_paths(account_id)['sessions']
state_path = _session_state_path(sessions_dir, session_id)
async def event_gen():
last_signature: str | None = None
last_heartbeat = time.monotonic()
last_remote_sync = 0.0
_state_entries_init, _ = _read_program_state(state_path)
_trigger_init = _extract_trigger_from_state(_state_entries_init) or _extract_trigger_from_session(
sessions_dir, session_id
)
_stream_target_set = _parse_target_set(_trigger_init, model_config=state.model_config_for(account_id))
refresher = asyncio.create_task(
_iter_count_refresh_loop(
session_id,
agent_state=state,
account_id=account_id,
)
)
metrics_refresher = asyncio.create_task(
_metrics_history_refresh_loop(
session_id,
agent_state=state,
account_id=account_id,
target_set_name=_stream_target_set,
)
)
try:
while True:
if await request.is_disconnected():
break
now_mono = time.monotonic()
if now_mono - last_remote_sync >= 5.0:
_bp = _jupyter_binding_path(sessions_dir, session_id) if session_id else None
if _bp and _bp.is_file():
await _subprocess_sync_remote(_bp, state_path)
last_remote_sync = now_mono
state_entries, state_mtime = _read_program_state(state_path)
iter_count = await asyncio.to_thread(
_read_iteration_log_count,
session_id,
agent_state=state,
account_id=account_id,
)
metrics_history = await asyncio.to_thread(
_read_iteration_log_metrics_history,
session_id,
agent_state=state,
account_id=account_id,
target_set_name=_stream_target_set,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
session_id,
sessions_dir=sessions_dir,
state_entries=state_entries,
agent_state=state,
account_id=account_id,
iteration_log_count=iter_count,
metrics_history=metrics_history,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
sessions_dir,
session_id,
)
run_active = _is_run_active(state, account_id, session_id)
state_entries = _maybe_inject_gate_fallback(
state_path, state_entries, sessions_dir, session_id, run_active,
)
_apply_program_state(
payload,
state_entries,
iteration_log_count=iter_count or 0,
session_failure_status=failure_status,
run_active=run_active,
session_id=session_id,
)
payload_str = json.dumps(payload, ensure_ascii=False)
signature = f'{state_mtime}|{hash(payload_str)}'
if signature != last_signature:
last_signature = signature
yield f'data: {payload_str}\n\n'
now = time.monotonic()
if now - last_heartbeat > 15:
yield ': heartbeat\n\n'
last_heartbeat = now
await asyncio.sleep(0.5)
finally:
refresher.cancel()
try:
await refresher
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
metrics_refresher.cancel()
try:
await metrics_refresher
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
return StreamingResponse(
event_gen(),
media_type='text/event-stream',
headers={
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive',
},
)
@app.get('/api/context-budget')
async def get_context_budget(
session_id: str | None = None,
account_id: str | None = None,
) -> dict[str, Any]:
with state.lock():
if session_id:
directory = state.account_paths(account_id)['sessions']
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=404, detail='Session not found')
try:
stored = load_agent_session(safe_id, directory=directory)
except FileNotFoundError:
raise HTTPException(status_code=404, detail='Session not found')
session = _session_state_from_stored(stored)
model = str(stored.model_config.get('model') or state.model)
runtime_config = deserialize_runtime_config(stored.runtime_config)
else:
agent = state.agent_for(account_id)
session = agent.last_session or agent.build_session()
model = agent.model_config.model
runtime_config = agent.runtime_config
snapshot = calculate_token_budget(
session=session,
model=model,
budget_config=runtime_config.budget_config,
output_schema=runtime_config.output_schema,
)
return _serialize_token_budget(snapshot)
def _merge_run_snapshot(
memory_snapshot: dict[str, Any] | None,
stored_snapshot: dict[str, Any] | None,
) -> dict[str, Any] | None:
if memory_snapshot is None:
return stored_snapshot
if stored_snapshot is None:
merged = dict(memory_snapshot)
else:
merged = {**stored_snapshot, **memory_snapshot}
if not memory_snapshot.get('events') and stored_snapshot.get('events'):
merged['events'] = stored_snapshot.get('events')
if memory_snapshot.get('current_stage'):
merged['current_stage'] = memory_snapshot.get('current_stage')
if (
memory_snapshot.get('status') not in ACTIVE_RUN_STATUSES
and stored_snapshot.get('elapsed_ms') is not None
):
merged['elapsed_ms'] = stored_snapshot.get('elapsed_ms')
if stored_snapshot.get('finished_at') is not None:
merged['finished_at'] = stored_snapshot.get('finished_at')
merged['cancellable'] = merged.get('status') in ACTIVE_RUN_STATUSES
return merged
@app.get('/api/runs/latest')
async def latest_run(
session_id: str,
account_id: str | None = None,
) -> dict[str, Any]:
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
account_key = state._account_key(account_id)
snapshot = state.run_manager.snapshot_latest(account_key, safe_id)
stored_snapshot = state.run_state_store.snapshot_latest(account_key, safe_id)
if (
snapshot is not None
and stored_snapshot is not None
and stored_snapshot.get('status') in ACTIVE_RUN_STATUSES
and snapshot.get('run_id') == stored_snapshot.get('run_id')
and snapshot.get('status') not in ACTIVE_RUN_STATUSES
):
state.run_state_store.finish(
str(stored_snapshot.get('run_id') or ''),
status=str(snapshot.get('status') or 'interrupted'),
elapsed_ms=(
int(snapshot['elapsed_ms'])
if isinstance(snapshot.get('elapsed_ms'), (int, float))
else None
),
stage=str(snapshot.get('current_stage') or ''),
error=(
str(snapshot.get('error'))
if snapshot.get('error') is not None
else None
),
)
stored_snapshot = state.run_state_store.snapshot_latest(account_key, safe_id)
if snapshot is not None:
return _merge_run_snapshot(snapshot, stored_snapshot) or snapshot
if stored_snapshot is not None:
if stored_snapshot.get('status') in ACTIVE_RUN_STATUSES:
interrupted = state.run_state_store.mark_interrupted_if_active(
str(stored_snapshot.get('run_id') or ''),
)
return interrupted or stored_snapshot
return stored_snapshot
directory = state.account_paths(account_id)['sessions']
try:
stored = load_agent_session(safe_id, directory=directory)
except FileNotFoundError:
return {'session_id': safe_id, 'status': 'idle'}
if _stored_session_has_incomplete_tail(stored):
return {
'session_id': safe_id,
'status': 'interrupted',
'current_stage': '上次运行已中断,后台没有正在执行的进程',
}
return {'session_id': safe_id, 'status': 'idle'}
@app.post('/api/runs/cancel')
async def cancel_run(payload: RunCancelRequest) -> dict[str, Any]:
safe_id = _safe_session_id(payload.session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
account_key = state._account_key(payload.account_id)
cancelled_ids: set[str] = set()
if payload.run_id and state.run_manager.cancel_run(payload.run_id):
cancelled_ids.add(payload.run_id)
cancelled_ids.update(state.run_manager.cancel_session(account_key, safe_id))
abandoned_runtime = state.abandon_session_runtime(account_key, safe_id)
cancelled_bg_task_ids = await _bash_bg_manager.cancel_session(
state,
account_key,
safe_id,
)
stored_cancelled = state.run_state_store.finish_active_for_session(
account_key,
safe_id,
status='cancelled',
stage='用户已取消',
)
queue_cancelled = cancel_session_input_queue(
safe_id,
state.account_paths(payload.account_id)['sessions'],
run_id=payload.run_id,
)
cancelled_ids.update(stored_cancelled)
cancelled = bool(
cancelled_ids
or cancelled_bg_task_ids
or abandoned_runtime
or queue_cancelled
)
if cancelled:
_mark_session_interrupted(
state.account_paths(payload.account_id)['sessions'],
safe_id,
status='cancelled',
)
return {
'session_id': safe_id,
'cancelled': cancelled,
'cancelled_run_ids': sorted(cancelled_ids),
'cancelled_bg_task_ids': sorted(cancelled_bg_task_ids),
'abandoned_runtime': abandoned_runtime,
'cancelled_input_queue_items': queue_cancelled,
}
def _schedule_next_queued_turn(
*,
account_id: str | None,
session_id: str,
directory: Path,
) -> dict[str, Any] | None:
item = consume_next_session_input(session_id, directory=directory)
if item is None:
return None
content = str(item.get('content') or '').strip()
if not content:
return None
try:
return _start_chat_run(
ChatRequest(
prompt=content,
account_id=account_id,
session_id=session_id,
resume_session_id=session_id,
)
)
except Exception as exc: # noqa: BLE001
update_session_input_queue_item(
session_id,
int(item.get('id') or 0),
directory=directory,
status='pending',
)
print(
f'[input-queue] failed to schedule queued turn '
f'session={session_id} item={item.get("id")}: {exc}',
flush=True,
)
return None
def _run_chat_payload(
request: ChatRequest,
event_sink: Any | None = None,
run_record: RunRecord | None = None,
) -> dict[str, Any]:
requested_session_id = _safe_session_id(
request.resume_session_id or request.session_id
) or uuid4().hex
account_key = state._account_key(request.account_id)
prompt = request.prompt.strip()
session_directory = state.account_paths(request.account_id)['sessions']
if (
request.resume_session_id is None
and run_record is None
and _agent_session_exists(session_directory, requested_session_id)
):
raise HTTPException(
status_code=409,
detail=(
'Session already exists; pass resume_session_id to continue '
'or create a fresh session_id.'
),
)
if run_record is None:
run_record = state.run_manager.start(
account_key,
requested_session_id,
prompt,
)
state.run_state_store.start(
run_id=run_record.run_id,
account_key=account_key,
session_id=requested_session_id,
pending_prompt=prompt,
started_at=run_record.started_at,
)
run_lock = state.run_lock_for(request.account_id, requested_session_id)
agent = state.agent_for(request.account_id, requested_session_id)
config = state.config_for(request.account_id)
jupyter_runtime = _jupyter_runtime_for_session(
state,
request.account_id,
requested_session_id,
)
runtime_context = request.runtime_context
memory_context = state.memory_manager.render_injection(
request.account_id,
state.enabled_skill_names(request.account_id),
)
if memory_context:
runtime_context = _append_runtime_context(runtime_context, memory_context)
if jupyter_runtime is not None:
runtime_context = _append_runtime_context(
runtime_context,
jupyter_runtime.render_context(),
)
def emit_agent_event(event: dict[str, object]) -> None:
stage = _runtime_event_stage(event)
if stage:
state.run_manager.update(run_record.run_id, stage=stage)
state.run_state_store.update(run_record.run_id, stage=stage)
stored_event = state.run_manager.record_event(run_record.run_id, event)
if stored_event is not None:
state.run_state_store.record_event(run_record.run_id, stored_event)
_emit_runtime_event(event_sink, event)
previous_title = _read_session_title(
session_directory,
requested_session_id,
)
fallback_title = _save_in_progress_session(
directory=session_directory,
agent=agent,
session_id=requested_session_id,
prompt=prompt,
)
if run_lock.locked():
queued_event = {
'type': 'run_queued',
'run_id': run_record.run_id,
'session_id': requested_session_id or '',
'account_id': request.account_id or '',
}
stored_event = state.run_manager.record_event(run_record.run_id, queued_event)
if stored_event is not None:
state.run_state_store.record_event(run_record.run_id, stored_event)
_emit_runtime_event(
event_sink,
queued_event,
)
with run_lock:
if run_record.cancel_event.is_set():
state.run_manager.finish(run_record.run_id, 'cancelled')
state.run_state_store.finish(
run_record.run_id,
status='cancelled',
elapsed_ms=max(0, int((time.time() - run_record.started_at) * 1000)),
stage='已取消排队中的请求',
)
return {
'final_output': '已取消排队中的请求。',
'turns': 0,
'tool_calls': 0,
'transcript': (),
'session_id': requested_session_id,
'usage': {},
'total_cost_usd': 0.0,
'stop_reason': 'cancelled',
}
state.run_manager.update(run_record.run_id, status='running', stage='后端开始执行')
state.run_state_store.update(
run_record.run_id,
status='running',
stage='后端开始执行',
cancellable=True,
)
started_event = {
'type': 'run_started',
'run_id': run_record.run_id,
'started_at': run_record.started_at,
'session_id': requested_session_id or '',
'account_id': request.account_id or '',
}
stored_event = state.run_manager.record_event(run_record.run_id, started_event)
if stored_event is not None:
state.run_state_store.record_event(run_record.run_id, stored_event)
_emit_runtime_event(event_sink, started_event)
event_loop = state.event_loop
def _bg_register(spec: BgTaskSpec) -> None:
if event_loop is None:
raise RuntimeError(
'event loop unavailable; backend not started via lifespan'
)
asyncio.run_coroutine_threadsafe(
_bash_bg_manager.register(state, spec),
event_loop,
)
def _bg_status_query(task_id: str) -> 'BgTaskStatus | None':
return _bash_bg_manager.status_query(state, task_id)
def _bg_kill_request(task_id: str) -> bool:
if event_loop is None:
return False
future = asyncio.run_coroutine_threadsafe(
_bash_bg_manager.cancel(state, task_id),
event_loop,
)
try:
return bool(future.result(timeout=15))
except Exception: # noqa: BLE001
return False
def _drain_runtime_guidance() -> tuple[dict[str, object], ...]:
items = consume_session_guidance(
requested_session_id,
run_record.run_id,
directory=session_directory,
)
return tuple(
{
'id': int(item.get('id') or 0),
'content': str(item.get('content') or ''),
}
for item in items
)
previous_guidance_provider = agent.runtime_guidance_provider
agent.runtime_guidance_provider = _drain_runtime_guidance
agent.tool_context = replace(
agent.tool_context,
cancel_event=run_record.cancel_event,
process_registry=run_record.process_registry,
jupyter_runtime=jupyter_runtime,
bg_register=_bg_register,
bg_status_query=_bg_status_query,
bg_kill_request=_bg_kill_request,
account_id=account_key,
session_id=requested_session_id,
run_id=run_record.run_id,
)
started_at = time.perf_counter()
try:
try:
if request.resume_session_id is not None:
try:
stored = load_agent_session(
requested_session_id,
directory=session_directory,
)
except FileNotFoundError:
raise HTTPException(
status_code=404,
detail='Session to resume not found',
)
stored = _sanitize_stored_session_for_resume(stored)
result = agent.resume(
prompt,
stored,
runtime_context=runtime_context,
event_sink=emit_agent_event,
)
else:
result = agent.run(
prompt,
session_id=requested_session_id,
runtime_context=runtime_context,
event_sink=emit_agent_event,
)
except Exception as exc:
_mark_session_interrupted(
session_directory,
requested_session_id,
status=(
'cancelled'
if run_record.cancel_event.is_set()
else 'failed'
),
detail=str(exc),
)
state.run_manager.update(
run_record.run_id,
status=(
'cancelled'
if run_record.cancel_event.is_set()
else 'failed'
),
error=str(exc),
)
state.run_state_store.finish(
run_record.run_id,
status=(
'cancelled'
if run_record.cancel_event.is_set()
else 'failed'
),
elapsed_ms=max(
0,
int((time.time() - run_record.started_at) * 1000),
),
error=str(exc),
)
raise
finally:
agent.runtime_guidance_provider = previous_guidance_provider
agent.tool_context = replace(
agent.tool_context,
cancel_event=None,
process_registry=None,
jupyter_runtime=None,
bg_register=None,
bg_status_query=None,
bg_kill_request=None,
account_id=None,
session_id=None,
run_id=None,
)
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
payload = _serialize_run_result(result)
payload['elapsed_ms'] = elapsed_ms
used_skills = _extract_used_skill_names(payload.get('transcript') or ())
if not used_skills:
used_skills = tuple(state.enabled_skill_names(request.account_id))
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,
fallback_title=fallback_title,
)
_annotate_transcript_elapsed(payload, elapsed_ms)
try:
state.memory_manager.enqueue_interaction(
account_id=request.account_id,
session_id=result.session_id,
user_prompt=prompt,
assistant_output=result.final_output,
skills=used_skills,
model=config.model,
)
except Exception:
pass
state.run_manager.finish(
run_record.run_id,
'cancelled' if run_record.cancel_event.is_set() else 'completed',
)
state.run_state_store.finish(
run_record.run_id,
status='cancelled' if run_record.cancel_event.is_set() else 'completed',
elapsed_ms=max(0, int((time.time() - run_record.started_at) * 1000)),
)
if run_record.cancel_event.is_set() and result.session_id:
_mark_session_interrupted(
session_directory,
result.session_id,
status='cancelled',
)
if not run_record.cancel_event.is_set() and result.session_id:
_schedule_next_queued_turn(
account_id=request.account_id,
session_id=result.session_id,
directory=session_directory,
)
return payload
def _start_chat_run(request: ChatRequest) -> dict[str, Any]:
requested_session_id = _safe_session_id(
request.resume_session_id or request.session_id
) or uuid4().hex
account_key = state._account_key(request.account_id)
prompt = request.prompt.strip()
session_directory = state.account_paths(request.account_id)['sessions']
if (
request.resume_session_id is None
and _agent_session_exists(session_directory, requested_session_id)
):
raise HTTPException(
status_code=409,
detail=(
'Session already exists; pass resume_session_id to continue '
'or create a fresh session_id.'
),
)
run_record = state.run_manager.start(account_key, requested_session_id, prompt)
state.run_state_store.start(
run_id=run_record.run_id,
account_key=account_key,
session_id=requested_session_id,
pending_prompt=prompt,
started_at=run_record.started_at,
)
# Persist the submitted user message before the worker starts so the UI
# can immediately reload this exact session from DB without depending on
# browser-local optimistic state.
try:
agent = state.agent_for(request.account_id, requested_session_id)
_save_in_progress_session(
directory=session_directory,
agent=agent,
session_id=requested_session_id,
prompt=prompt,
)
except Exception:
# The worker will surface the real failure through run_state_store.
pass
def worker() -> None:
try:
_run_chat_payload(request, run_record=run_record)
except Exception as exc: # noqa: BLE001
print(
f'[chat-start] background run failed '
f'session={requested_session_id} run={run_record.run_id}: {exc}',
flush=True,
)
threading.Thread(target=worker, daemon=True).start()
return {
'session_id': requested_session_id,
'run_id': run_record.run_id,
'status': 'queued',
'started_at': run_record.started_at,
}
# ------------- 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:
raise
except Exception as exc: # surface the error in the UI
return JSONResponse(
status_code=500,
content={
'error': str(exc),
'error_type': type(exc).__name__,
},
)
return payload
@app.post('/api/chat/start')
async def chat_start(request: ChatRequest) -> dict[str, Any]:
prompt = request.prompt.strip()
if not prompt:
raise HTTPException(status_code=400, detail='Prompt is empty')
try:
return _start_chat_run(request)
except HTTPException:
raise
except Exception as exc:
return JSONResponse(
status_code=500,
content={
'error': str(exc),
'error_type': type(exc).__name__,
},
)
@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()
stream_started = time.perf_counter()
def generate() -> Any:
while True:
try:
item = event_queue.get(timeout=15)
except queue.Empty:
yield json.dumps(
{
'kind': 'event',
'event': {
'type': 'server_heartbeat',
'elapsed_ms': max(
0,
int((time.perf_counter() - stream_started) * 1000),
),
},
},
ensure_ascii=False,
) + '\n'
continue
if item is None:
break
yield json.dumps(item, ensure_ascii=False) + '\n'
return StreamingResponse(
generate(),
media_type='application/x-ndjson',
headers={
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
)
@app.post('/api/clear')
async def clear_state(account_id: str | None = None) -> dict[str, Any]:
with state.lock():
state.agent_for(account_id).clear_runtime_state()
return state.snapshot(account_id)
return app
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _serialize_run_result(result: Any) -> dict[str, Any]:
return {
'final_output': result.final_output,
'turns': result.turns,
'tool_calls': result.tool_calls,
'transcript': [_normalize_transcript_entry(entry) for entry in result.transcript],
'session_id': result.session_id,
'usage': result.usage.to_dict(),
'total_cost_usd': result.total_cost_usd,
'stop_reason': result.stop_reason,
}
def _normalize_transcript_entry(entry: dict[str, Any]) -> dict[str, Any]:
content = entry.get('content', '')
if entry.get('role') == 'tool' and isinstance(content, str):
content = _truncate_api_text(content, API_TOOL_CONTENT_MAX_CHARS)
out: dict[str, Any] = {
'role': entry.get('role', ''),
'content': content,
}
for key in ('name', 'tool_call_id', 'tool_calls', 'metadata', 'message_id'):
if key in entry and entry[key] not in (None, '', [], {}):
out[key] = entry[key]
for key in ('state', 'stop_reason'):
if key in entry and entry[key] not in (None, ''):
out[key] = entry[key]
return out
def _append_guidance_display_message(
session_id: str,
*,
directory: Path,
item: dict[str, Any],
) -> None:
content = str(item.get('content') or '').strip()
item_id = item.get('id')
if (
not content
or not isinstance(item_id, int)
or isinstance(item_id, bool)
):
return
message_id = f'user_guidance_{item_id}'
append_agent_display_message(
session_id,
{
'role': 'user',
'content': content,
'state': 'final',
'message_id': message_id,
'metadata': {
'kind': 'runtime_guidance_display',
'queue_item_id': item_id,
'lineage_id': message_id,
'run_id': str(item.get('run_id') or ''),
'created_at': float(item.get('created_at') or time.time()),
},
},
directory=directory,
)
def _truncate_api_text(text: str, limit: int) -> str:
if len(text) <= limit:
return text
head = text[: limit // 2]
tail = text[-(limit // 2) :]
return f'{head}\n...[truncated for api response]...\n{tail}'
def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
display_messages = _stored_display_messages(stored)
return {
'session_id': stored.session_id,
'turns': stored.turns,
'tool_calls': stored.tool_calls,
'messages': [_normalize_transcript_entry(dict(m)) for m in display_messages],
'usage': stored.usage,
'total_cost_usd': stored.total_cost_usd,
'model': stored.model_config.get('model'),
'is_training': stored.is_training,
'session_metadata': stored.session_metadata or {},
}
def _stored_display_messages(
stored: StoredAgentSession,
) -> tuple[dict[str, Any], ...]:
if stored.display_messages:
return tuple(dict(message) for message in stored.display_messages)
return tuple(dict(message) for message in stored.messages)
def _stored_session_has_incomplete_tail(stored: StoredAgentSession) -> bool:
_, changed = _trim_incomplete_message_tail(stored.messages)
if changed:
return True
budget_state = stored.budget_state if isinstance(stored.budget_state, dict) else {}
return budget_state.get('status') in {'queued', 'running'}
def _is_run_active(state: 'AgentState', account_id: str | None, session_id: str | None) -> bool | None:
"""True iff `run_state_store` says the latest run for this session is in
`queued`/`running`. None when the lookup is impossible (no session id, or
no row yet). The pipeline endpoint uses this to detect orphan `running`
step entries — agent wrote `step:running`, finished its turn cleanly, and
never wrote the closing entry; from the user's POV the run is idle but
the panel was still spinning.
"""
if not session_id:
return None
try:
account_key = state._account_key(account_id)
snapshot = state.run_state_store.snapshot_latest(account_key, session_id)
except Exception:
return None
if snapshot is None:
return None
return snapshot.get('status') in ACTIVE_RUN_STATUSES
def _read_session_failure_status(
directory: Path,
session_id: str | None,
) -> str | None:
"""Returns 'failed' / 'cancelled' / 'interrupted' iff the stored session
was explicitly tombstoned by `_mark_session_interrupted`. Returns None for
healthy sessions AND for live runs (budget_state.status='running'/'queued').
The pipeline panel uses this to paint a failed run red. We deliberately do
NOT use `_stored_session_has_incomplete_tail` here — that helper is for
detecting incomplete tails when LOADING a session for resume, where
budget_state='running' implies a dead writer. Polled live, 'running' just
means the agent is still chugging.
"""
if not session_id:
return None
try:
stored = load_agent_session(session_id, directory=directory)
except (FileNotFoundError, OSError, json.JSONDecodeError):
return None
bs = stored.budget_state if isinstance(stored.budget_state, dict) else {}
status = bs.get('status')
if status in ('failed', 'cancelled', 'interrupted'):
return str(status)
return None
def _mark_session_interrupted(
directory: Path,
session_id: str,
*,
status: str,
detail: str = '',
) -> None:
try:
stored = load_agent_session(session_id, directory=directory)
except (FileNotFoundError, OSError, json.JSONDecodeError):
return
trimmed, changed = _trim_incomplete_message_tail(stored.messages)
display_trimmed, display_changed = _trim_incomplete_message_tail(
_stored_display_messages(stored)
)
budget_state = (
dict(stored.budget_state)
if isinstance(stored.budget_state, dict)
else {}
)
if (
not changed
and not display_changed
and budget_state.get('status') not in {'queued', 'running'}
):
return
messages = list(trimmed)
display_messages = list(display_trimmed)
run_status_message = {
'role': 'assistant',
'content': _interrupted_session_message(status),
'state': 'final',
'stop_reason': status,
'metadata': {
'kind': 'run_status',
'status': status,
'detail': detail[:500],
'created_at_ms': int(time.time() * 1000),
},
}
if not _last_message_is_run_status(messages, status):
messages.append(dict(run_status_message))
if not _last_message_is_run_status(display_messages, status):
display_messages.append(dict(run_status_message))
budget_state['status'] = status
budget_state['interrupted_at'] = int(time.time())
save_agent_session(
replace(
stored,
messages=tuple(messages),
display_messages=tuple(display_messages),
budget_state=budget_state,
),
directory=directory,
)
def _sanitize_stored_session_for_resume(
stored: StoredAgentSession,
) -> StoredAgentSession:
trimmed, changed = _trim_incomplete_message_tail(stored.messages)
if trimmed and _is_pending_user_message(trimmed[-1]):
trimmed = trimmed[:-1]
changed = True
messages = _without_run_status_messages(trimmed)
if len(messages) != len(trimmed):
changed = True
display_trimmed, display_changed = _trim_incomplete_message_tail(
_stored_display_messages(stored)
)
if display_trimmed and _is_pending_user_message(display_trimmed[-1]):
display_trimmed = display_trimmed[:-1]
display_changed = True
display_messages = _without_run_status_messages(display_trimmed)
if len(display_messages) != len(display_trimmed):
display_changed = True
if not changed and not display_changed:
return stored
budget_state = (
dict(stored.budget_state)
if isinstance(stored.budget_state, dict)
else {}
)
budget_state['status'] = 'interrupted'
return replace(
stored,
messages=messages,
display_messages=display_messages,
budget_state=budget_state,
)
def _trim_incomplete_message_tail(
messages: tuple[dict[str, Any], ...] | tuple[Any, ...],
) -> tuple[tuple[dict[str, Any], ...], bool]:
trimmed = [dict(message) for message in messages if isinstance(message, dict)]
changed = False
while trimmed and _is_incomplete_session_message(trimmed[-1]):
trimmed.pop()
changed = True
while trimmed and _assistant_has_trailing_tool_call(trimmed[-1]):
trimmed.pop()
changed = True
return tuple(trimmed), changed
def _is_incomplete_session_message(message: dict[str, Any]) -> bool:
state = message.get('state')
if isinstance(state, str) and state not in {'', 'final'}:
return True
metadata = message.get('metadata')
if isinstance(metadata, dict):
if (
message.get('role') != 'user'
and metadata.get('placeholder') is True
and metadata.get('status') == 'running'
):
return True
if metadata.get('phase') in {'starting', 'running'} and message.get('role') == 'tool':
return True
return False
def _is_pending_user_message(message: dict[str, Any]) -> bool:
metadata = message.get('metadata')
return (
message.get('role') == 'user'
and isinstance(metadata, dict)
and metadata.get('placeholder') is True
and metadata.get('status') == 'running'
)
def _assistant_has_trailing_tool_call(message: dict[str, Any]) -> bool:
if message.get('role') != 'assistant':
return False
tool_calls = message.get('tool_calls')
return isinstance(tool_calls, list) and bool(tool_calls)
def _without_run_status_messages(
messages: tuple[dict[str, Any], ...],
) -> tuple[dict[str, Any], ...]:
return tuple(
message
for message in messages
if not _is_run_status_message(message)
)
def _is_run_status_message(message: dict[str, Any]) -> bool:
metadata = message.get('metadata')
return isinstance(metadata, dict) and metadata.get('kind') == 'run_status'
def _last_message_is_run_status(messages: list[dict[str, Any]], status: str) -> bool:
if not messages:
return False
metadata = messages[-1].get('metadata')
return (
isinstance(metadata, dict)
and metadata.get('kind') == 'run_status'
and metadata.get('status') == status
)
def _interrupted_session_message(status: str) -> str:
if status == 'cancelled':
return '上一次任务已取消,后台没有正在执行的进程。你可以继续回复,或重新发起任务。'
if status == 'failed':
return '上一次任务异常中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。'
return '上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。'
def _runtime_event_stage(event: dict[str, object]) -> str:
event_type = event.get('type')
if event_type == 'content_delta':
return ''
if event_type == 'tool_start':
stage_note = str(event.get('assistant_content') or '').strip()
if stage_note.startswith(('进度:', '进度:')):
return stage_note
tool_name = str(event.get('tool_name') or '').strip()
return f'调用工具 {tool_name}' if tool_name else '正在调用工具'
if event_type == 'tool_delta':
tool_name = str(event.get('tool_name') or '').strip()
return f'{tool_name} 输出中' if tool_name else '工具输出中'
if event_type == 'tool_result':
tool_name = str(event.get('tool_name') or '').strip()
return f'工具完成 {tool_name}' if tool_name else '工具调用完成'
if event_type == 'final_text_start':
return '正在整理回复'
if event_type == 'final_text_end':
return '回复整理完成'
if event_type == 'user_review_required':
return '等待用户 review'
if event_type == 'continuation_request':
return '继续补全回复'
if event_type in {
'prompt_length_check',
'prompt_length_recovery',
'auto_compact_summary',
'auto_compact_circuit_breaker',
}:
return '整理上下文'
if event_type == 'task_budget_exceeded':
return '达到任务预算限制'
return ''
_RUN_EVENT_TYPES = {
'run_queued',
'run_started',
'content_delta',
'tool_start',
'tool_delta',
'tool_result',
'final_text_start',
'final_text_end',
'user_review_required',
'continuation_request',
'prompt_length_check',
'prompt_length_recovery',
'auto_compact_summary',
'auto_compact_circuit_breaker',
'task_budget_exceeded',
}
def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
event_type = event.get('type')
if not isinstance(event_type, str) or event_type not in _RUN_EVENT_TYPES:
return None
allowed = {
'type',
'run_id',
'session_id',
'account_id',
'tool_name',
'tool_call_id',
'arguments',
'delta',
'assistant_content',
'message_id',
'stream',
'ok',
'metadata',
'reason',
'continuation_index',
'turn_index',
'strategy',
'projected_input_tokens',
'soft_input_limit_tokens',
'hard_input_limit_tokens',
'exceeds_hard_limit',
'exceeds_soft_limit',
}
normalized = {
key: _json_safe_limited(value, parent_key=key)
for key, value in event.items()
if key in allowed and value is not None
}
normalized['type'] = event_type
return normalized
_VERBOSE_STRING_KEYS = {'code', 'command', 'content', 'delta', 'script', 'stdout', 'output'}
_VERBOSE_STRING_LIMIT = 20000
def _json_safe_limited(value: Any, *, depth: int = 0, parent_key: str | None = None) -> Any:
if depth > 4:
return str(value)[:500]
if isinstance(value, str):
limit = _VERBOSE_STRING_LIMIT if parent_key in _VERBOSE_STRING_KEYS else 2000
return value[:limit]
if isinstance(value, (int, float, bool)) or value is None:
return value
if isinstance(value, dict):
return {
str(key)[:200]: _json_safe_limited(item, depth=depth + 1, parent_key=str(key))
for key, item in list(value.items())[:80]
}
if isinstance(value, (list, tuple)):
return [_json_safe_limited(item, depth=depth + 1, parent_key=parent_key) for item in value[:80]]
try:
json.dumps(value)
return value
except TypeError:
return str(value)[:1000]
def _emit_runtime_event(
event_sink: Any | None,
event: dict[str, object],
) -> None:
if event_sink is None:
return
event_sink(event)
def _save_in_progress_session(
*,
directory: Path,
agent: LocalCodingAgent,
session_id: str | None,
prompt: str,
) -> str | None:
safe_id = _safe_session_id(session_id)
if safe_id is None or not prompt:
return None
initial_title = _derive_initial_session_title(prompt)
pending_metadata = {'status': 'running', 'placeholder': True}
session_path = _session_json_path(directory, safe_id)
if session_path.exists():
# 续聊也要先落盘用户消息。否则前端流断开或刷新时,用户刚发的内容只
# 存在于浏览器内存里,会出现“消息丢了但后端状态还在”的错觉。
try:
stored = load_agent_session(safe_id, directory=directory)
except (FileNotFoundError, OSError, json.JSONDecodeError):
return None
messages = list(stored.messages)
display_messages = list(_stored_display_messages(stored))
if messages and _is_pending_user_message(dict(messages[-1])):
messages.pop()
if display_messages and _is_pending_user_message(dict(display_messages[-1])):
display_messages.pop()
pending_message = {
'role': 'user',
'content': prompt,
'state': 'final',
'metadata': pending_metadata,
'message_id': f'user_pending_{int(time.time() * 1000)}',
}
messages.append(
dict(pending_message)
)
display_messages.append(dict(pending_message))
budget_state = (
dict(stored.budget_state)
if isinstance(stored.budget_state, dict)
else {}
)
budget_state['status'] = 'running'
try:
save_agent_session(
replace(
stored,
messages=tuple(messages),
display_messages=tuple(display_messages),
budget_state=budget_state,
),
directory=directory,
)
except OSError:
return None
return None
# 新会话的第一轮执行可能很久。先写一个运行中占位,避免刷新页面后找不到会话。
scratchpad_directory = (
agent.runtime_config.scratchpad_root / safe_id / 'scratchpad'
).resolve()
try:
scratchpad_directory.mkdir(parents=True, exist_ok=True)
session = agent.build_session(None, scratchpad_directory=scratchpad_directory)
session.append_user(
prompt,
metadata=pending_metadata,
message_id='user_pending_0',
)
session_metadata: dict[str, Any] = {}
if initial_title:
session_metadata.update(
{
'title': initial_title,
'title_source': 'first_message',
'title_generated_at': int(time.time()),
}
)
stored = StoredAgentSession(
session_id=safe_id,
model_config=serialize_model_config(agent.model_config),
runtime_config=serialize_runtime_config(agent.runtime_config),
system_prompt_parts=session.system_prompt_parts,
user_context=dict(session.user_context),
system_context=dict(session.system_context),
messages=session.model_transcript(),
display_messages=session.display_transcript(),
turns=0,
tool_calls=0,
usage={},
total_cost_usd=0.0,
file_history=(),
budget_state={'status': 'running'},
plugin_state={},
scratchpad_directory=str(scratchpad_directory),
session_metadata=session_metadata,
)
save_agent_session(stored, directory=directory)
return initial_title
except OSError:
return None
def _agent_session_exists(directory: Path, session_id: str) -> bool:
try:
load_agent_session(session_id, directory=directory)
except (FileNotFoundError, OSError, json.JSONDecodeError):
return False
return True
def _derive_initial_session_title(prompt: str) -> str | None:
# 第一条消息刚发出时先给侧边栏一个可读标题,后续再由模型摘要精修。
stripped = _strip_session_context(prompt)
stripped = re.sub(r'\s+', ' ', stripped).strip()
stripped = stripped.strip('"\'“”‘’`')
if not stripped:
return None
title = _clean_session_title(stripped)
return title or None
def _session_state_from_stored(stored: StoredAgentSession) -> AgentSessionState:
return AgentSessionState.from_persisted(
system_prompt_parts=stored.system_prompt_parts,
user_context=stored.user_context,
system_context=stored.system_context,
messages=stored.messages,
display_messages=stored.display_messages,
)
def _serialize_token_budget(snapshot: Any) -> dict[str, Any]:
return {
'model': snapshot.model,
'context_window_tokens': snapshot.context_window_tokens,
'projected_input_tokens': snapshot.projected_input_tokens,
'message_tokens': snapshot.message_tokens,
'chat_overhead_tokens': snapshot.chat_overhead_tokens,
'reserved_output_tokens': snapshot.reserved_output_tokens,
'reserved_compaction_buffer_tokens': snapshot.reserved_compaction_buffer_tokens,
'reserved_schema_tokens': snapshot.reserved_schema_tokens,
'hard_input_limit_tokens': snapshot.hard_input_limit_tokens,
'soft_input_limit_tokens': snapshot.soft_input_limit_tokens,
'overflow_tokens': snapshot.overflow_tokens,
'soft_overflow_tokens': snapshot.soft_overflow_tokens,
'exceeds_hard_limit': snapshot.exceeds_hard_limit,
'exceeds_soft_limit': snapshot.exceeds_soft_limit,
'token_counter_backend': snapshot.token_counter_backend,
'token_counter_source': snapshot.token_counter_source,
'token_counter_accurate': snapshot.token_counter_accurate,
}
def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]:
req = request.Request(
_join_url(base_url, '/models'),
headers={
'Authorization': f'Bearer {api_key}',
'api-key': api_key,
'Content-Type': 'application/json',
},
method='GET',
)
try:
with request.urlopen(req, timeout=10) as response:
payload = json.loads(response.read().decode('utf-8'))
except error.HTTPError as exc:
detail = exc.read().decode('utf-8', errors='replace')
raise HTTPException(
status_code=502,
detail=f'HTTP {exc.code} from model backend: {detail}',
) from exc
except (error.URLError, OSError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=502,
detail=f'Unable to list models from {base_url}: {exc}',
) from exc
models = _normalize_model_list(payload)
return {
'models': models,
'raw_count': _raw_model_count(payload),
'filtered_count': len(models),
}
def _normalize_model_list(payload: Any) -> list[dict[str, str]]:
data = payload.get('data') if isinstance(payload, dict) else payload
if not isinstance(data, list):
return []
models_by_key: dict[str, dict[str, str]] = {}
for item in data:
if isinstance(item, str):
normalized = _normalize_model_id(item)
if normalized is not None:
models_by_key.setdefault(normalized.lower(), {'id': normalized})
continue
if not isinstance(item, dict):
continue
model_id = item.get('id') or item.get('model') or item.get('name')
if not isinstance(model_id, str) or not model_id:
continue
model_type = _optional_model_string(item.get('model_type') or item.get('type'))
if not _is_llm_model(model_id, model_type):
continue
provider = _optional_model_string(
item.get('owned_by') or item.get('provider') or item.get('owner')
)
if provider and provider not in VALIDATED_CHAT_MODEL_PROVIDERS:
continue
normalized = _normalize_model_id(model_id, provider=provider)
if normalized is not None and normalized.lower() in UNSUPPORTED_TOOL_CHAT_MODEL_IDS:
continue
if normalized is not None:
entry = {'id': normalized}
if provider:
entry['provider'] = provider
if model_type:
entry['model_type'] = model_type
models_by_key.setdefault(normalized.lower(), entry)
return sorted(models_by_key.values(), key=lambda item: item['id'].lower())
def _raw_model_count(payload: Any) -> int:
data = payload.get('data') if isinstance(payload, dict) else payload
return len(data) if isinstance(data, list) else 0
def _optional_model_string(value: Any) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _normalize_model_id(model: str, *, provider: str | None = None) -> str | None:
value = model.strip()
if not value:
return None
if provider and not value.lower().startswith(f'{provider.lower()}/'):
return f'{provider}/{value}'
return value
def _is_llm_model(model_id: str, model_type: str | None) -> bool:
lower = model_id.strip().lower()
blocked_fragments = (
'asr',
'audio',
'embedding',
'image_generation',
'rerank',
'speech',
'text2image',
'transcribe',
'translation',
'tts',
'voiceclone',
'voicedesign',
)
if any(fragment in lower for fragment in blocked_fragments):
return False
if model_type:
return model_type.strip().lower() == 'llm'
return True
def _join_url(base_url: str, suffix: str) -> str:
return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}'
def _content_type_for_filename(filename: str) -> str:
suffix = Path(filename).suffix.lower()
if suffix == '.json':
return 'application/json; charset=utf-8'
if suffix in {'.jsonl', '.txt', '.md', '.csv'}:
return 'text/plain; charset=utf-8'
if suffix == '.xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
if suffix == '.docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
return 'application/octet-stream'
def _ascii_download_filename(filename: str) -> str:
safe = re.sub(r'[^A-Za-z0-9._-]+', '_', filename).strip('._')
return safe[:120] or 'download'
def _url_quote_filename(filename: str) -> str:
return quote(filename, safe='')
def _annotate_transcript_elapsed(payload: dict[str, Any], elapsed_ms: int) -> None:
transcript = payload.get('transcript')
if not isinstance(transcript, list):
return
for entry in reversed(transcript):
if not isinstance(entry, dict) or entry.get('role') != 'assistant':
continue
metadata = entry.get('metadata')
if not isinstance(metadata, dict):
metadata = {}
entry['metadata'] = metadata
metadata['elapsed_ms'] = elapsed_ms
return
def _annotate_last_assistant_elapsed(
directory: Path,
session_id: str,
elapsed_ms: int,
) -> None:
try:
stored = load_agent_session(session_id, directory=directory)
except (
FileNotFoundError,
OSError,
json.JSONDecodeError,
KeyError,
TypeError,
ValueError,
):
return
messages, messages_changed = _annotate_last_assistant_in_messages(
stored.messages,
elapsed_ms,
)
display_messages: tuple[dict[str, Any], ...] = ()
display_changed = False
if stored.display_messages:
display_messages, display_changed = _annotate_last_assistant_in_messages(
stored.display_messages,
elapsed_ms,
)
if not messages_changed and not display_changed:
return
save_agent_session(
replace(
stored,
messages=messages if messages_changed else stored.messages,
display_messages=(
display_messages
if display_changed
else stored.display_messages
),
),
directory=directory,
)
def _annotate_last_assistant_in_messages(
messages: tuple[dict[str, Any], ...] | tuple[Any, ...],
elapsed_ms: int,
) -> tuple[tuple[dict[str, Any], ...], bool]:
updated = [dict(message) for message in messages if isinstance(message, dict)]
for index in range(len(updated) - 1, -1, -1):
message = updated[index]
if message.get('role') != 'assistant':
continue
metadata = message.get('metadata')
if not isinstance(metadata, dict):
metadata = {}
else:
metadata = dict(metadata)
if metadata.get('elapsed_ms') == elapsed_ms:
return tuple(updated), False
metadata['elapsed_ms'] = elapsed_ms
message['metadata'] = metadata
updated[index] = message
return tuple(updated), True
return tuple(updated), False
def _read_session_title(directory: Path, session_id: str | None) -> str | None:
if not session_id:
return None
try:
stored = load_agent_session(session_id, directory=directory)
except FileNotFoundError:
return None
metadata = stored.session_metadata or {}
title = metadata.get('title')
if isinstance(title, str) and title.strip():
return title.strip()
return None
def _ensure_session_title(
directory: Path,
session_id: str,
*,
model: str,
base_url: str,
api_key: str,
previous_title: str | None = None,
fallback_title: str | None = None,
) -> None:
try:
stored = load_agent_session(session_id, directory=directory)
except FileNotFoundError:
return
metadata = dict(stored.session_metadata or {})
if previous_title:
metadata['title'] = previous_title
save_agent_session(
replace(stored, session_metadata=metadata),
directory=directory,
)
return
title = metadata.get('title')
title_source = metadata.get('title_source')
if (
isinstance(title, str)
and title.strip()
and title_source != 'first_message'
):
return
messages = [dict(message) for message in _stored_display_messages(stored)]
user_messages = _session_user_messages(messages)
if not user_messages:
if fallback_title and not (isinstance(title, str) and title.strip()):
metadata['title'] = fallback_title
metadata['title_source'] = 'first_message'
metadata['title_generated_at'] = int(time.time())
save_agent_session(
replace(stored, session_metadata=metadata),
directory=directory,
)
return
generated = _generate_session_title(
user_messages,
model=model,
base_url=base_url,
api_key=api_key,
)
if not generated:
if fallback_title and not (isinstance(title, str) and title.strip()):
metadata['title'] = fallback_title
metadata['title_source'] = 'first_message'
metadata['title_generated_at'] = int(time.time())
save_agent_session(
replace(stored, session_metadata=metadata),
directory=directory,
)
return
metadata['title'] = generated
metadata['title_source'] = 'llm'
metadata['title_generated_at'] = int(time.time())
save_agent_session(
replace(stored, session_metadata=metadata),
directory=directory,
)
def _session_user_messages(messages: list[Any]) -> list[str]:
user_messages: list[str] = []
for message in messages:
if not isinstance(message, dict) or message.get('role') != 'user':
continue
content = message.get('content')
if not isinstance(content, str) or _is_internal_message(content):
continue
stripped = _strip_session_context(content)
if stripped:
user_messages.append(stripped)
return user_messages
def _generate_session_title(
user_messages: list[str],
*,
model: str,
base_url: str,
api_key: str,
) -> str | None:
conversation = '\n'.join(
f'用户第{index}轮:{message[:240]}'
for index, message in enumerate(user_messages[-6:], start=1)
)
payload = {
'model': model,
'temperature': 0.2,
'max_tokens': 32,
'messages': [
{
'role': 'system',
'content': (
'你是会话标题生成器。请根据用户对话生成一个中文短标题,'
'要求 4 到 12 个字,只输出标题,不要解释,不要引号。'
),
},
{'role': 'user', 'content': conversation},
],
}
client = OpenAICompatClient(
ModelConfig(
model=model,
base_url=base_url,
api_key=api_key,
temperature=0.2,
)
)
try:
turn = client.complete(payload['messages'], tools=[])
except OpenAICompatError:
return None
return _clean_session_title(turn.content)
def _clean_session_title(value: str) -> str | None:
title = ' '.join(value.strip().strip('"\'“”‘’`').split())
title = title.rstrip('。.!?')
if not title:
return None
return title[:24].rstrip()
def _clean_manual_session_title(value: str) -> str | None:
title = ' '.join(value.strip().strip('"\'“”‘’`').split())
if not title:
return None
return title[:80]
def _write_session_metadata(path: Path, data: dict[str, Any]) -> None:
try:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
except OSError:
return
def _session_json_path(directory: Path, session_id: str) -> Path:
path = directory / session_id / 'session.json'
if path.exists():
return path
return directory / f'{session_id}.json'
def _jupyter_binding_path(directory: Path, session_id: str) -> Path:
return directory / session_id / JUPYTER_WORKSPACE_FILENAME
def _save_jupyter_runtime_binding(
directory: Path,
runtime: JupyterRuntimeSession,
) -> None:
path = _jupyter_binding_path(directory, runtime.binding.session_id)
try:
path.parent.mkdir(parents=True, exist_ok=True)
_write_session_metadata(path, runtime.to_persisted_dict())
except OSError:
return
# ── Per-account remote-Jupyter workspace registry ─────────────────────────────
# Saved at accounts/<aid>/workspaces.json. Each entry stores enough to re-bind a
# new chat without re-prompting (base_url + workspace_root + password). Cookies
# expire and aren't reusable across sessions, so for one-click re-bind we store
# the password — same plaintext-on-disk security posture as the per-session
# jupyter_workspace.json that already keeps login cookies.
def _jupyter_workspaces_registry_path(state: 'AgentState', account_id: str | None) -> Path:
return state.account_paths(account_id)['base'] / JUPYTER_WORKSPACES_REGISTRY_FILENAME
def _load_jupyter_workspaces_registry(
state: 'AgentState', account_id: str | None
) -> list[dict[str, Any]]:
path = _jupyter_workspaces_registry_path(state, account_id)
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(payload, list):
return []
out: list[dict[str, Any]] = []
for item in payload:
if isinstance(item, dict) and isinstance(item.get('id'), str):
out.append(item)
return out
def _save_jupyter_workspaces_registry(
state: 'AgentState', account_id: str | None, entries: list[dict[str, Any]]
) -> None:
path = _jupyter_workspaces_registry_path(state, account_id)
try:
path.parent.mkdir(parents=True, exist_ok=True)
_write_session_metadata(path, entries)
except OSError:
return
def _jupyter_workspace_registry_id(base_url: str, workspace_root: str) -> str:
"""Deterministic ID = sha1(base_url|workspace_root). Same (host, root) tuple
de-duplicates rather than piling up entries on every successful bind."""
digest = hashlib.sha1(
f'{base_url}|{workspace_root}'.encode('utf-8'),
).hexdigest()
return digest[:16]
def _public_jupyter_workspace_entry(entry: dict[str, Any]) -> dict[str, Any]:
"""Strip password before returning to the frontend."""
return {
'id': entry.get('id'),
'label': entry.get('label'),
'base_url': entry.get('base_url'),
'workspace_root': entry.get('workspace_root'),
'created_at': entry.get('created_at'),
'last_used_at': entry.get('last_used_at'),
}
def _register_jupyter_workspace(
state: 'AgentState',
account_id: str | None,
*,
base_url: str,
workspace_root: str,
password: str,
label: str | None = None,
) -> dict[str, Any]:
entries = _load_jupyter_workspaces_registry(state, account_id)
workspace_id = _jupyter_workspace_registry_id(base_url, workspace_root)
now = time.time()
found: dict[str, Any] | None = None
rest: list[dict[str, Any]] = []
for entry in entries:
if entry.get('id') == workspace_id:
found = entry
else:
rest.append(entry)
if found is None:
found = {
'id': workspace_id,
'label': label or base_url,
'base_url': base_url,
'workspace_root': workspace_root,
'created_at': now,
}
else:
if label:
found['label'] = label
elif not found.get('label'):
found['label'] = base_url
found['base_url'] = base_url
found['workspace_root'] = workspace_root
found['password'] = password
found['last_used_at'] = now
rest.insert(0, found) # most-recently-used first
_save_jupyter_workspaces_registry(state, account_id, rest)
return found
def _jupyter_runtime_for_session(
state: AgentState,
account_id: str | None,
session_id: str,
) -> JupyterRuntimeSession | None:
account_key = state._account_key(account_id)
runtime = state.jupyter_runtime_manager.get(account_key, session_id)
if runtime is not None:
return runtime
path = _jupyter_binding_path(
state.account_paths(account_id)['sessions'],
session_id,
)
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
try:
return state.jupyter_runtime_manager.restore_session(
account_id=account_key,
session_id=session_id,
payload=payload,
)
except JupyterRuntimeError:
return None
def _feishu_paths(state: AgentState, account_id: str | None) -> dict[str, Path]:
base = state.account_paths(account_id)['base'] / 'integrations' / 'feishu'
paths = {
'root': base,
'home': base / 'home',
'config': base / 'config',
'npm_cache': base / 'npm-cache',
}
for path in paths.values():
path.mkdir(parents=True, exist_ok=True)
return paths
def _feishu_command() -> list[str]:
return [
_feishu_npx_executable(),
'-y',
f'--registry={FEISHU_NPM_REGISTRY}',
FEISHU_MCP_PACKAGE,
]
def _feishu_npx_executable() -> str:
explicit = os.environ.get('CLAW_NPX_BIN')
if explicit and Path(explicit).expanduser().exists():
return str(Path(explicit).expanduser())
node_bin_dir = os.environ.get('CLAW_NODE_BIN_DIR')
if node_bin_dir:
candidate = Path(node_bin_dir).expanduser() / 'npx'
if candidate.exists():
return str(candidate)
return shutil.which('npx') or 'npx'
def _feishu_env(paths: dict[str, Path]) -> dict[str, str]:
env = {
'HOME': str(paths['home']),
'XDG_CONFIG_HOME': str(paths['config']),
'npm_config_cache': str(paths['npm_cache']),
'npm_config_update_notifier': 'false',
'NO_UPDATE_NOTIFIER': 'true',
'FEISHU_LOGIN_MODE': 'devicecode',
}
node_bin_dir = os.environ.get('CLAW_NODE_BIN_DIR')
if node_bin_dir:
env['PATH'] = f'{node_bin_dir}{os.pathsep}{os.environ.get("PATH", "")}'
return env
def _run_feishu_cli(
paths: dict[str, Path],
args: list[str],
*,
timeout_seconds: float,
) -> dict[str, Any]:
env = os.environ.copy()
env.update(_feishu_env(paths))
try:
completed = subprocess.run(
[*_feishu_command(), *args],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(paths['root']),
env=env,
timeout=timeout_seconds,
check=False,
)
return {
'returncode': completed.returncode,
'output': completed.stdout.strip(),
}
except FileNotFoundError as exc:
return {
'returncode': 127,
'output': f'缺少命令: {exc.filename}',
}
except subprocess.TimeoutExpired as exc:
output = exc.stdout or ''
return {
'returncode': 124,
'output': str(output).strip() or '飞书状态检查超时',
}
def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[str, Any]:
paths = _feishu_paths(state, account_id)
_reap_feishu_login(account_id)
result = _run_feishu_cli(paths, ['status'], timeout_seconds=30)
output = str(result.get('output') or '')
lowered = output.lower()
logged_in = result.get('returncode') == 0 and not any(
marker in lowered
for marker in ('not logged in', '未登录', 'no credentials')
)
pending = _feishu_login_snapshot(account_id)
payload: dict[str, Any] = {
'logged_in': logged_in,
'status': 'logged_in' if logged_in else 'not_logged_in',
'output': output,
}
if pending:
payload['login'] = pending
if not logged_in:
payload['status'] = 'login_pending'
if not logged_in and result.get('returncode') not in (0, 1):
payload['status'] = 'error'
payload['error'] = output or '无法获取飞书登录状态'
return payload
def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str, Any]:
status = _feishu_status_payload(state, account_id)
if status.get('logged_in'):
return status
key = _feishu_login_key(account_id)
with _FEISHU_LOGIN_LOCK:
existing = _FEISHU_LOGIN_PROCESSES.get(key)
if existing is not None and existing.process.poll() is None:
return {
'logged_in': False,
'status': 'login_pending',
'login': _feishu_login_snapshot_unlocked(existing),
}
_FEISHU_LOGIN_PROCESSES.pop(key, None)
paths = _feishu_paths(state, account_id)
env = os.environ.copy()
env.update(_feishu_env(paths))
try:
process = subprocess.Popen(
[*_feishu_command(), 'login', '--device'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
cwd=str(paths['root']),
env=env,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail=f'缺少命令: {exc.filename}')
entry = FeishuLoginProcess(process=process, started_at=time.time())
with _FEISHU_LOGIN_LOCK:
_FEISHU_LOGIN_PROCESSES[key] = entry
reader = threading.Thread(
target=_read_feishu_login_output,
args=(key, entry),
name=f'feishu-login-{key}',
daemon=True,
)
reader.start()
deadline = time.time() + 5.0
while time.time() < deadline:
snapshot = _feishu_login_snapshot(account_id)
if snapshot and snapshot.get('login_url'):
return {
'logged_in': False,
'status': 'login_pending',
'login': snapshot,
}
if process.poll() is not None:
break
time.sleep(0.1)
snapshot = _feishu_login_snapshot(account_id)
return {
'logged_in': False,
'status': 'login_pending' if process.poll() is None else 'login_failed',
'login': snapshot,
}
def _stop_feishu_login(account_id: str | None) -> None:
key = _feishu_login_key(account_id)
with _FEISHU_LOGIN_LOCK:
entry = _FEISHU_LOGIN_PROCESSES.pop(key, None)
if entry is None:
return
process = entry.process
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2.0)
def _reap_feishu_login(account_id: str | None) -> None:
key = _feishu_login_key(account_id)
with _FEISHU_LOGIN_LOCK:
entry = _FEISHU_LOGIN_PROCESSES.get(key)
if entry is not None and entry.process.poll() is not None:
_FEISHU_LOGIN_PROCESSES.pop(key, None)
def _read_feishu_login_output(key: str, entry: FeishuLoginProcess) -> None:
stream = entry.process.stdout
if stream is None:
return
for line in stream:
with _FEISHU_LOGIN_LOCK:
current = _FEISHU_LOGIN_PROCESSES.get(key)
if current is not entry:
return
entry.output.append(line.rstrip())
text = '\n'.join(entry.output)
login_url, user_code = _parse_feishu_login_details(text)
if login_url:
entry.login_url = login_url
if user_code:
entry.user_code = user_code
def _feishu_login_snapshot(account_id: str | None) -> dict[str, Any] | None:
key = _feishu_login_key(account_id)
with _FEISHU_LOGIN_LOCK:
entry = _FEISHU_LOGIN_PROCESSES.get(key)
if entry is None:
return None
return _feishu_login_snapshot_unlocked(entry)
def _feishu_login_snapshot_unlocked(entry: FeishuLoginProcess) -> dict[str, Any]:
output = '\n'.join(entry.output[-20:])
if not entry.login_url or not entry.user_code:
login_url, user_code = _parse_feishu_login_details(output)
entry.login_url = entry.login_url or login_url
entry.user_code = entry.user_code or user_code
return {
'running': entry.process.poll() is None,
'started_at': entry.started_at,
'elapsed_seconds': max(0, int(time.time() - entry.started_at)),
'login_url': entry.login_url,
'user_code': entry.user_code,
'output': output,
}
def _parse_feishu_login_details(text: str) -> tuple[str | None, str | None]:
urls = re.findall(r'https?://[^\s]+', text)
login_url = next((url.rstrip('.,;') for url in urls if 'feishu' in url.lower()), None)
user_code = None
match = re.search(r'user_code=([A-Za-z0-9_-]+)', login_url or '')
if match:
user_code = match.group(1)
if not user_code:
match = re.search(r'\b([A-Z0-9]{4}-[A-Z0-9]{4})\b', text)
if match:
user_code = match.group(1)
return login_url, user_code
def _feishu_login_key(account_id: str | None) -> str:
return _safe_account_id(account_id)
def _feishu_server_profile(paths: dict[str, Path]) -> MCPServerProfile:
return MCPServerProfile(
name=FEISHU_MCP_SERVER_NAME,
source_manifest='builtin:feishu-mcp-pro',
transport='stdio',
command='npx',
args=(
'-y',
f'--registry={FEISHU_NPM_REGISTRY}',
FEISHU_MCP_PACKAGE,
),
env=_feishu_env(paths),
cwd=str(paths['root']),
description='账号隔离的飞书 MCP Pro。',
)
def _record_feishu_online_doc(
state: AgentState,
account_id: str | None,
*,
file_path: Path,
map_key: str | None = None,
title: str,
url: str,
kind: str = 'doc',
) -> None:
paths = _feishu_paths(state, account_id)
map_path = paths['root'] / FEISHU_ONLINE_DOCS_FILENAME
now = int(time.time())
clean_url = _clean_url(url)
with _FEISHU_DOC_MAP_LOCK:
try:
payload = json.loads(map_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
payload = {}
files = payload.get('files') if isinstance(payload, dict) else None
if not isinstance(files, dict):
files = {}
key = map_key or str(file_path)
previous = files.get(key)
created_at = (
previous.get('created_at')
if isinstance(previous, dict) and isinstance(previous.get('created_at'), int)
else now
)
files[key] = {
'url': clean_url,
'title': title,
'kind': kind,
'file_path': str(file_path),
'source_path': key,
'created_at': created_at,
'updated_at': now,
}
map_path.write_text(
json.dumps({'files': files}, ensure_ascii=False, indent=2),
encoding='utf-8',
)
def _resolve_feishu_source_file(
state: AgentState,
account_id: str | None,
raw_path: str,
) -> tuple[Path, str | None]:
if raw_path.startswith('jupyter://'):
return _materialize_jupyter_file_for_feishu(state, account_id, raw_path), raw_path
return _resolve_account_file(state, account_id, raw_path), None
def _materialize_jupyter_file_for_feishu(
state: AgentState,
account_id: str | None,
raw_uri: str,
) -> Path:
session_id, remote_path = _parse_jupyter_file_uri(raw_uri)
runtime = _jupyter_runtime_for_session(state, account_id, session_id)
if runtime is None:
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
try:
info = runtime.file_info(remote_path)
filename = _safe_uploaded_filename(str(info.get('name') or 'remote-file'))
suffix = Path(filename).suffix.lower()
max_bytes = (
FEISHU_REMOTE_SHEET_MAX_BYTES
if suffix in FEISHU_SPREADSHEET_SUFFIXES
else FEISHU_REMOTE_DOC_MAX_BYTES
)
size = info.get('size')
if isinstance(size, int) and size > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f'远端文件过大,当前在线文档转换上限为 '
f'{_format_bytes(max_bytes)},请先在 Jupyter 侧裁剪或抽样后再转换。'
),
)
response, stream_filename = runtime.open_file_stream(remote_path)
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
filename = filename or _safe_uploaded_filename(stream_filename)
target_dir = (
state.account_paths(account_id)['base']
/ 'integrations'
/ 'feishu'
/ 'remote-files'
/ session_id
)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / filename
written = 0
try:
with target.open('wb') as handle:
for chunk in response.iter_content(chunk_size=JUPYTER_STREAM_CHUNK_BYTES):
if not chunk:
continue
written += len(chunk)
if written > max_bytes:
handle.close()
target.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail=(
f'远端文件过大,当前在线文档转换上限为 '
f'{_format_bytes(max_bytes)},请先在 Jupyter 侧裁剪或抽样后再转换。'
),
)
handle.write(chunk)
finally:
response.close()
return target
def _parse_jupyter_file_uri(raw_uri: str) -> tuple[str, str]:
parsed = urlparse(raw_uri)
session_id = _safe_session_id(parsed.netloc)
remote_path = unquote(parsed.path or '')
if not session_id or not remote_path.startswith('/'):
raise HTTPException(status_code=400, detail='Invalid Jupyter file URI')
return session_id, remote_path
def _safe_uploaded_filename(filename: str) -> str:
cleaned = re.sub(r'[\x00-\x1f/\\]+', '_', filename).strip(' ._')
return cleaned[:180] or 'remote-file'
def _format_bytes(value: int) -> str:
if value >= 1024 * 1024 * 1024:
return f'{value / (1024 * 1024 * 1024):.1f}GB'
if value >= 1024 * 1024:
return f'{value / (1024 * 1024):.0f}MB'
if value >= 1024:
return f'{value / 1024:.0f}KB'
return f'{value}B'
def _resolve_account_file(state: AgentState, account_id: str | None, raw_path: str) -> Path:
path = Path(raw_path).expanduser().resolve()
account_base = state.account_paths(account_id)['base'].resolve()
try:
path.relative_to(account_base)
except ValueError:
raise HTTPException(status_code=403, detail='文件不在当前账号目录内')
if not path.exists():
raise HTTPException(status_code=404, detail='文件不存在')
if not path.is_file():
raise HTTPException(status_code=400, detail='目标不是文件')
return path
def _clean_feishu_doc_title(value: str) -> str:
title = re.sub(r'[\r\n\t/\\]+', ' ', value).strip()
return title[:80].strip() or 'Claw 生成文档'
def _create_feishu_online_spreadsheet(
runtime: MCPRuntime,
file_path: Path,
*,
title: str,
folder_token: str | None = None,
) -> tuple[str, dict[str, Any]]:
sheets = _load_file_as_feishu_sheets(file_path)
create_params: dict[str, Any] = {'title': title}
if folder_token and folder_token.strip():
create_params['folder_token'] = folder_token.strip()
rendered, metadata = _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops',
arguments={'action': 'create', 'params': create_params},
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
spreadsheet_token = _extract_feishu_spreadsheet_token(rendered)
if not spreadsheet_token:
raise DataAgentInputError('飞书表格已创建,但没有解析到 spreadsheet token')
for index, sheet_data in enumerate(sheets):
rows = sheet_data['rows']
if not rows:
continue
if index == 0:
# 新建飞书表格默认会带一个名为 Sheet1 的工作表。
# 不从 spreadsheet meta 里取顶层 title,避免把文件名误当成工作表名。
sheet_ref = 'Sheet1'
else:
sheet_ref = _create_feishu_worksheet(
runtime,
spreadsheet_token,
title=str(sheet_data.get('title') or f'Sheet{index + 1}'),
index=index,
)
_write_feishu_sheet_rows(runtime, spreadsheet_token, sheet_ref, rows)
return rendered, metadata
def _call_feishu_mcp_tool_checked(
runtime: MCPRuntime,
tool_name: str,
*,
arguments: dict[str, Any],
max_chars: int,
timeout_seconds: float,
) -> tuple[str, dict[str, Any]]:
rendered, metadata = runtime.call_tool(
tool_name,
arguments=arguments,
server_name=FEISHU_MCP_SERVER_NAME,
max_chars=max_chars,
timeout_seconds=timeout_seconds,
)
error_message = _extract_feishu_mcp_error(rendered)
if metadata.get('is_error') or error_message:
raise DataAgentInputError(error_message or f'飞书 MCP 工具 {tool_name} 调用失败')
return rendered, metadata
def _extract_feishu_mcp_error(text: str) -> str | None:
payload = _parse_first_json_object(text)
if not isinstance(payload, dict):
return None
error_value = payload.get('error')
if not error_value:
return None
suggestion = payload.get('suggestion')
code = payload.get('code')
parts = [str(error_value)]
if code is not None:
parts.append(f'code={code}')
if suggestion:
parts.append(str(suggestion))
return ''.join(parts)
def _load_file_as_feishu_sheets(file_path: Path) -> list[dict[str, Any]]:
suffix = file_path.suffix.lower()
if suffix == '.csv':
rows = _load_csv_rows_for_feishu(file_path)
return [{'title': _clean_feishu_sheet_title(file_path.stem) or 'Sheet1', 'rows': rows}]
if suffix == '.xlsx':
return _load_xlsx_rows_for_feishu(file_path)
raise DataAgentInputError(f'不支持转为飞书表格的文件类型: {suffix}')
def _load_csv_rows_for_feishu(file_path: Path) -> list[list[str]]:
rows: list[list[str]] = []
with file_path.open('r', encoding='utf-8-sig', errors='replace', newline='') as handle:
reader = csv.reader(handle)
for row_index, row in enumerate(reader):
if row_index >= FEISHU_SPREADSHEET_MAX_ROWS:
break
rows.append(_clean_feishu_sheet_row(row))
normalized_rows = _normalize_feishu_sheet_rows(rows)
if not normalized_rows:
raise DataAgentInputError('没有解析到可转成飞书表格的数据')
return normalized_rows
def _load_xlsx_rows_for_feishu(file_path: Path) -> list[dict[str, Any]]:
try:
import openpyxl # type: ignore[import-not-found]
except ImportError as exc:
raise DataAgentInputError('解析 xlsx 需要 openpyxl') from exc
workbook = openpyxl.load_workbook(file_path, data_only=True, read_only=True)
sheets: list[dict[str, Any]] = []
for index, sheet in enumerate(workbook.worksheets):
if hasattr(sheet, 'reset_dimensions'):
sheet.reset_dimensions()
rows: list[list[str]] = []
for row_index, row in enumerate(sheet.iter_rows(values_only=True)):
if row_index >= FEISHU_SPREADSHEET_MAX_ROWS:
break
rows.append(_clean_feishu_sheet_row(row))
normalized_rows = _normalize_feishu_sheet_rows(rows)
if normalized_rows:
sheets.append(
{
'title': _clean_feishu_sheet_title(str(sheet.title)) or f'Sheet{index + 1}',
'rows': normalized_rows,
}
)
if not sheets:
raise DataAgentInputError('没有解析到可转成飞书表格的数据')
return sheets
def _clean_feishu_sheet_row(row: Any) -> list[str]:
if not isinstance(row, (list, tuple)):
return []
return [_clean_feishu_sheet_cell(cell) for cell in row[:FEISHU_SPREADSHEET_MAX_COLS]]
def _clean_feishu_sheet_cell(value: Any) -> str:
if value is None:
return ''
text = str(value)
if len(text) > FEISHU_SPREADSHEET_MAX_CELL_CHARS:
return text[:FEISHU_SPREADSHEET_MAX_CELL_CHARS]
return text
def _normalize_feishu_sheet_rows(rows: list[list[str]]) -> list[list[str]]:
while rows and not any(cell for cell in rows[-1]):
rows.pop()
if not rows:
return []
width = max((len(row) for row in rows), default=0)
width = min(max(width, 1), FEISHU_SPREADSHEET_MAX_COLS)
return [row[:width] + [''] * max(0, width - len(row)) for row in rows]
def _clean_feishu_sheet_title(value: str) -> str:
title = re.sub(r'[\r\n\t/\\?*\[\]:]+', ' ', value).strip()
return title[:80].strip()
def _extract_feishu_spreadsheet_token(text: str) -> str | None:
url = _extract_first_url(text)
candidates = [item for item in (url, text) if item]
for candidate in candidates:
match = re.search(r'/sheets/([A-Za-z0-9]+)', candidate)
if match:
return match.group(1)
payload = _parse_first_json_object(text)
token = _find_first_string_value(
payload,
{'spreadsheet_token', 'spreadsheetToken', 'token'},
)
return token.strip() if token else None
def _discover_feishu_sheet_refs(runtime: MCPRuntime, spreadsheet_token: str) -> list[str]:
try:
rendered, _metadata = _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops',
arguments={'action': 'meta', 'params': {'url_or_token': spreadsheet_token}},
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
except Exception:
return []
refs = _extract_feishu_sheet_refs(rendered)
return refs
def _extract_feishu_sheet_refs(text: str) -> list[str]:
payload = _parse_first_json_object(text)
refs: list[str] = []
def visit(value: Any) -> None:
if isinstance(value, dict):
raw_id = value.get('sheet_id') or value.get('sheetId')
raw_title = value.get('title') or value.get('name')
if isinstance(raw_id, str) and raw_id.strip():
refs.append(raw_id.strip())
elif (
isinstance(raw_title, str)
and raw_title.strip()
and ('index' in value or 'row_count' in value or 'col_count' in value)
):
refs.append(raw_title.strip())
for child in value.values():
visit(child)
elif isinstance(value, list):
for child in value:
visit(child)
visit(payload)
return _dedupe_strings([ref for ref in refs if ref])
def _dedupe_strings(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
result.append(value)
return result
def _create_feishu_worksheet(
runtime: MCPRuntime,
spreadsheet_token: str,
*,
title: str,
index: int,
) -> str:
sheet_title = _clean_feishu_sheet_title(title) or f'Sheet{index + 1}'
rendered, _metadata = _call_feishu_mcp_tool_checked(
runtime,
'sheet_ops',
arguments={
'action': 'add_sheet',
'params': {
'spreadsheet_token': spreadsheet_token,
'title': sheet_title,
'index': index,
},
},
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
refs = _extract_feishu_sheet_refs(rendered)
return refs[0] if refs else sheet_title
def _write_feishu_sheet_rows(
runtime: MCPRuntime,
spreadsheet_token: str,
sheet_ref: str,
rows: list[list[str]],
) -> None:
if not rows:
return
width = max(len(row) for row in rows)
end_column = _spreadsheet_column_name(width)
for start in range(0, len(rows), FEISHU_SPREADSHEET_WRITE_BATCH_ROWS):
chunk = rows[start : start + FEISHU_SPREADSHEET_WRITE_BATCH_ROWS]
start_row = start + 1
end_row = start + len(chunk)
_call_feishu_mcp_tool_checked(
runtime,
'sheet_ops',
arguments={
'action': 'write',
'params': {
'spreadsheet_token': spreadsheet_token,
'range': f'{sheet_ref}!A{start_row}:{end_column}{end_row}',
'values': json.dumps(chunk, ensure_ascii=False),
},
},
max_chars=API_TOOL_CONTENT_MAX_CHARS,
timeout_seconds=60.0,
)
def _spreadsheet_column_name(index: int) -> str:
if index <= 0:
raise ValueError('index must be positive')
name = ''
current = index
while current:
current, remainder = divmod(current - 1, 26)
name = chr(ord('A') + remainder) + name
return name
def _parse_first_json_object(text: str) -> Any:
stripped = text.strip()
candidates = [stripped]
json_match = re.search(r'(\{.*\})', stripped, flags=re.DOTALL)
if json_match:
candidates.append(json_match.group(1))
for candidate in candidates:
try:
return json.loads(candidate)
except json.JSONDecodeError:
continue
return None
def _find_first_string_value(value: Any, keys: set[str]) -> str | None:
if isinstance(value, dict):
for key in keys:
item = value.get(key)
if isinstance(item, str) and item.strip():
return item
for child in value.values():
found = _find_first_string_value(child, keys)
if found:
return found
elif isinstance(value, list):
for child in value:
found = _find_first_string_value(child, keys)
if found:
return found
return None
def _convert_file_to_feishu_markdown(file_path: Path, *, title: str) -> str:
suffix = file_path.suffix.lower()
if suffix == '.md':
body = file_path.read_text(encoding='utf-8', errors='replace')
return _limit_feishu_markdown(f'# {title}\n\n> 来源文件:{file_path.name}\n\n{body}')
if suffix == '.txt':
body = file_path.read_text(encoding='utf-8', errors='replace')
return _limit_feishu_markdown(f'# {title}\n\n> 来源文件:{file_path.name}\n\n{body}')
loaded = load_input_sources(
file_path.parent,
[file_path.name],
max_files=1,
max_paragraphs_per_file=200,
max_tables_per_file=30,
max_rows_per_table=120,
max_cell_chars=500,
)
sources = loaded.get('sources')
if not isinstance(sources, list) or not sources:
raise DataAgentInputError('没有解析到可转成在线文档的内容')
source = sources[0]
lines = [
f'# {title}',
'',
f'> 来源文件:{file_path.name}',
f'> 文件类型:{suffix.lstrip(".")}',
]
warnings = source.get('warnings')
if isinstance(warnings, list) and warnings:
lines.append(f'> 解析提示:{"".join(str(item) for item in warnings)}')
for paragraph in source.get('paragraphs', []):
if not isinstance(paragraph, dict):
continue
text = str(paragraph.get('text') or '').strip()
if text:
lines.extend(['', text])
for table in source.get('tables', []):
if not isinstance(table, dict):
continue
rows = table.get('rows')
if not isinstance(rows, list) or not rows:
continue
title_text = str(table.get('title') or '表格').strip() or '表格'
lines.extend(['', f'## {title_text}', ''])
lines.append(_render_markdown_table(rows))
row_count = table.get('row_count')
if isinstance(row_count, int) and row_count > len(rows):
lines.append(f'\n> 仅展示前 {len(rows)} 行,原表约 {row_count} 行。')
markdown = '\n'.join(lines).strip()
if not markdown:
raise DataAgentInputError('没有解析到可转成在线文档的内容')
return _limit_feishu_markdown(markdown)
def _render_markdown_table(raw_rows: list[Any]) -> str:
rows = [
[str(cell) for cell in row]
for row in raw_rows
if isinstance(row, list)
]
if not rows:
return ''
width = max(len(row) for row in rows)
normalized = [row + [''] * (width - len(row)) for row in rows]
header = normalized[0] if any(cell.strip() for cell in normalized[0]) else [
f'{index + 1}' for index in range(width)
]
body = normalized[1:] if header is normalized[0] else normalized
lines = [
'| ' + ' | '.join(_escape_markdown_table_cell(cell) for cell in header) + ' |',
'| ' + ' | '.join('---' for _ in range(width)) + ' |',
]
for row in body:
lines.append('| ' + ' | '.join(_escape_markdown_table_cell(cell) for cell in row) + ' |')
return '\n'.join(lines)
def _escape_markdown_table_cell(value: str) -> str:
return value.replace('\\', '\\\\').replace('|', '\\|').replace('\n', ' ').strip()
def _limit_feishu_markdown(markdown: str) -> str:
if len(markdown) <= FEISHU_DOC_MARKDOWN_MAX_CHARS:
return markdown
suffix = '\n\n> 内容较长,已截断后写入在线文档。'
return markdown[: FEISHU_DOC_MARKDOWN_MAX_CHARS - len(suffix)].rstrip() + suffix
def _extract_first_url(text: str) -> str | None:
match = re.search(r'https?://[^\s<>\]\)\"\'“”‘’]+', text)
return _clean_url(match.group(0)) if match else None
def _clean_url(value: str) -> str:
return value.strip().rstrip('.,;,。;、"\'“”‘’')
def _safe_account_id(account_id: str | None) -> str:
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip())
return normalized[:80] or 'default'
def _linux_accounts_enabled() -> bool:
return os.environ.get(LINUX_ACCOUNT_ENV, '').strip().lower() in {
'1',
'true',
'yes',
'on',
}
def _linux_account_workspace(account_id: str) -> Path:
return Path('/home') / account_id / LINUX_ACCOUNT_WORKSPACE_NAME
def _validate_linux_username(account_id: str) -> None:
if not re.fullmatch(r'[a-z_][a-z0-9_-]{1,31}', account_id):
raise HTTPException(
status_code=400,
detail='启用 Linux 账号时,账号名必须以小写字母或下划线开头,只包含小写字母、数字、下划线或中划线,长度 2-32',
)
def _ensure_linux_account(account_id: str, password: str) -> None:
_validate_linux_username(account_id)
if os.geteuid() != 0:
raise HTTPException(
status_code=500,
detail='CLAW_ENABLE_LINUX_ACCOUNTS=1 需要后端以 root 身份运行',
)
try:
pwd.getpwnam(account_id)
except KeyError:
subprocess.run(
[
'useradd',
'-m',
'-d',
f'/home/{account_id}',
'-s',
'/bin/bash',
account_id,
],
check=True,
capture_output=True,
text=True,
timeout=30,
)
chpasswd = subprocess.run(
['chpasswd'],
input=f'{account_id}:{password}\n',
check=False,
capture_output=True,
text=True,
timeout=30,
)
if chpasswd.returncode != 0:
detail = (chpasswd.stderr or chpasswd.stdout or '').strip()
raise HTTPException(status_code=500, detail=f'同步 Linux 用户密码失败:{detail}')
workspace = _linux_account_workspace(account_id)
for child in (
workspace / 'sessions',
workspace / 'python',
workspace / 'memory',
workspace / 'integrations',
):
child.mkdir(parents=True, exist_ok=True)
_chown_path_for_linux_user(workspace, account_id, recursive=True)
def _ensure_linux_user_exists(account_id: str) -> None:
try:
pwd.getpwnam(account_id)
except KeyError as exc:
raise RuntimeError(
f'Linux runtime user does not exist: {account_id}. '
'Create the account through the platform first, or disable CLAW_ENABLE_LINUX_ACCOUNTS.'
) from exc
def _chown_path_for_linux_user(path: Path, account_id: str, *, recursive: bool = False) -> None:
if os.name != 'posix':
return
try:
user_info = pwd.getpwnam(account_id)
except KeyError:
return
target = path.resolve(strict=False)
try:
os.chown(target, user_info.pw_uid, user_info.pw_gid)
except PermissionError:
return
except FileNotFoundError:
return
if not recursive or not target.is_dir():
return
for child in target.rglob('*'):
try:
os.chown(child, user_info.pw_uid, user_info.pw_gid)
except (PermissionError, FileNotFoundError):
continue
def _admin_token() -> str:
return os.environ.get('ZK_ADMIN_TOKEN') or 'admin'
def _require_admin_token(token: str) -> None:
if token != _admin_token():
raise HTTPException(status_code=401, detail='Admin unauthorized')
def _accounts_root(state: AgentState) -> Path:
return state.session_directory.parent / 'accounts'
def _users_json_path(state: AgentState) -> Path:
return _accounts_root(state) / 'users.json'
def _auth_sessions_json_path(state: AgentState) -> Path:
return _accounts_root(state) / 'auth_sessions.json'
def _load_users_file(state: AgentState) -> dict[str, Any]:
path = _users_json_path(state)
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
payload = {'users': []}
if not isinstance(payload, dict) or not isinstance(payload.get('users'), list):
return {'users': []}
return payload
def _save_users_file(state: AgentState, payload: dict[str, Any]) -> None:
path = _users_json_path(state)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def _hash_admin_created_password(password: str = '123456') -> str:
salt = os.urandom(16).hex()
digest = hashlib.scrypt(
password.encode('utf-8'),
salt=salt.encode('utf-8'),
n=16384,
r=8,
p=1,
dklen=64,
).hex()
return f'{salt}:{digest}'
ADMIN_PERIOD_LABELS = {
'this_month': '本月',
'last_month': '上月',
'last_7_days': '近7天',
'last_30_days': '近30天',
'all': '全部',
}
def _admin_period_bounds(period: str) -> tuple[str, str, float | None, float | None]:
key = period if period in ADMIN_PERIOD_LABELS else 'this_month'
now = datetime.now().astimezone()
if key == 'all':
return key, ADMIN_PERIOD_LABELS[key], None, None
if key == 'last_7_days':
start = now - timedelta(days=7)
return key, ADMIN_PERIOD_LABELS[key], start.timestamp(), None
if key == 'last_30_days':
start = now - timedelta(days=30)
return key, ADMIN_PERIOD_LABELS[key], start.timestamp(), None
this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if key == 'last_month':
last_month_end = this_month_start
last_month_start = (this_month_start - timedelta(days=1)).replace(day=1)
return (
key,
ADMIN_PERIOD_LABELS[key],
last_month_start.timestamp(),
last_month_end.timestamp(),
)
return key, ADMIN_PERIOD_LABELS[key], this_month_start.timestamp(), None
def _admin_period_payload(period: str) -> dict[str, Any]:
key, label, start, end = _admin_period_bounds(period)
return {
'key': key,
'label': label,
'start': start,
'end': end,
}
def _build_admin_summary(state: AgentState, *, period: str = 'this_month') -> dict[str, Any]:
accounts = _list_admin_accounts(state, period=period)
totals = {
'accounts': len(accounts),
'sessions': sum(item['session_count'] for item in accounts),
'visible_sessions': sum(item.get('visible_session_count', 0) for item in accounts),
'child_sessions': sum(item.get('child_session_count', 0) for item in accounts),
'tool_calls': sum(item['tool_calls'] for item in accounts),
'tokens': sum(item['total_tokens'] for item in accounts),
}
return {
'period': _admin_period_payload(period),
'totals': totals,
'daily_series': _merge_admin_daily_series(
item.get('daily_series', []) for item in accounts
),
'memory_queue': state.memory_manager.queue_snapshot(),
'accounts': accounts[:20],
}
def _list_admin_accounts(
state: AgentState,
*,
period: str = 'this_month',
) -> list[dict[str, Any]]:
period_key, _period_label, start_ts, end_ts = _admin_period_bounds(period)
users_payload = _load_users_file(state)
known_users = {
str(item.get('id') or item.get('username') or '').strip()
for item in users_payload.get('users', [])
if isinstance(item, dict)
}
accounts_root = _accounts_root(state)
directory_users = (
{
path.name
for path in accounts_root.iterdir()
if path.is_dir() and path.name not in {'__pycache__'}
}
if accounts_root.exists()
else set()
)
linux_workspace_users = _linux_workspace_account_ids() if _linux_accounts_enabled() else set()
account_ids = sorted({
item
for item in known_users | directory_users | linux_workspace_users
if item
})
result: list[dict[str, Any]] = []
for account_id in account_ids:
bases = _admin_account_bases(state, account_id)
session_files = _iter_admin_session_files(bases)
session_count = 0
visible_session_count = 0
child_session_count = 0
tool_calls = 0
total_tokens = 0
input_tokens = 0
output_tokens = 0
reasoning_tokens = 0
latest_mtime = 0.0
models: dict[str, int] = {}
daily_buckets: dict[str, dict[str, Any]] = {}
for path in session_files:
try:
data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
continue
mtime = _session_mtime(path)
if start_ts is not None and mtime < start_ts:
continue
if end_ts is not None and mtime >= end_ts:
continue
session_metadata = (
data.get('session_metadata')
if isinstance(data.get('session_metadata'), dict)
else {}
)
is_child = session_metadata.get('visibility') == 'child'
session_count += 1
if is_child:
child_session_count += 1
else:
visible_session_count += 1
row_tool_calls = _admin_session_tool_call_count(data)
tool_calls += row_tool_calls
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
row_input_tokens = int(usage.get('input_tokens') or 0)
row_output_tokens = int(usage.get('output_tokens') or 0)
row_reasoning_tokens = int(usage.get('reasoning_tokens') or 0)
row_total_tokens = int(
usage.get('total_tokens')
or (usage.get('input_tokens') or 0) + (usage.get('output_tokens') or 0)
)
input_tokens += row_input_tokens
output_tokens += row_output_tokens
reasoning_tokens += row_reasoning_tokens
total_tokens += row_total_tokens
model_config = data.get('model_config') if isinstance(data.get('model_config'), dict) else {}
model = str(model_config.get('model') or data.get('model') or 'unknown')
models[model] = models.get(model, 0) + 1
latest_mtime = max(latest_mtime, mtime)
_admin_add_daily_bucket(
daily_buckets,
mtime=mtime,
sessions=1,
visible_sessions=0 if is_child else 1,
child_sessions=1 if is_child else 0,
tool_calls=row_tool_calls,
input_tokens=row_input_tokens,
output_tokens=row_output_tokens,
reasoning_tokens=row_reasoning_tokens,
total_tokens=row_total_tokens,
)
memory_dirs = [base / 'memory' for base in bases]
result.append(
{
'account_id': account_id,
'registered': account_id in known_users,
'period': period_key,
'session_count': session_count,
'visible_session_count': visible_session_count,
'child_session_count': child_session_count,
'tool_calls': tool_calls,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'reasoning_tokens': reasoning_tokens,
'total_tokens': total_tokens,
'average_tokens_per_session': (
round(total_tokens / session_count, 1) if session_count else 0
),
'average_tool_calls_per_session': (
round(tool_calls / session_count, 1) if session_count else 0
),
'latest_session_at': latest_mtime,
'models': models,
'daily_series': _admin_daily_series(daily_buckets),
'user_memory_lines': max(
(
_count_optional_lines(memory_dir / USER_MEMORY_FILENAME)
for memory_dir in memory_dirs
),
default=0,
),
'skill_memory_count': max(
(
len(list((memory_dir / SKILL_MEMORY_DIRNAME).glob('*.md')))
if (memory_dir / SKILL_MEMORY_DIRNAME).exists()
else 0
for memory_dir in memory_dirs
),
default=0,
),
}
)
return sorted(result, key=lambda item: item['latest_session_at'], reverse=True)
def _linux_workspace_account_ids() -> set[str]:
home = Path('/home')
if not home.exists():
return set()
result: set[str] = set()
for path in home.iterdir():
if path.is_dir() and (path / LINUX_ACCOUNT_WORKSPACE_NAME).is_dir():
result.add(path.name)
return result
def _admin_account_bases(state: AgentState, account_id: str) -> list[Path]:
candidates = [
state.account_paths(account_id)['base'],
_accounts_root(state) / account_id,
]
bases: list[Path] = []
seen: set[str] = set()
for base in candidates:
key = str(base.resolve(strict=False))
if key in seen:
continue
seen.add(key)
bases.append(base)
return bases
def _iter_admin_session_files(bases: list[Path]) -> list[Path]:
by_session_id: dict[str, Path] = {}
for base in bases:
for path in _iter_session_files(base / 'sessions'):
session_id = _session_id_from_path(path)
existing = by_session_id.get(session_id)
if existing is None or _session_mtime(path) >= _session_mtime(existing):
by_session_id[session_id] = path
return list(by_session_id.values())
def _admin_day_key(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp).astimezone().strftime('%Y-%m-%d')
def _admin_add_daily_bucket(
buckets: dict[str, dict[str, Any]],
*,
mtime: float,
sessions: int,
visible_sessions: int,
child_sessions: int,
tool_calls: int,
input_tokens: int,
output_tokens: int,
reasoning_tokens: int,
total_tokens: int,
) -> None:
key = _admin_day_key(mtime)
bucket = buckets.setdefault(
key,
{
'date': key,
'sessions': 0,
'visible_sessions': 0,
'child_sessions': 0,
'tool_calls': 0,
'input_tokens': 0,
'output_tokens': 0,
'reasoning_tokens': 0,
'tokens': 0,
},
)
bucket['sessions'] += sessions
bucket['visible_sessions'] += visible_sessions
bucket['child_sessions'] += child_sessions
bucket['tool_calls'] += tool_calls
bucket['input_tokens'] += input_tokens
bucket['output_tokens'] += output_tokens
bucket['reasoning_tokens'] += reasoning_tokens
bucket['tokens'] += total_tokens
def _admin_daily_series(buckets: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
return [buckets[key] for key in sorted(buckets)]
def _admin_session_tool_call_count(data: dict[str, Any]) -> int:
raw_value = data.get('tool_calls')
raw_count = raw_value if isinstance(raw_value, int) else 0
if raw_count < 0:
raw_count = 0
file_history = data.get('file_history')
file_history_count = len(file_history) if isinstance(file_history, list) else 0
message_count = max(
_admin_count_tool_calls_in_items(data.get('display_messages') or data.get('messages')),
_admin_count_tool_calls_in_items(data.get('turns')),
)
derived_count = max(file_history_count, message_count)
if raw_count == 0 and derived_count:
return derived_count
# Some historical session files contain corrupted cumulative values here.
# Prefer a conservative derived count when the stored number is wildly
# larger than the persisted tool history/message structure can support.
if derived_count and raw_count > max(5000, derived_count * 20 + 100):
return derived_count
return raw_count
def _admin_count_tool_calls_in_items(value: Any) -> int:
if not isinstance(value, list):
return 0
count = 0
for item in value:
if not isinstance(item, dict):
continue
tool_calls = item.get('tool_calls')
if isinstance(tool_calls, list):
count += len(tool_calls)
content = item.get('content')
if isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
block_type = str(block.get('type') or '')
if block_type in {'tool_use', 'tool-call', 'tool_call'}:
count += 1
return count
def _merge_admin_daily_series(
series_items: Iterable[list[dict[str, Any]]],
) -> list[dict[str, Any]]:
buckets: dict[str, dict[str, Any]] = {}
for series in series_items:
for item in series:
date = str(item.get('date') or '')
if not date:
continue
bucket = buckets.setdefault(
date,
{
'date': date,
'sessions': 0,
'visible_sessions': 0,
'child_sessions': 0,
'tool_calls': 0,
'input_tokens': 0,
'output_tokens': 0,
'reasoning_tokens': 0,
'tokens': 0,
},
)
for key in (
'sessions',
'visible_sessions',
'child_sessions',
'tool_calls',
'input_tokens',
'output_tokens',
'reasoning_tokens',
'tokens',
):
bucket[key] += int(item.get(key) or 0)
return _admin_daily_series(buckets)
def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
safe_id = _safe_account_id(account_id)
if safe_id == 'default':
raise HTTPException(status_code=400, detail='账号名不合法')
if _linux_accounts_enabled():
_validate_linux_username(safe_id)
users = _load_users_file(state)
user_rows = users.setdefault('users', [])
if any(
isinstance(item, dict)
and str(item.get('id') or item.get('username') or '').strip() == safe_id
for item in user_rows
):
raise HTTPException(status_code=400, detail='账号已存在')
if _linux_accounts_enabled():
_ensure_linux_account(safe_id, '123456')
user_rows.append(
{
'id': safe_id,
'username': safe_id,
'passwordHash': _hash_admin_created_password(),
'createdAt': datetime_utc_iso(),
}
)
_save_users_file(state, users)
base = _accounts_root(state) / safe_id
(base / 'sessions').mkdir(parents=True, exist_ok=True)
state.memory_manager.ensure_account(safe_id)
return {'account_id': safe_id, 'created': True, 'initial_password': '123456'}
def _admin_delete_account(state: AgentState, account_id: str) -> dict[str, Any]:
safe_id = _safe_account_id(account_id)
users = _load_users_file(state)
users['users'] = [
item
for item in users.get('users', [])
if not (
isinstance(item, dict)
and str(item.get('id') or item.get('username') or '').strip() == safe_id
)
]
_save_users_file(state, users)
sessions_path = _auth_sessions_json_path(state)
try:
auth_sessions = json.loads(sessions_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
auth_sessions = {'sessions': {}}
if isinstance(auth_sessions.get('sessions'), dict):
auth_sessions['sessions'] = {
token: session
for token, session in auth_sessions['sessions'].items()
if not (isinstance(session, dict) and session.get('accountId') == safe_id)
}
sessions_path.write_text(
json.dumps(auth_sessions, ensure_ascii=False, indent=2) + '\n',
encoding='utf-8',
)
base = _accounts_root(state) / safe_id
if _linux_accounts_enabled():
base = _linux_account_workspace(safe_id)
if base.exists():
shutil.rmtree(base)
if _linux_accounts_enabled():
subprocess.run(
['passwd', '-l', safe_id],
check=False,
capture_output=True,
text=True,
timeout=30,
)
state._clear_agents_for_account(safe_id)
return {'account_id': safe_id, 'deleted': True}
def _count_optional_lines(path: Path) -> int:
try:
return len(path.read_text(encoding='utf-8').splitlines())
except OSError:
return 0
def datetime_utc_iso() -> str:
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
def _is_internal_message(content: str) -> bool:
return content.lstrip().startswith('<system-reminder>')
def _strip_session_context(content: str) -> str:
marker = '\n\n[当前会话目录]'
if marker in content:
return content.split(marker, 1)[0].strip()
marker = '\n[当前会话目录]'
if marker in content:
return content.split(marker, 1)[0].strip()
return content.strip()
def _extract_used_skill_names(transcript: Any) -> tuple[str, ...]:
"""从 transcript 中尽量提取实际调用过的 Skill 名称。"""
names: list[str] = []
if not isinstance(transcript, (list, tuple)):
return ()
for entry in transcript:
if not isinstance(entry, dict):
continue
tool_calls = entry.get('tool_calls')
if not isinstance(tool_calls, list):
continue
for call in tool_calls:
if not isinstance(call, dict):
continue
function = call.get('function') if isinstance(call.get('function'), dict) else {}
tool_name = call.get('name') or function.get('name')
if str(tool_name).lower() != 'skill':
continue
arguments = call.get('arguments') or function.get('arguments')
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError:
arguments = {}
if not isinstance(arguments, dict):
continue
for key in ('skill', 'skill_name', 'name'):
value = arguments.get(key)
if isinstance(value, str) and value.strip():
names.append(value.strip())
break
return tuple(dict.fromkeys(names))
def _iter_session_files(directory: Path) -> list[Path]:
if not directory.exists():
return []
by_session_id: dict[str, Path] = {}
for path in directory.glob('*.json'):
by_session_id[_session_id_from_path(path)] = path
for path in directory.glob('*/session.json'):
# 新目录格式包含 input/output/scratchpad,优先级高于旧的平铺 json。
by_session_id[_session_id_from_path(path)] = path
return list(by_session_id.values())
def _delete_session_files(directory: Path, session_id: str) -> bool:
deleted = False
# 新目录格式:每个 session 独立目录,里面包含 session/input/output。
nested = directory / session_id
if nested.exists():
shutil.rmtree(nested)
deleted = True
# 兼容早期平铺 session json,避免历史列表删除后旧数据又出现。
legacy = directory / f'{session_id}.json'
if legacy.exists():
legacy.unlink()
deleted = True
return deleted
def _update_session_title(directory: Path, session_id: str, title: str) -> bool:
try:
stored = load_agent_session(session_id, directory=directory)
except FileNotFoundError:
return False
metadata = dict(stored.session_metadata or {})
metadata['title'] = title
metadata['title_source'] = 'manual'
metadata['title_updated_at'] = int(time.time())
save_agent_session(
replace(stored, session_metadata=metadata),
directory=directory,
)
return True
def _update_session_training(directory: Path, session_id: str, is_training: bool) -> bool:
try:
stored = load_agent_session(session_id, directory=directory)
except FileNotFoundError:
return False
save_agent_session(
replace(stored, is_training=is_training),
directory=directory,
)
return True
# ---------------------------------------------------------------------------
# Pipeline state watcher: agent declares 「watch」 entries in program-state.jsonl;
# a backend scanner picks them up and polls the target file/dir on its own,
# writing complete/failed entries when the watcher resolves. This decouples UI
# state from agent's discipline (or lack thereof).
# ---------------------------------------------------------------------------
def _now_iso_local() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds')
def _watcher_append(path: Path, entry: dict[str, Any]) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('a', encoding='utf-8') as fp:
fp.write(json.dumps(entry, ensure_ascii=False) + '\n')
except OSError:
return
class _WatcherManager:
def __init__(self) -> None:
self._tasks: dict[tuple[str, str], asyncio.Task[None]] = {}
def is_watching(self, session_id: str, step: str) -> bool:
return (session_id, step) in self._tasks
def register_file_exists(
self,
*,
session_id: str,
state_path: Path,
step: str,
target: Path,
interval: float,
timeout: float,
run_id: str,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> None:
key = (session_id, step)
if key in self._tasks:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(
self._poll_file_exists(
key=key,
state_path=state_path,
step=step,
target=target,
interval=max(2.0, interval),
timeout=max(60.0, timeout),
run_id=run_id,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
)
self._tasks[key] = task
async def _poll_file_exists(
self,
*,
key: tuple[str, str],
state_path: Path,
step: str,
target: Path,
interval: float,
timeout: float,
run_id: str,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
session_id: str | None = None,
) -> None:
try:
mode = 'remote' if (
agent_state is not None
and account_id is not None
and session_id is not None
and _jupyter_runtime_for_session(agent_state, account_id, session_id)
is not None
) else 'local'
await self._write_log(
state_path,
run_id,
f'watcher 启动 step={step} target={target.name} 周期={int(interval)}s 超时={int(timeout)}s mode={mode}',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
start = time.monotonic()
while True:
exists = await self._check_target_exists(
target,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
if exists:
await self._write_step(
state_path,
step=step,
status='complete',
run_id=run_id,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
await self._write_log(
state_path,
run_id,
f'✅ watcher 检测到 {target.name} 落盘 → step {step} complete',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
# R{n>=1} 链路:SFT 训练完后 submit_sft_via_cml.sh 内置 nohup
# watcher 自动起 R{n} 评测,agent 不在回路中,没人写
# cml=runningUI 会从 sft=complete 直接跳到 cml=complete
# 中间 ~20min 评测期看起来像"流程卡死"。检测到同 session
# 已注册 cml watch(说明 agent 提交 SFT 时预注册了下游评测
# 的 watch)就顺手补写 cml=running 同 run_id。
if (
step == 'sft'
and session_id is not None
and self.is_watching(session_id, 'cml')
):
await self._write_step(
state_path,
step='cml',
status='running',
run_id=run_id,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
await self._write_log(
state_path,
run_id,
'↪ sft 完成自动衔接 → step cml running '
'(submit_sft_via_cml.sh 内置 watcher 已触发 R 评测)',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
return
if time.monotonic() - start > timeout:
await self._write_step(
state_path,
step=step,
status='failed',
run_id=run_id,
error=f'watcher 超时 {int(timeout)}s, target 未出现: {target}',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
await self._write_log(
state_path,
run_id,
f'⚠️ watcher 超时 step={step} 未在 {int(timeout)}s 内看到 {target.name}',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
return
await asyncio.sleep(interval)
except asyncio.CancelledError:
return
except Exception as exc: # noqa: BLE001
await self._write_log(
state_path,
run_id,
f'⚠️ watcher 异常 step={step}: {exc}',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
finally:
self._tasks.pop(key, None)
async def _write_step(
self,
state_path: Path,
*,
step: str,
status: str,
run_id: str | None = None,
error: str | None = None,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
session_id: str | None = None,
) -> None:
entry: dict[str, Any] = {
'step': step,
'status': status,
'ts': _now_iso_local(),
}
if run_id:
entry['run_id'] = run_id
if error:
entry['error'] = error
await self._append_state_entry(
state_path,
entry,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
async def _write_log(
self,
state_path: Path,
run_id: str,
text: str,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
session_id: str | None = None,
) -> None:
entry = {
'log': {
'ts': time.strftime('%H:%M:%S'),
'iter': run_id,
'text': text,
}
}
await self._append_state_entry(
state_path,
entry,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
def cancel_all(self) -> None:
for task in self._tasks.values():
task.cancel()
self._tasks.clear()
async def _append_state_entry(
self,
state_path: Path,
entry: dict[str, Any],
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
session_id: str | None = None,
) -> None:
"""Append a state entry. Prefers remote (so sync doesn't overwrite our
writes); falls back to local when no Jupyter binding exists."""
line = json.dumps(entry, ensure_ascii=False) + '\n'
if (
agent_state is not None
and account_id is not None
and session_id is not None
):
runtime = _jupyter_runtime_for_session(
agent_state, account_id, session_id
)
if runtime is not None:
remote_dir = f'{runtime.binding.workspace_cwd}/output'
remote_path = f'{remote_dir}/program-state.jsonl'
cmd = (
f'mkdir -p {shlex.quote(remote_dir)} && '
f'printf %s {shlex.quote(line)} >> {shlex.quote(remote_path)}'
)
try:
await asyncio.to_thread(
runtime.run_command,
cmd,
timeout_seconds=10.0,
max_output_chars=64,
)
return
except Exception: # noqa: BLE001
pass
# Local fallback
await asyncio.to_thread(_watcher_append, state_path, entry)
async def _check_target_exists(
self,
target: Path,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
session_id: str | None = None,
) -> bool:
"""File-existence check that prefers remote Jupyter when bound (so
targets on /mnt/* mounts visible to the remote workspace are reachable)."""
if (
agent_state is not None
and account_id is not None
and session_id is not None
):
runtime = _jupyter_runtime_for_session(
agent_state, account_id, session_id
)
if runtime is not None:
cmd = (
f'test -f {shlex.quote(str(target))} '
f'&& echo OK || echo MISSING'
)
try:
result = await asyncio.to_thread(
runtime.run_command,
cmd,
timeout_seconds=10.0,
max_output_chars=64,
)
except Exception: # noqa: BLE001
return False
return bool(result and result.stdout.strip().endswith('OK'))
# Fallback: local file system
try:
return target.is_file()
except OSError:
return False
@staticmethod
def _append_step(state_path: Path, **fields: Any) -> None:
entry: dict[str, Any] = {**fields, 'ts': _now_iso_local()}
_watcher_append(state_path, entry)
@staticmethod
def _append_log(state_path: Path, run_id: str, text: str) -> None:
entry = {
'log': {
'ts': time.strftime('%H:%M:%S'),
'iter': run_id,
'text': text,
}
}
_watcher_append(state_path, entry)
_watcher_manager = _WatcherManager()
class _BashBackgroundManager:
"""Polls remote/local bg bash tasks; finalizes them and (optionally) fires
an auto-resume turn when the agent has paused with the previous run already
completed.
Lifecycle mirrors `_WatcherManager`: one asyncio.Task per task_id, written
to/read from `BashBgStore` so polling can resume after a server restart.
"""
POLL_INTERVAL_SECONDS = 5.0
MAX_TASK_LIFETIME_SECONDS = 6 * 3600.0
REMOTE_PROBE_TIMEOUT_SECONDS = 10.0
OUTPUT_PREVIEW_BYTES = 4096
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task[None]] = {}
self._chat_runner: 'Callable[[ChatRequest], dict[str, Any]] | None' = None
def set_chat_runner(
self,
runner: 'Callable[[ChatRequest], dict[str, Any]]',
) -> None:
self._chat_runner = runner
async def register(self, state: 'AgentState', spec: BgTaskSpec) -> None:
state.bash_bg_store.record_start(spec)
if spec.task_id in self._tasks:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
self._tasks[spec.task_id] = loop.create_task(
self._poll_alive(state, spec)
)
async def recover_from_store(self, state: 'AgentState') -> None:
for row in state.bash_bg_store.list_active():
spec = self._spec_from_row(row)
await self.register(state, spec)
async def cancel(self, state: 'AgentState', task_id: str) -> bool:
row = state.bash_bg_store.get(task_id)
if row is None or row['status'] != 'running':
return False
await self._kill_remote_or_local(state, row)
state.bash_bg_store.mark_killed(task_id)
existing = self._tasks.pop(task_id, None)
if existing is not None:
existing.cancel()
return True
async def cancel_session(
self,
state: 'AgentState',
account_key: str,
session_id: str,
) -> list[str]:
rows = state.bash_bg_store.list_active_for_session(account_key, session_id)
cancelled: list[str] = []
for row in rows:
task_id = str(row.get('task_id') or '')
if not task_id:
continue
await self._kill_remote_or_local(state, row)
state.bash_bg_store.mark_killed(task_id)
existing = self._tasks.pop(task_id, None)
if existing is not None:
existing.cancel()
cancelled.append(task_id)
return cancelled
def status_query(
self, state: 'AgentState', task_id: str
) -> 'BgTaskStatus | None':
row = state.bash_bg_store.get(task_id)
if row is None:
return None
preview = self._read_output_preview_sync(state, row)
return BgTaskStatus(
task_id=row['task_id'],
status=row['status'],
pid=row['pid'],
started_at=row['started_at'],
finished_at=row['finished_at'],
exit_code=row['exit_code'],
output_path=row['output_path'],
output_preview=preview,
auto_resumed=bool(row['auto_resumed']),
)
def cancel_all(self) -> None:
for task in list(self._tasks.values()):
task.cancel()
self._tasks.clear()
async def _poll_alive(self, state: 'AgentState', spec: BgTaskSpec) -> None:
try:
start = time.monotonic()
while True:
alive = await self._check_pid_alive(state, spec)
if not alive:
await self._finalize(state, spec)
return
if time.monotonic() - start > self.MAX_TASK_LIFETIME_SECONDS:
state.bash_bg_store.mark_completed(
spec.task_id, exit_code=124,
)
return
await asyncio.sleep(self.POLL_INTERVAL_SECONDS)
except asyncio.CancelledError:
return
except Exception as exc: # noqa: BLE001
print(
f'[bg-bash] poll error task={spec.task_id}: {exc}', flush=True,
)
finally:
self._tasks.pop(spec.task_id, None)
async def _check_pid_alive(
self, state: 'AgentState', spec: BgTaskSpec
) -> bool:
runtime = _jupyter_runtime_for_session(
state, spec.account_key or None, spec.session_id,
)
if runtime is not None:
cmd = (
f'kill -0 {spec.pid} 2>/dev/null && echo alive || echo dead'
)
try:
result = await asyncio.to_thread(
runtime.run_command,
cmd,
timeout_seconds=self.REMOTE_PROBE_TIMEOUT_SECONDS,
max_output_chars=64,
cancel_event=None,
)
except Exception: # noqa: BLE001
# Treat probe failure as "still running" — don't finalize on
# transient runtime errors. The 6h lifetime cap is the backstop.
return True
return 'alive' in result.stdout
# Local fallback. Note: the spawned child is detached via
# start_new_session=True but the FastAPI process never reaps it, so
# after exit it becomes a zombie and kill(pid, 0) keeps reporting it
# alive. The MAX_TASK_LIFETIME_SECONDS cap is the eventual backstop;
# production runs use jupyter_runtime where the child belongs to a
# different process tree.
try:
os.kill(spec.pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
except OSError:
return True
async def _finalize(self, state: 'AgentState', spec: BgTaskSpec) -> None:
exit_code = await self._read_exit_code(state, spec)
state.bash_bg_store.mark_completed(spec.task_id, exit_code=exit_code)
if not spec.wait_for_completion:
return
await self._maybe_auto_resume(state, spec, exit_code)
async def _read_exit_code(
self, state: 'AgentState', spec: BgTaskSpec
) -> int:
runtime = _jupyter_runtime_for_session(
state, spec.account_key or None, spec.session_id,
)
if runtime is not None:
cmd = f'cat {shlex.quote(spec.exit_code_path)} 2>/dev/null || echo'
try:
result = await asyncio.to_thread(
runtime.run_command,
cmd,
timeout_seconds=self.REMOTE_PROBE_TIMEOUT_SECONDS,
max_output_chars=64,
cancel_event=None,
)
text = result.stdout.strip()
return int(text) if text else -1
except (ValueError, Exception): # noqa: BLE001
return -1
try:
text = Path(spec.exit_code_path).read_text(
encoding='utf-8',
).strip()
return int(text) if text else -1
except (OSError, ValueError):
return -1
async def _read_output_preview_async(
self, state: 'AgentState', spec_row: 'dict[str, Any] | BgTaskSpec'
) -> str:
if isinstance(spec_row, BgTaskSpec):
account = spec_row.account_key
session = spec_row.session_id
output_path = spec_row.output_path
else:
account = spec_row['account_key']
session = spec_row['session_id']
output_path = spec_row['output_path']
runtime = _jupyter_runtime_for_session(state, account or None, session)
if runtime is not None:
cmd = (
f'tail -c {self.OUTPUT_PREVIEW_BYTES} '
f'{shlex.quote(output_path)} 2>/dev/null || true'
)
try:
result = await asyncio.to_thread(
runtime.run_command,
cmd,
timeout_seconds=self.REMOTE_PROBE_TIMEOUT_SECONDS,
max_output_chars=self.OUTPUT_PREVIEW_BYTES + 256,
cancel_event=None,
)
return result.stdout.strip()
except Exception: # noqa: BLE001
return '(读取远端输出失败)'
try:
text = Path(output_path).read_text(
encoding='utf-8', errors='replace',
)
return text[-self.OUTPUT_PREVIEW_BYTES:].strip()
except OSError:
return '(无法读取输出文件)'
def _read_output_preview_sync(
self, state: 'AgentState', row: dict[str, Any]
) -> str:
runtime = _jupyter_runtime_for_session(
state, row['account_key'] or None, row['session_id'],
)
if runtime is not None:
cmd = (
f'tail -c {self.OUTPUT_PREVIEW_BYTES} '
f'{shlex.quote(row["output_path"])} 2>/dev/null || true'
)
try:
result = runtime.run_command(
cmd,
timeout_seconds=self.REMOTE_PROBE_TIMEOUT_SECONDS,
max_output_chars=self.OUTPUT_PREVIEW_BYTES + 256,
cancel_event=None,
)
return result.stdout.strip()
except Exception: # noqa: BLE001
return '(读取远端输出失败)'
try:
text = Path(row['output_path']).read_text(
encoding='utf-8', errors='replace',
)
return text[-self.OUTPUT_PREVIEW_BYTES:].strip()
except OSError:
return '(无法读取输出文件)'
async def _kill_remote_or_local(
self, state: 'AgentState', row: dict[str, Any]
) -> None:
runtime = _jupyter_runtime_for_session(
state, row['account_key'] or None, row['session_id'],
)
pid = int(row['pid'])
if runtime is not None:
cmd = (
f'pgid=$(ps -o pgid= -p {pid} 2>/dev/null | tr -d " "); '
'if [ -n "$pgid" ]; then '
'kill -TERM -- "-$pgid" 2>/dev/null || true; '
'fi; '
f'kill -TERM {pid} 2>/dev/null || true; '
'sleep 0.5; '
'if [ -n "$pgid" ]; then '
'kill -KILL -- "-$pgid" 2>/dev/null || true; '
'fi; '
f'kill -KILL {pid} 2>/dev/null || true'
)
try:
await asyncio.to_thread(
runtime.run_command,
cmd,
timeout_seconds=self.REMOTE_PROBE_TIMEOUT_SECONDS,
max_output_chars=64,
cancel_event=None,
)
except Exception: # noqa: BLE001
pass
return
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
try:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
pass
time.sleep(0.2)
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
async def _maybe_auto_resume(
self, state: 'AgentState', spec: BgTaskSpec, exit_code: int,
) -> None:
runner = self._chat_runner
if runner is None:
return
account_key = spec.account_key or state._account_key(None)
session_id = spec.session_id
if not session_id:
return
snapshot = state.run_state_store.snapshot_latest(
account_key, session_id,
)
if not snapshot or snapshot.get('status') != 'completed':
return
run_lock = state.run_lock_for(account_key, session_id)
if not run_lock.acquire(blocking=False):
return
run_lock.release()
if not state.bash_bg_store.mark_auto_resumed(spec.task_id):
return
output_preview = await self._read_output_preview_async(state, spec)
prompt = (
f'[system] 后台任务 {spec.task_id} 已结束,'
f'exit_code={exit_code}\n'
f'命令:{spec.command}\n'
f'输出预览(最后 {self.OUTPUT_PREVIEW_BYTES} 字节):\n'
f'{output_preview}'
)
request = ChatRequest(
prompt=prompt,
account_id=spec.account_key or None,
session_id=session_id,
resume_session_id=session_id,
)
loop = asyncio.get_running_loop()
loop.run_in_executor(None, _safe_run_chat_payload, runner, request)
@staticmethod
def _spec_from_row(row: dict[str, Any]) -> BgTaskSpec:
return BgTaskSpec(
task_id=row['task_id'],
account_key=row['account_key'] or '',
session_id=row['session_id'] or '',
run_id=row['run_id'] or '',
pid=int(row['pid']),
task_dir=row['task_dir'],
output_path=row['output_path'],
pid_path=row['pid_path'],
exit_code_path=row['exit_code_path'],
command=row['command'],
started_at=float(row['started_at']),
wait_for_completion=bool(row['wait_for_completion']),
)
_bash_bg_manager = _BashBackgroundManager()
def _safe_run_chat_payload(
runner: 'Callable[[ChatRequest], dict[str, Any]]',
request: ChatRequest,
) -> None:
"""Fire-and-forget wrapper around the chat payload runner for auto-resume.
Runs in the default thread pool. Any exception is logged but swallowed —
the bg task is already finalized in SQLite, so the user can still
bash_status / 继续 manually if the auto-resume fails.
"""
try:
runner(request)
except Exception as exc: # noqa: BLE001
print(
f'[bg-bash] auto-resume failed session={request.session_id}: {exc}',
flush=True,
)
def _sync_remote_program_state(
state: AgentState,
account_id: str,
session_id: str,
local_state_path: Path,
) -> None:
"""If a Jupyter binding exists, cat the remote program-state.jsonl into local.
Backend reads only the local file; this keeps both in sync so agent writes
on remote (where its bash runs) are visible to UI/watcher logic."""
runtime = _jupyter_runtime_for_session(state, account_id, session_id)
if runtime is None:
return
remote_state_path = (
f'{runtime.binding.workspace_cwd}/output/program-state.jsonl'
)
try:
result = runtime.run_command(
f'test -f {shlex.quote(remote_state_path)} && '
f'cat {shlex.quote(remote_state_path)} || true',
timeout_seconds=10.0,
max_output_chars=200_000,
)
except Exception: # noqa: BLE001
return
if not result or result.exit_code != 0:
return
new_content = result.stdout or ''
if not new_content.strip():
return
# Preserve local-only synthetic gate entries that remote doesn't have.
local_synthetics: list[str] = []
try:
if local_state_path.is_file():
# Parse remote gate keys once
remote_gate_keys: set[str] = set()
for raw_line in new_content.splitlines():
if not raw_line.strip():
continue
try:
r = json.loads(raw_line)
if r.get('step') and not r.get('_synthetic'):
remote_gate_keys.add(f"{r.get('run_id','')}:{r.get('step','')}")
except (json.JSONDecodeError, ValueError):
pass
for line in local_state_path.read_text(encoding='utf-8').splitlines():
if '"_synthetic"' not in line:
continue
try:
obj = json.loads(line)
if obj.get('_synthetic'):
gate_key = f"{obj.get('run_id','')}:{obj.get('step','')}"
if gate_key not in remote_gate_keys:
local_synthetics.append(line)
except (json.JSONDecodeError, AttributeError):
pass
except OSError:
pass
merged = new_content.rstrip('\n')
if local_synthetics:
merged += '\n' + '\n'.join(local_synthetics)
merged += '\n'
# Skip rewrite if local already matches (avoid touching mtime, which
# SSE uses to detect changes).
try:
if local_state_path.is_file() and (
local_state_path.read_text(encoding='utf-8') == merged
):
return
except OSError:
pass
try:
local_state_path.parent.mkdir(parents=True, exist_ok=True)
tmp = local_state_path.with_suffix('.jsonl.sync-tmp')
tmp.write_text(merged, encoding='utf-8')
tmp.replace(local_state_path)
except OSError:
return
def _scan_for_watchers(state: AgentState) -> None:
"""Synchronous scan body — used as fallback when no event loop is available.
Prefer _scan_for_watchers_async in normal operation so blocking calls
(remote sync, LLM) run in worker threads instead of the loop."""
accounts_root = state.session_directory.parent / 'accounts'
if not accounts_root.is_dir():
return
for account_dir in accounts_root.iterdir():
if not account_dir.is_dir():
continue
account_id = account_dir.name
sessions_dir = account_dir / 'sessions'
if not sessions_dir.is_dir():
continue
for session_dir in sessions_dir.iterdir():
session_id = session_dir.name
state_path = session_dir / 'output' / 'program-state.jsonl'
try:
_sync_remote_program_state(
state, account_id, session_id, state_path
)
except Exception as exc: # noqa: BLE001
print(
f'[scanner] remote sync failed for {account_id}/{session_id}: {exc}',
flush=True,
)
if not state_path.is_file():
continue
entries, _ = _read_program_state(state_path)
last_status_per_step: dict[str, str] = {}
for entry in entries:
step = entry.get('step')
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str):
last_status_per_step[step] = status
# See note in _scan_for_watchers_async: only the latest watch
# entry per step is current.
latest_watch_per_step: dict[str, dict[str, Any]] = {}
latest_running_run_id: dict[str, str] = {}
for entry in entries:
watch = entry.get('watch')
if isinstance(watch, dict):
w_step = watch.get('step')
if isinstance(w_step, str) and w_step:
latest_watch_per_step[w_step] = watch
continue
e_step = entry.get('step')
e_status = entry.get('status')
e_run = entry.get('run_id')
if (
isinstance(e_step, str) and e_step
and e_status == 'running'
and isinstance(e_run, str) and e_run
):
latest_running_run_id[e_step] = e_run
for step, watch in latest_watch_per_step.items():
if last_status_per_step.get(step) in {
'complete',
'failed',
'cancelled',
}:
continue
if _watcher_manager.is_watching(session_id, step):
continue
kind = watch.get('kind', 'file_exists')
if kind != 'file_exists':
continue
path_str = watch.get('path')
if not isinstance(path_str, str) or not path_str:
continue
try:
interval = float(watch.get('interval', 30) or 30)
timeout = float(watch.get('timeout', 3600) or 3600)
except (TypeError, ValueError):
interval = 30.0
timeout = 3600.0
run_id = str(watch.get('run_id') or '?')
running_run_id = latest_running_run_id.get(step)
if running_run_id and run_id != running_run_id:
continue
_watcher_manager.register_file_exists(
session_id=session_id,
state_path=state_path,
step=step,
target=Path(path_str),
interval=interval,
timeout=timeout,
run_id=run_id,
agent_state=state,
account_id=account_id,
)
_SYNC_SCRIPT = str(Path(__file__).resolve().parent.parent.parent / 'scripts' / 'sync_remote_state.py')
async def _subprocess_sync_remote(binding_path: Path, local_state_path: Path) -> None:
"""Sync remote program-state via subprocess. Killable on timeout."""
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, _SYNC_SCRIPT,
str(binding_path), str(local_state_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=10.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
except OSError:
pass
async def _scan_for_watchers_async(state: AgentState) -> None:
"""Scan program-state.jsonl files for watch declarations and register
watchers. Uses subprocess for remote sync (killable, no thread pool)."""
accounts_root = state.session_directory.parent / 'accounts'
if not accounts_root.is_dir():
return
try:
account_dirs = list(accounts_root.iterdir())
except OSError:
return
for account_dir in account_dirs:
if not account_dir.is_dir():
continue
account_id = account_dir.name
sessions_dir = state.account_paths(account_id)['sessions']
if not sessions_dir.is_dir():
continue
try:
session_dirs = list(sessions_dir.iterdir())
except OSError:
continue
for session_dir in session_dirs:
session_id = session_dir.name
state_path = session_dir / 'output' / 'program-state.jsonl'
binding_path = session_dir / 'jupyter_workspace.json'
# Subprocess remote sync only for sessions with a jupyter binding
if binding_path.is_file():
await _subprocess_sync_remote(binding_path, state_path)
if not state_path.is_file():
continue
try:
entries, _ = _read_program_state(state_path)
except Exception: # noqa: BLE001
continue
# Watch registration is fast (just spawns asyncio tasks); stay on loop.
last_status_per_step: dict[str, str] = {}
for entry in entries:
step = entry.get('step')
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str):
last_status_per_step[step] = status
# Only the LAST watch entry per step represents the current round's
# intent. Earlier watch entries from previous rounds must NOT be
# re-registered when a new round resets the step to running, or
# the resulting watcher will fire with a stale run_id and the UI
# will never see a complete tagged with the current round.
latest_watch_per_step: dict[str, dict[str, Any]] = {}
latest_running_run_id: dict[str, str] = {}
for entry in entries:
watch = entry.get('watch')
if isinstance(watch, dict):
w_step = watch.get('step')
if isinstance(w_step, str) and w_step:
latest_watch_per_step[w_step] = watch
continue
e_step = entry.get('step')
e_status = entry.get('status')
e_run = entry.get('run_id')
if (
isinstance(e_step, str) and e_step
and e_status == 'running'
and isinstance(e_run, str) and e_run
):
latest_running_run_id[e_step] = e_run
for step, watch in latest_watch_per_step.items():
if last_status_per_step.get(step) in {
'complete',
'failed',
'cancelled',
}:
continue
if _watcher_manager.is_watching(session_id, step):
continue
kind = watch.get('kind', 'file_exists')
if kind != 'file_exists':
continue
path_str = watch.get('path')
if not isinstance(path_str, str) or not path_str:
continue
try:
interval_s = float(watch.get('interval', 30) or 30)
timeout_s = float(watch.get('timeout', 3600) or 3600)
except (TypeError, ValueError):
interval_s = 30.0
timeout_s = 3600.0
run_id = str(watch.get('run_id') or '?')
# If a newer round has written `step=<step>, status=running`
# but the matching watch entry hasn't landed yet, skip and
# wait for the next scan rather than spawning a watcher tied
# to the previous round's run_id.
running_run_id = latest_running_run_id.get(step)
if running_run_id and run_id != running_run_id:
continue
_watcher_manager.register_file_exists(
session_id=session_id,
state_path=state_path,
step=step,
target=Path(path_str),
interval=interval_s,
timeout=timeout_s,
run_id=run_id,
agent_state=state,
account_id=account_id,
)
async def _watcher_scanner_loop(
state: AgentState, interval: float = 5.0
) -> None:
"""Periodically scan all session program-state.jsonl files for new watch
declarations and spawn watchers for them. Blocking calls run in threads."""
while True:
try:
await _scan_for_watchers_async(state)
except asyncio.CancelledError:
return
except Exception as exc: # noqa: BLE001
print(f'[watcher-scanner] scan error: {exc}', flush=True)
try:
await asyncio.sleep(interval)
except asyncio.CancelledError:
return
def _session_state_path(sessions_dir: Path, session_id: str | None) -> Path | None:
"""Resolve <sessions>/<safe_id>/output/program-state.jsonl. Returns None if unsafe id."""
safe_id = _safe_session_id(session_id)
if safe_id is None:
return None
return sessions_dir / safe_id / 'output' / 'program-state.jsonl'
def _read_program_state(path: Path | None) -> tuple[list[dict[str, Any]], float | None]:
"""Returns (entries, mtime_seconds). Empty if path is None / missing / unreadable."""
if path is None:
return [], None
try:
if not path.is_file():
return [], None
st = path.stat()
text = path.read_text(encoding='utf-8', errors='replace')
except (FileNotFoundError, OSError, PermissionError):
return [], None
entries: list[dict[str, Any]] = []
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith('#'):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
entries.append(obj)
return entries, st.st_mtime
def _normalize_step_token(value: str) -> str:
return re.sub(r'[\s_\-/.]+', '', value.strip().lower())
def _step_matches_card(step: str, card: dict[str, Any]) -> bool:
s = _normalize_step_token(step)
if not s:
return False
key = _normalize_step_token(str(card.get('key') or ''))
if s == key:
return True
title = _normalize_step_token(str(card.get('title') or ''))
if title and (s in title or title.startswith(s) or s in key):
return True
return False
# Round-section pipeline data model.
#
# Old: 3 fixed phases (eval / intervene / train) with cards dynamically injected.
# New: per-round numbered sections (R0·Baseline → R1·Train → R1·Analysis → ...),
# with optional HiTL gates inserted between rounds when agent writes
# step:"human-check" or step:"human-review".
#
# Each step belongs to a "section type" (baseline / train / analysis):
# - R0 has only one section: R0·Baseline (cml/gold-drift/dist-analysis/report/hypothesis/log)
# - R{n>=1} has two sections: R{n}·Train (augment/verify/sft)
# R{n}·Analysis (cml/gold-drift/dist-analysis/report/hypothesis/log)
#
# 'hypothesis' is classified as 'analysis' (not 'train'): forming the next-round
# hypothesis is the conclusion of the current round's analysis cycle, not the
# start of the next round's training. Putting it in analysis makes the gate
# (Human Check / Review) — which sorts after all sections of run_id=R{n} but
# before R{n+1}·Train — block training kickoff cleanly.
#
# 'next-round' is a boundary marker — does not produce a card.
# 'human-check' / 'human-review' produce gate items, not cards.
# step → kind: 'analysis' | 'train' | 'gate' | 'boundary'
_STEP_KIND: dict[str, str] = {
'cml': 'analysis',
'gold-drift': 'analysis',
'dist-analysis': 'analysis',
'hypothesis': 'analysis',
'log': 'analysis',
'augment': 'analysis',
'verify': 'train',
'sft': 'train',
'human-check': 'gate',
'human-review': 'gate',
'next-round': 'boundary',
}
_GATE_TITLES: dict[str, dict[str, str]] = {
'human-check': {
'title': '人工确认',
'description': '已生成本轮假设与候选,等待你点头进入下一轮训练',
},
'human-review': {
'title': '人工复核',
'description': '触发了 §4.0.1 / Gold-drift 规则,需要你逐条审一下再继续',
},
}
# (step, section_type) → display metadata. section_type is 'baseline' for R0
# steps, 'train'/'analysis' for R{n>=1}.
_CARD_META: dict[tuple[str, str], dict[str, Any]] = {
# R0·Baseline
('cml', 'baseline'): {'icon': 'bar-chart', 'title': 'Baseline 评测', 'subtitle': 'CML workflow metric_diff'},
('gold-drift', 'baseline'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
('hypothesis', 'baseline'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
('log', 'baseline'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
# augment — lives under previous round's analysis/baseline
('augment', 'baseline'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_<runDic>.jsonl'},
('augment', 'analysis'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_<runDic>.jsonl'},
# R{n}·Train
('verify', 'train'): {'icon': 'shield', 'title': '修改返回验证', 'subtitle': 'sanity + 人审'},
('sft', 'train'): {'icon': 'graduation-cap','title': 'SFT 训练', 'subtitle': 'submit_sft_via_cml.sh'},
# R{n}·Analysis
('cml', 'analysis'): {'icon': 'bar-chart', 'title': 'CML 评测', 'subtitle': 'workflow metric_diff'},
('gold-drift', 'analysis'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
('hypothesis', 'analysis'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
('log', 'analysis'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
}
# Order within a section.
_CARD_ORDER: dict[tuple[str, str], int] = {
# baseline
('cml', 'baseline'): 0,
('gold-drift', 'baseline'): 1,
('dist-analysis', 'baseline'): 2,
('hypothesis', 'baseline'): 3,
('log', 'baseline'): 4,
('augment', 'baseline'): 5,
# train
('verify', 'train'): 0,
('sft', 'train'): 1,
# analysis
('cml', 'analysis'): 0,
('gold-drift', 'analysis'): 1,
('dist-analysis', 'analysis'): 2,
('hypothesis', 'analysis'): 3,
('log', 'analysis'): 4,
('augment', 'analysis'): 5,
}
_SECTION_LABELS: dict[str, str] = {
'baseline': 'Baseline',
'train': 'Train',
'analysis': 'Analysis',
}
_SECTION_DESCRIPTIONS: dict[str, str] = {
'baseline': 'Establish baseline performance and metrics',
'train': 'Form hypothesis & retrain on augmented data',
'analysis': 'Evaluate new checkpoint & diagnose regression',
}
def _parse_run_index(run_id: str | None) -> int | None:
"""'R0' → 0, 'R12' → 12, anything else → None."""
if not isinstance(run_id, str):
return None
m = re.match(r'^[Rr](\d+)$', run_id.strip())
if not m:
return None
try:
return int(m.group(1))
except ValueError:
return None
def _infer_run_id(
entry: dict[str, Any],
iteration_log_count: int,
*,
same_step_hint: str | None = None,
) -> str | None:
"""Resolve which round this state entry belongs to.
1. explicit entry.run_id wins — UNLESS it's semantically invalid (a train
step tagged R0; R0 is baseline-only by design). In that case we treat
the explicit value as a typo and fall through to inference.
2. else: same_step_hint (run_id from nearest same-step entry) — handles
stateless writes like the watcher's complete/failed entries that lack
run_id but should obviously inherit the round of the agent-tagged
cml/running line they accompany.
3. else: derive from kind + iteration_log line count
- analysis steps: evaluating R{count}'s output → R{count}
- train steps: preparing R{count+1} → R{count+1}
- gates / boundary: cannot infer alone — caller back-fills from neighbor
"""
step = entry.get('step')
kind = _STEP_KIND.get(step) if isinstance(step, str) else None
explicit = entry.get('run_id')
if isinstance(explicit, str) and explicit.strip():
explicit_idx = _parse_run_index(explicit)
if explicit_idx is not None:
# R0+train is a common agent typo: hypothesis/augment/verify/sft are
# "preparing R1", but agents sometimes tag them with the round they
# see in the report. We rewrite to R1 specifically (NOT current-time
# inference) so retroactive iteration_log changes don't shift the
# cards into R{n>=2}.
if kind == 'train' and explicit_idx == 0:
return 'R1'
return explicit.strip()
if not isinstance(step, str):
return None
if same_step_hint:
return same_step_hint
if kind == 'analysis':
return f'R{iteration_log_count}'
if kind == 'train':
return f'R{max(iteration_log_count, 0) + 1}'
return None
def _build_same_step_hints(
state_entries: list[dict[str, Any]],
) -> list[str | None]:
"""For each entry, find the nearest same-step entry (by index distance)
that carries an explicit, semantically-valid run_id, and return its
run_id. Used as a fallback for stateless writes (e.g. watcher's complete
line lacks run_id but the agent's cml/running line right above has it).
Returns a parallel list aligned to state_entries; None for entries with
no usable neighbor.
"""
explicit_by_step: dict[str, list[tuple[int, str]]] = {}
for idx, entry in enumerate(state_entries):
step = entry.get('step')
if not isinstance(step, str) or not step:
continue
kind = _STEP_KIND.get(step)
if kind in (None, 'boundary', 'gate'):
continue
rid = entry.get('run_id')
if not (isinstance(rid, str) and rid.strip()):
continue
parsed = _parse_run_index(rid)
if parsed is None:
continue
# Apply same R0+train typo rewrite as _infer_run_id so the hint stays
# consistent with what _infer_run_id would have returned for that
# entry on its own.
canonical = 'R1' if (kind == 'train' and parsed == 0) else rid.strip()
explicit_by_step.setdefault(step, []).append((idx, canonical))
hints: list[str | None] = [None] * len(state_entries)
for idx, entry in enumerate(state_entries):
step = entry.get('step')
if not isinstance(step, str) or not step:
continue
candidates = explicit_by_step.get(step)
if not candidates:
continue
best = min(candidates, key=lambda ix_rid: abs(ix_rid[0] - idx))
hints[idx] = best[1]
return hints
def _section_type_for(step: str, run_index: int) -> str | None:
"""Which section a card belongs to within its round.
Train steps under R0 are filtered out by `_infer_run_id` (R0 has no train
phase). If one slips through here, we drop it rather than aliasing to
baseline — silently mixing train cards into a baseline section is what
caused hypothesis to render under R0·Baseline.
"""
kind = _STEP_KIND.get(step)
if kind == 'analysis':
return 'baseline' if run_index == 0 else 'analysis'
if kind == 'train':
return 'train' if run_index >= 1 else None
return None
def _derive_section_status(card_statuses: list[str]) -> str:
if not card_statuses:
return 'pending'
if all(s == 'complete' for s in card_statuses):
return 'complete'
if 'failed' in card_statuses and not any(s in ('running', 'complete', 'waiting') for s in card_statuses):
return 'failed'
if any(s in ('running', 'complete', 'waiting') for s in card_statuses):
return 'running'
return 'pending'
def _build_pipeline_items(
state_entries: list[dict[str, Any]],
iteration_log_count: int,
) -> list[dict[str, Any]]:
"""Assemble the items[] payload from raw state entries.
Returns an ordered list of {type:'round', ...} and {type:'gate', ...} items
in display order. Each item shows agent-written progress only — sections
not yet touched do not appear.
"""
# 1. Walk entries: keep latest per step (per round-namespaced key).
# We namespace by run_id so the same step name on different rounds
# doesn't overwrite each other.
latest_step_by_key: dict[str, dict[str, Any]] = {}
latest_gate_by_key: dict[str, dict[str, Any]] = {}
section_first_ts: dict[tuple[str, str], str] = {} # (run_id, section_type) → earliest ts
last_non_gate_run_id: str | None = None
gate_position_index: dict[str, int] = {} # gate_key → position in entries (for ordering)
same_step_hints = _build_same_step_hints(state_entries)
for idx, entry in enumerate(state_entries):
step = entry.get('step')
if not isinstance(step, str) or not step:
continue
kind = _STEP_KIND.get(step)
if kind == 'boundary':
continue
if kind == 'gate':
run_id = entry.get('run_id')
if not (isinstance(run_id, str) and run_id.strip()):
run_id = last_non_gate_run_id
if not run_id:
continue
run_id = run_id.strip()
gate_key = f'{run_id}:{step}'
existing = latest_gate_by_key.get(gate_key)
if existing is None:
latest_gate_by_key[gate_key] = {
**entry,
'_run_id': run_id,
'_first_ts': entry.get('ts', ''),
}
gate_position_index[gate_key] = idx
else:
existing.update({k: v for k, v in entry.items() if k != 'ts'})
# keep first ts for ordering
continue
# analysis or train
run_id = _infer_run_id(
entry, iteration_log_count, same_step_hint=same_step_hints[idx]
)
if not run_id:
continue
last_non_gate_run_id = run_id
# augment belongs to the previous round's analysis/baseline section
if step == 'augment':
aug_index = _parse_run_index(run_id)
if aug_index is not None and aug_index >= 1:
run_id = f'R{aug_index - 1}'
run_index = _parse_run_index(run_id)
if run_index is None:
continue
section_type = _section_type_for(step, run_index)
if section_type is None:
continue
card_key = f'{run_id}:{step}'
ts = entry.get('ts', '')
if card_key not in latest_step_by_key:
latest_step_by_key[card_key] = {
**entry,
'_run_id': run_id,
'_section_type': section_type,
'_first_ts': ts,
}
else:
existing = latest_step_by_key[card_key]
for k, v in entry.items():
existing[k] = v
section_key = (run_id, section_type)
if section_key not in section_first_ts:
section_first_ts[section_key] = ts
# 2. Group cards by section.
sections: dict[tuple[str, str], dict[str, Any]] = {}
for card_key, entry in latest_step_by_key.items():
run_id = entry['_run_id']
section_type = entry['_section_type']
section_key = (run_id, section_type)
if section_key not in sections:
sections[section_key] = {
'run_id': run_id,
'section_type': section_type,
'cards': [],
'first_ts': section_first_ts.get(section_key, ''),
}
step = entry['step']
meta = _CARD_META.get((step, section_type), {
'icon': 'clock',
'title': step,
'subtitle': '',
})
order = _CARD_ORDER.get((step, section_type), 999)
card: dict[str, Any] = {
'key': card_key,
'step': step,
'icon': meta['icon'],
'title': meta['title'],
'subtitle': meta['subtitle'],
'status': entry.get('status') or 'pending',
'_order': order,
}
if 'progress' in entry:
try:
value = float(entry['progress'])
if value > 1:
value /= 100
card['progress'] = max(0.0, min(1.0, value))
except (TypeError, ValueError):
pass
if step == 'augment' and 'count' in entry:
try:
card['subtitle'] = f'{int(entry["count"])} 条新增样本'
except (TypeError, ValueError):
pass
sections[section_key]['cards'].append(card)
# 3. Sort cards within each section.
for sec in sections.values():
sec['cards'].sort(key=lambda c: c.get('_order', 999))
for c in sec['cards']:
c.pop('_order', None)
# 3b. If a gate is running for a given run_id, downgrade running cards
# in that run to 'waiting' (the step is blocked on human review).
runs_with_gate_running: set[str] = set()
for entry in latest_gate_by_key.values():
if entry.get('status') == 'running':
runs_with_gate_running.add(entry['_run_id'])
if runs_with_gate_running:
for sec in sections.values():
if sec['run_id'] in runs_with_gate_running:
for card in sec['cards']:
if card['status'] == 'running':
card['status'] = 'waiting'
# 4. Build round items in display order (chronological by first_ts).
section_items: list[dict[str, Any]] = []
for (run_id, section_type), sec in sections.items():
run_index = _parse_run_index(run_id)
if run_index is None:
continue
statuses = [c['status'] for c in sec['cards']]
round_status = _derive_section_status(statuses)
label = f'{run_id} · {_SECTION_LABELS[section_type]}'
section_items.append({
'type': 'round',
'run_id': run_id,
'run_index': run_index,
'section_type': section_type,
'label': label,
'description': _SECTION_DESCRIPTIONS[section_type],
'status': round_status,
'cards': sec['cards'],
'_order_key': sec.get('first_ts', ''),
})
# 5. Build gate items.
gate_items: list[dict[str, Any]] = []
for gate_key, entry in latest_gate_by_key.items():
run_id = entry['_run_id']
run_index = _parse_run_index(run_id)
if run_index is None:
continue
step = entry['step']
meta_g = _GATE_TITLES.get(step, {'title': step, 'description': ''})
# Place gate after the latest section of this run_id (so use a high
# secondary order). Use position index for tie-break across same round.
raw_status = entry.get('status') or 'pending'
# Gate running → 'waiting': the agent has surfaced the checkpoint and
# ended its turn. There's no work in flight on the agent side; we're
# waiting on the user. The frontend renders 'waiting' with a distinct
# amber pill ("等待人工确认") instead of the spinning blue "IN PROGRESS"
# used for actual running steps.
gate_status = 'waiting' if raw_status == 'running' else raw_status
# Structured fields for the gate body — agent should prefer these so
# the UI can render labeled rows (现状 / 提议 / 请选). `reason` is kept
# as a free-text fallback for entries that haven't been migrated.
gate_items.append({
'type': 'gate',
'key': gate_key,
'run_id': run_id,
'run_index': run_index,
'gate_kind': step,
'title': meta_g['title'],
'description': meta_g['description'],
'status': gate_status,
'reason': entry.get('reason'),
'summary': entry.get('summary'),
'proposal': entry.get('proposal'),
'ask': entry.get('ask'),
'_order_key': entry.get('ts', ''),
})
# 6. Filter: drop empty/pending sections (no cards or all pending). Running
# and complete sections both render — RoundSection's status pill conveys
# the difference, so the transition complete→running on the same numbered
# box is in-place rather than a separate chip strip popping in/out.
section_items = [
s for s in section_items if s['status'] in ('running', 'complete', 'failed')
]
# 7. Merge and sort all items by (run_index, section_order, ts/position).
all_items = section_items + gate_items
all_items.sort(key=lambda x: x['_order_key'])
# 8. Attach 1-based numeric index for display ("01", "02", ...) after
# filtering so numbering is dense (no gaps from hidden running sections).
for i, item in enumerate(all_items, start=1):
item['index'] = i
item.pop('_order_key', None)
return all_items
def _has_active_watch_for_step(state_entries: list[dict[str, Any]], step: str) -> bool:
"""True if state_entries contain a watch for this step that hasn't resolved."""
last_status: dict[str, str] = {}
has_watch = False
for entry in state_entries:
s = entry.get('step')
st = entry.get('status')
if isinstance(s, str) and isinstance(st, str):
last_status[s] = st
watch = entry.get('watch')
if isinstance(watch, dict) and watch.get('step') == step:
has_watch = True
return has_watch and last_status.get(step) not in ('complete', 'failed', 'cancelled')
def _compute_in_flight(
state_entries: list[dict[str, Any]],
iteration_log_count: int,
) -> dict[str, Any] | None:
"""Render the in-progress phase as an inline chip row.
The wrapping section only appears once the whole phase is COMPLETE.
Until then we still want the user to see what's been done in the current
phase: every step that has reached `complete` plus the one currently
`running`. Pending steps stay hidden — they appear when their turn
comes and replace nothing.
Returns a single object describing the in-progress phase + its
progressively-filling cards, in canonical step order. None when no
phase is currently running (everything settled, or the latest signal
is a gate/boundary).
"""
same_step_hints = _build_same_step_hints(state_entries)
target_run_id: str | None = None
target_section: str | None = None
seen_keys: set[str] = set()
for idx in range(len(state_entries) - 1, -1, -1):
entry = state_entries[idx]
if entry.get('kpi'):
continue
if entry.get('log') and not entry.get('step'):
continue
step = entry.get('step')
if not isinstance(step, str) or not step:
continue
kind = _STEP_KIND.get(step)
if kind in ('boundary', 'gate'):
continue
run_id = _infer_run_id(
entry, iteration_log_count, same_step_hint=same_step_hints[idx]
)
if not run_id:
continue
key = f'{run_id}:{step}'
if key in seen_keys:
continue
seen_keys.add(key)
if entry.get('status') != 'running':
continue
run_index = _parse_run_index(run_id)
if run_index is None:
continue
section_type = _section_type_for(step, run_index)
if section_type is None:
continue
target_run_id = run_id
target_section = section_type
break
if not target_run_id or not target_section:
return None
# Check if a gate (human-check/human-review) is running for the target run_id
gate_running_for_run = False
for entry in state_entries:
step = entry.get('step')
if not isinstance(step, str) or not step:
continue
if _STEP_KIND.get(step) == 'gate' and entry.get('status') == 'running':
entry_run_id = entry.get('run_id')
if entry_run_id == target_run_id:
gate_running_for_run = True
latest_per_step: dict[str, dict[str, Any]] = {}
for idx, entry in enumerate(state_entries):
if entry.get('kpi'):
continue
if entry.get('log') and not entry.get('step'):
continue
step = entry.get('step')
if not isinstance(step, str) or not step:
continue
kind = _STEP_KIND.get(step)
if kind in ('boundary', 'gate'):
continue
run_id = _infer_run_id(
entry, iteration_log_count, same_step_hint=same_step_hints[idx]
)
if run_id != target_run_id:
continue
run_index = _parse_run_index(run_id)
if run_index is None:
continue
section_type = _section_type_for(step, run_index)
if section_type != target_section:
continue
latest_per_step[step] = entry
cards: list[dict[str, Any]] = []
for step, entry in latest_per_step.items():
status = entry.get('status')
if status not in ('complete', 'running'):
continue
if status == 'running' and gate_running_for_run:
if not _has_active_watch_for_step(state_entries, step):
status = 'waiting'
meta = _CARD_META.get((step, target_section), {
'icon': 'clock',
'title': step,
'subtitle': '',
})
progress: float | None = None
if 'progress' in entry:
try:
value = float(entry['progress'])
if value > 1:
value /= 100
progress = max(0.0, min(1.0, value))
except (TypeError, ValueError):
pass
subtitle = meta['subtitle']
if step == 'augment' and 'count' in entry:
try:
subtitle = f'{int(entry["count"])} 条新增样本'
except (TypeError, ValueError):
pass
cards.append({
'key': f'{target_run_id}:{step}',
'step': step,
'icon': meta['icon'],
'title': meta['title'],
'subtitle': subtitle,
'status': status,
'progress': progress,
})
if not cards:
return None
cards.sort(key=lambda c: _CARD_ORDER.get((c['step'], target_section), 999))
return {
'run_id': target_run_id,
'section_type': target_section,
'section_label': f'{target_run_id} · {_SECTION_LABELS[target_section]}',
'cards': cards,
}
_QUESTION_PATTERN = re.compile(
r'[?]|拍板|选择.*哪|决定|确认.*吗|建议.*哪|要不要|是否|请.*选|你.*决定|三个选项|哪个方案|哪条路'
)
def _last_assistant_text(sessions_dir: Path, session_id: str | None) -> str | None:
if not session_id:
return None
safe_id = _safe_session_id(session_id)
if safe_id is None:
return None
path = sessions_dir / safe_id / 'session.json'
try:
data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
messages = data.get('display_messages') or data.get('messages')
if not isinstance(messages, list):
return None
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if msg.get('role') != 'assistant':
continue
content = msg.get('content')
if isinstance(content, str) and content.strip():
return content.strip()
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get('type') == 'text':
text = block.get('text', '').strip()
if text:
return text
return None
def _maybe_inject_gate_fallback(
state_path: Path,
state_entries: list[dict[str, Any]],
sessions_dir: Path,
session_id: str | None,
run_active: bool | None,
) -> list[dict[str, Any]]:
"""If agent's last message asks a question but no gate is running,
append a synthetic human-review entry to program-state.jsonl."""
if run_active is not False:
return state_entries
for entry in reversed(state_entries):
step = entry.get('step')
if step in ('human-check', 'human-review') and entry.get('status') == 'running':
return state_entries
last_text = _last_assistant_text(sessions_dir, session_id)
if not last_text:
return state_entries
if not _QUESTION_PATTERN.search(last_text[-500:]):
return state_entries
run_id = None
for entry in reversed(state_entries):
rid = entry.get('run_id')
if isinstance(rid, str) and rid.strip():
run_id = rid.strip()
break
gate_entry: dict[str, Any] = {
'step': 'human-review',
'status': 'running',
'run_id': run_id or 'R0',
'reason': last_text[-300:],
'ts': time.strftime('%Y-%m-%dT%H:%M:%S+08:00', time.localtime()),
'_synthetic': True,
}
try:
with open(state_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(gate_entry, ensure_ascii=False) + '\n')
except OSError:
pass
state_entries.append(gate_entry)
return state_entries
def _apply_program_state(
payload: dict[str, Any],
state_entries: list[dict[str, Any]],
*,
iteration_log_count: int = 0,
session_failure_status: str | None = None,
run_active: bool | None = None,
session_id: str | None = None,
) -> None:
"""Mutate payload in place. Builds items[] from state entries and appends
log lines. KPI entries (legacy `{kpi:..,value:..}`) are ignored —
KPIs are computed by the backend in _compute_kpis."""
# Append logs first (they are independent of items[]).
appended_logs: list[dict[str, Any]] = []
for entry in state_entries:
if entry.get('kpi'):
continue
if entry.get('log'):
log_obj = entry.get('log')
if isinstance(log_obj, dict):
appended_logs.append(log_obj)
elif isinstance(log_obj, str):
appended_logs.append({'text': log_obj, 'ts': entry.get('ts', '')})
if appended_logs:
existing = payload.setdefault('logs', [])
existing.extend(appended_logs)
# Build items (already filtered to complete sections + visible gates).
items = _build_pipeline_items(state_entries, iteration_log_count)
payload['items'] = items
payload['in_flight'] = _compute_in_flight(state_entries, iteration_log_count)
# Derive overall status from RAW entries — items[] is filtered to complete
# sections only, so deriving from items would falsely report 'complete'
# whenever a running section is hidden.
latest_per_step: dict[str, str] = {}
for entry in state_entries:
if entry.get('kpi'):
continue
if entry.get('log') and not entry.get('step'):
continue
step = entry.get('step')
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str) and status:
# boundary steps don't count toward overall progress
if _STEP_KIND.get(step) == 'boundary':
continue
latest_per_step[step] = status
non_gate_running = any(
status == 'running' and _STEP_KIND.get(step) != 'gate'
for step, status in latest_per_step.items()
)
gate_running = any(
status == 'running' and _STEP_KIND.get(step) == 'gate'
for step, status in latest_per_step.items()
)
statuses = list(latest_per_step.values())
if not statuses:
payload['status']['state'] = 'pending'
elif non_gate_running:
payload['status']['state'] = 'running'
elif gate_running:
# HiTL gate is running — agent has handed control back to the user.
# Exception: if a non-gate step has an active watcher (e.g. cml eval
# running on remote), the session is still progressing — show running.
watched_running = any(
status == 'running' and _STEP_KIND.get(step) != 'gate'
and _has_active_watch_for_step(state_entries, step)
for step, status in latest_per_step.items()
)
payload['status']['state'] = 'running' if watched_running else 'waiting'
elif all(s == 'complete' for s in statuses):
if run_active:
payload['status']['state'] = 'running'
else:
payload['status']['state'] = 'complete'
elif 'complete' in statuses:
payload['status']['state'] = 'running'
elif 'failed' in statuses:
payload['status']['state'] = 'failed'
else:
payload['status']['state'] = 'pending'
# Orphan detection: agent wrote `step:running` then turn ended cleanly
# without a closing `complete`/`failed` entry — run_state_store says the
# run is no longer active, but the pipeline still claims `running`. Fold
# this into session_failure_status='failed' so the same red-failed visual
# applies (per user UX choice). Note: only triggers when there's at least
# one explicit running step — purely-pending state stays pending.
#
# Exception: if every running step has an active watcher polling for it,
# it's NOT an orphan — agent legitimately ended its turn and is waiting
# for a remote file to land via the watcher. The watcher will write the
# closing entry when it triggers (file_exists / timeout / cancel).
#
# Gates (human-check / human-review) are also never orphans: agent writes
# `step:running` to surface the gate card, ends the turn, and waits for
# the user to reply. There's no watcher and no remote file — the closing
# `complete` entry comes from the next user-driven turn.
running_steps = {
step for step, status in latest_per_step.items()
if status == 'running' and _STEP_KIND.get(step) != 'gate'
}
all_watched = bool(running_steps) and all(
_watcher_manager.is_watching(session_id, step)
for step in running_steps
if session_id
)
if (
run_active is False
and session_failure_status is None
and payload['status']['state'] == 'running'
and running_steps
and not all_watched
and not gate_running
):
session_failure_status = 'failed'
# Session-level failure (agent process died / cancelled) overrides the
# state derived from program-state.jsonl. The state file can't write its
# own tombstone when the writer is the dead process — we cross-reference
# session.json to surface the failure on the monitor panel.
if session_failure_status in ('failed', 'cancelled', 'interrupted'):
# Cancelled keeps a softer 'cancelled' label so the UI can distinguish
# user-stop from crash; everything else maps to 'failed' (red).
target_state = 'cancelled' if session_failure_status == 'cancelled' else 'failed'
if payload['status']['state'] != 'complete':
payload['status']['state'] = target_state
in_flight = payload.get('in_flight')
if isinstance(in_flight, dict):
cards = in_flight.get('cards')
if isinstance(cards, list):
for card in cards:
if isinstance(card, dict) and card.get('status') == 'running':
card['status'] = target_state
# Same treatment for any running cards inside items[] — running
# round sections also get flipped, and the section status follows.
for item in payload.get('items', []):
if not isinstance(item, dict):
continue
if item.get('type') == 'round':
section_cards = item.get('cards', [])
flipped = False
if isinstance(section_cards, list):
for card in section_cards:
if isinstance(card, dict) and card.get('status') == 'running':
card['status'] = target_state
flipped = True
if flipped or item.get('status') == 'running':
item['status'] = target_state
elif item.get('type') == 'gate' and item.get('status') == 'running':
item['status'] = target_state
def _autoresearch_root() -> Path:
return Path(os.environ.get('AUTORESEARCH_ROOT', '/mnt/wangsenhao/autoresearch-zk'))
def _chat_root_for_runtime(
runtime: 'JupyterRuntimeSession | None',
) -> str | None:
"""Resolve the per-chat autoresearch root as a REMOTE path string.
Returns the NFS path under /mnt/<account>/autoresearch-zk-users/<session>/
so jupyter pod and SFT training pod both read/write the same directory.
Returns None when no runtime is bound — caller decides whether to fall
back to the legacy global path."""
if runtime is None:
return None
return runtime.chat_workspace_root
def _run_history_dir() -> Path:
return Path(
os.environ.get(
'RUN_HISTORY_DIR',
'/mnt/xiaoai-zk-model-train-tj5/workflow5',
)
)
def _skill_config_yaml_path() -> Path:
# backend/api/server.py → ../../skills/model-iteration/assets/config.yaml
return (
Path(__file__).resolve().parent.parent.parent
/ 'skills'
/ 'model-iteration'
/ 'assets'
/ 'config.yaml'
)
def _read_workflow_version() -> str | None:
path = _skill_config_yaml_path()
try:
text = path.read_text(encoding='utf-8')
except (FileNotFoundError, OSError):
return None
# Tiny parser to avoid pulling pyyaml just for two lines.
in_cml = False
for raw in text.splitlines():
stripped = raw.rstrip()
if not stripped or stripped.lstrip().startswith('#'):
continue
if not stripped.startswith(' '):
in_cml = stripped.split(':', 1)[0].strip() == 'cml_eval'
continue
if in_cml:
inner = stripped.strip()
if inner.startswith('version:'):
value = inner.split(':', 1)[1].strip().strip('"\'')
return value or None
return None
def _scan_run_history_max() -> int | None:
root = _run_history_dir()
try:
if not root.is_dir():
return None
except OSError:
return None
best = -1
for entry in root.iterdir():
if not entry.is_dir():
continue
m = re.match(r'^workflow(\d+)$', entry.name)
if m:
try:
value = int(m.group(1))
except ValueError:
continue
if value > best:
best = value
return best if best >= 0 else None
_ITER_COUNT_CACHE: dict[str, int | None] = {}
def _iter_count_cache_key(session_id: str | None, account_id: str | None) -> str:
return f'{account_id or "_"}|{session_id or "_"}'
def _read_iteration_log_count(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> int | None:
"""Count non-empty lines in iteration_log.jsonl.
Chat-root lives under the remote workspace_cwd, so reads MUST go through
the bound jupyter runtime — the round-trip is multi-second. To keep the
SSE pipeline loop responsive, this function returns the last cached value
when one exists; only a cold cache pays the synchronous remote cost. The
cache is refreshed in the background by `_iter_count_refresh_loop`, which
the SSE handler spawns alongside its event loop.
"""
cache_key = _iter_count_cache_key(session_id, account_id)
if cache_key in _ITER_COUNT_CACHE:
return _ITER_COUNT_CACHE[cache_key]
value = _read_iteration_log_count_uncached(
session_id, agent_state=agent_state, account_id=account_id,
)
_ITER_COUNT_CACHE[cache_key] = value
return value
async def _iter_count_refresh_loop(
session_id: str | None,
*,
agent_state: 'AgentState | None',
account_id: str | None,
interval_seconds: float = 3.0,
) -> None:
"""Background task that re-reads iteration_log_count every `interval_seconds`
and updates the in-memory cache. Cancelled when the caller (SSE handler)
exits."""
cache_key = _iter_count_cache_key(session_id, account_id)
while True:
try:
value = await asyncio.to_thread(
_read_iteration_log_count_uncached,
session_id,
agent_state=agent_state,
account_id=account_id,
)
_ITER_COUNT_CACHE[cache_key] = value
except Exception: # noqa: BLE001
pass
await asyncio.sleep(interval_seconds)
def _read_iteration_log_count_uncached(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> int | None:
runtime = (
_jupyter_runtime_for_session(agent_state, account_id, session_id)
if session_id and agent_state is not None
else None
)
remote_root = _chat_root_for_runtime(runtime)
if runtime is not None and remote_root is not None:
remote_path = f'{remote_root}/results/iteration_log.jsonl'
cmd = (
f'test -f {shlex.quote(remote_path)} '
f'&& grep -c "[^[:space:]]" {shlex.quote(remote_path)} '
f'|| echo __MISSING__'
)
try:
result = runtime.run_command(
cmd,
timeout_seconds=10.0,
max_output_chars=2000,
)
except Exception: # noqa: BLE001
return None
stdout = (result.stdout or '').strip() if result is not None else ''
if stdout and stdout != '__MISSING__':
try:
return int(stdout.splitlines()[-1])
except (ValueError, IndexError):
return None
return None
legacy = _autoresearch_root() / 'results' / 'iteration_log.jsonl'
try:
if legacy.is_file():
with legacy.open('r', encoding='utf-8') as fp:
return sum(1 for line in fp if line.strip())
except (OSError, UnicodeDecodeError):
pass
return None
_METRICS_HISTORY_CACHE: dict[str, dict[str, Any] | None] = {}
def _read_iteration_log_metrics_history(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
target_set_name: str | None = None,
) -> dict[str, Any] | None:
"""Cached read of full iteration_log.jsonl as metrics time-series."""
cache_key = _iter_count_cache_key(session_id, account_id)
if cache_key in _METRICS_HISTORY_CACHE:
return _METRICS_HISTORY_CACHE[cache_key]
value = _read_iteration_log_metrics_history_uncached(
session_id, agent_state=agent_state, account_id=account_id,
target_set_name=target_set_name,
)
_METRICS_HISTORY_CACHE[cache_key] = value
return value
async def _metrics_history_refresh_loop(
session_id: str | None,
*,
agent_state: 'AgentState | None',
account_id: str | None,
target_set_name: str | None = None,
interval_seconds: float = 3.0,
) -> None:
cache_key = _iter_count_cache_key(session_id, account_id)
while True:
try:
value = await asyncio.to_thread(
_read_iteration_log_metrics_history_uncached,
session_id,
agent_state=agent_state,
account_id=account_id,
target_set_name=target_set_name,
)
_METRICS_HISTORY_CACHE[cache_key] = value
except Exception: # noqa: BLE001
pass
await asyncio.sleep(interval_seconds)
def _read_iteration_log_metrics_history_uncached(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
target_set_name: str | None = None,
) -> dict[str, Any] | None:
"""Parse iteration_log.jsonl into {'metrics': [keys], 'rounds': [...]}.
Each entry's `results` dict contributes one round point; metric key union
is taken across all rounds (sorted by first-appearance for stable column
order). Missing values produce nulls so frontend line charts break the
series naturally."""
runtime = (
_jupyter_runtime_for_session(agent_state, account_id, session_id)
if session_id and agent_state is not None
else None
)
text: str | None = None
if runtime is not None:
remote_root = _chat_root_for_runtime(runtime)
if remote_root is not None:
remote_path = f'{remote_root}/results/iteration_log.jsonl'
cmd = (
f'test -f {shlex.quote(remote_path)} '
f'&& cat {shlex.quote(remote_path)} '
f'|| echo __MISSING__'
)
try:
result = runtime.run_command(
cmd, timeout_seconds=10.0, max_output_chars=400_000,
)
except Exception: # noqa: BLE001
return None
stdout = (result.stdout or '').strip() if result is not None else ''
if stdout and stdout != '__MISSING__':
text = stdout
if text is None:
legacy = _autoresearch_root() / 'results' / 'iteration_log.jsonl'
try:
if legacy.is_file():
text = legacy.read_text(encoding='utf-8', errors='replace')
except OSError:
return None
if not text:
return None
metrics_order: list[str] = []
metrics_seen: set[str] = set()
rounds: list[dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (ValueError, TypeError):
continue
if not isinstance(obj, dict):
continue
results = obj.get('results')
if not isinstance(results, dict):
continue
values: dict[str, float] = {}
for k, v in results.items():
if not isinstance(k, str):
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
values[k] = fv
if k not in metrics_seen:
metrics_seen.add(k)
metrics_order.append(k)
if not values:
continue
iteration = obj.get('iteration')
if isinstance(iteration, str) and iteration.lstrip('-').isdigit():
iteration = int(iteration)
if not isinstance(iteration, int):
iteration = len(rounds)
run_dic = obj.get('runDic') or obj.get('run_dic')
if isinstance(run_dic, str) and run_dic.isdigit():
run_dic = int(run_dic)
if not isinstance(run_dic, int):
run_dic = 0
timestamp = obj.get('timestamp')
if not isinstance(timestamp, str):
timestamp = ''
rounds.append({
'iteration': iteration,
'runDic': run_dic,
'label': f'R{iteration}',
'timestamp': timestamp,
'values': values,
})
# Deduplicate: keep only the last entry per (iteration, runDic).
# Agent may write multiple entries for the same round (e.g. re-running
# dist-analysis); the last one has the most complete metrics.
seen_keys: dict[tuple[int, int], int] = {}
for idx, r in enumerate(rounds):
key = (r['iteration'], r['runDic'])
seen_keys[key] = idx
rounds = [rounds[i] for i in sorted(seen_keys.values())]
# Rebuild metrics_order from surviving rounds only (dedup may have removed
# entries whose keys shouldn't appear in the chart).
metrics_order = []
metrics_seen = set()
for r in rounds:
for k in r['values']:
if k not in metrics_seen:
metrics_seen.add(k)
metrics_order.append(k)
if not rounds:
return None
# Enrich each round with KPI-equivalent metrics from lark_template.json.
# This ensures dapan_car / specific_test / target_subset are always present
# even if the agent omitted them from iteration_log.
target_key = target_set_name if target_set_name and target_set_name != '' else 'target_subset'
_KPI_METRIC_MAP = {
'specific_test_pass_rate': 'specific_test',
'overall_car_pass_rate': 'dapan_car',
'target_set_pass_rate': target_key,
}
# Rename agent-written 'target_subset' to the actual target set name first,
# so enrichment doesn't create duplicates.
if target_key != 'target_subset':
if 'target_subset' in metrics_seen:
metrics_order = [target_key if m == 'target_subset' else m for m in metrics_order]
metrics_seen.discard('target_subset')
metrics_seen.add(target_key)
for r in rounds:
if 'target_subset' in r['values']:
r['values'][target_key] = r['values'].pop('target_subset')
for r in rounds:
run_dic = r.get('runDic')
if not isinstance(run_dic, int) or run_dic == 0:
continue
metric_diff = _read_workflow_metric_diff(
run_dic,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
if metric_diff is None:
continue
_enrich_metrics_from_eval_output(metric_diff, None)
for src_key, dst_key in _KPI_METRIC_MAP.items():
if dst_key not in r['values']:
raw = metric_diff.get(src_key)
if isinstance(raw, (int, float)):
r['values'][dst_key] = round(raw * 100, 2)
if dst_key not in metrics_seen:
metrics_seen.add(dst_key)
metrics_order.append(dst_key)
return {'metrics': metrics_order, 'rounds': rounds}
def _read_iteration_log_h1_label(
runtime: 'JupyterRuntimeSession | None',
run_dic: int | None,
*,
max_chars: int = 32,
) -> str | None:
"""读取 iteration_log.jsonl,提取与本轮 dist-analysis 候选集对应的 H1 假设短标签。
优先匹配 runDic 一致的 entry,否则取最新一条;从 ``hypothesis`` /
``next_hypothesis`` 字段切第一句并截断。读不到或无 iteration_log 返回 None
(调用方据此决定是否在标题里加括号)。
"""
if runtime is None:
return None
chat_root = _chat_root_for_runtime(runtime)
if not chat_root:
return None
remote_path = f'{chat_root}/results/iteration_log.jsonl'
cmd = (
f'test -f {shlex.quote(remote_path)} '
f'&& cat {shlex.quote(remote_path)} '
f'|| echo __MISSING__'
)
try:
result = runtime.run_command(
cmd, timeout_seconds=10.0, max_output_chars=200_000
)
except Exception: # noqa: BLE001
return None
stdout = (result.stdout or '').strip() if result is not None else ''
if not stdout or stdout == '__MISSING__':
return None
entries: list[dict[str, Any]] = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (ValueError, TypeError):
continue
if isinstance(obj, dict):
entries.append(obj)
if not entries:
return None
chosen: dict[str, Any] | None = None
if run_dic is not None:
for entry in reversed(entries):
rd = entry.get('runDic') or entry.get('run_dic')
if isinstance(rd, str) and rd.isdigit():
rd = int(rd)
if rd == run_dic:
chosen = entry
break
if chosen is None:
chosen = entries[-1]
for field in ('hypothesis', 'next_hypothesis'):
val = chosen.get(field)
if isinstance(val, str) and val.strip():
label = val.strip()
for sep in ('', ';', '', '\n'):
if sep in label:
label = label.split(sep, 1)[0].strip()
break
if len(label) > max_chars:
label = label[: max_chars - 1] + ''
return label or None
return None
def _extract_trigger_from_state(state_entries: list[dict[str, Any]]) -> str | None:
"""Backwards compat: if agent wrote {kpi:'TRIGGER',value:...}, use that.
Backend now owns KPI rendering, but TRIGGER is a passthrough of the user's
own phrasing — fine to read from state."""
last: str | None = None
for entry in state_entries:
if entry.get('kpi') == 'TRIGGER':
value = entry.get('value')
if isinstance(value, str) and value.strip():
last = value
return last
def _extract_trigger_from_session(
sessions_dir: Path, session_id: str | None
) -> str | None:
"""Return the user's first non-empty message that looks like a training
trigger. Used as raw input for _parse_target_set."""
if not session_id:
return None
safe_id = _safe_session_id(session_id)
if safe_id is None:
return None
path = sessions_dir / safe_id / 'session.json'
try:
data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
messages = data.get('display_messages') or data.get('messages')
if not isinstance(messages, list):
return None
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get('role') != 'user':
continue
content = msg.get('content')
text = content if isinstance(content, str) else ''
if not text:
continue
# Strip <system-reminder> blocks etc.
cleaned = re.sub(r'<[^>]+>', ' ', text).strip()
if not cleaned:
continue
if _looks_like_training_trigger(cleaned):
return cleaned
return None
def _looks_like_training_trigger(text: str) -> bool:
keywords = (
'训练',
'training',
'评测',
'迭代',
'开始',
'需求集合',
'目标集合',
'目标是',
'针对',
'cml',
'复杂导航',
'.csv',
)
lower = text.lower()
return any(k.lower() in lower for k in keywords)
_TARGET_SET_LLM_CACHE: dict[str, str | None] = {}
def _llm_extract_target_set(
model_config: ModelConfig | None, text: str
) -> str | None:
"""Use the configured LLM to extract a target set name from a user message.
Cached by message hash so the same input doesn't repeatedly call the model."""
if not text or not model_config:
return None
text = text.strip()
if len(text) > 4000:
text = text[:4000]
cache_key = hashlib.sha256(text.encode('utf-8')).hexdigest()
if cache_key in _TARGET_SET_LLM_CACHE:
return _TARGET_SET_LLM_CACHE[cache_key]
# Tighten timeout for this short auxiliary call so SSE doesn't stall.
cfg = replace(model_config, timeout_seconds=min(20.0, model_config.timeout_seconds))
try:
from src.openai_compat import OpenAICompatClient, OpenAICompatError
except ImportError:
_TARGET_SET_LLM_CACHE[cache_key] = None
return None
client = OpenAICompatClient(cfg)
system_prompt = (
'你是一个文本抽取器。任务:从用户的训练触发消息里抽出"目标需求集合"的名称。\n'
'- 名称可能是 CSV 文件名、目录名、或一个标识词\n'
'- 只返回名称字符串本身,不要任何前后缀、引号、说明、标点\n'
'- 没有明确目标时返回大写 NONE'
)
few_shot = [
{'role': 'user', 'content': '开始, icl_test'},
{'role': 'assistant', 'content': 'icl_test'},
{'role': 'user', 'content': '开始,复杂导航过召专项0511.csv'},
{'role': 'assistant', 'content': '复杂导航过召专项0511.csv'},
{
'role': 'user',
'content': '我要进行模型训练,目标是icl_test中的复杂导航过召专项0511.csv,基模在/mnt/zhangzhaowen/icl/v2/test/',
},
{'role': 'assistant', 'content': '复杂导航过召专项0511.csv'},
{'role': 'user', 'content': '针对 dapan_test 跑一轮 SFT'},
{'role': 'assistant', 'content': 'dapan_test'},
{'role': 'user', 'content': '你好'},
{'role': 'assistant', 'content': 'NONE'},
]
messages: list[dict[str, Any]] = [{'role': 'system', 'content': system_prompt}]
messages.extend(few_shot)
messages.append({'role': 'user', 'content': text})
try:
result = client.complete(messages, tools=[])
except OpenAICompatError:
_TARGET_SET_LLM_CACHE[cache_key] = None
return None
except Exception: # noqa: BLE001 — keep KPI render robust to upstream LLM hiccups
_TARGET_SET_LLM_CACHE[cache_key] = None
return None
answer = (getattr(result, 'content', '') or '').strip().strip('"\'`,。()() ')
if not answer or answer.upper() == 'NONE' or len(answer) > 200:
_TARGET_SET_LLM_CACHE[cache_key] = None
return None
# Single-line: take only the first line in case model added explanation
answer = answer.split('\n', 1)[0].strip()
if not answer or answer.upper() == 'NONE':
_TARGET_SET_LLM_CACHE[cache_key] = None
return None
_TARGET_SET_LLM_CACHE[cache_key] = answer
return answer
def _parse_target_set(
trigger: str | None, model_config: ModelConfig | None = None
) -> str | None:
"""Sync, non-blocking. Reads cache only — never makes the LLM call inline.
Cache is filled by the watcher scanner background loop via
_refresh_target_set_cache_blocking (which is run in a thread).
Falls back to regex extraction of .csv filenames from trigger text."""
if not trigger:
return None
text = trigger.strip()
if not text:
return None
if len(text) > 4000:
text = text[:4000]
cache_key = hashlib.sha256(text.encode('utf-8')).hexdigest()
cached = _TARGET_SET_LLM_CACHE.get(cache_key)
if cached is not None:
return cached
# Regex fallback: extract .csv filename from trigger text.
m = re.search(r'(?:(?<=[的/,\s])|(?<=^))([^\s,。;的中是在]+\.csv)', text)
if m:
return m.group(1)
return None
def _refresh_target_set_cache_blocking(
model_config: ModelConfig | None, trigger: str
) -> None:
"""Synchronous wrapper that calls the LLM and populates the cache.
Designed to be invoked via asyncio.to_thread from a background task."""
if not model_config or not trigger:
return
text = trigger.strip()
if not text:
return
if len(text) > 4000:
text = text[:4000]
cache_key = hashlib.sha256(text.encode('utf-8')).hexdigest()
if cache_key in _TARGET_SET_LLM_CACHE:
return
_llm_extract_target_set(model_config, text)
_METRIC_DIFF_CACHE: dict[int, dict[str, Any]] = {}
_METRIC_DIFF_NEG_RETRY_AT: dict[int, float] = {}
_METRIC_DIFF_NEG_TTL_SECONDS = 30.0
def _read_workflow_metric_diff(
run_id: int,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
session_id: str | None = None,
) -> dict[str, Any] | None:
"""Read workflow<run_id>/metric_diff/lark_template.json.
metric_diff lives on /mnt/xiaoai-zk-model-train-tj5/... which is mounted on
the remote jupyter workspace but typically not on the backend host. So we
try local first, then fall back to `cat` over the session's jupyter runtime
if available. lark_template.json is written once by cml workflow and never
changes, so successful reads are cached forever; misses honor a 30s
negative cache to avoid hammering the remote during the cml-running phase.
"""
cached = _METRIC_DIFF_CACHE.get(run_id)
if cached is not None:
return cached
now = time.monotonic()
next_retry = _METRIC_DIFF_NEG_RETRY_AT.get(run_id)
if next_retry is not None and now < next_retry:
return None
relative = f'workflow{run_id}/metric_diff/lark_template.json'
local_path = _run_history_dir() / relative
try:
if local_path.is_file():
data = json.loads(local_path.read_text(encoding='utf-8'))
if isinstance(data, dict):
_METRIC_DIFF_CACHE[run_id] = data
_METRIC_DIFF_NEG_RETRY_AT.pop(run_id, None)
return data
except (OSError, json.JSONDecodeError):
pass
if agent_state is not None and session_id is not None:
runtime = _jupyter_runtime_for_session(agent_state, account_id, session_id)
if runtime is not None:
remote_path = f'{_run_history_dir()}/{relative}'
cmd = (
f'test -f {shlex.quote(remote_path)} '
f'&& cat {shlex.quote(remote_path)} || echo __MISSING__'
)
try:
result = runtime.run_command(
cmd,
timeout_seconds=10.0,
max_output_chars=200_000,
)
except Exception: # noqa: BLE001
_METRIC_DIFF_NEG_RETRY_AT[run_id] = now + _METRIC_DIFF_NEG_TTL_SECONDS
return None
stdout = (result.stdout or '').strip() if result is not None else ''
if (
result is not None
and result.exit_code == 0
and stdout
and stdout != '__MISSING__'
):
try:
data = json.loads(stdout)
if isinstance(data, dict):
_METRIC_DIFF_CACHE[run_id] = data
_METRIC_DIFF_NEG_RETRY_AT.pop(run_id, None)
return data
except json.JSONDecodeError:
pass
_METRIC_DIFF_NEG_RETRY_AT[run_id] = now + _METRIC_DIFF_NEG_TTL_SECONDS
return None
def _format_metric_value(metrics: dict[str, Any] | None, key: str) -> str:
if not metrics:
return ''
value = metrics.get(key)
if value is None:
return ''
if isinstance(value, (int, float)):
if 0 <= value <= 1:
return f'{value * 100:.2f}%'
return f'{value:.2f}'
return str(value)
# lark_template.json 的指标都嵌在 eval_output_info 文本里,行格式:
# 🟢 specific_test || icl_test: 86.11% (1873/2175) -> 86.11% (1873/2175) (+0.00%)
# 我们要 "->" 后面那个值(after-migration pass rate)。
_EVAL_LINE_RE = re.compile(
r'^\s*[🟢🔴]\s*(?P<path>.+?):\s*[\d.]+%\s*\([^)]+\)\s*->\s*(?P<post>[\d.]+)%'
)
def _extract_eval_metric(eval_output_info: str | None, target_path: str) -> float | None:
"""Find the line whose 'path' (the bit between emoji and ":") equals
target_path, return the post-value as a 0-100 float, or None.
Match is exact-equality first, then falls back to path-tail equality —
eval lines often carry full relative paths like
`specific_test || data/specific_test_set/icl_test/<name>.csv`
while target_path is just `specific_test || <name>.csv`."""
if not isinstance(eval_output_info, str) or not eval_output_info:
return None
target = target_path.strip()
# Strip the optional `<prefix> || ` so we can suffix-compare just the path tail.
target_tail = target.split('||', 1)[-1].strip() if '||' in target else target
for line in eval_output_info.splitlines():
m = _EVAL_LINE_RE.match(line)
if not m:
continue
path = m.group('path').strip()
path_tail = path.split('||', 1)[-1].strip() if '||' in path else path
matched = path == target or path_tail == target_tail or path_tail.endswith(
f'/{target_tail}'
)
if matched:
try:
return float(m.group('post'))
except ValueError:
return None
return None
def _enrich_metrics_from_eval_output(
metrics: dict[str, Any] | None, target_set: str | None
) -> None:
"""Backfill structured pass-rate keys parsed out of eval_output_info text.
Mutates metrics in place. No-op if metrics is None or already has the keys."""
if not metrics:
return
eval_text = metrics.get('eval_output_info')
if not isinstance(eval_text, str):
return
# _format_metric_value 把 0..1 当作 fraction 格式化为 XX.XX%,所以这里 /100 入库。
# specific_test 大盘
if metrics.get('specific_test_pass_rate') is None:
v = _extract_eval_metric(eval_text, 'specific_test')
if v is not None:
metrics['specific_test_pass_rate'] = v / 100.0
# 大盘 overrall || 车载(注意 lark template 里就是 "overrall" 拼写,不是 "overall"
if metrics.get('overall_car_pass_rate') is None:
v = _extract_eval_metric(eval_text, 'overrall || 车载')
if v is not None:
metrics['overall_car_pass_rate'] = v / 100.0
# target set: specific_test || <name>
# Trigger 里带 .csv 后缀;lark template 偶尔写不带后缀的。两种都试。
if (
metrics.get('target_set_pass_rate') is None
and target_set
and target_set != ''
):
candidates = [target_set]
if target_set.endswith('.csv'):
candidates.append(target_set[:-4])
else:
candidates.append(f'{target_set}.csv')
for name in candidates:
v = _extract_eval_metric(eval_text, f'specific_test || {name}')
if v is not None:
metrics['target_set_pass_rate'] = v / 100.0
break
def _compute_step0_start_seconds(
state_entries: list[dict[str, Any]],
) -> float | None:
"""Earliest cml running ts seen in state file."""
earliest: float | None = None
for entry in state_entries:
if entry.get('step') != 'cml':
continue
if entry.get('status') != 'running':
continue
ts = entry.get('ts')
if not isinstance(ts, str):
continue
try:
from datetime import datetime
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
seconds = dt.timestamp()
except (ValueError, OSError):
continue
if earliest is None or seconds < earliest:
earliest = seconds
return earliest
def _format_elapsed(start_seconds: float | None) -> str:
if start_seconds is None:
return '00:00:00'
delta = max(0.0, time.time() - start_seconds)
total = int(delta)
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
return f'{h:02d}:{m:02d}:{s:02d}'
def _compute_kpis(
sessions_dir: Path,
session_id: str | None,
state_entries: list[dict[str, Any]],
model_config: ModelConfig | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
iteration_log_count: int | None = None,
metrics_history: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Compute the 7 top KPIs from authoritative sources. Falls back to ''.
RUN ID prefers the runDic this session has already written into
program-state.jsonl — that's the only source guaranteed to belong to THIS
chat. Falls back to a global scan of RUN_HISTORY_DIR for sessions that
haven't logged a runDic yet (e.g. baseline pre-cml). Without this priority,
a concurrently-running chat that allocated a larger runDic on the shared
/mnt/xiaoai-zk-model-train-tj5/workflow5/ would shadow this chat's value.
Metric reads use the same jupyter runtime the watcher uses, so /mnt/* paths
reachable only on the remote workspace still work.
"""
run_max = _resolve_run_dic_from_state(state_entries)
if run_max is None:
run_max = _scan_run_history_max()
run_id_value = str(run_max) if run_max is not None else ''
version = _read_workflow_version() or ''
trigger = _extract_trigger_from_state(state_entries) or _extract_trigger_from_session(
sessions_dir, session_id
)
target_set = _parse_target_set(trigger, model_config=model_config) or ''
metrics = (
_read_workflow_metric_diff(
run_max,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
if run_max is not None
else None
)
# Fallback: max runDic 可能命中 R{n} retry 轮(cml 还在 runninglark_template
# 没产出),让 KPI 全部空白。这时回退到 state 里最新一个 `cml=complete` 的
# 那一轮的 runDic — baseline / 上一轮成功 eval 的指标至少能显示出来。
if metrics is None:
per_round = _resolve_run_dic_per_round(state_entries)
for entry in reversed(state_entries):
if entry.get('step') != 'cml' or entry.get('status') != 'complete':
continue
rid = entry.get('run_id')
if not isinstance(rid, str):
continue
fallback_run = per_round.get(rid.strip())
if fallback_run is None or fallback_run == run_max:
continue
metrics = _read_workflow_metric_diff(
fallback_run,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
if metrics is not None:
run_max = fallback_run
run_id_value = str(fallback_run)
break
_enrich_metrics_from_eval_output(metrics, target_set)
target_metric = _format_metric_value(metrics, 'target_set_pass_rate')
overall_metric = _format_metric_value(metrics, 'overall_car_pass_rate')
specific_metric = _format_metric_value(metrics, 'specific_test_pass_rate')
# Round number = MAX numeric `iteration` field across iteration_log.jsonl
# entries, NOT the line count. iteration_log.jsonl carries multiple rows
# per round (separate hypothesis-only / results / final-summary entries),
# so line count overcounts. Falls back to line-count for backwards
# compatibility when metrics_history isn't available.
max_iteration: int | None = None
if metrics_history and isinstance(metrics_history.get('rounds'), list):
for r in metrics_history['rounds']:
it = r.get('iteration') if isinstance(r, dict) else None
if isinstance(it, int):
if max_iteration is None or it > max_iteration:
max_iteration = it
if max_iteration is not None:
iteration = 'R0-baseline' if max_iteration == 0 else f'R{max_iteration}'
else:
if iteration_log_count is not None:
iter_count = iteration_log_count
else:
iter_count = _read_iteration_log_count(
session_id,
agent_state=agent_state,
account_id=account_id,
)
if iter_count is None or iter_count == 0:
iteration = 'R0-baseline'
else:
iteration = f'R{iter_count}'
elapsed = _format_elapsed(_compute_step0_start_seconds(state_entries))
target_value = (
f'{target_set} · {target_metric}' if target_metric != '' else target_set
)
return [
{'label': 'RUN ID', 'value': run_id_value},
{'label': 'VERSION', 'value': version},
{'label': '目标集合', 'value': target_value},
{'label': '大盘车载', 'value': overall_metric},
{'label': 'SPECIFIC TEST', 'value': specific_metric},
{'label': 'ITERATION', 'value': iteration},
{'label': '耗时', 'value': elapsed, 'icon': 'clock'},
]
def _resolve_run_dic_from_state(
state_entries: list[dict[str, Any]],
) -> int | None:
"""Extract runDic from program-state.jsonl.
Priority:
1. Explicit ``runDic`` field on any entry (latest wins).
2. Fallback: scan string fields recursively for ``workflow<N>`` /
``runDic=N`` and pick the **max** match. Path strings often contain
both the global mount root (``/mnt/.../workflow5/...``) and the actual
run number (``workflow17793/...``) — max picks the run number, not the
fixed mount-dir digit.
"""
workflow_re = re.compile(r'workflow(\d+)')
rundic_re = re.compile(r'runDic\s*[=:取]?\s*(\d+)')
def _scan_strings(obj: Any) -> int | None:
if isinstance(obj, str):
candidates = [int(x) for x in workflow_re.findall(obj)]
candidates += [int(x) for x in rundic_re.findall(obj)]
return max(candidates) if candidates else None
if isinstance(obj, dict):
best: int | None = None
for v in obj.values():
hit = _scan_strings(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
if isinstance(obj, list):
best = None
for v in obj:
hit = _scan_strings(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
return None
# 1. Explicit field — latest wins.
for entry in reversed(state_entries):
run_dic = entry.get('runDic') or entry.get('run_dic') or entry.get('rundic')
if isinstance(run_dic, int):
return run_dic
if isinstance(run_dic, str) and run_dic.isdigit():
return int(run_dic)
# 2. Fallback string scan — take max across all entries.
best: int | None = None
for entry in state_entries:
hit = _scan_strings(entry)
if hit is not None and (best is None or hit > best):
best = hit
return best
def _resolve_run_dic_per_round(
state_entries: list[dict[str, Any]],
) -> dict[str, int]:
"""Map each run_id (e.g., 'R2') to the runDic that round actually used.
Same priority as ``_resolve_run_dic_from_state`` but bucketed per run_id
so per-round step-detail views read the right artifact directory instead
of always using the latest workflow id seen anywhere in state."""
workflow_re = re.compile(r'workflow(\d+)')
rundic_re = re.compile(r'runDic\s*[=:取]?\s*(\d+)')
def _scan(obj: Any) -> int | None:
if isinstance(obj, str):
cands = [int(x) for x in workflow_re.findall(obj)]
cands += [int(x) for x in rundic_re.findall(obj)]
return max(cands) if cands else None
if isinstance(obj, dict):
best: int | None = None
for v in obj.values():
hit = _scan(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
if isinstance(obj, list):
best = None
for v in obj:
hit = _scan(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
return None
def _entry_run_id(entry: dict[str, Any]) -> str | None:
# 顶层 run_id 优先;否则从 watch.run_id / log.iter 兜底,让 watcher/log
# 这种 agent 不写顶层 run_id 但嵌套里带轮次信号的条目也能贡献 runDic。
rid = entry.get('run_id')
if isinstance(rid, str) and rid.strip():
return rid.strip()
watch = entry.get('watch')
if isinstance(watch, dict):
wrid = watch.get('run_id')
if isinstance(wrid, str) and wrid.strip():
return wrid.strip()
log = entry.get('log')
if isinstance(log, dict):
lit = log.get('iter')
if isinstance(lit, str) and lit.strip():
return lit.strip()
return None
explicit: dict[str, int] = {}
fallback: dict[str, int] = {}
for entry in state_entries:
run_id = _entry_run_id(entry)
if not run_id:
continue
rd = entry.get('runDic') or entry.get('run_dic') or entry.get('rundic')
if isinstance(rd, str) and rd.isdigit():
rd = int(rd)
if isinstance(rd, int):
explicit[run_id] = rd # latest wins
continue
hit = _scan(entry)
if hit is not None:
cur = fallback.get(run_id)
if cur is None or hit > cur:
fallback[run_id] = hit
out = dict(fallback)
out.update(explicit)
return out
def _step_artifact_paths(
step: str,
run_dic: int | None,
runtime: 'JupyterRuntimeSession | None' = None,
) -> list[str]:
"""Map step key → ordered list of remote artifact paths to try.
Per-chat artifacts (results/, ai-planning/, output/) live under the chat
workspace root on shared NFS at
`/mnt/<account>/autoresearch-zk-users/<session>/`. Reads still
go through the bound jupyter runtime since the path is only mounted there.
metric_diff (cml step) stays on the global RUN_HISTORY_DIR mount that is
shared across chats. When no runtime is bound, falls back to the legacy
global AUTORESEARCH_ROOT so KPI fetches don't crash on unbound sessions.
"""
chat_root = _chat_root_for_runtime(runtime) or str(_autoresearch_root())
metric_root = os.environ.get(
'RUN_HISTORY_DIR', '/mnt/xiaoai-zk-model-train-tj5/workflow5'
)
paths: list[str] = []
if step == 'cml':
if run_dic is not None:
paths.append(
f'{metric_root}/workflow{run_dic}/metric_diff/lark_template.json'
)
paths.append(
f'{metric_root}/workflow{run_dic}/metric_diff/specific_comparison.csv'
)
elif step == 'gold-drift':
if run_dic is not None:
paths.append(f'{chat_root}/results/gold_drift/drift_{run_dic}.json')
elif step == 'dist-analysis':
if run_dic is not None:
paths.append(f'{chat_root}/results/workflow{run_dic}.md')
paths.append(
f'{chat_root}/output/relabel_candidates_{run_dic}.csv'
)
# 后向兼容:旧 agent 把 csv 写到 jupyter pod 本地 $(pwd)/output/
if runtime is not None:
paths.append(
f'{runtime.binding.workspace_cwd}/output/relabel_candidates_{run_dic}.csv'
)
elif step == 'hypothesis':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
elif step == 'augment':
if run_dic is not None:
paths.append(
f'{chat_root}/results/data_clean_{run_dic}/modified_samples.jsonl'
)
paths.append(
f'{chat_root}/results/augment_raw/augment_{run_dic}_raw.jsonl'
)
paths.append(
f'{chat_root}/ai-planning/data/train_set/zk_intent/augment_{run_dic}.jsonl'
)
paths.append(
f'{chat_root}/results/data_clean_{run_dic}/label_master_review.jsonl'
)
elif step == 'verify':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
elif step == 'sft':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
elif step == 'log':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
paths.append(f'{chat_root}/results/error_registry.jsonl')
elif step == 'next-round':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
return paths
def _csv_to_table(
text: str, max_rows: int | None
) -> tuple[list[str], list[list[str]], int]:
"""Parse CSV text into (columns, rows, total). Strips BOM, handles
quoted multi-line cells. ``max_rows=None`` means no row cap."""
if text.startswith(''):
text = text[1:]
import io as _io
reader = csv.reader(_io.StringIO(text))
columns: list[str] = []
rows: list[list[str]] = []
total = 0
for i, record in enumerate(reader):
if i == 0:
columns = [c.strip() for c in record]
continue
# 跳过整行全空的视觉分隔行(agent 写人审 CSV 时常插空行)
if not any(cell.strip() for cell in record):
continue
total += 1
if max_rows is None or len(rows) < max_rows:
row = [_truncate_cell(c) for c in record]
if len(row) < len(columns):
row += [''] * (len(columns) - len(row))
elif len(row) > len(columns):
row = row[: len(columns)]
rows.append(row)
return columns, rows, total
def _jsonl_to_table(
text: str, max_rows: int | None
) -> tuple[list[str], list[list[str]], int]:
"""Parse jsonl into (columns, rows, total). Column order is the order
keys appear in the first record, with new keys appended.
``max_rows=None`` means no row cap."""
columns: list[str] = []
seen: set[str] = set()
sample_rows: list[dict[str, Any]] = []
total = 0
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
try:
obj = json.loads(stripped)
except json.JSONDecodeError:
continue
if not isinstance(obj, dict):
continue
total += 1
for k in obj.keys():
if k not in seen:
seen.add(k)
columns.append(k)
if max_rows is None or len(sample_rows) < max_rows:
sample_rows.append(obj)
rows: list[list[str]] = []
for obj in sample_rows:
row: list[str] = []
for col in columns:
val = obj.get(col)
if val is None:
row.append('')
elif isinstance(val, str):
row.append(_truncate_cell(val))
elif isinstance(val, (int, float, bool)):
row.append(str(val))
else:
row.append(_truncate_cell(json.dumps(val, ensure_ascii=False)))
rows.append(row)
return columns, rows, total
def _truncate_cell(text: str, limit: int = 4000) -> str:
if len(text) <= limit:
return text
return text[:limit] + f'…(共 {len(text)} 字,已截断)'
def _detect_artifact_format(path: str) -> str:
lower = path.lower()
if lower.endswith('.md'):
return 'markdown'
if lower.endswith('.jsonl'):
return 'jsonl'
if lower.endswith('.json'):
return 'json'
if lower.endswith('.csv'):
return 'csv'
return 'text'
def _read_artifact(
runtime: 'JupyterRuntimeSession | None', remote_path: str
) -> tuple[str | None, str | None]:
"""Read a remote artifact via Jupyter `cat`. Falls back to local Path read
if no remote runtime (dev / unbound sessions). Returns (content, error)."""
if runtime is not None:
cmd = (
f'test -f {shlex.quote(remote_path)} && '
f'cat {shlex.quote(remote_path)} || echo __MISSING__'
)
try:
result = runtime.run_command(
cmd,
timeout_seconds=15.0,
max_output_chars=400_000,
)
except Exception as exc: # noqa: BLE001
return None, f'remote read failed: {exc}'
if not result:
return None, 'no result'
out = result.stdout or ''
# Trim our own marker if file missing
if out.strip().endswith('__MISSING__'):
return None, f'remote file not found: {remote_path}'
return out, None
try:
p = Path(remote_path)
if not p.is_file():
return None, f'file not found: {remote_path}'
return p.read_text(encoding='utf-8', errors='replace'), None
except OSError as exc:
return None, f'local read failed: {exc}'
def _hardcoded_autoresearch_pipeline(
session_id: str | None,
sessions_dir: Path | None = None,
state_entries: list[dict[str, Any]] | None = None,
model_config: ModelConfig | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
iteration_log_count: int | None = None,
metrics_history: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Canonical autoresearch model-iteration pipeline structure.
Card / phase / log dynamics still come from program-state.jsonl, but the
7 top KPIs are now backend-owned (read from skills/config.yaml,
iteration_log, run history dir, CML metric_diff, session state) — agent
cannot override them via state file.
"""
now_ms = int(time.time() * 1000)
if sessions_dir is not None:
kpis = _compute_kpis(
sessions_dir,
session_id,
state_entries or [],
model_config=model_config,
agent_state=agent_state,
account_id=account_id,
iteration_log_count=iteration_log_count,
metrics_history=metrics_history,
)
else:
# Defensive default if caller didn't pass sessions_dir.
kpis = [
{'label': 'RUN ID', 'value': ''},
{'label': 'VERSION', 'value': ''},
{'label': '目标集合', 'value': ''},
{'label': '大盘车载', 'value': ''},
{'label': 'SPECIFIC TEST', 'value': ''},
{'label': 'ITERATION', 'value': 'R0-baseline'},
{'label': '耗时', 'value': '00:00:00', 'icon': 'clock'},
]
return {
'session_id': session_id or '',
'generated_at_ms': now_ms,
'available': True,
'status': {'mode': 'Model Iteration', 'state': 'pending'},
'kpis': kpis,
# items[] is heterogeneous: {type:'round',...} for R0·Baseline /
# R{n}·Train / R{n}·Analysis, and {type:'gate',...} for HiTL gates.
# Built dynamically from program-state.jsonl by _build_pipeline_items —
# sections only appear when the agent has touched at least one step
# in them.
'items': [],
'in_flight': None,
'logs': [],
'metrics_history': metrics_history,
}
def _session_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _session_id_from_path(path: Path) -> str:
if path.name == 'session.json':
return path.parent.name
return path.stem
def _safe_session_id(session_id: str | None) -> str | None:
if not session_id:
return None
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', session_id.strip())
return normalized[:120] or None