98b3e586de
- Replace thread-based remote state sync with subprocess (sync_remote_state.py) to prevent thread pool exhaustion hanging the API - Fix pipeline showing 'waiting' when a real step is running alongside a gate - Fix watcher scanner path for linux accounts mode - Disable label-master semantic review (Step A.5 + §4.5) per user request - Correct field names: use is_model_correct_dev for accuracy, origin_predict_dev for model output (not cleaned_predict) - Add prohibition against putting test set data in relabel_candidates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8314 lines
309 KiB
Python
8314 lines
309 KiB
Python
"""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
|
||
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
|
||
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 AgentMessage, 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,
|
||
deserialize_runtime_config,
|
||
load_agent_session,
|
||
save_agent_session,
|
||
serialize_model_config,
|
||
serialize_runtime_config,
|
||
)
|
||
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
|
||
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 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 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) -> dict[str, Any]:
|
||
_require_admin_token(token)
|
||
return _build_admin_summary(state)
|
||
|
||
@app.get('/api/admin/accounts')
|
||
async def admin_accounts(token: str) -> list[dict[str, Any]]:
|
||
_require_admin_token(token)
|
||
return _list_admin_accounts(state)
|
||
|
||
@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) -> list[dict[str, Any]]:
|
||
directory = state.account_paths(account_id)['sessions']
|
||
if not directory.exists():
|
||
return []
|
||
results: list[dict[str, Any]] = []
|
||
for path in sorted(_iter_session_files(directory), key=_session_mtime, reverse=True):
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
continue
|
||
session_id = str(data.get('session_id', _session_id_from_path(path)))
|
||
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
|
||
model_config = (
|
||
data.get('model_config')
|
||
if isinstance(data.get('model_config'), dict)
|
||
else {}
|
||
)
|
||
messages = data.get('messages') or []
|
||
title = data.get('title')
|
||
preview = ''
|
||
for msg in messages:
|
||
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': data.get('turns', 0),
|
||
'tool_calls': data.get('tool_calls', 0),
|
||
'preview': title if isinstance(title, str) and title.strip() else preview,
|
||
'modified_at': _session_mtime(path),
|
||
'model': model_config.get('model'),
|
||
'usage': usage,
|
||
'is_training': bool(data.get('is_training', False)),
|
||
}
|
||
)
|
||
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.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_session_files(directory, safe_id):
|
||
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_id(R7=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'},
|
||
)
|
||
|
||
# 多分区表格视图:augment(H1 修改 / 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 仿写候选 raw(GPT 原始输出,未过 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))
|
||
stored_cancelled = state.run_state_store.finish_active_for_session(
|
||
account_key,
|
||
safe_id,
|
||
status='cancelled',
|
||
stage='用户已取消',
|
||
)
|
||
cancelled_ids.update(stored_cancelled)
|
||
cancelled = bool(cancelled_ids)
|
||
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),
|
||
}
|
||
|
||
def _run_chat_payload(
|
||
request: ChatRequest,
|
||
event_sink: Any | 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()
|
||
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)
|
||
session_directory = state.account_paths(request.account_id)['sessions']
|
||
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
|
||
|
||
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.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',
|
||
)
|
||
return payload
|
||
|
||
# ------------- chat ------------------------------------------------------
|
||
@app.post('/api/chat')
|
||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||
prompt = request.prompt.strip()
|
||
if not prompt:
|
||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||
|
||
def _run() -> dict[str, Any]:
|
||
return _run_chat_payload(request)
|
||
|
||
try:
|
||
payload = await asyncio.to_thread(_run)
|
||
except HTTPException:
|
||
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/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 _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]:
|
||
return {
|
||
'session_id': stored.session_id,
|
||
'turns': stored.turns,
|
||
'tool_calls': stored.tool_calls,
|
||
'messages': [_normalize_transcript_entry(dict(m)) for m in stored.messages],
|
||
'usage': stored.usage,
|
||
'total_cost_usd': stored.total_cost_usd,
|
||
'model': stored.model_config.get('model'),
|
||
'is_training': stored.is_training,
|
||
}
|
||
|
||
|
||
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)
|
||
budget_state = (
|
||
dict(stored.budget_state)
|
||
if isinstance(stored.budget_state, dict)
|
||
else {}
|
||
)
|
||
if not changed and budget_state.get('status') not in {'queued', 'running'}:
|
||
return
|
||
messages = list(trimmed)
|
||
if not _last_message_is_run_status(messages, status):
|
||
messages.append(
|
||
{
|
||
'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),
|
||
},
|
||
}
|
||
)
|
||
budget_state['status'] = status
|
||
budget_state['interrupted_at'] = int(time.time())
|
||
save_agent_session(
|
||
replace(
|
||
stored,
|
||
messages=tuple(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
|
||
if not 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,
|
||
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)
|
||
if messages and _is_pending_user_message(dict(messages[-1])):
|
||
messages.pop()
|
||
messages.append(
|
||
{
|
||
'role': 'user',
|
||
'content': prompt,
|
||
'state': 'final',
|
||
'metadata': pending_metadata,
|
||
'message_id': f'user_pending_{int(time.time() * 1000)}',
|
||
}
|
||
)
|
||
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),
|
||
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',
|
||
)
|
||
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.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),
|
||
)
|
||
saved_path = save_agent_session(stored, directory=directory)
|
||
if initial_title:
|
||
try:
|
||
data = json.loads(saved_path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return initial_title
|
||
data['title'] = initial_title
|
||
data['title_source'] = 'first_message'
|
||
data['title_generated_at'] = int(time.time())
|
||
_write_session_metadata(saved_path, data)
|
||
return initial_title
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
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(
|
||
system_prompt_parts=stored.system_prompt_parts,
|
||
user_context=stored.user_context,
|
||
system_context=stored.system_context,
|
||
messages=[
|
||
AgentMessage.from_openai_message(message)
|
||
for message in stored.messages
|
||
if isinstance(message, dict)
|
||
],
|
||
)
|
||
|
||
|
||
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:
|
||
path = _session_json_path(directory, session_id)
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return
|
||
messages = data.get('messages')
|
||
if not isinstance(messages, list):
|
||
return
|
||
for message in reversed(messages):
|
||
if not isinstance(message, dict) or message.get('role') != 'assistant':
|
||
continue
|
||
metadata = message.get('metadata')
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
message['metadata'] = metadata
|
||
metadata['elapsed_ms'] = elapsed_ms
|
||
try:
|
||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
except OSError:
|
||
return
|
||
return
|
||
|
||
|
||
def _read_session_title(directory: Path, session_id: str | None) -> str | None:
|
||
if not session_id:
|
||
return None
|
||
path = _session_json_path(directory, session_id)
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return None
|
||
title = data.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:
|
||
path = _session_json_path(directory, session_id)
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return
|
||
if previous_title:
|
||
data['title'] = previous_title
|
||
_write_session_metadata(path, data)
|
||
return
|
||
title = data.get('title')
|
||
title_source = data.get('title_source')
|
||
if (
|
||
isinstance(title, str)
|
||
and title.strip()
|
||
and title_source != 'first_message'
|
||
):
|
||
return
|
||
messages = data.get('messages')
|
||
if not isinstance(messages, list):
|
||
if fallback_title and not (isinstance(title, str) and title.strip()):
|
||
data['title'] = fallback_title
|
||
data['title_source'] = 'first_message'
|
||
data['title_generated_at'] = int(time.time())
|
||
_write_session_metadata(path, data)
|
||
return
|
||
user_messages = _session_user_messages(messages)
|
||
if not user_messages:
|
||
if fallback_title and not (isinstance(title, str) and title.strip()):
|
||
data['title'] = fallback_title
|
||
data['title_source'] = 'first_message'
|
||
data['title_generated_at'] = int(time.time())
|
||
_write_session_metadata(path, data)
|
||
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()):
|
||
data['title'] = fallback_title
|
||
data['title_source'] = 'first_message'
|
||
data['title_generated_at'] = int(time.time())
|
||
_write_session_metadata(path, data)
|
||
return
|
||
data['title'] = generated
|
||
data['title_source'] = 'llm'
|
||
data['title_generated_at'] = int(time.time())
|
||
_write_session_metadata(path, data)
|
||
|
||
|
||
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}'
|
||
|
||
|
||
def _build_admin_summary(state: AgentState) -> dict[str, Any]:
|
||
accounts = _list_admin_accounts(state)
|
||
totals = {
|
||
'accounts': len(accounts),
|
||
'sessions': sum(item['session_count'] for item in accounts),
|
||
'tool_calls': sum(item['tool_calls'] for item in accounts),
|
||
'tokens': sum(item['total_tokens'] for item in accounts),
|
||
}
|
||
return {
|
||
'totals': totals,
|
||
'memory_queue': state.memory_manager.queue_snapshot(),
|
||
'accounts': accounts[:20],
|
||
}
|
||
|
||
|
||
def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
|
||
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()
|
||
)
|
||
account_ids = sorted({item for item in known_users | directory_users if item})
|
||
result: list[dict[str, Any]] = []
|
||
for account_id in account_ids:
|
||
base = accounts_root / account_id
|
||
sessions_dir = base / 'sessions'
|
||
session_files = _iter_session_files(sessions_dir)
|
||
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] = {}
|
||
for path in session_files:
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
continue
|
||
session_count += 1
|
||
tool_calls += int(data.get('tool_calls') or 0)
|
||
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
|
||
input_tokens += int(usage.get('input_tokens') or 0)
|
||
output_tokens += int(usage.get('output_tokens') or 0)
|
||
reasoning_tokens += int(usage.get('reasoning_tokens') or 0)
|
||
total_tokens += int(
|
||
usage.get('total_tokens')
|
||
or (usage.get('input_tokens') or 0) + (usage.get('output_tokens') or 0)
|
||
)
|
||
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, _session_mtime(path))
|
||
memory_dir = base / 'memory'
|
||
result.append(
|
||
{
|
||
'account_id': account_id,
|
||
'registered': account_id in known_users,
|
||
'session_count': 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,
|
||
'user_memory_lines': _count_optional_lines(memory_dir / USER_MEMORY_FILENAME),
|
||
'skill_memory_count': len(list((memory_dir / SKILL_MEMORY_DIRNAME).glob('*.md')))
|
||
if (memory_dir / SKILL_MEMORY_DIRNAME).exists()
|
||
else 0,
|
||
}
|
||
)
|
||
return sorted(result, key=lambda item: item['latest_session_at'], reverse=True)
|
||
|
||
|
||
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:
|
||
path = _session_json_path(directory, session_id)
|
||
if not path.exists():
|
||
return False
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return False
|
||
data['title'] = title
|
||
data['title_source'] = 'manual'
|
||
data['title_updated_at'] = int(time.time())
|
||
_write_session_metadata(path, data)
|
||
return True
|
||
|
||
|
||
def _update_session_training(directory: Path, session_id: str, is_training: bool) -> bool:
|
||
path = _session_json_path(directory, session_id)
|
||
if not path.exists():
|
||
return False
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return False
|
||
data['is_training'] = is_training
|
||
_write_session_metadata(path, data)
|
||
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=running,UI 会从 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
|
||
|
||
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'kill -- -$(ps -o pgid= -p {pid} 2>/dev/null '
|
||
f'| tr -d " ") 2>/dev/null; '
|
||
f'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
|
||
|
||
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('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('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 还在 running,lark_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
|