| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- 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),
- )
|