"""目标模式(Goal Mode)的工作区级状态管理。 每个工作区/项目最多存在一个活动目标。状态落盘到 `{data_dir}/goal_state.json`, 以便在切换对话、对话压缩(conversation_id 变化)、进程重启后仍能维持。 设计要点: - 不依赖 web_terminal,只接受一个 data_dir,便于单测与复用。 - token 基线对齐工作区级累计 `{data_dir}/token_totals.json`(input/output/total)。 - review_history 保存历轮 {main_output, review_reply},用于构建交叉结构审核文本。 """ from __future__ import annotations import json import time from copy import deepcopy from pathlib import Path from typing import Any, Dict, List, Optional, Union GOAL_STATE_FILENAME = "goal_state.json" # 状态机 STATUS_RUNNING = "running" STATUS_DONE = "done" STATUS_STOPPED = "stopped" # 停止原因 REASON_IDLE_NO_TOOL = "idle_no_tool" REASON_MAX_TURNS = "max_turns" REASON_MAX_TOKENS = "max_tokens" REASON_USER_CANCEL = "user_cancel" # 审核模式 REVIEW_MODE_READONLY = "readonly" REVIEW_MODE_ACTIVE = "active" PathLike = Union[str, Path] def _empty_state() -> Dict[str, Any]: return { "active": False, "status": STATUS_STOPPED, "goal": "", "review_mode": REVIEW_MODE_READONLY, "max_turns": 5, "max_tokens": None, "start_conversation_id": None, "started_at": None, "turn_count": 0, "token_baseline": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, "tool_call_count_baseline": 0, "tool_calls_used": 0, "review_history": [], "final_summary": None, "stopped_reason": None, } class GoalStateManager: """工作区级目标状态。一个实例对应一个工作区的 goal_state.json。""" def __init__(self, data_dir: PathLike): self.data_dir = Path(data_dir).expanduser() self.state: Dict[str, Any] = self.load() # ------------------------------------------------------------------ 路径/持久化 def _path(self) -> Path: return self.data_dir / GOAL_STATE_FILENAME @classmethod def load_from(cls, data_dir: PathLike) -> "GoalStateManager": """便捷构造:等价于 GoalStateManager(data_dir)。""" return cls(data_dir) def load(self) -> Dict[str, Any]: path = self._path() if not path.exists(): self.state = _empty_state() return self.state try: with open(path, "r", encoding="utf-8") as fh: raw = json.load(fh) or {} merged = _empty_state() if isinstance(raw, dict): merged.update(raw) # 结构兜底 if not isinstance(merged.get("review_history"), list): merged["review_history"] = [] if not isinstance(merged.get("token_baseline"), dict): merged["token_baseline"] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} self.state = merged except (OSError, json.JSONDecodeError, ValueError): self.state = _empty_state() return self.state def save(self) -> None: path = self._path() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") with open(tmp, "w", encoding="utf-8") as fh: json.dump(self.state, fh, ensure_ascii=False, indent=2) tmp.replace(path) # ------------------------------------------------------------------ 生命周期 def start( self, *, goal: str, review_mode: str, max_turns: Optional[int], max_tokens: Optional[int], conversation_id: Optional[str], token_baseline: Optional[Dict[str, int]] = None, tool_call_baseline: int = 0, ) -> Dict[str, Any]: rm = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY baseline = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} if isinstance(token_baseline, dict): for k in baseline: try: baseline[k] = max(0, int(token_baseline.get(k) or 0)) except (TypeError, ValueError): baseline[k] = 0 self.state = { "active": True, "status": STATUS_RUNNING, "goal": str(goal or "").strip(), "review_mode": rm, "max_turns": int(max_turns) if max_turns else None, "max_tokens": int(max_tokens) if max_tokens else None, "start_conversation_id": conversation_id, "started_at": time.time(), "turn_count": 0, "token_baseline": baseline, "tool_call_count_baseline": max(0, int(tool_call_baseline or 0)), "tool_calls_used": 0, "review_history": [], "final_summary": None, "stopped_reason": None, } self.save() return deepcopy(self.state) def is_active(self) -> bool: return bool(self.state.get("active")) and self.state.get("status") == STATUS_RUNNING def get_goal(self) -> str: return str(self.state.get("goal") or "") def get_review_mode(self) -> str: rm = self.state.get("review_mode") return rm if rm in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY def get_turn(self) -> int: try: return int(self.state.get("turn_count") or 0) except (TypeError, ValueError): return 0 def increment_turn(self) -> int: self.state["turn_count"] = self.get_turn() + 1 self.save() return self.state["turn_count"] def append_review(self, main_output: str, review_reply: str) -> None: history = self.state.get("review_history") if not isinstance(history, list): history = [] history.append( { "main_output": str(main_output or ""), "review_reply": str(review_reply or ""), } ) self.state["review_history"] = history self.save() def mark_done(self, summary: str) -> Dict[str, Any]: self.state["active"] = False self.state["status"] = STATUS_DONE self.state["final_summary"] = str(summary or "") self.state["stopped_reason"] = None self.save() return deepcopy(self.state) def mark_stopped(self, reason: str) -> Dict[str, Any]: self.state["active"] = False self.state["status"] = STATUS_STOPPED self.state["stopped_reason"] = reason self.save() return deepcopy(self.state) def clear(self) -> None: """完全清除目标状态(用户取消)。""" self.state = _empty_state() self.save() # ------------------------------------------------------------------ 边界判定 def reached_max_turns(self) -> bool: mt = self.state.get("max_turns") if not mt: return False return self.get_turn() >= int(mt) def reached_max_tokens(self, current_total_tokens: int) -> bool: """current_total_tokens 为工作区当前累计 total_tokens。""" mt = self.state.get("max_tokens") if not mt: return False used = self.tokens_used(current_total_tokens) return used >= int(mt) def tokens_used(self, current_total_tokens: int) -> int: baseline = self.state.get("token_baseline") or {} try: base_total = int(baseline.get("total_tokens") or 0) except (TypeError, ValueError): base_total = 0 return max(0, int(current_total_tokens or 0) - base_total) # ------------------------------------------------------------------ 审核输入构建 def build_review_payload_text(self, current_main_output: str) -> str: """生成交叉结构的单条 user 文本:目标 + 历轮(主模型输出/审核反馈) + 当前轮待审。""" goal = self.get_goal() lines: List[str] = ["【本次目标】", goal or "(目标为空)", ""] history = self.state.get("review_history") or [] for idx, entry in enumerate(history, start=1): if not isinstance(entry, dict): continue lines.append(f"【第{idx}轮】") lines.append(f"主执行模型输出:{str(entry.get('main_output') or '').strip()}") lines.append(f"你的反馈:{str(entry.get('review_reply') or '').strip()}") lines.append("") lines.append("【当前轮·待审核】") lines.append(f"主执行模型输出:{str(current_main_output or '').strip()}") return "\n".join(lines) # ------------------------------------------------------------------ 前端快照 def progress_snapshot(self, *, current_total_tokens: int = 0, current_tool_calls: int = 0) -> Dict[str, Any]: started_at = self.state.get("started_at") duration = None if started_at: try: duration = max(0.0, time.time() - float(started_at)) except (TypeError, ValueError): duration = None try: tool_baseline = int(self.state.get("tool_call_count_baseline") or 0) except (TypeError, ValueError): tool_baseline = 0 try: stored_tool_calls = max(0, int(self.state.get("tool_calls_used") or 0)) except (TypeError, ValueError): stored_tool_calls = 0 current_tool_calls_used = max(0, int(current_tool_calls or 0) - tool_baseline) tool_calls_used = max(stored_tool_calls, current_tool_calls_used) if tool_calls_used != stored_tool_calls: self.state["tool_calls_used"] = tool_calls_used try: self.save() except Exception: pass return { "goal": self.get_goal(), "status": self.state.get("status"), "turn_count": self.get_turn(), "tokens_used": self.tokens_used(current_total_tokens), "tool_calls": tool_calls_used, "duration_seconds": duration, "review_mode": self.get_review_mode(), "max_turns": self.state.get("max_turns"), "max_tokens": self.state.get("max_tokens"), "final_summary": self.state.get("final_summary"), "stopped_reason": self.state.get("stopped_reason"), }