修改agent流程,分步构建cdsl

This commit is contained in:
2026-08-27 18:59:33 +08:00
parent 7fc1dfa84d
commit b26db86d78
54 changed files with 6835 additions and 9984 deletions
+163 -102
View File
@@ -12,6 +12,7 @@ from app.settings import Settings
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
CANDIDATE_ID = re.compile(r"^candidate_[a-z0-9]{12,32}$")
def now_iso() -> str:
@@ -96,15 +97,6 @@ class WorkspaceStore:
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str:
"""Persist structured intake/planning evidence before a task exists."""
conversation = safe_conversation_id(conversation_id)
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning"
relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def ensure_conversation(
self,
conversation_id: str | None,
@@ -184,10 +176,14 @@ class WorkspaceStore:
return self._migrate_task(current, path)
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
source_requirements = str(request or "").strip()
source_path = task_dir / "source-requirements.md"
source_path.write_text(source_requirements + "\n", encoding="utf-8")
record = {
"schema_version": "1.3",
"schema_version": "2.0",
"task_id": tid,
"request": request,
"source_requirements_path": "source-requirements.md",
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
@@ -195,9 +191,10 @@ class WorkspaceStore:
"published_revision": "",
"lifecycle": "completed",
"run_id": "",
"generation_spec_path": "",
"run_context_path": "",
"active_node_id": "",
"requirements_path": "",
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
"revisions": [],
}
@@ -205,24 +202,42 @@ class WorkspaceStore:
return record
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
"""Add run-state fields lazily without rewriting successful history."""
"""Fill in omitted fields for native autonomous tasks only.
A task from a retired protocol must never be made to look resumable by
rewriting its schema version. In particular, doing so would make an
old revision and its obsolete planning state appear to be a valid
autonomous work head. Historical tasks remain read-only records; the
service startup path marks only *running* legacy tasks as obsolete.
"""
if str(task.get("schema_version") or "") != "2.0":
return task
changed = False
current = str(task.get("current_revision") or "")
defaults = {
"schema_version": "1.3",
"active_revision": current,
"published_revision": current,
"lifecycle": "completed",
"run_id": "",
"generation_spec_path": "",
"run_context_path": "",
"active_node_id": "",
"requirements_path": "",
"source_requirements_path": "",
"completion_checklist_path": "",
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
}
for key, value in defaults.items():
if key not in task:
task[key] = value
changed = True
if not str(task.get("source_requirements_path") or ""):
relative = Path("source-requirements.md")
source_path = path.parent / relative
if not source_path.exists():
source_path.write_text(str(task.get("request") or "").strip() + "\n", encoding="utf-8")
task["source_requirements_path"] = relative.as_posix()
changed = True
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
@@ -267,10 +282,11 @@ class WorkspaceStore:
task.update({
"lifecycle": "running",
"run_id": run_id or new_id("run"),
"active_node_id": "",
"active_candidate_id": "",
"run_failure_path": "",
"request": request or task.get("request") or "",
"active_revision": str(task.get("current_revision") or ""),
"active_branch_id": str(task.get("active_branch_id") or "main"),
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
@@ -291,7 +307,7 @@ class WorkspaceStore:
revision["visibility"] = "final"
task.update({
"lifecycle": lifecycle,
"active_node_id": "",
"active_candidate_id": "",
"run_failure_path": failure_path,
"updated_at": now_iso(),
})
@@ -303,6 +319,8 @@ class WorkspaceStore:
if not revision_id:
task["active_revision"] = ""
task["current_revision"] = ""
if branch_id:
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
@@ -317,13 +335,122 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task)
return task
def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
def set_agent_state(self, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
"""Persist enough autonomous-agent state to resume after a restart."""
task = self.ensure_task(task_id, "")
task["active_node_id"] = node_id
relative = Path("agent-state.json")
write_json(self.task_dir(task_id) / relative, values)
task["agent_state_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_agent_state(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("agent_state_path") or "")
value = read_json(self.artifact_path(task_id, relative)) if relative else None
return value if isinstance(value, dict) else None
def requirements_document_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "requirements.md"
def read_source_requirements(self, task_id: str) -> str:
"""Return the server-owned, immutable user request for this task.
Older tasks predate the artifact. Their persisted request is a
read-only compatibility fallback; new tasks always have the artifact.
"""
task = self.read_task(task_id) or {}
relative = str(task.get("source_requirements_path") or "")
if relative:
path = self.artifact_path(task_id, relative)
if path.is_file():
return path.read_text(encoding="utf-8")
return str(task.get("request") or "")
def write_requirements_document(self, task_id: str, markdown: str) -> Path:
"""Write the one immutable requirements document for an agent run."""
text = str(markdown or "").strip()
if not text:
raise ValueError("requirements.md must not be empty")
task = self.ensure_task(task_id, "")
relative = Path("requirements.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("requirements_path") or ""):
raise ValueError("requirements.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["requirements_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_requirements_document(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("requirements_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def completion_checklist_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "completion.md"
def write_completion_checklist(self, task_id: str, markdown: str) -> Path:
"""Persist the one immutable, author-owned completion checklist."""
text = str(markdown or "").strip()
if not text:
raise ValueError("completion.md must not be empty")
task = self.ensure_task(task_id, "")
if not self.read_requirements_document(task_id):
raise ValueError("requirements.md must be written before completion.md")
relative = Path("completion.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("completion_checklist_path") or ""):
raise ValueError("completion.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["completion_checklist_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_completion_checklist(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("completion_checklist_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def new_candidate(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
candidate_id = f"candidate_{secrets.token_hex(8)}"
path = self.task_dir(task_id) / "candidates" / candidate_id
path.mkdir(parents=True, exist_ok=False)
task["active_candidate_id"] = candidate_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return candidate_id, path
def candidate_dir(self, task_id: str, candidate_id: str) -> Path:
if not CANDIDATE_ID.fullmatch(str(candidate_id or "")):
raise ValueError("Invalid candidate id")
return self.task_dir(task_id) / "candidates" / candidate_id
def clear_active_candidate(self, task_id: str, candidate_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
if candidate_id and str(task.get("active_candidate_id") or "") not in {"", candidate_id}:
raise ValueError("Candidate is not active")
task["active_candidate_id"] = ""
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def append_agent_audit(self, task_id: str, kind: str, payload: dict[str, Any]) -> str:
safe_kind = re.sub(r"[^a-z0-9_-]+", "-", str(kind).lower()).strip("-") or "event"
relative = Path("agent-audit") / f"{now_iso().replace(':', '-').replace('+', '_')}_{safe_kind}_{secrets.token_hex(4)}.json"
write_json(self.task_dir(task_id) / relative, {"recorded_at": now_iso(), "kind": safe_kind, **payload})
return relative.as_posix()
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
@@ -336,21 +463,25 @@ class WorkspaceStore:
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
"""Move the generation head without deleting immutable checkpoint artifacts."""
task = self.set_active_revision(task_id, revision_id, branch_id=branch_id)
task = self.ensure_task(task_id, "")
source_branch_id = str(task.get("active_branch_id") or "main")
if revision_id:
target = next(
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id),
None,
)
if not isinstance(target, dict) or target.get("status") != "success":
raise ValueError("Active revision must be a successful revision")
children: dict[str, set[str]] = {}
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
parent = str(revision.get("parent_revision_id") or "")
child = str(revision.get("revision_id") or "")
if parent and child:
if child and str(revision.get("branch_id") or "main") == source_branch_id:
children.setdefault(parent, set()).add(child)
superseded: set[str] = set()
pending = list(children.get(revision_id, set())) if revision_id else [
str(item.get("revision_id") or "")
for item in task.get("revisions") or ()
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
]
pending = list(children.get(revision_id, set()))
while pending:
child = pending.pop()
if not child or child in superseded:
@@ -360,6 +491,9 @@ class WorkspaceStore:
for revision in task.get("revisions") or ():
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
revision["visibility"] = "superseded"
task["active_revision"] = revision_id
task["current_revision"] = revision_id
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
@@ -415,37 +549,6 @@ class WorkspaceStore:
write_json(self.task_dir(task_id) / relative, payload)
return relative.as_posix()
def generation_spec_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "generation-spec.json"
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
task = self.ensure_task(task_id, "")
path = self.generation_spec_path(task_id)
write_json(path, spec)
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def generation_run_context_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "generation-run-context.json"
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
"""Persist the frozen authoring inputs needed to resume after restart."""
task = self.ensure_task(task_id, "")
path = self.generation_run_context_path(task_id)
write_json(path, context)
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("run_context_path") or "")
context = read_json(self.artifact_path(task_id, relative)) if relative else None
return context if isinstance(context, dict) else None
def running_tasks(self) -> list[dict[str, Any]]:
"""Enumerate durable tasks that need a process-local worker."""
tasks: list[dict[str, Any]] = []
@@ -457,11 +560,6 @@ class WorkspaceStore:
tasks.append(task)
return tasks
def read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("generation_spec_path") or "")
return read_json(self.artifact_path(task_id, relative)) if relative else None
def revision_dir(self, task_id: str, revision_id: str) -> Path:
return self.task_dir(task_id) / "revisions" / revision_id
@@ -473,43 +571,6 @@ class WorkspaceStore:
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
return candidate if candidate.is_file() else None
def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None:
"""Return the latest revision CDSL when a quality failure needs repair."""
task = self.read_task(task_id)
for revision in reversed((task or {}).get("revisions") or []):
revision_id = str(revision.get("revision_id") or "")
if not revision_id or str(revision.get("quality_status") or "") != "needs_repair":
continue
path = self.revision_cdsl_path(task_id, revision_id)
if path is not None:
return revision_id, path
return None
def feature_plan_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "feature-plan.json"
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
safe_task = safe_task_id(task_id)
plan = read_json(self.feature_plan_path(safe_task))
if not isinstance(plan, dict):
return None
plan_task = str(plan.get("task_id") or "")
if plan_task and plan_task != safe_task:
return None
return plan
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
safe_task = safe_task_id(task_id)
if not isinstance(plan, dict):
raise ValueError("Feature plan must be an object")
plan_task = str(plan.get("task_id") or "")
if plan_task and plan_task != safe_task:
raise ValueError("Feature plan task_id does not match its task")
plan["task_id"] = safe_task
path = self.feature_plan_path(safe_task)
write_json(path, plan)
return path
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(