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.
367 lines
17 KiB
Python
367 lines
17 KiB
Python
"""目标审核智能体(Goal Review Agent)。
|
||
|
||
由 modules/approval_agent.py fork 而来。职责不同:
|
||
- approval_agent 判断"工具调用是否越权/危险"。
|
||
- goal_review_agent 判断"长期目标是否真正达成"。
|
||
|
||
两种审核模式:
|
||
- readonly:只给 report_goal_status 一个工具,不注入 run_command,system prompt 不含模式说明。
|
||
- active:额外注入只读 run_command 工具,system prompt 追加一句允许其取证。
|
||
|
||
返回结构:{"status": "done"|"continue", "message": str, "source": "goal_review_agent"}。
|
||
兜底(超轮/异常/未产出结论)保守返回 continue,避免误判完成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional
|
||
|
||
import httpx
|
||
|
||
from config import PROMPTS_DIR
|
||
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
|
||
|
||
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "goal_review.json"
|
||
PROMPT_NAME = "goal_review_agent.txt"
|
||
DEFAULT_MAX_ROUNDS = 3
|
||
DEFAULT_TIMEOUT_SECONDS = 60
|
||
DEFAULT_MAX_COMMAND_TIMEOUT = 60
|
||
DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT = True
|
||
DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "goal_review_agent"
|
||
|
||
# 兜底续命消息(审核智能体未能产出明确结论时使用)
|
||
FALLBACK_CONTINUE_MESSAGE = "审核未能给出明确结论。请重新对照目标核查当前进度,找出尚未完成的部分并继续推进。"
|
||
|
||
|
||
def _resolve_env_token(value: str) -> str:
|
||
text = str(value or "").strip()
|
||
if text.startswith("${") and text.endswith("}"):
|
||
key = text[2:-1].strip()
|
||
return os.environ.get(key, "")
|
||
return text
|
||
|
||
|
||
def load_goal_review_agent_config() -> Dict[str, Any]:
|
||
base = {
|
||
"name": "goal-review-agent",
|
||
"url": "",
|
||
"key": "",
|
||
"model": "",
|
||
"extra_params": {},
|
||
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
|
||
"max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT,
|
||
}
|
||
try:
|
||
if not CONFIG_PATH.exists():
|
||
return base
|
||
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||
if isinstance(raw, dict):
|
||
base.update(raw)
|
||
base["url"] = _resolve_env_token(base.get("url", ""))
|
||
base["key"] = _resolve_env_token(base.get("key", ""))
|
||
base["model"] = str(base.get("model") or "").strip()
|
||
base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {}
|
||
base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||
base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT))
|
||
return base
|
||
except Exception:
|
||
return base
|
||
|
||
|
||
class GoalReviewAgent:
|
||
def __init__(self, *, web_terminal: Any):
|
||
self.web_terminal = web_terminal
|
||
self.cfg = load_goal_review_agent_config()
|
||
|
||
@staticmethod
|
||
def _system_prompt(review_mode: str) -> str:
|
||
try:
|
||
template = (Path(PROMPTS_DIR) / PROMPT_NAME).read_text(encoding="utf-8")
|
||
except Exception:
|
||
template = "你是目标审核智能体。你必须调用 report_goal_status 返回 done 或 continue。"
|
||
|
||
active_start = "{{ACTIVE_REVIEW_ONLY}}"
|
||
active_end = "{{/ACTIVE_REVIEW_ONLY}}"
|
||
if review_mode == REVIEW_MODE_ACTIVE:
|
||
return template.replace(active_start, "").replace(active_end, "").strip()
|
||
|
||
while active_start in template and active_end in template:
|
||
start = template.find(active_start)
|
||
end = template.find(active_end, start)
|
||
if start < 0 or end < 0:
|
||
break
|
||
template = template[:start] + template[end + len(active_end):]
|
||
return template.strip()
|
||
|
||
@staticmethod
|
||
def _report_tool() -> Dict[str, Any]:
|
||
return {
|
||
"type": "function",
|
||
"function": {
|
||
"name": "report_goal_status",
|
||
"description": (
|
||
"提交目标审核结论并结束本次审核。done=目标已达成,结束循环;"
|
||
"continue=尚未达成,需主执行模型继续工作。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["done", "continue"],
|
||
"description": "done=目标已达成;continue=尚未达成,继续下一轮。",
|
||
},
|
||
"message": {
|
||
"type": "string",
|
||
"description": (
|
||
"status=done 时写给用户的完成总结;"
|
||
"status=continue 时写给主执行模型的下一步指示,需具体可执行。"
|
||
),
|
||
},
|
||
},
|
||
"required": ["status", "message"],
|
||
},
|
||
},
|
||
}
|
||
|
||
@staticmethod
|
||
def _run_command_tool() -> Dict[str, Any]:
|
||
return {
|
||
"type": "function",
|
||
"function": {
|
||
"name": "run_command",
|
||
"description": (
|
||
"在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、"
|
||
"查看 git 状态与差异、运行测试以核实目标是否达成。"
|
||
"禁止用于写入、删除、改权限或其他修改性操作。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"command": {
|
||
"type": "string",
|
||
"description": (
|
||
"要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、"
|
||
"rg、git status、git diff、pwd、stat、以及运行测试的命令等。"
|
||
),
|
||
}
|
||
},
|
||
"required": ["command"],
|
||
},
|
||
},
|
||
}
|
||
|
||
def _build_tools(self, review_mode: str) -> List[Dict[str, Any]]:
|
||
if review_mode == REVIEW_MODE_ACTIVE:
|
||
return [self._run_command_tool(), self._report_tool()]
|
||
return [self._report_tool()]
|
||
|
||
async def _run_readonly_command(self, command: str) -> Dict[str, Any]:
|
||
work_path = Path(getattr(self.web_terminal.context_manager, "project_path", "."))
|
||
timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)
|
||
try:
|
||
result = await self.web_terminal.terminal_ops.run_command(
|
||
command=command,
|
||
working_dir=str(work_path),
|
||
timeout=timeout,
|
||
sandbox_write_access=False,
|
||
)
|
||
return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"}
|
||
except Exception as exc:
|
||
return {"success": False, "error": str(exc)}
|
||
|
||
async def review(
|
||
self,
|
||
*,
|
||
payload_text: str,
|
||
review_mode: str = REVIEW_MODE_READONLY,
|
||
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||
cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
|
||
) -> Dict[str, Any]:
|
||
review_mode = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY
|
||
debug_transcript: List[Dict[str, Any]] = []
|
||
|
||
def _trace(event: str, payload: Dict[str, Any]) -> None:
|
||
if not DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT:
|
||
return
|
||
debug_transcript.append({"ts": int(time.time() * 1000), "event": event, "payload": payload})
|
||
|
||
def _flush_trace(final_result: Dict[str, Any]) -> None:
|
||
if not DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT:
|
||
return
|
||
try:
|
||
DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
|
||
row = {
|
||
"created_at": int(time.time() * 1000),
|
||
"review_mode": review_mode,
|
||
"messages": messages,
|
||
"trace": debug_transcript,
|
||
"final_result": final_result,
|
||
}
|
||
file = DEBUG_TRANSCRIPT_DIR / f"goal_review_{int(time.time() * 1000)}.json"
|
||
file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
def _continue(message: str) -> Dict[str, Any]:
|
||
return {"status": "continue", "message": message or FALLBACK_CONTINUE_MESSAGE, "source": "goal_review_agent"}
|
||
|
||
url = str(self.cfg.get("url") or "").strip()
|
||
key = str(self.cfg.get("key") or "").strip()
|
||
model = str(self.cfg.get("model") or "").strip()
|
||
if not url or not key or not model:
|
||
out = _continue("目标审核智能体配置缺失,无法判断完成情况,请继续推进目标。")
|
||
_flush_trace(out)
|
||
return out
|
||
endpoint = f"{url.rstrip('/')}/chat/completions"
|
||
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||
extra_params = dict(self.cfg.get("extra_params") or {})
|
||
tools = self._build_tools(review_mode)
|
||
|
||
messages: List[Dict[str, Any]] = [
|
||
{"role": "system", "content": self._system_prompt(review_mode)},
|
||
{"role": "user", "content": payload_text},
|
||
]
|
||
|
||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||
rounds = 0
|
||
forced_tool_retry_used = False
|
||
while True:
|
||
rounds += 1
|
||
if cancel_check:
|
||
external = cancel_check()
|
||
if external:
|
||
return external
|
||
if progress_cb:
|
||
progress_cb({"stage": "model_call", "round": rounds, "message": f"审核轮次 {rounds}"})
|
||
req = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"tools": tools,
|
||
"tool_choice": "auto",
|
||
"temperature": 0.0,
|
||
**extra_params,
|
||
}
|
||
_trace("request", {"round": rounds, "request": req})
|
||
try:
|
||
resp = await client.post(endpoint, headers=headers, json=req)
|
||
resp.raise_for_status()
|
||
except httpx.HTTPStatusError as exc:
|
||
body = ""
|
||
try:
|
||
body = exc.response.text
|
||
except Exception:
|
||
body = str(exc)
|
||
_trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body})
|
||
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params)
|
||
if extra_params:
|
||
retry_req = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"tools": tools,
|
||
"tool_choice": "auto",
|
||
"temperature": 0.0,
|
||
}
|
||
_trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req})
|
||
retry_resp = await client.post(endpoint, headers=headers, json=retry_req)
|
||
try:
|
||
retry_resp.raise_for_status()
|
||
resp = retry_resp
|
||
except httpx.HTTPStatusError:
|
||
out = _continue(f"目标审核请求失败({retry_resp.status_code}),请继续推进目标。")
|
||
_flush_trace(out)
|
||
return out
|
||
else:
|
||
out = _continue(
|
||
f"目标审核请求失败({exc.response.status_code if exc.response else 'unknown'}),请继续推进目标。"
|
||
)
|
||
_flush_trace(out)
|
||
return out
|
||
except Exception as exc:
|
||
_trace("request_exception", {"round": rounds, "error": str(exc)})
|
||
out = _continue("目标审核请求异常,请继续推进目标。")
|
||
_flush_trace(out)
|
||
return out
|
||
|
||
choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {}
|
||
reasoning_content = (
|
||
choice.get("reasoning_content") or choice.get("reasoning") or choice.get("thinking") or ""
|
||
)
|
||
_trace("response", {"round": rounds, "message": choice, "reasoning_content": reasoning_content})
|
||
tool_calls = choice.get("tool_calls") or []
|
||
content = choice.get("content")
|
||
|
||
if not tool_calls:
|
||
if content or reasoning_content:
|
||
messages.append(
|
||
{
|
||
"role": "assistant",
|
||
"content": str(content or ""),
|
||
"reasoning_content": str(reasoning_content or ""),
|
||
}
|
||
)
|
||
reminder = (
|
||
"禁止直接输出结论文本。必须调用工具:必要时先调用 run_command 核实,"
|
||
"再调用 report_goal_status 给出最终结论。"
|
||
)
|
||
if not forced_tool_retry_used:
|
||
forced_tool_retry_used = True
|
||
reminder = "你刚刚未通过工具给出明确结果。必须调用工具,禁止直接输出内容。请立即调用 report_goal_status 返回最终结论。"
|
||
messages.append({"role": "user", "content": reminder})
|
||
continue
|
||
|
||
# 有 tool_calls:追加完整 assistant 消息(与主智能体结构对齐)
|
||
assistant_message = {
|
||
"role": "assistant",
|
||
"content": str(content or ""),
|
||
"reasoning_content": str(reasoning_content or ""),
|
||
"tool_calls": tool_calls,
|
||
}
|
||
messages.append(assistant_message)
|
||
for call in tool_calls:
|
||
fn = (call.get("function") or {}).get("name")
|
||
raw_args = (call.get("function") or {}).get("arguments") or "{}"
|
||
try:
|
||
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
|
||
except Exception:
|
||
args = {}
|
||
if fn == "report_goal_status":
|
||
status = str(args.get("status") or "").strip().lower()
|
||
message = str(args.get("message") or "").strip()
|
||
if status == "done":
|
||
out = {"status": "done", "message": message or "目标已达成。", "source": "goal_review_agent"}
|
||
_flush_trace(out)
|
||
return out
|
||
if status == "continue":
|
||
out = _continue(message)
|
||
_flush_trace(out)
|
||
return out
|
||
# 非法 status:保守续命
|
||
out = _continue(message or "审核返回了无法识别的状态,请继续推进目标。")
|
||
_flush_trace(out)
|
||
return out
|
||
if fn == "run_command":
|
||
cmd = str(args.get("command") or "").strip()
|
||
if progress_cb:
|
||
progress_cb({"stage": "run_command", "round": rounds, "command": cmd})
|
||
tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"}
|
||
_trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result})
|
||
tool_content = json.dumps(tool_result, ensure_ascii=False)
|
||
messages.append(
|
||
{
|
||
"role": "tool",
|
||
"content": tool_content,
|
||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
|
||
+ f".{int((time.time() % 1) * 1000000):06d}",
|
||
"message_id": f"msg_{uuid.uuid4().hex}",
|
||
"tool_call_id": call.get("id"),
|
||
"name": "run_command",
|
||
}
|
||
)
|