from __future__ import annotations import json import os import re from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable from .models import ( AgentStep, AttemptDetail, DataUsage, ExecutionView, LifecycleEvent, PlannerTurn, RunSummary, TaskDetail, TaskNode, ) VISUALIZATION_ROOT = Path(__file__).resolve().parents[2] DEFAULT_AGENT_DATA_ROOT = VISUALIZATION_ROOT.parent / "script_build_host" / ".local" / "agent-data" BUILD_ID_PATTERN = re.compile(r"Script build (\d+)", re.IGNORECASE) TASK_KIND_PREFIX = "script-build://task-kinds/" CONTRACT_PREFIX = "script-build://task-contracts/sha256/" PLANNER_TOOLS = { "inspect_script_plan", "plan_script_tasks", "dispatch_script_tasks", "validate_attempt", "decide_script_task", "read_input_snapshot", } ACTION_LABELS = { "inspect_script_plan": "查看整棵任务树", "plan_script_tasks": "规划新 Task", "dispatch_script_tasks": "派发 Task 执行", "validate_attempt": "读取独立验收", "decide_script_task": "决定下一步", "read_input_snapshot": "读取冻结输入", } class RunNotFound(FileNotFoundError): pass def agent_data_root() -> Path: configured = os.getenv("SCRIPT_BUILD_AGENT_DATA_ROOT") return Path(configured).expanduser().resolve() if configured else DEFAULT_AGENT_DATA_ROOT.resolve() def _read_json(path: Path) -> dict[str, Any]: with path.open("r", encoding="utf-8") as handle: value = json.load(handle) return value if isinstance(value, dict) else {} def _message_files(root: Path, trace_id: str) -> list[Path]: return sorted((root / "traces" / trace_id / "messages").glob("*.json")) def _messages(root: Path, trace_id: str) -> list[dict[str, Any]]: return [_read_json(path) for path in _message_files(root, trace_id)] def _build_id_for_root(root: Path, root_trace_id: str) -> int | None: goal_path = root / "traces" / root_trace_id / "goal.json" if goal_path.exists(): match = BUILD_ID_PATTERN.search(str(_read_json(goal_path).get("mission", ""))) if match: return int(match.group(1)) for message in _messages(root, root_trace_id)[:6]: content = message.get("content") if isinstance(content, str): try: payload = json.loads(content) except json.JSONDecodeError: continue if isinstance(payload, dict) and isinstance(payload.get("script_build_id"), int): return payload["script_build_id"] return None def _roots_by_build() -> dict[int, str]: root = agent_data_root() values: dict[int, str] = {} for ledger in sorted((root / "task-ledger").glob("*/orchestration/ledger.json")): root_trace_id = ledger.parents[1].name build_id = _build_id_for_root(root, root_trace_id) if build_id is not None: values[build_id] = root_trace_id return values def _load_run(script_build_id: int) -> tuple[Path, str, dict[str, Any], list[dict[str, Any]]]: root = agent_data_root() root_trace_id = _roots_by_build().get(script_build_id) if not root_trace_id: raise RunNotFound(f"Persisted script build {script_build_id} was not found") ledger_path = root / "task-ledger" / root_trace_id / "orchestration" / "ledger.json" return root, root_trace_id, _read_json(ledger_path), _messages(root, root_trace_id) def _task_kind(task: dict[str, Any]) -> str: if task.get("parent_task_id") is None: return "root" specs = task.get("specs") or [] refs = (specs[-1].get("context_refs") or []) if specs else [] for ref in refs: if isinstance(ref, str) and ref.startswith(TASK_KIND_PREFIX): return ref.removeprefix(TASK_KIND_PREFIX) return "task" def _phase(display_path: str, task_kind: str) -> str: if task_kind == "root": return "mission" if display_path.startswith("0.1"): return "phase-one" if display_path.startswith("0.2"): return "phase-two" return "delivery" def _task_view(task: dict[str, Any]) -> TaskNode: specs = task.get("specs") or [{}] spec = specs[-1] kind = _task_kind(task) path = str(task.get("display_path", "?")) return TaskNode( task_id=str(task.get("task_id", "")), display_path=path, parent_task_id=task.get("parent_task_id"), child_task_ids=list(task.get("child_task_ids") or []), task_kind=kind, phase=_phase(path, kind), objective=str(spec.get("objective") or "未记录目标"), status=str(task.get("status") or "unknown"), blocked_reason=task.get("blocked_reason"), attempt_count=len(task.get("attempt_ids") or []), validation_count=len(task.get("validation_ids") or []), decision_count=len(task.get("decision_ids") or []), current_spec_version=int(task.get("current_spec_version") or 1), acceptance_criteria=list(spec.get("acceptance_criteria") or []), context_refs=list(spec.get("context_refs") or []), created_at=str(task.get("created_at") or ""), updated_at=str(task.get("updated_at") or ""), ) def _parse_object(value: Any) -> Any: if not isinstance(value, str): return value try: return json.loads(value) except json.JSONDecodeError: return value def _bounded_text(value: Any, limit: int = 3200) -> str: if value is None: return "" text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2) normalized = text.strip() return normalized if len(normalized) <= limit else f"{normalized[:limit]}\n…(已截断)" def _ids_from(value: Any, known_ids: set[str]) -> set[str]: found: set[str] = set() if isinstance(value, dict): for key, item in value.items(): if key in {"task_id", "parent_task_id"} and isinstance(item, str) and item in known_ids: found.add(item) elif key in {"task_ids", "child_task_ids"} and isinstance(item, list): found.update(entry for entry in item if isinstance(entry, str) and entry in known_ids) found.update(_ids_from(item, known_ids)) elif isinstance(value, list): for item in value: found.update(_ids_from(item, known_ids)) return found def _result_status(result: Any) -> str: if isinstance(result, dict): encoded = json.dumps(result, ensure_ascii=False).lower() if any(token in encoded for token in ('"error"', "protocol_violation", "failed")) and '"error": null' not in encoded: return "failed" return "succeeded" return "unknown" def _planner_turns( messages: list[dict[str, Any]], task_views: list[TaskNode] ) -> tuple[list[PlannerTurn], str | None]: known_ids = {task.task_id for task in task_views} root_ids = {task.task_id for task in task_views if task.parent_task_id is None} tool_results = { message.get("tool_call_id"): message for message in messages if message.get("role") == "tool" and message.get("tool_call_id") } visible_ids = set(root_ids) current_phase = "mission" turns: list[PlannerTurn] = [] current_focus: str | None = None for message in messages: if message.get("role") == "user": payload = _parse_object(message.get("content")) if isinstance(payload, dict) and payload.get("phase"): current_phase = f"phase-{payload['phase']}" continue if message.get("role") != "assistant" or not isinstance(message.get("content"), dict): continue content = message["content"] for call in content.get("tool_calls") or []: function = call.get("function") or {} tool_name = str(function.get("name") or "") if tool_name not in PLANNER_TOOLS: continue arguments = _parse_object(function.get("arguments") or "{}") arguments = arguments if isinstance(arguments, dict) else {"raw": arguments} result_message = tool_results.get(call.get("id"), {}) result_content = result_message.get("content") or {} raw_result = result_content.get("result") if isinstance(result_content, dict) else result_content result = _parse_object(raw_result) affected = _ids_from(arguments, known_ids) | _ids_from(result, known_ids) newly_visible = _ids_from(result, known_ids) visible_ids.update(newly_visible) explicit_focus = arguments.get("task_id") or arguments.get("parent_task_id") if not explicit_focus and isinstance(arguments.get("task_ids"), list) and arguments["task_ids"]: explicit_focus = arguments["task_ids"][0] if explicit_focus in known_ids: current_focus = explicit_focus elif affected: current_focus = sorted(affected)[0] order = len(turns) + 1 visible_snapshot = sorted(visible_ids) turns.append( PlannerTurn( turn_id=f"planner-{message.get('sequence', order)}-{len(turns)}", order=order, message_sequence=int(message.get("sequence") or order), phase=current_phase, action=tool_name, action_label=ACTION_LABELS[tool_name], summary=_bounded_text(content.get("text"), 520) or ACTION_LABELS[tool_name], observable_reasoning=_bounded_text(content.get("text"), 2200), result_summary=_bounded_text(result, 900), status=_result_status(result), focus_task_id=current_focus, affected_task_ids=sorted(affected), visible_task_ids=visible_snapshot, tool_name=tool_name, tool_arguments=arguments, tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0), created_at=str(message.get("created_at") or ""), ) ) if turns: turns[-1].visible_task_ids = sorted(known_ids) return turns, current_focus def _checkpoint(tasks: list[TaskNode]) -> str: kinds = {task.task_kind for task in tasks} if "candidate-portfolio" in kinds: if "compose" in kinds: return "Phase 2 · Compose 子任务回流" return "Phase 2 · Candidate Portfolio" if any(task.task_kind == "direction" and task.status == "completed" for task in tasks): return "Phase 1 · Direction 已验收" return "Phase 1 · 方向与证据" def _current_gap(tasks: list[TaskNode]) -> str: candidates = [task for task in tasks if task.status in {"needs_replan", "failed", "blocked"}] candidates.sort(key=lambda item: (item.display_path.count("."), item.updated_at), reverse=True) if candidates: task = candidates[0] reason = task.blocked_reason or task.status return f"{task.display_path} {task.task_kind} 等待 Planner 处理:{reason}" waiting = [task for task in tasks if task.status == "waiting_children"] if waiting: task = sorted(waiting, key=lambda item: item.display_path.count("."), reverse=True)[0] return f"{task.display_path} 正在等待子 Task 结果返回" return "当前持久化快照没有未处理缺口" def _run_summary( script_build_id: int, root_trace_id: str, ledger: dict[str, Any], tasks: list[TaskNode], turns: list[PlannerTurn], focus: str | None, ) -> RunSummary: root_id = ledger.get("root_task_id") root_task = next((task for task in tasks if task.task_id == root_id), tasks[0]) return RunSummary( script_build_id=script_build_id, title=root_task.objective, status=root_task.status, checkpoint=_checkpoint(tasks), root_trace_id=root_trace_id, revision=int(ledger.get("revision") or 0), task_count=len(tasks), attempt_count=len(ledger.get("attempts") or {}), planner_turn_count=len(turns), current_focus_task_id=focus, current_gap=_current_gap(tasks), created_at=root_task.created_at, updated_at=max((task.updated_at for task in tasks), default=root_task.updated_at), ) def execution_view(script_build_id: int) -> ExecutionView: _, root_trace_id, ledger, messages = _load_run(script_build_id) tasks = [_task_view(item) for item in (ledger.get("tasks") or {}).values()] tasks.sort(key=lambda item: [int(part) for part in item.display_path.split(".")]) turns, focus = _planner_turns(messages, tasks) run = _run_summary(script_build_id, root_trace_id, ledger, tasks, turns, focus) warnings = [ "树的生长顺序来自 Planner 工具结果;Task 状态来自当前 ledger,不伪造历史状态回放。", "Reasoning 仅展示模型主动输出的说明、工具参数与验收理由,不等同于隐藏思维链。", ] return ExecutionView( schema_version="script-build-execution-view/v1", data_mode="persisted-real-run", generated_at=datetime.now(timezone.utc).isoformat(), run=run, planner_turns=turns, tasks=tasks, snapshot_basis="Planner 工具调用顺序 + 当前 TaskLedger 持久化快照", warnings=warnings, ) def list_runs() -> list[RunSummary]: values: list[RunSummary] = [] for build_id in sorted(_roots_by_build(), reverse=True): try: values.append(execution_view(build_id).run) except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): continue return values def _contract(root: Path, root_trace_id: str, task: TaskNode) -> dict[str, Any] | None: for ref in task.context_refs: if ref.startswith(CONTRACT_PREFIX): digest = ref.removeprefix(CONTRACT_PREFIX) path = root / "script-task-contracts" / root_trace_id / "sha256" / digest if path.exists(): return _read_json(path) return None def _step_title(tool_name: str) -> str: if tool_name.startswith("read_"): return f"读取数据 · {tool_name}" if tool_name.startswith(("create_", "batch_create_", "append_")): return f"写入草稿 · {tool_name}" if tool_name.startswith(("update_", "batch_update_")): return f"修改草稿 · {tool_name}" if tool_name == "submit_attempt": return "提交 Attempt" return f"调用工具 · {tool_name}" def _agent_steps(root: Path, trace_id: str | None) -> tuple[str, list[AgentStep]]: if not trace_id: return "", [] prompt = "" steps: list[AgentStep] = [] for message in _messages(root, trace_id): role = str(message.get("role") or "unknown") content = message.get("content") if role == "system": prompt = _bounded_text(content, 9000) continue if role == "user": steps.append( AgentStep( sequence=int(message.get("sequence") or 0), role=role, step_kind="instruction", title="冻结的 TaskSpec", text=_bounded_text(content), tool_name=None, tool_arguments=None, tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0), duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""), ) ) continue if role == "assistant" and isinstance(content, dict): text = _bounded_text(content.get("text"), 2200) if text: steps.append( AgentStep( sequence=int(message.get("sequence") or 0), role=role, step_kind="explanation", title="Worker 的可观察说明", text=text, tool_name=None, tool_arguments=None, tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0), duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""), ) ) for call in content.get("tool_calls") or []: function = call.get("function") or {} name = str(function.get("name") or "unknown_tool") args = _parse_object(function.get("arguments") or "{}") steps.append( AgentStep( sequence=int(message.get("sequence") or 0), role=role, step_kind="tool_call", title=_step_title(name), text="", tool_name=name, tool_arguments=args if isinstance(args, dict) else {"raw": args}, tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0), duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""), ) ) elif role == "tool": tool_name = content.get("tool_name") if isinstance(content, dict) else None result = content.get("result") if isinstance(content, dict) else content steps.append( AgentStep( sequence=int(message.get("sequence") or 0), role=role, step_kind="tool_result", title=f"工具返回 · {tool_name or 'unknown'}", text=_bounded_text(_parse_object(result)), tool_name=tool_name, tool_arguments=None, tokens=0, cost=0, duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""), ) ) return prompt, steps def _values_for_task(values: dict[str, Any], task_id: str) -> list[dict[str, Any]]: return [value for value in values.values() if value.get("task_id") == task_id] def _first_for_attempt(values: Iterable[dict[str, Any]], attempt_id: str) -> dict[str, Any] | None: return next((value for value in values if value.get("attempt_id") == attempt_id), None) def task_detail(script_build_id: int, task_id: str) -> TaskDetail: root, root_trace_id, ledger, _ = _load_run(script_build_id) raw_task = (ledger.get("tasks") or {}).get(task_id) if not raw_task: raise RunNotFound(f"Task {task_id} was not found in build {script_build_id}") task = _task_view(raw_task) contract = _contract(root, root_trace_id, task) validations = _values_for_task(ledger.get("validations") or {}, task_id) decisions = _values_for_task(ledger.get("decisions") or {}, task_id) raw_attempts = _values_for_task(ledger.get("attempts") or {}, task_id) attempt_details: list[AttemptDetail] = [] reads: list[dict[str, Any]] = [] outputs: list[dict[str, Any]] = [] for attempt in sorted(raw_attempts, key=lambda item: item.get("created_at") or ""): prompt, steps = _agent_steps(root, attempt.get("worker_trace_id")) for step in steps: if step.step_kind == "tool_call" and step.tool_name and step.tool_name.startswith(("read_", "get_", "search_", "query_", "inspect_")): reads.append({"attempt_id": attempt.get("attempt_id"), "tool_name": step.tool_name, "arguments": step.tool_arguments, "proof": "worker_trace_tool_call"}) submission = attempt.get("submission") if isinstance(submission, dict): for artifact in submission.get("artifact_refs") or []: outputs.append({"attempt_id": attempt.get("attempt_id"), "artifact": artifact, "proof": "attempt_submission"}) validation = _first_for_attempt(validations, str(attempt.get("attempt_id"))) decision = _first_for_attempt(decisions, str(attempt.get("attempt_id"))) stats = attempt.get("execution_stats") or {} attempt_details.append( AttemptDetail( attempt_id=str(attempt.get("attempt_id") or ""), task_id=task_id, status=str(attempt.get("status") or "unknown"), worker_preset=attempt.get("worker_preset"), worker_trace_id=attempt.get("worker_trace_id"), execution_mode=attempt.get("execution_mode"), started_at=attempt.get("started_at"), completed_at=attempt.get("completed_at"), total_tokens=int(stats.get("total_tokens") or 0), total_cost=float(stats.get("total_cost") or 0), error=attempt.get("error"), submission=submission, validation=validation, decision=decision, prompt=prompt, steps=steps, ) ) lifecycle = [ LifecycleEvent(event_id=f"task-{task_id}", kind="task", title="Planner 创建 Task", status="created", summary=task.objective, created_at=task.created_at) ] for attempt in raw_attempts: lifecycle.append(LifecycleEvent(event_id=str(attempt.get("attempt_id")), kind="attempt", title="Worker 执行 Attempt", status=str(attempt.get("status")), summary=str(attempt.get("worker_preset") or "worker"), created_at=str(attempt.get("created_at") or ""), attempt_id=attempt.get("attempt_id"))) for validation in validations: lifecycle.append(LifecycleEvent(event_id=str(validation.get("validation_id")), kind="validation", title="Validator 独立验收", status=str(validation.get("verdict") or validation.get("status")), summary=str(validation.get("recommendation") or validation.get("summary") or ""), created_at=str(validation.get("created_at") or ""), attempt_id=validation.get("attempt_id"))) for decision in decisions: lifecycle.append(LifecycleEvent(event_id=str(decision.get("decision_id")), kind="decision", title="Planner 决定下一步", status=str(decision.get("action")), summary=str(decision.get("reason") or ""), created_at=str(decision.get("created_at") or ""), attempt_id=decision.get("attempt_id"))) lifecycle.sort(key=lambda item: item.created_at) inherited: list[dict[str, Any]] = [{"ref": ref, "proof": "task_spec_context_ref", "usage": "在冻结输入闭包中"} for ref in task.context_refs] if contract: for ref in contract.get("input_decision_refs") or []: inherited.append({"ref": ref, "proof": "task_contract_input_decision_ref", "usage": "显式接受的上游结果"}) if contract.get("base_artifact_ref"): inherited.append({"ref": contract["base_artifact_ref"], "proof": "task_contract_base_artifact_ref", "usage": "本 Attempt 的基础产物"}) adoption = [{"decision_id": value.get("decision_id"), "action": value.get("action"), "reason": value.get("reason"), "proof": "planner_decision"} for value in decisions] return TaskDetail( task=task, contract=contract, lifecycle=lifecycle, attempts=attempt_details, data_usage=DataUsage(inherited_inputs=inherited, confirmed_reads=reads, produced_outputs=outputs, adoption=adoption), )