Add dynamic analyst runtime updates and deployment guides

This commit is contained in:
2026-04-07 09:39:37 +08:00
parent 80ce63da5a
commit 62c7341cf6
45 changed files with 1886 additions and 159 deletions

View File

@@ -7,6 +7,7 @@ import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
import sys
@@ -154,6 +155,7 @@ class RunContextResponse(BaseModel):
class RuntimeAgentState(BaseModel):
agent_id: str
display_name: Optional[str] = None
status: str
last_session: Optional[str] = None
last_updated: str
@@ -300,6 +302,70 @@ def _load_run_server_state(run_dir: Path) -> Dict[str, Any]:
return {}
def _resolve_runtime_agent_display_name(run_id: str, agent_id: str) -> Optional[str]:
"""Best-effort display name for one runtime agent.
Priority:
1. PROFILE.md line like `角色定位:中文名`
2. PROFILE.md YAML frontmatter field `name`
"""
asset_dir = PROJECT_ROOT / "runs" / run_id / "agents" / agent_id
profile_path = asset_dir / "PROFILE.md"
if not profile_path.exists():
return None
try:
raw = profile_path.read_text(encoding="utf-8").strip()
except Exception:
return None
if not raw:
return None
frontmatter_name: Optional[str] = None
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
import yaml
parsed = yaml.safe_load(parts[1].strip()) or {}
if isinstance(parsed, dict):
value = parsed.get("name")
if isinstance(value, str) and value.strip():
frontmatter_name = value.strip()
except Exception:
pass
raw = parts[2].strip()
for line in raw.splitlines():
normalized = line.strip()
if normalized.startswith("角色定位:"):
value = normalized.split("", 1)[1].strip()
if value:
return value
if normalized.lower().startswith("role:"):
value = normalized.split(":", 1)[1].strip()
if value:
return value
return frontmatter_name
def _enrich_runtime_agents(run_id: Optional[str], agents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if not run_id:
return agents
enriched: List[Dict[str, Any]] = []
for item in agents:
payload = dict(item)
display_name = payload.get("display_name")
agent_id = str(payload.get("agent_id") or "").strip()
if agent_id and (not isinstance(display_name, str) or not display_name.strip()):
payload["display_name"] = _resolve_runtime_agent_display_name(run_id, agent_id)
enriched.append(payload)
return enriched
def _extract_history_metrics(run_dir: Path) -> tuple[int, Optional[float]]:
"""Prefer runtime state files over dashboard exports for history summaries."""
server_state = _load_run_server_state(run_dir)
@@ -566,10 +632,11 @@ def _validate_gateway_config(bootstrap: Dict[str, Any]) -> List[str]:
# Check LLM configuration
model_name = os.getenv("MODEL_NAME")
openai_key = os.getenv("OPENAI_API_KEY")
dashscope_key = os.getenv("DASHSCOPE_API_KEY")
if not model_name:
errors.append("MODEL_NAME environment variable is not set")
if not openai_key:
errors.append("OPENAI_API_KEY environment variable is not set")
if not openai_key and not dashscope_key:
errors.append("Either OPENAI_API_KEY or DASHSCOPE_API_KEY environment variable must be set")
# Validate tickers
tickers = bootstrap.get("tickers", [])
@@ -722,7 +789,8 @@ async def get_run_context() -> RunContextResponse:
async def get_runtime_agents() -> RuntimeAgentsResponse:
"""Return agent states from the active runtime, or latest persisted run."""
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
agents = snapshot.get("agents", [])
run_id = snapshot.get("context", {}).get("config_name")
agents = _enrich_runtime_agents(run_id, snapshot.get("agents", []))
return RuntimeAgentsResponse(
agents=[RuntimeAgentState(**a) for a in agents]
@@ -869,11 +937,24 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]:
def _get_active_runtime_snapshot() -> Dict[str, Any]:
"""Return the active runtime snapshot, preferring in-memory manager state."""
"""Return the active runtime snapshot.
For a running Gateway, the canonical runtime source of truth is the
run-scoped snapshot file under `runs/<run_id>/state/runtime_state.json`,
because the Gateway subprocess mutates it directly while the parent
runtime_service process may still hold a stale in-memory manager snapshot.
"""
if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running")
manager = _runtime_state.runtime_manager
if manager is not None:
run_id = str(getattr(manager, "config_name", "") or "").strip()
if run_id:
snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json"
if snapshot_path.exists():
return json.loads(snapshot_path.read_text(encoding="utf-8"))
if manager is not None and hasattr(manager, "build_snapshot"):
snapshot = manager.build_snapshot()
context = snapshot.get("context") or {}
@@ -900,11 +981,32 @@ def _read_log_tail(path: Path, max_chars: int = 120_000) -> str:
if not path.exists() or not path.is_file():
return ""
text = path.read_text(encoding="utf-8", errors="replace")
text = _sanitize_runtime_log_text(text)
if len(text) <= max_chars:
return text
return text[-max_chars:]
def _sanitize_runtime_log_text(text: str) -> str:
if not text:
return ""
# Drop repetitive development-only warnings for unsandboxed skill execution.
text = re.sub(
r"(?:^|\n)=+\n"
r"⚠️\s+\[安全警告\]\s+技能在无沙盒模式下运行\s+\(SKILL_SANDBOX_MODE=none\)\n"
r"\s+技能脚本将直接在当前进程中执行,无隔离保护。\n"
r"\s+建议:生产环境请设置\s+SKILL_SANDBOX_MODE=docker\n"
r"=+\n?",
"\n",
text,
flags=re.MULTILINE,
)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _get_current_runtime_context() -> Dict[str, Any]:
"""Return the active runtime context from the latest snapshot."""
if not _is_gateway_running():