fischer-agentkit/src/agentkit/core/spec_manager.py

213 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Spec Manager — 执行计划规格文档管理器
将 PlanExecEngine 生成的执行计划持久化为 Spec 文档,
用户可在执行前查看、编辑和确认 Spec作为人与 AI 之间的契约。
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
@dataclass
class SpecStep:
"""A single step in a spec."""
step_id: str
name: str
description: str
dependencies: list[str] = field(default_factory=list)
status: str = "pending" # pending | confirmed | executing | completed | failed
@dataclass
class Spec:
"""A specification document for a planned task."""
spec_id: str
goal: str
steps: list[SpecStep] = field(default_factory=list)
# draft | confirmed | executing | completed | failed | parked
# U8/R8: "parked" is set when the spec review gate times out (30 min).
# A parked spec is NOT failed — the user can resume the review on return.
status: str = "draft"
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
confirmed_at: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
class SpecManager:
"""Manages Spec documents as first-class citizens.
Specs are persisted to .agentkit/specs/ directory as YAML files.
Users can view, edit, and confirm specs before execution.
"""
def __init__(self, specs_dir: str | None = None):
"""
Args:
specs_dir: Directory for spec files. Default: .agentkit/specs/
"""
self._specs_dir = Path(specs_dir or ".agentkit/specs")
self._specs_dir.mkdir(parents=True, exist_ok=True)
self._cache: dict[str, Spec] = {}
def create(self, spec: Spec) -> Path:
"""Persist a Spec to disk. Returns the file path."""
path = self._specs_dir / f"{spec.spec_id}.yaml"
data = asdict(spec)
path.write_text(
yaml.dump(data, allow_unicode=True, default_flow_style=False), encoding="utf-8"
)
self._cache[spec.spec_id] = spec
logger.info(f"Spec created: {spec.spec_id} -> {path}")
return path
def get(self, spec_id: str) -> Spec | None:
"""Load a Spec by ID."""
if spec_id in self._cache:
return self._cache[spec_id]
path = self._specs_dir / f"{spec_id}.yaml"
if not path.exists():
return None
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
spec = self._dict_to_spec(data)
self._cache[spec_id] = spec
return spec
except Exception as e:
logger.error(f"Failed to load spec {spec_id}: {e}")
return None
def update(self, spec_id: str, **kwargs: Any) -> Spec | None:
"""Update spec fields and persist."""
spec = self.get(spec_id)
if spec is None:
return None
for key, value in kwargs.items():
if key == "steps" and isinstance(value, list):
spec.steps = [self._dict_to_step(s) if isinstance(s, dict) else s for s in value]
elif hasattr(spec, key):
setattr(spec, key, value)
self.create(spec) # re-persist
return spec
def confirm(self, spec_id: str) -> Spec | None:
"""Mark a spec as confirmed (user approved execution)."""
spec = self.get(spec_id)
if spec is None:
return None
spec.status = "confirmed"
spec.confirmed_at = datetime.now(timezone.utc).isoformat()
# Mark all steps as confirmed
for step in spec.steps:
if step.status == "pending":
step.status = "confirmed"
self.create(spec) # re-persist
logger.info(f"Spec confirmed: {spec_id}")
return spec
def park(self, spec_id: str) -> Spec | None:
"""U8/R8: Park a spec when the review gate times out.
A parked spec is distinct from a failed spec — the user can resume
the review flow on return (see ``resume``). Mirrors ``confirm``.
"""
spec = self.get(spec_id)
if spec is None:
return None
spec.status = "parked"
self.create(spec) # re-persist
logger.info(f"Spec parked: {spec_id}")
return spec
def resume(self, spec_id: str) -> Spec | None:
"""U8/R8: Un-park a spec back to ``draft`` so the review flow restarts.
Only valid when status == "parked". Returns the spec unchanged (no-op,
logged) when the spec is not parked — ponytail: no-op over raise keeps
callers simple; an idempotent resume is safer than crashing on a
double-resume. Returns None when the spec does not exist.
"""
spec = self.get(spec_id)
if spec is None:
return None
if spec.status != "parked":
logger.warning(f"Spec {spec_id} not parked (status={spec.status}), resume is a no-op")
return spec
spec.status = "draft"
self.create(spec) # re-persist
logger.info(f"Spec resumed: {spec_id}")
return spec
def list_specs(self, status: str | None = None) -> list[Spec]:
"""List all specs, optionally filtered by status. Sorted by created_at desc."""
specs: list[Spec] = []
for path in self._specs_dir.glob("*.yaml"):
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
spec = self._dict_to_spec(data)
if status is None or spec.status == status:
specs.append(spec)
except Exception as e:
logger.warning(f"Failed to load spec from {path}: {e}")
specs.sort(key=lambda s: s.created_at, reverse=True)
return specs
def delete(self, spec_id: str) -> bool:
"""Delete a spec file."""
path = self._specs_dir / f"{spec_id}.yaml"
if not path.exists():
return False
path.unlink()
self._cache.pop(spec_id, None)
logger.info(f"Spec deleted: {spec_id}")
return True
@staticmethod
def _dict_to_spec(data: dict[str, Any]) -> Spec:
"""Convert a dict to a Spec instance."""
steps = [SpecManager._dict_to_step(s) for s in data.get("steps", [])]
return Spec(
spec_id=data["spec_id"],
goal=data["goal"],
steps=steps,
status=data.get("status", "draft"),
created_at=data.get("created_at", ""),
confirmed_at=data.get("confirmed_at"),
metadata=data.get("metadata", {}),
)
@staticmethod
def _dict_to_step(data: dict[str, Any] | SpecStep) -> SpecStep:
"""Convert a dict to a SpecStep instance."""
if isinstance(data, SpecStep):
return data
return SpecStep(
step_id=data["step_id"],
name=data["name"],
description=data["description"],
dependencies=data.get("dependencies", []),
status=data.get("status", "pending"),
)