重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -15,6 +15,7 @@ from fastapi import APIRouter, Body, File, Header, HTTPException, Query, UploadF
from fastapi.responses import Response, StreamingResponse
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
from oclaw.runtime.agents.specialists import SPECIALIST_IDS
from oclaw.runtime.agents.factory import build_gateway_executor
from oclaw.runtime.chat.agent import GenerationInterrupted
from oclaw.platform.config.paths import db_path
@ -82,15 +83,15 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
"en": "Specialist executor factory failed",
},
"manager_route_missing": {
"zh": "总控路由缺失",
"zh": "全能者路由缺失",
"en": "Manager route missing",
},
"manager_select_failed": {
"zh": "总控决策失败",
"zh": "全能者决策失败",
"en": "Manager selection failed",
},
"manager_model_missing": {
"zh": "总控模型不可用",
"zh": "全能者模型不可用",
"en": "Manager model missing",
},
"dynamic_agent_selected": {
@ -100,7 +101,7 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
}
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
_CHAT_SPECIALIST_IDS: tuple[str, ...] = ("generalist", "ops", "image", "memory_curator")
_CHAT_SPECIALIST_IDS: tuple[str, ...] = tuple(str(x) for x in SPECIALIST_IDS if str(x).strip())
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
@ -1247,6 +1248,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
if not sess:
raise HTTPException(status_code=404, detail="session_not_found")
text_raw = str(payload.get("text") or "").strip()
idempotency_key = str(payload.get("idempotency_key") or payload.get("idempotencyKey") or "").strip()
attachments = _parse_attachments_payload(payload.get("attachments"))
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
memory_mode = _normalize_memory_mode(payload)
@ -1311,6 +1313,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
msg=msg,
lang=lang,
executor=manager_agent,
run_id=(idempotency_key or None),
specialist_executor_factory=specialist_factory,
)
reply = gw_result.reply_text

View file

@ -4,6 +4,7 @@ from pathlib import Path
import hashlib
import hmac
import json
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
@ -14,13 +15,20 @@ from fastapi.responses import HTMLResponse
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
from oclaw.runtime.operations.providers.registry import build_channel_registry
from oclaw.runtime.operations.runtime import cleanup_orphan_service_processes, detect_orphan_service_processes, status_services
from oclaw.runtime.operations.runtime import (
cleanup_service_processes_by_pid,
detect_orphan_service_processes,
is_pid_running,
list_listen_ports_for_pid,
list_service_process_pids,
status_services,
)
from oclaw.runtime.operations.stack import cmd_stack_down, cmd_stack_status, cmd_stack_up
from oclaw.runtime.orchestration.vector_store import read_vector_memory_runtime
from oclaw.platform.config.paths import PROJECT_ROOT, db_path
from oclaw.platform.config.passwords import load_expected_password
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.agents.specialists import SPECIALISTS
from oclaw.runtime.agents.specialists import discover_specialist_ids
from oclaw.runtime.tools.mcp.installer import (
_safe_server_id,
detect_local_dependencies,
@ -44,6 +52,8 @@ from oclaw.runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
from oclaw.platform.llm.chat_models import LLMToolCall
from oclaw.runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES
from oclaw.runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes
from oclaw.runtime.prompt_prebuild import run_runtime_prewarm, runtime_prewarm_prompts_snapshot, runtime_prewarm_status
from oclaw.runtime.workspaces.experts import create_expert, delete_expert, list_experts, normalize_expert_id, update_expert_files
_WECOM_CLEAR_KEYS = [
"wecom_mode",
@ -230,7 +240,7 @@ def build_admin_router() -> APIRouter:
raise HTTPException(status_code=403, detail="cross_tenant_forbidden")
def _ordered_specialists() -> list[str]:
base = [str(k).strip().lower() for k in SPECIALISTS.keys() if str(k).strip()]
base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()]
preferred = [x for x in ("generalist", "ops", "image") if x in set(base)]
return preferred + [x for x in base if x not in set(preferred)]
@ -243,6 +253,30 @@ def build_admin_router() -> APIRouter:
specs = _ordered_specialists()
return ["manager", *[x for x in specs if x != "manager"]]
_EXPERT_ALLOWED_FILES = {"SOUL.md", "ROLE_SYSTEM.md"}
def _serialize_expert_row(row: dict[str, Any]) -> dict[str, Any]:
files = row.get("files") if isinstance(row, dict) else {}
obj = files if isinstance(files, dict) else {}
return {
"id": str(row.get("id") or ""),
"builtin": bool(row.get("builtin")),
"has_required_soul": bool(row.get("has_required_soul")),
"path": str(row.get("path") or ""),
"files": {
"SOUL.md": str(obj.get("SOUL.md") or ""),
"ROLE_SYSTEM.md": str(obj.get("ROLE_SYSTEM.md") or ""),
},
}
def _sanitize_expert_files_payload(raw: Any) -> dict[str, str]:
files_raw = raw if isinstance(raw, dict) else {}
files = {str(k): str(v or "") for k, v in files_raw.items()}
bad = [k for k in files.keys() if k not in _EXPERT_ALLOWED_FILES]
if bad:
raise ValueError("unsupported_file_name")
return files
@router.post("/admin/api/tools/internal/reload")
def api_internal_tools_reload(
authorization: str | None = Header(default=None),
@ -543,7 +577,50 @@ def build_admin_router() -> APIRouter:
_require_permission(ctx, "admin:read")
items = []
for s in status_services():
items.append({"name": s.name, "pid": s.pid, "running": bool(s.running)})
all_pids = list_service_process_pids(str(s.name))
pid_ports: dict[str, list[int]] = {}
pid_running: dict[str, bool] = {}
ports_set: set[int] = set()
for p in all_pids:
pp = list_listen_ports_for_pid(int(p))
pid_ports[str(int(p))] = [int(x) for x in pp]
pid_running[str(int(p))] = bool(is_pid_running(int(p)))
for x in pp:
ports_set.add(int(x))
running_any = any(bool(v) for v in pid_running.values()) if pid_running else bool(s.running)
# Choose primary pid from the current snapshot (do not "stick" to state pid).
live = sorted([int(k) for k, v in pid_running.items() if v])
primary_pid = 0
if live:
name = str(getattr(s, "name", "") or "")
# Prefer gateway PID that actually binds the gateway port.
if name == "gateway":
prefer_port = 8787
candidates = [p for p in live if int(prefer_port) in set(pid_ports.get(str(p), []) or [])]
if candidates:
primary_pid = int(candidates[0])
if not primary_pid:
# Prefer PID(s) with any listening ports.
with_ports = [p for p in live if (pid_ports.get(str(p), []) or [])]
if with_ports:
# Choose the one with most ports, tie-breaker: smallest pid
with_ports.sort(key=lambda p: (-len(pid_ports.get(str(p), []) or []), int(p)))
primary_pid = int(with_ports[0])
if not primary_pid:
# Fall back to smallest live pid for stability.
primary_pid = int(live[0])
items.append(
{
"name": s.name,
"pid": int(primary_pid or 0),
"running": bool(running_any),
"all_pids": sorted(int(x) for x in all_pids if int(x) > 0),
"ports": sorted(ports_set),
"pid_ports": pid_ports,
"pid_running": pid_running,
"duplicate_count": max(0, len(set(int(x) for x in all_pids if int(x) > 0)) - 1),
}
)
return {"ok": True, "items": items}
@router.post("/admin/api/stack/up")
@ -577,15 +654,35 @@ def build_admin_router() -> APIRouter:
cmd_stack_down(ns)
return api_stack_status()
@router.get("/admin/api/runtime/anomalies")
def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
def _collect_runtime_anomalies(store: SqliteStore) -> dict[str, Any]:
items = []
running = status_services()
for s in running:
if not bool(getattr(s, "running", False)):
# Duplicate worker checks only make sense when a primary service
# is currently running. Otherwise this creates noisy conflicts
# with "missing service" alerts.
continue
keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set()
all_pids = list_service_process_pids(s.name)
if (not keep) and len(all_pids) > 1:
# Ambiguous primary process: do not guess and do not auto-clean.
# Require explicit stack restart to re-elect primary PID safely.
items.append(
{
"type": "duplicate_process_ambiguous",
"service": s.name,
"expected_pid": 0,
"orphan_pids": sorted(int(x) for x in all_pids),
"severity": "critical",
"message": (
f"{s.name} has multiple workers but no stable primary pid in runtime state: "
f"{', '.join(str(x) for x in sorted(all_pids))}. "
"Please restart stack (down/up)."
),
}
)
continue
orphans = detect_orphan_service_processes(s.name, keep_pids=keep)
if orphans:
items.append(
@ -624,19 +721,187 @@ def build_admin_router() -> APIRouter:
must_cleanup = any(str(x.get("type") or "") == "duplicate_process" for x in items)
return {"ok": True, "must_cleanup": must_cleanup, "items": items}
@router.get("/admin/api/runtime/anomalies")
def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
return _collect_runtime_anomalies(store)
@router.post("/admin/api/runtime/cleanup")
def api_runtime_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
killed: list[dict[str, Any]] = []
# Cleanup rule:
# - show all PIDs to user; cleanup should be conservative.
# - for a service with multiple workers, kill those with NO listening ports.
# - never delete the last remaining worker for a service.
for s in status_services():
keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set()
dead = cleanup_orphan_service_processes(s.name, keep_pids=keep)
service = str(getattr(s, "name", "") or "").strip()
if not service:
continue
all_pids = [int(x) for x in list_service_process_pids(service) if int(x) > 0]
if len(all_pids) <= 1:
continue
pid_ports: dict[int, list[int]] = {int(p): list_listen_ports_for_pid(int(p)) for p in all_pids}
with_ports = [p for p in all_pids if pid_ports.get(int(p))]
without_ports = [p for p in all_pids if not pid_ports.get(int(p))]
victims: list[int] = []
if with_ports:
victims = list(without_ports)
else:
# If none are listening (e.g. long-polling channel workers),
# keep the recorded primary pid when possible, otherwise keep the first.
primary = int(getattr(s, "pid", 0) or 0)
keep = primary if primary in all_pids else int(all_pids[0])
victims = [p for p in all_pids if int(p) != int(keep)]
if victims and len(victims) >= len(all_pids):
victims = victims[: max(0, len(all_pids) - 1)]
dead = cleanup_service_processes_by_pid(service, victims)
if dead:
killed.append({"service": s.name, "killed_pids": sorted(int(x) for x in dead)})
anomalies = api_runtime_anomalies()
killed.append({"service": service, "killed_pids": sorted(int(x) for x in dead)})
anomalies = _collect_runtime_anomalies(store)
return {"ok": True, "killed": killed, "anomalies": anomalies}
@router.get("/admin/api/runtime/prewarm/status")
def api_runtime_prewarm_status(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
return runtime_prewarm_status(store=store)
@router.post("/admin/api/runtime/prewarm")
def api_runtime_prewarm(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
body = payload or {}
mode = str(body.get("mode") or "async").strip().lower()
reason = str(body.get("reason") or "admin_manual").strip() or "admin_manual"
if mode == "sync":
return run_runtime_prewarm(reason=reason, store=store)
import threading
def _run() -> None:
try:
_ = run_runtime_prewarm(reason=reason, store=SqliteStore(db_path()))
except Exception:
pass
th = threading.Thread(target=_run, name="oclaw-admin-prewarm", daemon=True)
th.start()
return {"ok": True, "accepted": True, "mode": "async", "status": runtime_prewarm_status(store=store)}
@router.get("/admin/api/runtime/prewarm/prompts")
def api_runtime_prewarm_prompts(
role: str | None = Query(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
return runtime_prewarm_prompts_snapshot(store=store, role=str(role or "").strip().lower() or None)
@router.get("/admin/api/runtime/scan-artifacts")
def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]:
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
_require_permission(ctx, "admin:read")
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
allowed_prefixes = ("history_entries_", "state_scan_")
items: list[dict[str, Any]] = []
if root.exists() and root.is_dir():
for p in sorted(root.glob("*.json"), key=lambda x: x.name.lower()):
if not p.name.startswith(allowed_prefixes):
continue
try:
st = p.stat()
items.append(
{
"name": p.name,
"path": str(p),
"bytes": int(st.st_size),
"modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
}
)
except Exception:
continue
return {"ok": True, "dir": str(root), "count": len(items), "items": items}
@router.post("/admin/api/runtime/scan-artifacts/cleanup")
def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
_require_permission(ctx, "admin:runtime:write")
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
allowed_prefixes = ("history_entries_", "state_scan_")
removed = 0
if root.exists() and root.is_dir():
for p in root.glob("*.json"):
if not p.name.startswith(allowed_prefixes):
continue
try:
p.unlink(missing_ok=True)
removed += 1
except Exception:
continue
return {"ok": True, "dir": str(root), "removed": int(removed)}
@router.post("/admin/api/runtime/scan-artifacts/prune")
def api_runtime_scan_artifacts_prune(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
body = payload or {}
ctx = _resolve_auth(SqliteStore(db_path()), authorization)
_require_permission(ctx, "admin:runtime:write")
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
try:
keep_latest = int(body.get("keep_latest", 20))
except Exception:
keep_latest = 20
try:
max_age_days = int(body.get("max_age_days", 7))
except Exception:
max_age_days = 7
keep_latest = max(0, min(keep_latest, 500))
max_age_days = max(0, min(max_age_days, 3650))
allowed_prefixes = ("history_entries_", "state_scan_")
rows: list[tuple[Path, float]] = []
if root.exists() and root.is_dir():
for p in root.glob("*.json"):
if not p.name.startswith(allowed_prefixes):
continue
try:
rows.append((p, float(p.stat().st_mtime)))
except Exception:
continue
rows.sort(key=lambda x: x[1], reverse=True)
cutoff_ts = None
if max_age_days > 0:
cutoff_ts = (_now_utc() - timedelta(days=max_age_days)).timestamp()
removed = 0
for idx, (p, mtime) in enumerate(rows):
remove_by_rank = keep_latest >= 0 and idx >= keep_latest
remove_by_age = cutoff_ts is not None and mtime < cutoff_ts
if not remove_by_rank and not remove_by_age:
continue
try:
p.unlink(missing_ok=True)
removed += 1
except Exception:
continue
return {
"ok": True,
"dir": str(root),
"removed": int(removed),
"keep_latest": int(keep_latest),
"max_age_days": int(max_age_days),
}
@router.get("/admin/api/tenants")
def api_tenants(
scope: str | None = Query(default=None),
@ -1417,12 +1682,80 @@ def build_admin_router() -> APIRouter:
trace_rows = store.list_trace_events_for_trace(session_id=sid, trace_id=tid, limit=800)
start_ts, end_ts = store.get_turn_time_window(session_id=sid, trace_id=tid)
msgs = store.list_messages_in_time_window(session_id=sid, start_ts=start_ts, end_ts=end_ts, limit=1200)
def _to_ms(v: Any) -> int | None:
s = str(v or "").strip()
if not s:
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return int(datetime.fromisoformat(s).timestamp() * 1000)
except Exception:
return None
ws_accepted_ms: int | None = None
gateway_received_ms: int | None = None
model_chat_start_ms: int | None = None
ws_first_token_ms: int | None = None
for row in trace_rows or []:
if not isinstance(row, dict):
continue
event_type = str(row.get("event_type") or "").strip()
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
row_ts = _to_ms(row.get("timestamp"))
if event_type == "gateway_received":
gateway_received_ms = row_ts if row_ts is not None else gateway_received_ms
if ws_accepted_ms is None:
try:
v = payload.get("ws_client_send_ms")
if v is not None:
ws_accepted_ms = int(v)
except Exception:
pass
elif event_type == "model_chat_start":
model_chat_start_ms = row_ts if row_ts is not None else model_chat_start_ms
elif event_type == "ws_first_token":
if ws_first_token_ms is None:
try:
vft = payload.get("ws_first_token_ms")
if vft is not None:
ws_first_token_ms = int(vft)
except Exception:
pass
if ws_accepted_ms is None:
try:
va = payload.get("ws_accepted_ms")
if va is not None:
ws_accepted_ms = int(va)
except Exception:
pass
if ws_first_token_ms is None:
ws_first_token_ms = row_ts if row_ts is not None else ws_first_token_ms
def _delta(a: int | None, b: int | None) -> int | None:
if a is None or b is None:
return None
d = int(b - a)
return d if d >= 0 else None
ttft = {
"ws_accepted_ms": ws_accepted_ms,
"gateway_received_ms": gateway_received_ms,
"model_chat_start_ms": model_chat_start_ms,
"ws_first_token_ms": ws_first_token_ms,
"accepted_to_gateway_ms": _delta(ws_accepted_ms, gateway_received_ms),
"gateway_to_model_start_ms": _delta(gateway_received_ms, model_chat_start_ms),
"model_start_to_first_token_ms": _delta(model_chat_start_ms, ws_first_token_ms),
"gateway_to_first_token_ms": _delta(gateway_received_ms, ws_first_token_ms),
"accepted_to_first_token_ms": _delta(ws_accepted_ms, ws_first_token_ms),
}
return {
"ok": True,
"session_id": sid,
"trace_id": tid,
"start_ts": start_ts,
"end_ts": end_ts,
"ttft": ttft,
"trace": trace_rows,
"messages": msgs,
}
@ -1463,6 +1796,13 @@ def build_admin_router() -> APIRouter:
enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off")
epl_raw = str(store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") or "").strip().lower()
enable_plugin_tools = epl_raw in ("1", "true", "yes", "on")
erc_raw = str(store.get_setting("AIA_ENABLE_RUN_COMMAND") or "").strip().lower()
enable_run_command = erc_raw not in ("0", "false", "no", "off")
tctx_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
tool_context_truncate_enabled = tctx_raw not in ("0", "false", "no", "off")
ttft_raw = str(store.get_setting("AIA_CHAT_SHOW_TTFT_DEBUG") or "").strip().lower()
# default OFF (release-safe): only enabled when explicitly set truthy.
chat_show_ttft_debug = ttft_raw in ("1", "true", "yes", "on")
skl_raw = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
skill_runtime_enabled = skl_raw not in ("0", "false", "no", "off")
sai_raw = str(store.get_setting("AIA_SKILL_AUTO_INSTALL_ENABLED") or "").strip().lower()
@ -1503,6 +1843,9 @@ def build_admin_router() -> APIRouter:
"tool_log_max_chars": tool_log_max_chars,
"enable_mcp_tools": enable_mcp_tools,
"enable_plugin_tools": enable_plugin_tools,
"enable_run_command": enable_run_command,
"tool_context_truncate_enabled": tool_context_truncate_enabled,
"chat_show_ttft_debug": chat_show_ttft_debug,
"skill_runtime_enabled": skill_runtime_enabled,
"skill_auto_install_enabled": skill_auto_install_enabled,
"tool_llm_message_max_chars": tool_llm_message_max_chars,
@ -1557,6 +1900,9 @@ def build_admin_router() -> APIRouter:
tl_cap_n = 200_000
emcp = bool(payload.get("enable_mcp_tools", True))
epl = bool(payload.get("enable_plugin_tools", False))
erc = bool(payload.get("enable_run_command", True))
tctx = bool(payload.get("tool_context_truncate_enabled", True))
ttft_debug = bool(payload.get("chat_show_ttft_debug", False))
skl_rt = bool(payload.get("skill_runtime_enabled", True))
skl_ai = bool(payload.get("skill_auto_install_enabled", True))
tmsg = payload.get("tool_llm_message_max_chars", 0)
@ -1626,6 +1972,11 @@ def build_admin_router() -> APIRouter:
store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_n))
store.set_setting("AIA_ENABLE_MCP_TOOLS", "1" if emcp else "0")
store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl else "0")
store.set_setting("AIA_ENABLE_RUN_COMMAND", "1" if erc else "0")
# Keep runtime gate aligned with Admin toggle immediately.
os.environ["AIA_ENABLE_RUN_COMMAND"] = "1" if erc else "0"
store.set_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED", "1" if tctx else "0")
store.set_setting("AIA_CHAT_SHOW_TTFT_DEBUG", "1" if ttft_debug else "0")
store.set_setting("AIA_SKILL_RUNTIME_ENABLED", "1" if skl_rt else "0")
store.set_setting("AIA_SKILL_AUTO_INSTALL_ENABLED", "1" if skl_ai else "0")
store.set_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS", str(tmsg_n))
@ -1642,7 +1993,7 @@ def build_admin_router() -> APIRouter:
actor_user_id=ctx["user_id"],
action="tool_policy_update",
target_type="tool_policy",
target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OCLAW_RETRYABLE_ERROR_CODES,AIA_OCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE",
target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_ENABLE_RUN_COMMAND,AIA_TOOL_CONTEXT_TRUNCATE_ENABLED,AIA_CHAT_SHOW_TTFT_DEBUG,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OCLAW_RETRYABLE_ERROR_CODES,AIA_OCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE",
status="ok",
detail={
"disable_tool_confirm": disable_confirm,
@ -1658,6 +2009,9 @@ def build_admin_router() -> APIRouter:
"tool_log_max_chars": tl_cap_n,
"enable_mcp_tools": emcp,
"enable_plugin_tools": epl,
"enable_run_command": erc,
"tool_context_truncate_enabled": tctx,
"chat_show_ttft_debug": ttft_debug,
"skill_runtime_enabled": skl_rt,
"skill_auto_install_enabled": skl_ai,
"tool_llm_message_max_chars": tmsg_n,
@ -1685,6 +2039,9 @@ def build_admin_router() -> APIRouter:
"tool_log_max_chars": tl_cap_n,
"enable_mcp_tools": emcp,
"enable_plugin_tools": epl,
"enable_run_command": erc,
"tool_context_truncate_enabled": tctx,
"chat_show_ttft_debug": ttft_debug,
"skill_runtime_enabled": skl_rt,
"skill_auto_install_enabled": skl_ai,
"tool_llm_message_max_chars": tmsg_n,
@ -2162,6 +2519,93 @@ def build_admin_router() -> APIRouter:
)
return {"ok": True, "available_specialists": available, "allowed_specialists": ordered}
@router.get("/admin/api/experts")
def api_experts_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write")
items = [_serialize_expert_row(x) for x in list_experts()]
return {"ok": True, "items": items}
@router.post("/admin/api/experts")
def api_experts_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write")
expert_id = normalize_expert_id(payload.get("id"))
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
try:
files = _sanitize_expert_files_payload(files_raw)
created = create_expert(expert_id=expert_id, files=files)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
store.add_admin_audit_log(
actor_tenant_id=ctx["tenant_id"],
actor_user_id=ctx["user_id"],
action="expert_create",
target_type="expert",
target_id=str(created.get("id") or ""),
status="ok",
detail={"id": str(created.get("id") or "")},
)
items = [_serialize_expert_row(x) for x in list_experts()]
return {"ok": True, "created": str(created.get("id") or ""), "items": items}
@router.patch("/admin/api/experts/{expert_id}")
def api_experts_update(
expert_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write")
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
eid = normalize_expert_id(expert_id)
try:
files = _sanitize_expert_files_payload(files_raw)
update_expert_files(expert_id=eid, files=files)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
store.add_admin_audit_log(
actor_tenant_id=ctx["tenant_id"],
actor_user_id=ctx["user_id"],
action="expert_update",
target_type="expert",
target_id=eid,
status="ok",
detail={"id": eid, "updated_files": sorted(list(files.keys()))[:10]},
)
items = [_serialize_expert_row(x) for x in list_experts()]
return {"ok": True, "updated": eid, "items": items}
@router.delete("/admin/api/experts/{expert_id}")
def api_experts_delete(expert_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write")
eid = normalize_expert_id(expert_id)
try:
delete_expert(eid)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
store.add_admin_audit_log(
actor_tenant_id=ctx["tenant_id"],
actor_user_id=ctx["user_id"],
action="expert_delete",
target_type="expert",
target_id=eid,
status="ok",
detail={"id": eid},
)
items = [_serialize_expert_row(x) for x in list_experts()]
return {"ok": True, "deleted": eid, "items": items}
@router.post("/admin/api/mcp/install")
def api_mcp_install(
payload: dict[str, Any] | None = Body(default=None),

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException
@ -10,14 +11,17 @@ from oclaw.runtime.agents.factory import build_gateway_executor
from oclaw.runtime.skill_installer import (
auto_install_skill_from_payload,
create_skill_from_template,
create_workspace_skill,
install_skill_from_local_dir,
install_skill_from_registry_archive,
list_skills_with_status,
set_skill_enabled,
uninstall_skill,
)
from oclaw.runtime.skill_role_binding import (
SKILL_ROLE_BINDING_ENABLED_SETTING,
SKILL_ROLE_BINDING_KEY,
SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING,
load_skill_role_binding_dict,
normalize_skill_role_binding,
ordered_binding_roles,
@ -25,10 +29,10 @@ from oclaw.runtime.skill_role_binding import (
)
from oclaw.runtime.skills_prompt import collect_skill_catalog_entries
from oclaw.runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests
from oclaw.runtime.skills_market import get_market_adapter
from oclaw.runtime.tools.skills_runtime.subprocess_exec import run_skill_runtime_entry
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from oclaw.runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
def include_skill_routes(
@ -82,6 +86,44 @@ def include_skill_routes(
items = list_skills_with_status(store=store)
return {"ok": True, "items": items}
@sk.get("/mode")
def api_skills_mode_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
raw_toolcall = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip().lower()
prompt_in_system = raw_prompt not in {"0", "false", "no", "off"}
toolcall_enabled = raw_toolcall in {"1", "true", "yes", "on"}
return {"ok": True, "prompt_in_system": bool(prompt_in_system), "toolcall_enabled": bool(toolcall_enabled)}
@sk.post("/mode")
def api_skills_mode_save(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
if "prompt_in_system" in payload:
store.set_setting("AIA_SKILLS_PROMPT_IN_SYSTEM", "1" if bool(payload.get("prompt_in_system")) else "0")
if "toolcall_enabled" in payload:
store.set_setting("AIA_SKILL_TOOLCALL_ENABLED", "1" if bool(payload.get("toolcall_enabled")) else "0")
raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
raw_toolcall = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip().lower()
prompt_in_system = raw_prompt not in {"0", "false", "no", "off"}
toolcall_enabled = raw_toolcall in {"1", "true", "yes", "on"}
_audit(
store,
ctx,
action="skill_mode_update",
target_id="skill_mode",
status="ok",
detail={"prompt_in_system": bool(prompt_in_system), "toolcall_enabled": bool(toolcall_enabled)},
)
return {"ok": True, "prompt_in_system": bool(prompt_in_system), "toolcall_enabled": bool(toolcall_enabled)}
@sk.post("/install")
def api_skills_install(
payload: dict[str, Any] | None = Body(default=None),
@ -162,7 +204,8 @@ def include_skill_routes(
query = str(q or "").strip()
lim = int(limit) if isinstance(limit, int) and limit > 0 else 20
lim = max(1, min(lim, 200))
items = clawhub_search_skills(query, limit=lim)
provider = str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub").strip()
items = get_market_adapter(provider).search(query, limit=lim)
return {"ok": True, "items": items}
@sk.get("/market/detail")
@ -176,7 +219,8 @@ def include_skill_routes(
s = str(slug or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
detail = clawhub_get_skill_detail(s)
provider = str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub").strip()
detail = get_market_adapter(provider).detail(s)
return {"ok": True, "detail": detail}
@sk.post("/market/install")
@ -194,18 +238,9 @@ def include_skill_routes(
requested_version = str(payload.get("version") or "").strip()
overwrite = bool(payload.get("overwrite"))
detail = clawhub_get_skill_detail(s)
archive_url = str(detail.get("archiveUrl") or "").strip()
chosen_version = str(detail.get("latestVersion") or "").strip()
if requested_version:
chosen_version = requested_version
archive_url = ""
for v in (detail.get("versions") or []):
if not isinstance(v, dict):
continue
if str(v.get("version") or "").strip() == requested_version:
archive_url = str(v.get("archiveUrl") or "").strip()
break
provider = str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub").strip()
adapter = get_market_adapter(provider)
archive_url, chosen_version = adapter.resolve_archive_url(slug=s, version=requested_version or None)
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_unavailable")
@ -216,7 +251,7 @@ def include_skill_routes(
action="skill_install_started",
target_id=archive_url,
status="start",
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
detail={"source": provider, "slug": s, "version": chosen_version, "input_target": s},
)
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
@ -228,7 +263,7 @@ def include_skill_routes(
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": "clawhub",
"source": provider,
"slug": s,
"version": chosen_version,
"input_target": archive_url,
@ -245,6 +280,36 @@ def include_skill_routes(
},
}
@sk.post("/create-workspace")
def api_skills_create_workspace(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
desc = str(payload.get("description") or "").strip() or f"{name} workspace skill"
runtime_type = str(payload.get("runtime_type") or "python").strip().lower()
overwrite = bool(payload.get("overwrite"))
out = create_workspace_skill(
store=store,
name=name,
description=desc,
runtime_type=runtime_type,
overwrite=overwrite,
)
_audit(
store,
ctx,
action="skill_create_workspace",
target_id=out.name or name,
status="ok" if out.ok else "fail",
detail={"runtime_type": runtime_type, "detail": out.detail, "target_dir": out.target_dir},
)
return {"ok": bool(out.ok), "result": {"name": out.name, "target_dir": out.target_dir, "detail": out.detail, "error_code": out.error_code, "retryable": bool(out.retryable)}}
@sk.get("/binding")
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
@ -255,6 +320,7 @@ def include_skill_routes(
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"manager_inherit": str(store.get_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING) or "").strip() not in {"0", "false", "False"},
"available_roles": roles,
"installed_skills": items,
"mapping": mapping,
@ -271,6 +337,8 @@ def include_skill_routes(
_require_tenant_write(ctx)
if "enabled" in payload:
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
if "manager_inherit" in payload:
store.set_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING, "1" if bool(payload.get("manager_inherit")) else "0")
roles, _prev_mapping, valid = _normalized_skill_binding(store)
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
mapping = normalize_skill_role_binding(
@ -290,6 +358,7 @@ def include_skill_routes(
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"manager_inherit": str(store.get_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING) or "").strip() not in {"0", "false", "False"},
"available_roles": roles,
"mapping": mapping,
}
@ -455,6 +524,38 @@ def include_skill_routes(
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/uninstall")
def api_skills_uninstall(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
out = uninstall_skill(store=store, skill_name=name)
_audit(
store,
ctx,
action="skill_uninstall_finished" if out.ok else "skill_uninstall_failed",
target_id=out.name or name,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/auto-install")
def api_skills_auto_install(
payload: dict[str, Any] | None = Body(default=None),
@ -581,11 +682,24 @@ def include_skill_routes(
raise HTTPException(status_code=500, detail="tool_registry_unavailable")
spec = tools.get(name)
if spec is None:
raise HTTPException(status_code=404, detail="tool_not_found")
try:
result = spec.handler(dict(args))
except Exception as exc:
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
# Skill prompt mode: toolcall may be disabled, fallback to direct runtime execution from manifest.
mf = next((m for m in discover_workspace_skill_manifests() if str(m.name or "").strip() == name), None)
if mf is None:
raise HTTPException(status_code=404, detail="tool_not_found")
runtime = dict(mf.runtime or {}) if isinstance(mf.runtime, dict) else {}
if not runtime:
raise HTTPException(status_code=400, detail="skill_not_executable")
result = run_skill_runtime_entry(
skill_name=str(mf.name or ""),
skill_dir=str(mf.skill_dir or ""),
runtime=runtime,
args=dict(args),
)
else:
try:
result = spec.handler(dict(args))
except Exception as exc:
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
_audit(
store,
@ -597,6 +711,61 @@ def include_skill_routes(
)
return {"ok": True, "name": name, "result": result}
@sk.get("/self-check")
def api_skills_self_check(
include_execution: bool = False,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
manifests = discover_workspace_skill_manifests()
executable = [m for m in manifests if isinstance(m.runtime, dict) and bool(m.runtime)]
invalid_entries = []
execution_checks = []
classification_counts: dict[str, int] = {}
def _bump(code: str) -> None:
k = str(code or "unknown").strip() or "unknown"
classification_counts[k] = int(classification_counts.get(k) or 0) + 1
for m in executable:
entry = str((m.runtime or {}).get("entry") or "").strip().replace("\\", "/")
if not entry:
invalid_entries.append({"name": m.name, "error": "runtime_entry_missing"})
_bump("runtime_entry_missing")
continue
p = (Path(m.skill_dir) / entry).resolve()
if not p.exists():
invalid_entries.append({"name": m.name, "error": f"runtime_entry_not_found:{entry}"})
_bump("runtime_entry_not_found")
continue
if include_execution:
rr = run_skill_runtime_entry(
skill_name=str(m.name or ""),
skill_dir=str(m.skill_dir or ""),
runtime=dict(m.runtime or {}),
args={},
)
row = {"name": m.name, "ok": bool(rr.get("ok")), "error_code": str(rr.get("error_code") or "")}
if bool(rr.get("stdout_truncated")) or bool(rr.get("stderr_truncated")):
row["output_truncated"] = True
_bump("output_truncated")
if not bool(rr.get("ok")):
_bump(str(rr.get("error_code") or "runtime_error"))
execution_checks.append(row)
return {
"ok": True,
"skills_total": len(manifests),
"executable_total": len(executable),
"invalid_runtime_entries": invalid_entries,
"execution_checked": bool(include_execution),
"execution_checked_total": len(execution_checks),
"execution_checks": execution_checks,
"classification_counts": classification_counts,
"market_provider": str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "clawhub"),
}
router.include_router(sk)

File diff suppressed because it is too large Load diff

View file

@ -4,12 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
<style>
@ -443,13 +438,75 @@
line-height: 1.45;
color: var(--ds-text, #e8e8e8);
}
/* First paint before chat.js runs (desktop / slow network): avoid empty #app “black hole”. */
.chat-boot-splash {
flex: 1;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.chat-boot-splash__card {
width: min(520px, 92vw);
padding: 22px 20px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
line-height: 1.5;
}
.chat-boot-splash__title {
font-weight: 600;
font-size: 15px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 10px;
}
.chat-boot-splash__spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.75);
border-radius: 999px;
animation: chat-boot-spin 1s linear infinite;
flex: 0 0 auto;
}
@keyframes chat-boot-spin {
to {
transform: rotate(360deg);
}
}
.chat-boot-splash__muted {
opacity: 0.78;
font-size: 13px;
white-space: pre-wrap;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
<!-- Keep markdown helpers non-blocking. chat.js already falls back when they are unavailable. -->
<script async src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
<script async src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
<script
defer
src="/admin/assets/chat.js?v=20260425-4"
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
></script>
</head>
<body class="theme-ds-body chat-standalone-page">
<div id="app"></div>
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
<script src="/admin/assets/chat.js?v=20260421-1"></script>
<div id="app">
<div id="chat-boot-splash" class="chat-boot-splash" role="status" aria-live="polite">
<div class="chat-boot-splash__card">
<div class="chat-boot-splash__title">
<span class="chat-boot-splash__spinner" aria-hidden="true"></span>
<span>正在加载登录界面…</span>
</div>
<div class="chat-boot-splash__muted">
若长时间停留在此,请检查网络或稍后重试;桌面端可查看日志目录中的 desktop.log / backend.log。
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -66,7 +66,7 @@ const I18N = {
"chat.specialistGeneralist": "通用",
"chat.specialistOps": "运维",
"chat.specialistImage": "图像",
"chat.specialistMemoryCurator": "记忆整理",
"chat.specialistMemory": "记忆",
"chat.memoryModeLabel": "记忆模式",
"chat.memoryModeDefault": "默认(可注入)",
"chat.memoryModeStoreOnly": "仅记录不注入",
@ -80,8 +80,8 @@ const I18N = {
"chat.wikiPreviewTitle": "Wiki 预览:{path}",
"chat.wikiPreviewClose": "关闭",
"chat.specialistManage": "专家开关",
"chat.specialistManagePromptOn": "是否启用 memory_curator 专家?",
"chat.specialistManagePromptOff": "是否关闭 memory_curator 专家?",
"chat.specialistManagePromptOn": "是否启用 memory 专家?",
"chat.specialistManagePromptOff": "是否关闭 memory 专家?",
"chat.specialistManageDenied": "无权限修改专家开关",
"chat.attachmentLimits": "附件阈值",
"chat.attachmentLimitsPrompt": "编辑附件阈值 JSON(留空恢复默认)",
@ -127,16 +127,17 @@ const I18N = {
"chat.reason.manager_no_specialist_fallback": "无可用专家,回退通用",
"chat.reason.dynamic_agent_build_failed": "动态专家构建失败",
"chat.reason.manager_factory_failed": "专家执行器创建失败",
"chat.reason.manager_route_missing": "总控路由缺失",
"chat.reason.manager_select_failed": "总控决策失败",
"chat.reason.manager_model_missing": "总控模型不可用",
"chat.reason.manager_route_missing": "全能者路由缺失",
"chat.reason.manager_select_failed": "全能者决策失败",
"chat.reason.manager_model_missing": "全能者模型不可用",
"chat.reason.dynamic_agent_selected": "动态专家命中",
"chat.modeComprehensiveShort": "综合",
"chat.modeExpertShort": "专家",
"chat.specialistGeneralistShort": "通用",
"chat.specialistOpsShort": "运维",
"chat.specialistImageShort": "图像",
"chat.specialistMemoryCuratorShort": "记忆整理",
"chat.specialistMemoryShort": "记忆",
"chat.specialistManagerSelfShort": "全能者",
},
en: {
"chat.pageTitle": "oliver",
@ -201,7 +202,7 @@ const I18N = {
"chat.specialistGeneralist": "Generalist",
"chat.specialistOps": "Ops",
"chat.specialistImage": "Image",
"chat.specialistMemoryCurator": "Memory Curator",
"chat.specialistMemory": "Memory",
"chat.memoryModeLabel": "Memory Mode",
"chat.memoryModeDefault": "Default (inject allowed)",
"chat.memoryModeStoreOnly": "Store only (no inject)",
@ -215,8 +216,8 @@ const I18N = {
"chat.wikiPreviewTitle": "Wiki preview: {path}",
"chat.wikiPreviewClose": "Close",
"chat.specialistManage": "Specialist Toggle",
"chat.specialistManagePromptOn": "Enable memory_curator specialist?",
"chat.specialistManagePromptOff": "Disable memory_curator specialist?",
"chat.specialistManagePromptOn": "Enable memory specialist?",
"chat.specialistManagePromptOff": "Disable memory specialist?",
"chat.specialistManageDenied": "Not authorized to change specialist flags",
"chat.attachmentLimits": "Attachment limits",
"chat.attachmentLimitsPrompt": "Edit attachment limits JSON (empty to restore defaults)",
@ -271,7 +272,8 @@ const I18N = {
"chat.specialistGeneralistShort": "Generalist",
"chat.specialistOpsShort": "Ops",
"chat.specialistImageShort": "Image",
"chat.specialistMemoryCuratorShort": "Memory Curator",
"chat.specialistMemoryShort": "Memory",
"chat.specialistManagerSelfShort": "Manager",
},
};
@ -388,6 +390,12 @@ function _buildAggregatedAssistantBubble(tsIso, items) {
}
inner.insertBefore(det, inner.firstChild);
}
const hasVisible =
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
String(inner.textContent || "").trim().length > 0;
if (!hasVisible) {
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
}
return wrapAssistantMessage(inner, tsIso);
}
@ -480,9 +488,11 @@ function interactionModeLabel(mode) {
function specialistLabel(specialist) {
const s = String(specialist || "").toLowerCase();
if (s === "manager") return currentLang === "zh" ? "主控" : "Manager";
if (s === "manager_self") return t("chat.specialistManagerSelfShort");
if (s === "ops") return t("chat.specialistOpsShort");
if (s === "image") return t("chat.specialistImageShort");
if (s === "memory_curator") return t("chat.specialistMemoryCuratorShort");
if (s === "memory") return t("chat.specialistMemoryShort");
return t("chat.specialistGeneralistShort");
}
@ -1197,6 +1207,12 @@ function createStreamStitcher() {
}
function formatToolPanelText(name, payload) {
const truncateToolPanel = (s) => {
const raw = String(s || "").trim();
const maxChars = 50;
if (raw.length <= maxChars) return raw;
return `${raw.slice(0, maxChars)}…(详情请重新阅读)`;
};
const n = String(name || "").trim() || "tool";
const p = payload && typeof payload === "object" ? payload : {};
const r = p.result && typeof p.result === "object" ? p.result : p;
@ -1219,7 +1235,7 @@ function formatToolPanelText(name, payload) {
`engine=${String(r.engine || "")}`,
`rows_returned=${String(r.rows_returned != null ? r.rows_returned : "")}`,
];
return lines.join("\n").trim();
return truncateToolPanel(lines.join("\n").trim());
}
let body = "";
// Prefer human-readable text from tool content blocks when available.
@ -1237,7 +1253,7 @@ function formatToolPanelText(name, payload) {
}
}
body = normalizeStreamText(body).replace(/\\n/g, "").replace(/\n/g, "");
return `${n}\n${body}`.trim();
return `${n}\n${truncateToolPanel(body)}`.trim();
}
function extractSqlAuditPayload(payload) {
@ -1464,6 +1480,12 @@ async function buildMessageBubble(role, content, tsIso) {
inner.insertBefore(det, inner.firstChild);
}
}
const hasVisible =
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
String(inner.textContent || "").trim().length > 0;
if (!hasVisible) {
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
}
return wrapAssistantMessage(inner, tsIso);
}
@ -1772,6 +1794,31 @@ async function renderChatUi() {
const loadMoreWrap = el("div", { class: "chat-load-more" });
const messagesEl = el("div", { class: "chat-messages" });
bindChatImageViewer(messagesEl);
const AUTO_SCROLL_BOTTOM_GAP_PX = 56;
let shouldFollowMessages = true;
const isNearBottom = () => {
const remaining = messagesEl.scrollHeight - (messagesEl.scrollTop + messagesEl.clientHeight);
return remaining <= AUTO_SCROLL_BOTTOM_GAP_PX;
};
const scrollMessagesToBottom = (force = false) => {
if (!force && !shouldFollowMessages) return;
messagesEl.scrollTop = messagesEl.scrollHeight;
requestAnimationFrame(() => {
if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight;
});
};
messagesEl.addEventListener("scroll", () => {
shouldFollowMessages = isNearBottom();
});
messagesEl.addEventListener(
"load",
(ev) => {
const target = ev && ev.target;
if (!target || target.tagName !== "IMG") return;
scrollMessagesToBottom();
},
true,
);
const textarea = el("textarea", {
class: "chat-composer__field",
rows: "1",
@ -1815,7 +1862,7 @@ async function renderChatUi() {
generalist: true,
ops: true,
image: true,
memory_curator: true,
memory: true,
};
const modeSelect = el("select", {
class: "input",
@ -1837,7 +1884,7 @@ async function renderChatUi() {
{ value: "generalist", text: t("chat.specialistGeneralist"), enabled: true },
{ value: "ops", text: t("chat.specialistOps"), enabled: !!specialistFlags.ops },
{ value: "image", text: t("chat.specialistImage"), enabled: !!specialistFlags.image },
{ value: "memory_curator", text: t("chat.specialistMemoryCurator"), enabled: !!specialistFlags.memory_curator },
{ value: "memory", text: t("chat.specialistMemory"), enabled: !!specialistFlags.memory },
];
for (const op of opts) {
if (!op.enabled) continue;
@ -1865,7 +1912,7 @@ async function renderChatUi() {
applySpecialistOptions();
};
const _sp = String(localStorage.getItem(CHAT_SPECIALIST_PREF_KEY) || "generalist").toLowerCase();
specialistSelect.value = ["generalist", "ops", "image", "memory_curator"].includes(_sp) ? _sp : "generalist";
specialistSelect.value = ["generalist", "ops", "image", "memory"].includes(_sp) ? _sp : "generalist";
await loadSpecialistFlags();
specialistSelect.addEventListener("change", () => {
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
@ -1892,7 +1939,7 @@ async function renderChatUi() {
const s = String((resp && resp.specialist) || "").toLowerCase();
const mm = String((resp && resp.memory_mode) || "").toLowerCase();
if (["comprehensive", "expert"].includes(m)) modeSelect.value = m;
if (["generalist", "ops", "image", "memory_curator"].includes(s)) specialistSelect.value = s;
if (["generalist", "ops", "image", "memory"].includes(s)) specialistSelect.value = s;
if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm);
if (String(modeSelect.value || "").toLowerCase() !== "expert") {
specialistSelect.value = "generalist";
@ -2125,6 +2172,14 @@ async function renderChatUi() {
showToolOutput = !showToolOutput;
adminChatShowToolOutput = showToolOutput;
syncToolToggleBtn();
const isStreaming = composerShell.classList.contains("chat-composer-shell--busy");
if (isStreaming) {
// Avoid clearing active stream bubble mid-turn (loadMessagesForActive() resets messagesEl).
statusBar.textContent = `${showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden")} · ${
currentLang === "zh" ? "本轮结束后应用到历史消息" : "applies to history after current turn"
}`;
return;
}
statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
loadMessagesForActive().catch(() => {});
});
@ -2315,8 +2370,11 @@ async function renderChatUi() {
}
const loadMessagesForActive = async () => {
messagesEl.innerHTML = "";
loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1;
const rid = loadMessagesForActive._rid;
shouldFollowMessages = true;
if (!activeId) {
messagesEl.innerHTML = "";
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
return;
@ -2324,9 +2382,11 @@ async function renderChatUi() {
statusBar.textContent = t("chat.loading");
try {
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`);
if (rid !== loadMessagesForActive._rid) return;
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
const renderRows = _buildRenderRows(msgs);
const total = intOr(resp.message_count, msgs.length);
messagesEl.innerHTML = "";
if (total > msgs.length) {
messagesEl.appendChild(
el("div", {
@ -2343,8 +2403,10 @@ async function renderChatUi() {
}
}
statusBar.textContent = "";
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollMessagesToBottom(true);
} catch (e) {
// Keep existing message list on reload failure (e.g. toggle reload races),
// so users don't perceive "messages disappeared".
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
};
@ -2539,7 +2601,7 @@ async function renderChatUi() {
this.ws = null;
}
}
async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, signal, onEvent }) {
async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, idempotencyKey, signal, onEvent }) {
await this._openAndHandshake();
const reqId = this._nextReqId();
const req = {
@ -2550,7 +2612,7 @@ async function renderChatUi() {
sessionKey: String(sessionId || ""),
message: String(text || ""),
attachments: Array.isArray(attachments) ? attachments : [],
idempotencyKey: `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
idempotencyKey: String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`),
thinking: "default",
interaction_mode: String(interactionMode || "comprehensive"),
specialist: String(specialist || "generalist"),
@ -2569,7 +2631,8 @@ async function renderChatUi() {
msg.event === "session.message" ||
msg.event === "agent.event" ||
msg.event === "session.tool" ||
msg.event === "session.marker") &&
msg.event === "session.marker" ||
msg.event === "session.turn_started") &&
typeof onEvent === "function"
) {
const payload = msg.payload || {};
@ -2602,6 +2665,7 @@ async function renderChatUi() {
relay_ttl_turn_count: Number(p.relayTtlTurnCount || 0) || 0,
relay_ttl_session_count: Number(p.relayTtlSessionCount || 0) || 0,
relay_ttl_keep_count: Number(p.relayTtlKeepCount || 0) || 0,
ttft: p.ttft && typeof p.ttft === "object" ? p.ttft : null,
reply: String(p.reply || ""),
};
if (String(p.status || "") === "started") {
@ -2646,6 +2710,7 @@ async function renderChatUi() {
let currentStreamAbortController = null;
let currentWsTransport = null;
let currentAbortMeta = { sessionId: "", runId: "" };
const WS_CHAT_SEND_TIMEOUT_MS = 60000;
const isAbortError = (err) => {
const name = String(err && err.name ? err.name : "");
const msg = String(err && err.message ? err.message : err || "");
@ -2664,8 +2729,9 @@ async function renderChatUi() {
return m;
};
const sendMessageStream = async (userText, attachmentPayload) => {
const sendMessageStream = async (userText, attachmentPayload, turnId) => {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const turnStartedAtMs = Date.now();
let streamBubble = null;
let streamRow = null;
let doneMeta = null;
@ -2674,6 +2740,7 @@ async function renderChatUi() {
let chatStreamSegments = [];
let renderRafId = null;
let renderPending = false;
let transportUsed = "ws";
let typingRafId = null;
let typingTimerId = null;
let streamStatusTimerId = null;
@ -2682,7 +2749,41 @@ async function renderChatUi() {
let streamDisplayShown = "";
let toolSeq = 0;
let hasRealStreamText = false;
let sawWsChatEvent = false;
let sawWsTerminalEvent = false;
let markerState = { p: 0, e: false, ep: 0, t: 0, s: 0, k: 0, reclaimed: 0 };
let turnAcceptedAtMs = null;
let phaseRunningAtMs = null;
let firstDeltaAtMs = null;
const _numOrNull = (v) => {
if (v === null || v === undefined) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const _sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, Math.max(0, Number(ms) || 0));
});
const _recoverLatestAssistantFromHistory = async () => {
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`);
const msgs = Array.isArray(resp && resp.messages) ? resp.messages : [];
if (!msgs.length) return false;
let last = null;
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i];
if (String((m && m.role) || "").toLowerCase() === "assistant") {
last = m;
break;
}
}
if (!last) return false;
const tsRaw = String((last && last.timestamp) || "");
const tsMs = tsRaw ? Date.parse(tsRaw) : NaN;
const fresh = Number.isFinite(tsMs) ? tsMs >= turnStartedAtMs - 1500 : true;
if (!fresh) return false;
await loadMessagesForActive();
return true;
};
const ensureStreamBubble = () => {
if (streamBubble) return streamBubble;
const inner = el("div", { class: "chat-msg chat-msg--assistant" });
@ -2726,7 +2827,10 @@ async function renderChatUi() {
const statusEl = bubble.querySelector(".chat-msg__stream-status");
if (!statusEl) return;
const dots = ".".repeat((Math.floor(Date.now() / 400) % 3) + 1);
statusEl.textContent = `${_statusLabel(streamStatusBase)}${dots}`;
const baseLabel = _statusLabel(streamStatusBase);
const elapsedMs = turnAcceptedAtMs ? Math.max(0, Date.now() - Number(turnAcceptedAtMs || 0)) : 0;
const elapsedText = elapsedMs > 0 ? ` ${Math.round(elapsedMs / 100) / 10}s` : "";
statusEl.textContent = `${baseLabel}${elapsedText}${dots}`;
};
const _startDynamicStreamStatus = () => {
_setDynamicStreamStatus();
@ -2839,7 +2943,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}
md.innerHTML = blocks.join("");
}
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollMessagesToBottom();
};
const renderStreamComposite = () => {
if (renderPending) return;
@ -2913,7 +3017,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
if (last) {
if (streamRow && streamRow.parentNode) streamRow.remove();
await appendMessageRow(messagesEl, last);
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollMessagesToBottom(true);
return true;
}
}
@ -2925,7 +3029,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
content: decodeEscapedNewlines(t0),
timestamp: new Date().toISOString(),
});
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollMessagesToBottom(true);
return true;
}
return false;
@ -2945,11 +3049,12 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
// Immediate assistant placeholder bubble with dynamic status.
ensureStreamBubble();
_startDynamicStreamStatus();
doneMeta = await transport.sendSessionSend({
const wsSendPromise = transport.sendSessionSend({
sessionId: activeId,
text: userText,
attachments: attachmentPayload,
interactionMode: String(modeSelect.value || "comprehensive"),
idempotencyKey: String(turnId || ""),
specialist:
String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist")
@ -2960,6 +3065,19 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const eventName = String((frame && frame.event) || "");
const payload = (frame && frame.payload) || {};
if (eventName === "agent.event") {
try {
const stream = String((payload && payload.stream) || "");
const data = (payload && payload.data) || {};
const phase = String((data && data.phase) || "").toLowerCase();
if (stream === "lifecycle") {
if (phase === "start" && !turnAcceptedAtMs) {
turnAcceptedAtMs = Date.now();
}
if (phase === "running" && !phaseRunningAtMs) {
phaseRunningAtMs = Date.now();
}
}
} catch (_) {}
_setStreamStatusBase((payload && (payload.stream || payload.event || payload.type)) || "agent");
return;
}
@ -2980,6 +3098,11 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
renderStreamComposite();
return;
}
if (eventName === "session.turn_started") {
turnAcceptedAtMs = Number(payload.acceptedAt || Date.now()) || Date.now();
_setStreamStatusBase("start");
return;
}
if (eventName === "session.tool") {
_setStreamStatusBase("tool");
// Keep strict order: tool card appears exactly where tool event arrives.
@ -2989,12 +3112,14 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
return;
}
if (eventName !== "chat") return;
sawWsChatEvent = true;
const state = String(payload.state || "");
if (state) _setStreamStatusBase(state);
if (payload.runId && !chatRunId) chatRunId = String(payload.runId);
if (chatRunId) currentAbortMeta = { sessionId: String(activeId || ""), runId: String(chatRunId || "") };
if (chatRunId && payload.runId && String(payload.runId) !== chatRunId && state !== "final") return;
if (state === "delta") {
if (!firstDeltaAtMs) firstDeltaAtMs = Date.now();
const next = extractWsAssistantText(payload.message);
const d = String(payload.delta || "");
if (d && !_isSilentReplyStream(d)) {
@ -3011,6 +3136,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
return;
}
if (state === "final") {
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
@ -3022,6 +3148,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
return;
}
if (state === "aborted") {
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const ok = await appendFinalAssistant(payload.message, chatStream);
@ -3032,6 +3159,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
return;
}
if (state === "error") {
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
chatStream = "";
@ -3041,6 +3169,21 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}
},
});
doneMeta = await Promise.race([
wsSendPromise,
new Promise((_, reject) => {
setTimeout(() => reject(new Error(`ws_send_timeout:${WS_CHAT_SEND_TIMEOUT_MS}`)), WS_CHAT_SEND_TIMEOUT_MS);
}),
]);
if (doneMeta && typeof doneMeta === "object") doneMeta.__transport = "ws";
if (doneMeta && typeof doneMeta === "object") {
const startToRunning =
turnAcceptedAtMs && phaseRunningAtMs ? Math.max(0, Number(phaseRunningAtMs) - Number(turnAcceptedAtMs)) : null;
const runningToFirst =
phaseRunningAtMs && firstDeltaAtMs ? Math.max(0, Number(firstDeltaAtMs) - Number(phaseRunningAtMs)) : null;
doneMeta.__phase_start_to_running_ms = Number.isFinite(Number(startToRunning)) ? Number(startToRunning) : null;
doneMeta.__phase_running_to_first_ms = Number.isFinite(Number(runningToFirst)) ? Number(runningToFirst) : null;
}
} finally {
transport.close();
if (currentWsTransport === transport) currentWsTransport = null;
@ -3051,6 +3194,38 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
return doneMeta;
} catch (e) {
if (isAbortError(e)) return doneMeta;
const emsg = String(e && e.message ? e.message : e || "").toLowerCase();
const wsLikeFailure =
emsg.includes("ws_") ||
emsg.includes("websocket") ||
emsg.includes("receive_failed") ||
emsg.includes("closed") ||
emsg.includes("timeout");
if (wsLikeFailure) {
const wsLikelyAlreadyProducedReply = sawWsTerminalEvent || hasRealStreamText || sawWsChatEvent;
if (wsLikelyAlreadyProducedReply) {
setTimeout(() => {
loadMessagesForActive().catch(() => {});
}, 350);
return doneMeta;
}
// WS may time out/close while backend persists final message slightly later.
// Poll history briefly before surfacing hard failure.
try {
for (let i = 0; i < 6; i++) {
if (await _recoverLatestAssistantFromHistory()) {
statusBar.textContent =
currentLang === "zh"
? "WS流式中断,已从历史补齐本轮结果。"
: "WS stream interrupted; recovered this turn from history.";
return doneMeta || { __transport: "ws_history_recovered" };
}
await _sleep(500 + i * 350);
}
} catch (_) {
// fall through to original error
}
}
throw e;
} finally {
if (typingRafId != null) {
@ -3120,10 +3295,8 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
statusBar.textContent = t("chat.sending");
let attPayload = null;
if (filesSnapshot.length) {
attPayload = [];
for (const f of filesSnapshot) {
attPayload.push(await fileToPayloadEntry(f));
}
statusBar.textContent = currentLang === "zh" ? "准备附件中…" : "Preparing attachments…";
attPayload = await Promise.all(filesSnapshot.map((f) => fileToPayloadEntry(f)));
pendingFiles = [];
syncPendingUi();
}
@ -3154,15 +3327,18 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}
const userWrap = wrapUserMessage(innerUser, new Date().toISOString());
messagesEl.appendChild(userWrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollMessagesToBottom(true);
fitComposerTextarea();
const doneMeta = await sendMessageStream(userText, attPayload);
const turnId = `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
statusBar.textContent = currentLang === "zh" ? "连接中…" : "Connecting…";
const doneMeta = await sendMessageStream(userText, attPayload, turnId);
const transportTag = (doneMeta && doneMeta.__transport) || "";
const mRaw = String((doneMeta && doneMeta.interaction_mode) || "").toLowerCase();
const m = interactionModeLabel(mRaw);
const s = specialistLabel(
mRaw === "expert"
? (doneMeta && doneMeta.selected_specialist)
: (doneMeta && doneMeta.requested_specialist),
: (doneMeta && (doneMeta.manager_selected_specialist || doneMeta.selected_specialist || doneMeta.requested_specialist)),
);
const baseText = t("chat.execApplied", { mode: m, specialist: s });
const memoryModeNow = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
@ -3182,6 +3358,49 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
k: String(Number(doneMeta.relay_ttl_keep_count || 0) || 0),
})}`
: "";
const ttftObj = doneMeta && doneMeta.ttft && typeof doneMeta.ttft === "object" ? doneMeta.ttft : null;
const localStartToRunningRaw = doneMeta && doneMeta.__phase_start_to_running_ms;
const localRunningToFirstRaw = doneMeta && doneMeta.__phase_running_to_first_ms;
const localStartToRunning =
typeof localStartToRunningRaw === "number"
? localStartToRunningRaw
: (() => {
const n = Number(localStartToRunningRaw);
return Number.isFinite(n) ? n : null;
})();
const localRunningToFirst =
typeof localRunningToFirstRaw === "number"
? localRunningToFirstRaw
: (() => {
const n = Number(localRunningToFirstRaw);
return Number.isFinite(n) ? n : null;
})();
const segA2G = ttftObj && ttftObj.accepted_to_gateway_ms != null ? Number(ttftObj.accepted_to_gateway_ms) : null;
const segG2M =
ttftObj && ttftObj.gateway_to_model_start_ms != null
? Number(ttftObj.gateway_to_model_start_ms)
: (Number.isFinite(localStartToRunning) ? localStartToRunning : null);
const segM2F =
ttftObj && ttftObj.model_start_to_first_token_ms != null
? Number(ttftObj.model_start_to_first_token_ms)
: (Number.isFinite(localRunningToFirst) ? localRunningToFirst : null);
const ttftTotal =
ttftObj && ttftObj.accepted_to_first_token_ms != null
? Number(ttftObj.accepted_to_first_token_ms)
: (Number.isFinite(segG2M) && Number.isFinite(segM2F) ? Number(segG2M) + Number(segM2F) : null);
const ttftText =
Number.isFinite(ttftTotal) || Number.isFinite(segA2G) || Number.isFinite(segG2M) || Number.isFinite(segM2F)
? ` · 阶段(A→G:${Number.isFinite(segA2G) ? `${segA2G}ms` : "-"} / G→M:${Number.isFinite(segG2M) ? `${segG2M}ms` : "-"} / M→F:${Number.isFinite(segM2F) ? `${segM2F}ms` : "-"})` +
`${Number.isFinite(ttftTotal) ? ` · TTFT=${ttftTotal}ms` : ""}`
: "";
const phaseText =
Number.isFinite(localStartToRunning)
? (() => {
return Number.isFinite(localRunningToFirst)
? ` · 阶段耗时(启动→执行)=${localStartToRunning}ms (执行→首字)=${localRunningToFirst}ms`
: ` · 阶段耗时(启动→执行)=${localStartToRunning}ms`;
})()
: "";
const stats = await fetchDynamicExpertStats();
if (stats) {
let topReason = "-";
@ -3205,10 +3424,10 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const topReasonLabel = String((stats.reasonLabels && stats.reasonLabels[topReason]) || reasonLabel(topReason));
const topReasonText = t("chat.dynamicStatsDetail", { rate: ratePct, reason: topReasonLabel });
const topMixText = t("chat.dynamicTopReasons", { items: top5 || "-" });
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}`;
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}${ttftText}${phaseText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}${transportTag ? ` · transport=${transportTag}` : ""}`;
} else {
_statusReasonPairs = [];
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}`;
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}${ttftText}${phaseText}${transportTag ? ` · transport=${transportTag}` : ""}`;
}
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;

View file

@ -4,14 +4,47 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
<style>
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
.admin-boot-splash {
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
color: var(--ds-text, #e8e8e8);
line-height: 1.5;
max-width: 520px;
}
.admin-boot-splash__title {
font-weight: 650;
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.admin-boot-splash__spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.75);
border-radius: 999px;
animation: admin-boot-spin 1s linear infinite;
flex: 0 0 auto;
}
@keyframes admin-boot-spin {
to {
transform: rotate(360deg);
}
}
.admin-boot-splash__muted {
opacity: 0.78;
font-size: 13px;
white-space: pre-wrap;
}
</style>
</head>
<body class="theme-ds-body">
<div class="layout">
@ -51,9 +84,21 @@
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
</div>
</header>
<section id="content" class="content"></section>
<section id="content" class="content">
<div id="admin-boot-splash" class="admin-boot-splash" role="status" aria-live="polite">
<div class="admin-boot-splash__title">
<span class="admin-boot-splash__spinner" aria-hidden="true"></span>
<span>正在加载管理台…</span>
</div>
<div class="admin-boot-splash__muted">若长时间停留在此,请检查网络/代理是否拦截外部资源。</div>
</div>
</section>
</main>
</div>
<script src="/admin/assets/app.js"></script>
<script
defer
src="/admin/assets/app.js?v=20260425-1"
onerror="(function(){var s=document.getElementById('admin-boot-splash');if(s){s.querySelector('.admin-boot-splash__title span:last-child').textContent='无法加载 app.js';s.querySelector('.admin-boot-splash__muted').textContent='请确认 /admin/assets/app.js 可访问。';}})()"
></script>
</body>
</html>

View file

@ -1,5 +1,39 @@
* { box-sizing: border-box; }
html, body { height: 100%; }
/* Local Outfit font (no external network dependency). */
@font-face {
font-family: "Outfit";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/admin/assets/fonts/Outfit-latin-500-600.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Outfit";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/admin/assets/fonts/Outfit-latinext-500-600.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Outfit";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/admin/assets/fonts/Outfit-latin-500-600.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Outfit";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/admin/assets/fonts/Outfit-latinext-500-600.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",

View file

@ -7,11 +7,9 @@ from .validation import error_shape
DEFAULT_AGENT_ID = "main"
ALLOWED_FILE_NAMES = {
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"ROLE_SYSTEM.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"memory/README.md",

View file

@ -2,8 +2,10 @@ from __future__ import annotations
import asyncio
import os
import shutil
from contextlib import asynccontextmanager
from typing import Any
import threading
from fastapi import FastAPI, WebSocket
from fastapi import Request
@ -22,9 +24,11 @@ from oclaw.runtime.hooks_runtime import (
trigger_hook_event,
)
from oclaw.runtime.skills import skill_runtime_diagnostics
from oclaw.runtime.prompt_prebuild import run_runtime_prewarm
from oclaw.platform.config.paths import PROJECT_ROOT, db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.interfaces.http.weixin_ilink_api import router as weixin_ilink_router
from oclaw.runtime.workspaces.experts import warm_expert_workspace_cache
def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
@ -36,7 +40,7 @@ def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
return ws_text
except Exception:
pass
return str((PROJECT_ROOT / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main").resolve())
return str((PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve())
def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]:
@ -67,6 +71,76 @@ def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]
return [("default", _resolve_startup_workspace_dir(cfg))]
def _relocate_root_scan_artifacts() -> None:
"""Keep generated scan artifacts out of repo root.
Some helper scripts write scan snapshots to the current working directory.
Normalize them into runtime/data/scan on gateway startup.
"""
root = PROJECT_ROOT.resolve()
target_dir = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve()
target_dir.mkdir(parents=True, exist_ok=True)
patterns = ("history_entries_*.json", "state_scan_*.json")
for pat in patterns:
for src in root.glob(pat):
try:
if not src.is_file():
continue
dst = target_dir / src.name
if dst.exists():
# Keep newest artifact when both paths exist.
src_mtime = src.stat().st_mtime
dst_mtime = dst.stat().st_mtime
if src_mtime <= dst_mtime:
src.unlink(missing_ok=True)
continue
dst.unlink(missing_ok=True)
shutil.move(str(src), str(dst))
except Exception:
continue
_PREWARM_SCHEDULER_LOCK = threading.Lock()
_PREWARM_SCHEDULER_RUNNING = False
def _prewarm_interval_seconds() -> int:
raw = str(os.getenv("AIA_PREWARM_INTERVAL_SECONDS") or "").strip()
if raw.isdigit():
return max(60, min(int(raw), 86_400))
return 600
def _spawn_periodic_prewarm_loop() -> None:
global _PREWARM_SCHEDULER_RUNNING
with _PREWARM_SCHEDULER_LOCK:
if _PREWARM_SCHEDULER_RUNNING:
return
_PREWARM_SCHEDULER_RUNNING = True
def _runner() -> None:
global _PREWARM_SCHEDULER_RUNNING
interval = _prewarm_interval_seconds()
while True:
try:
out = run_runtime_prewarm(reason="scheduler")
_ = out
except Exception:
pass
time_to_sleep = max(30, interval)
try:
import time
time.sleep(time_to_sleep)
except Exception:
break
with _PREWARM_SCHEDULER_LOCK:
_PREWARM_SCHEDULER_RUNNING = False
th = threading.Thread(target=_runner, name="oclaw-prewarm-scheduler", daemon=True)
th.start()
def _run_startup_hooks(app: FastAPI) -> None:
def _log_info(message: str) -> None:
print(message)
@ -113,6 +187,26 @@ def _run_startup_hooks(app: FastAPI) -> None:
_log_info(f"[skills] root={diag.get('skills_root')} total={diag.get('skills_total')}")
except Exception:
pass
try:
warm_expert_workspace_cache()
except Exception:
pass
try:
prewarm = run_runtime_prewarm(reason="startup")
_log_info(
"[startup-prebuild] "
f"ok={bool(prewarm.get('ok'))} "
f"elapsed_ms={int(prewarm.get('elapsed_ms') or 0)} "
f"freeze={bool(((prewarm.get('freeze') or {}).get('frozen')))}"
)
except Exception as exc:
_log_warn(f"[startup-prebuild] failed: {exc}")
try:
_spawn_periodic_prewarm_loop()
_log_info(f"[startup-prebuild] scheduler_started interval_s={_prewarm_interval_seconds()}")
except Exception as exc:
_log_warn(f"[startup-prebuild] scheduler_start_failed: {exc}")
_relocate_root_scan_artifacts()
startup_targets = _resolve_startup_workspace_dirs(cfg)
initialize_hooks_runtime(cfg=cfg, workspace_dir=startup_targets[0][1])
for agent_id, ws_dir in startup_targets:

View file

@ -3,6 +3,33 @@ from __future__ import annotations
import json
from typing import Any
from starlette.websockets import WebSocketState
def _ws_is_disconnected(conn: Any) -> bool:
ws = getattr(conn, "ws", None)
if ws is None:
return True
state = getattr(ws, "application_state", None)
if state == WebSocketState.DISCONNECTED:
return True
state = getattr(ws, "client_state", None)
if state == WebSocketState.DISCONNECTED:
return True
return False
async def _safe_send_text(conn: Any, text: str) -> None:
if _ws_is_disconnected(conn):
return
try:
await conn.ws.send_text(text)
except Exception as e:
msg = str(e or "")
if "Unexpected ASGI message 'websocket.send'" in msg or "response already completed" in msg:
return
raise
async def send_res(conn: Any, req_id: str, *, ok: bool, payload: Any | None = None, error: Any | None = None) -> None:
frame: dict[str, Any] = {"type": "res", "id": str(req_id or "invalid"), "ok": bool(ok)}
@ -10,15 +37,17 @@ async def send_res(conn: Any, req_id: str, *, ok: bool, payload: Any | None = No
frame["payload"] = payload
if error is not None:
frame["error"] = error
await conn.ws.send_text(json.dumps(frame, ensure_ascii=False))
await _safe_send_text(conn, json.dumps(frame, ensure_ascii=False))
async def send_event(conn: Any, event: str, payload: Any | None = None) -> None:
if _ws_is_disconnected(conn):
return
conn.seq += 1
frame: dict[str, Any] = {"type": "event", "event": str(event or "event"), "seq": int(conn.seq)}
if payload is not None:
frame["payload"] = payload
await conn.ws.send_text(json.dumps(frame, ensure_ascii=False))
await _safe_send_text(conn, json.dumps(frame, ensure_ascii=False))
async def emit_agent_event(conn: Any, *, run_id: str, stream: str, data: dict[str, Any], now_ms: int) -> None:

View file

@ -40,6 +40,12 @@ def build_gateway_context(
if not sid or not txt:
return False
rid = str(run_id or "").strip() or uuid.uuid4().hex
try:
existing = store.oclaw_run_get(run_id=rid)
if existing is not None:
return True
except Exception:
pass
with abort_lock:
active_run_session[rid] = sid
p = dict(params or {})
@ -61,7 +67,11 @@ def build_gateway_context(
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
"runId": rid,
}
asyncio.create_task(run_agent_turn("server_methods.chat.send", agent_params, session_id=sid, send_response=False))
async def _delayed() -> None:
await asyncio.sleep(0.05)
await run_agent_turn("server_methods.chat.send", agent_params, session_id=sid, send_response=False)
asyncio.create_task(_delayed())
return True
def _run_agent(params: dict[str, Any]) -> dict[str, Any]:
@ -89,7 +99,11 @@ def build_gateway_context(
}
with abort_lock:
active_run_session[str(run_id)] = str(session_id)
asyncio.create_task(run_agent_turn("server_methods.agent", agent_params, session_id=session_id, send_response=False))
async def _delayed() -> None:
await asyncio.sleep(0.05)
await run_agent_turn("server_methods.agent", agent_params, session_id=session_id, send_response=False)
asyncio.create_task(_delayed())
return {"runId": run_id, "status": "started"}
def _enqueue_session_send(session_key: str, message: str, params: dict[str, Any]) -> dict[str, Any]:

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import threading
import uuid
from datetime import datetime
from typing import Any, Callable
from oclaw.runtime.agents.factory import build_gateway_executor
@ -24,6 +25,68 @@ async def run_agent_turn_via_bridge(
now_ms: Callable[[], int],
error_shape: Callable[[str, str], dict[str, Any]],
) -> None:
def _to_ms(v: Any) -> int | None:
s = str(v or "").strip()
if not s:
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return int(datetime.fromisoformat(s).timestamp() * 1000)
except Exception:
return None
def _delta(a: int | None, b: int | None) -> int | None:
if a is None or b is None:
return None
d = int(b - a)
return d if d >= 0 else None
def _compute_ttft(trace_rows: list[dict[str, Any]], *, accepted_ms: int | None) -> dict[str, Any]:
ws_accepted_ms = accepted_ms
gateway_received_ms = None
model_chat_start_ms = None
ws_first_token_ms = None
for row in trace_rows or []:
if not isinstance(row, dict):
continue
et = str(row.get("event_type") or "").strip()
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
rts = _to_ms(row.get("timestamp"))
if et == "gateway_received":
gateway_received_ms = rts if rts is not None else gateway_received_ms
if ws_accepted_ms is None:
try:
vv = payload.get("ws_client_send_ms")
if vv is not None:
ws_accepted_ms = int(vv)
except Exception:
pass
elif et == "model_chat_start":
model_chat_start_ms = rts if rts is not None else model_chat_start_ms
elif et == "ws_first_token":
if ws_first_token_ms is None:
try:
vf = payload.get("ws_first_token_ms")
if vf is not None:
ws_first_token_ms = int(vf)
except Exception:
pass
if ws_first_token_ms is None:
ws_first_token_ms = rts if rts is not None else ws_first_token_ms
return {
"ws_accepted_ms": ws_accepted_ms,
"gateway_received_ms": gateway_received_ms,
"model_chat_start_ms": model_chat_start_ms,
"ws_first_token_ms": ws_first_token_ms,
"accepted_to_gateway_ms": _delta(ws_accepted_ms, gateway_received_ms),
"gateway_to_model_start_ms": _delta(gateway_received_ms, model_chat_start_ms),
"model_start_to_first_token_ms": _delta(model_chat_start_ms, ws_first_token_ms),
"gateway_to_first_token_ms": _delta(gateway_received_ms, ws_first_token_ms),
"accepted_to_first_token_ms": _delta(ws_accepted_ms, ws_first_token_ms),
}
accepted_ms = now_ms()
msg_text = str(p.get("message") or "").strip()
attachments = list(p.get("attachments") or [])
store = SqliteStore(db_path())
@ -66,6 +129,7 @@ async def run_agent_turn_via_bridge(
buf_lock = threading.Lock()
token_chunks: list[str] = []
first_token_ms_holder: dict[str, int | None] = {"ms": None}
marker_turn_count = 0
marker_session_count = 0
marker_keep_count = 0
@ -79,6 +143,8 @@ async def run_agent_turn_via_bridge(
def on_token(tok: str) -> None:
token_text = str(tok)
rid = run_id_holder.get("run_id") or ""
if first_token_ms_holder["ms"] is None and token_text:
first_token_ms_holder["ms"] = now_ms()
if rid:
with conn._abort_lock:
if rid in conn._aborted_run_ids:
@ -129,49 +195,58 @@ async def run_agent_turn_via_bridge(
else {},
"acp_parent_run_id": str(p.get("acp_parent_run_id") or ""),
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
"ws_accepted_ms": int(accepted_ms),
},
)
await conn.emit_agent_event(run_id=run_id_holder["run_id"], stream="lifecycle", data={"phase": "start", "status": "accepted"})
try:
relay_env = p.get("relay_share_envelope") if isinstance(p.get("relay_share_envelope"), dict) else {}
relay_pointers = 0
relay_turn = 0
relay_session = 0
relay_keep = 0
if isinstance(relay_env, dict):
ad = relay_env.get("attachments") if isinstance(relay_env.get("attachments"), dict) else {}
ps = ad.get("pointers") if isinstance(ad.get("pointers"), list) else []
relay_pointers = len(ps)
for item in ps:
if not isinstance(item, dict):
continue
ttl = str(item.get("ttl_policy") or "").strip().lower()
if ttl == "turn":
relay_turn += 1
elif ttl == "session":
relay_session += 1
elif ttl == "keep":
relay_keep += 1
marker_turn_count = int(relay_turn)
marker_session_count = int(relay_session)
marker_keep_count = int(relay_keep)
await conn.send_event(
"session.marker",
{
"runId": run_id_holder["run_id"],
"sessionKey": str(session_id),
"action": "ingress",
"relayPointerCount": int(relay_pointers),
"relayEnvelopePresent": bool(isinstance(relay_env, dict) and bool(relay_env)),
"relayEnvelopePointerCount": int(relay_pointers),
"relayTtlTurnCount": int(relay_turn),
"relayTtlSessionCount": int(relay_session),
"relayTtlKeepCount": int(relay_keep),
},
)
except Exception:
pass
if send_response:
try:
await conn.send_event(
"session.turn_started",
{"runId": run_id_holder["run_id"], "sessionKey": str(session_id), "acceptedAt": int(accepted_ms)},
)
except Exception:
pass
try:
relay_env = p.get("relay_share_envelope") if isinstance(p.get("relay_share_envelope"), dict) else {}
relay_pointers = 0
relay_turn = 0
relay_session = 0
relay_keep = 0
if isinstance(relay_env, dict):
ad = relay_env.get("attachments") if isinstance(relay_env.get("attachments"), dict) else {}
ps = ad.get("pointers") if isinstance(ad.get("pointers"), list) else []
relay_pointers = len(ps)
for item in ps:
if not isinstance(item, dict):
continue
ttl = str(item.get("ttl_policy") or "").strip().lower()
if ttl == "turn":
relay_turn += 1
elif ttl == "session":
relay_session += 1
elif ttl == "keep":
relay_keep += 1
marker_turn_count = int(relay_turn)
marker_session_count = int(relay_session)
marker_keep_count = int(relay_keep)
await conn.send_event(
"session.marker",
{
"runId": run_id_holder["run_id"],
"sessionKey": str(session_id),
"action": "ingress",
"relayPointerCount": int(relay_pointers),
"relayEnvelopePresent": bool(isinstance(relay_env, dict) and bool(relay_env)),
"relayEnvelopePointerCount": int(relay_pointers),
"relayTtlTurnCount": int(relay_turn),
"relayTtlSessionCount": int(relay_session),
"relayTtlKeepCount": int(relay_keep),
},
)
except Exception:
pass
def _run_turn_sync() -> Any:
return gw.handle_turn(
@ -193,7 +268,8 @@ async def run_agent_turn_via_bridge(
await conn.emit_agent_event(
run_id=run_id_holder.get("run_id") or "",
stream="lifecycle",
data={"phase": "error", "status": "error", "error": str(exc or "agent_failed")},
# Always emit a terminal lifecycle event even on failure.
data={"phase": "end", "status": "error", "error": str(exc or "agent_failed")},
)
await conn.emit_chat_event(run_id=run_id_holder.get("run_id") or "", state="error", error=str(exc or "agent_failed"))
try:
@ -220,6 +296,29 @@ async def run_agent_turn_via_bridge(
conn._aborted_run_ids.discard(_rid0)
rid = str(getattr(result, "run_id", "") or run_id_holder.get("run_id") or "")
ttft_payload: dict[str, Any] | None = None
try:
ft = first_token_ms_holder.get("ms")
tid = str(getattr(result, "trace_id", "") or "")
if ft is not None and tid:
store.add_trace_event(
session_id=str(session_id),
trace_id=tid,
span_id=uuid.uuid4().hex,
parent_span_id=None,
event_type="ws_first_token",
payload={"run_id": str(rid), "ws_first_token_ms": int(ft), "ws_accepted_ms": int(accepted_ms)},
)
except Exception:
pass
try:
show_ttft = str(store.get_setting("AIA_CHAT_SHOW_TTFT_DEBUG") or "").strip().lower() in {"1", "true", "yes", "on"}
tid = str(getattr(result, "trace_id", "") or "")
if show_ttft and tid:
rows = store.list_trace_events_for_trace(session_id=str(session_id), trace_id=tid, limit=400)
ttft_payload = _compute_ttft(rows, accepted_ms=int(accepted_ms))
except Exception:
ttft_payload = None
with conn._abort_lock:
if rid and rid in conn._aborted_run_ids:
try:
@ -262,6 +361,7 @@ async def run_agent_turn_via_bridge(
"relayTtlTurnCount": int(getattr(result, "relay_ttl_turn_count", 0) or 0),
"relayTtlSessionCount": int(getattr(result, "relay_ttl_session_count", 0) or 0),
"relayTtlKeepCount": int(getattr(result, "relay_ttl_keep_count", 0) or 0),
"ttft": ttft_payload if isinstance(ttft_payload, dict) else None,
},
)
await conn.emit_agent_event(