Introduce workspace-level goal state persistence, goal prompt injection, and after-turn review handling so an active task can continue until the configured completion conditions are met. Add a dedicated goal review agent with readonly and active evidence modes, configurable model settings, review prompt, token/turn boundaries, idle-no-tool protection, and progress/completed/stopped events. Wire goal_mode through task creation, task restoration, compression handoff, runtime user messages, API message sanitization, and tool-call ordering so goal continuations survive long-running tasks and deep compression. Add Vue UI for arming goal mode from the quick menu, showing running/completed banners, displaying progress metrics, restoring running goal state, and exposing personalization settings for review mode and stop limits. Include goal mode research notes and default goal review configuration.
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""目标模式(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"),
|
||
}
|