|
|
@@ -22,6 +22,7 @@ import os
|
|
|
import uuid
|
|
|
from dataclasses import dataclass, field
|
|
|
from datetime import datetime
|
|
|
+from hashlib import sha256
|
|
|
from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal, Tuple, Union
|
|
|
|
|
|
from cyber_agent.trace.models import Trace, Message
|
|
|
@@ -38,8 +39,26 @@ from cyber_agent.trace.compaction import (
|
|
|
from cyber_agent.skill.models import Skill
|
|
|
from cyber_agent.skill.skill_loader import load_skills_from_dir
|
|
|
from cyber_agent.tools import ToolRegistry, get_tool_registry
|
|
|
+from cyber_agent.tools.approval import (
|
|
|
+ ToolApprovalBatchV1,
|
|
|
+ ToolApprovalCallV1,
|
|
|
+ approval_grant,
|
|
|
+ tool_argument_hash,
|
|
|
+)
|
|
|
from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
|
|
|
-from cyber_agent.core.memory import MemoryConfig
|
|
|
+from cyber_agent.core.memory import (
|
|
|
+ MEMORY_IDENTITY_CONTEXT_KEY,
|
|
|
+ MemoryConfig,
|
|
|
+ compute_memory_identity,
|
|
|
+)
|
|
|
+from cyber_agent.core.dream import DreamScope
|
|
|
+from cyber_agent.core.run_snapshot import (
|
|
|
+ RUN_CONFIG_SNAPSHOT_CONTEXT_KEY,
|
|
|
+ RunConfigSnapshotError,
|
|
|
+ RunConfigSnapshotV1,
|
|
|
+ load_run_config_snapshot,
|
|
|
+ persist_run_config_snapshot,
|
|
|
+)
|
|
|
from cyber_agent.core.agent_mode import (
|
|
|
AGENT_MODE_CONTEXT_KEY,
|
|
|
AgentMode,
|
|
|
@@ -138,8 +157,9 @@ class SideBranchContext:
|
|
|
start_head_seq: int # 侧分支起点的 head_seq
|
|
|
start_sequence: int # 侧分支第一条消息的 sequence
|
|
|
start_history_length: int # 侧分支起点的 history 长度
|
|
|
- start_iteration: int # 侧分支开始时的 iteration
|
|
|
+ start_iteration: int # 旧 Trace 兼容字段;不再用于轮次预算
|
|
|
max_turns: int = 5 # 最大轮次
|
|
|
+ turns_used: int = 0 # 已持久化的 assistant 轮次
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
"""转换为字典(用于持久化和传递给工具)"""
|
|
|
@@ -150,6 +170,7 @@ class SideBranchContext:
|
|
|
"start_sequence": self.start_sequence,
|
|
|
"start_iteration": self.start_iteration,
|
|
|
"max_turns": self.max_turns,
|
|
|
+ "turns_used": self.turns_used,
|
|
|
"is_side_branch": True,
|
|
|
"started_at": datetime.now().isoformat(),
|
|
|
}
|
|
|
@@ -214,6 +235,36 @@ class RunConfig:
|
|
|
# None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
|
|
|
memory: Optional["MemoryConfig"] = None
|
|
|
|
|
|
+ # --- 一次性恢复动作(不进入持久化 RunConfigSnapshot) ---
|
|
|
+ approval_batch_id: Optional[str] = None
|
|
|
+
|
|
|
+ def apply_snapshot(self, snapshot: RunConfigSnapshotV1) -> None:
|
|
|
+ """Restore all persisted behavior fields while retaining invocation routing."""
|
|
|
+ self.model = snapshot.model
|
|
|
+ self.temperature = snapshot.temperature
|
|
|
+ self.max_iterations = snapshot.max_iterations
|
|
|
+ self.extra_llm_params = dict(snapshot.extra_llm_params)
|
|
|
+ self.tools = list(snapshot.tools) if snapshot.tools is not None else None
|
|
|
+ self.tool_groups = (
|
|
|
+ list(snapshot.tool_groups) if snapshot.tool_groups is not None else None
|
|
|
+ )
|
|
|
+ self.exclude_tools = list(snapshot.exclude_tools)
|
|
|
+ self.auto_execute_tools = snapshot.auto_execute_tools
|
|
|
+ self.agent_type = snapshot.agent_type
|
|
|
+ self.uid = snapshot.uid
|
|
|
+ self.skills = list(snapshot.skills) if snapshot.skills is not None else None
|
|
|
+ self.enable_memory = snapshot.enable_memory
|
|
|
+ self.memory = MemoryConfig(**snapshot.memory) if snapshot.memory else None
|
|
|
+ self.knowledge = KnowledgeConfig(**snapshot.knowledge)
|
|
|
+ self.parallel_tool_execution = snapshot.parallel_tool_execution
|
|
|
+ self.child_execution_mode = snapshot.child_execution_mode
|
|
|
+ self.max_parallel_children = snapshot.max_parallel_children
|
|
|
+ self.side_branch_max_turns = snapshot.side_branch_max_turns
|
|
|
+ self.goal_compression = snapshot.goal_compression
|
|
|
+ self.enable_prompt_caching = snapshot.enable_prompt_caching
|
|
|
+ self.enable_research_flow = snapshot.enable_research_flow
|
|
|
+ self.context = dict(snapshot.custom_context)
|
|
|
+
|
|
|
|
|
|
# BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
|
|
|
# 工具可用性现在由 @tool(groups=[...]) 声明 + RunConfig.tool_groups 过滤控制。
|
|
|
@@ -306,9 +357,6 @@ class AgentRunner:
|
|
|
# key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
|
|
|
self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
|
|
|
|
|
|
- # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
|
|
|
- # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
|
|
|
- self._current_memory_config: Optional[MemoryConfig] = None
|
|
|
self.resource_budget = (
|
|
|
ResourceBudgetController(trace_store) if trace_store else None
|
|
|
)
|
|
|
@@ -742,7 +790,9 @@ class AgentRunner:
|
|
|
async def dream(
|
|
|
self,
|
|
|
memory_config: MemoryConfig,
|
|
|
- trace_filter: Optional[Callable[["Trace"], bool]] = None,
|
|
|
+ *,
|
|
|
+ uid: Optional[str],
|
|
|
+ agent_type: str,
|
|
|
reflect_model: str = "gpt-4o-mini",
|
|
|
dream_model: str = "gpt-4o",
|
|
|
) -> "DreamReport":
|
|
|
@@ -752,18 +802,22 @@ class AgentRunner:
|
|
|
|
|
|
Args:
|
|
|
memory_config: 记忆配置
|
|
|
- trace_filter: 可选 trace 过滤(按 agent_type/owner 等)
|
|
|
+ uid/agent_type: 与 MemoryConfig 一起形成强制 Dream 身份边界
|
|
|
reflect_model: per-trace 反思模型
|
|
|
dream_model: 跨 trace 整合模型
|
|
|
"""
|
|
|
- from cyber_agent.core.dream import run_dream
|
|
|
+ from cyber_agent.core.dream import DreamScope, run_dream
|
|
|
if not self.trace_store or not self.llm_call:
|
|
|
raise RuntimeError("dream 需要 trace_store 和 llm_call 均已配置")
|
|
|
return await run_dream(
|
|
|
store=self.trace_store,
|
|
|
llm_call=self.llm_call,
|
|
|
memory_config=memory_config,
|
|
|
- trace_filter=trace_filter,
|
|
|
+ dream_scope=DreamScope(
|
|
|
+ uid=uid,
|
|
|
+ agent_type=agent_type,
|
|
|
+ memory_identity=compute_memory_identity(memory_config),
|
|
|
+ ),
|
|
|
reflect_model=reflect_model,
|
|
|
dream_model=dream_model,
|
|
|
)
|
|
|
@@ -797,9 +851,6 @@ class AgentRunner:
|
|
|
trace = None
|
|
|
run_cancel_event: Optional[asyncio.Event] = None
|
|
|
|
|
|
- # Memory 模式开关(dream 工具会读取此字段)
|
|
|
- self._current_memory_config = config.memory
|
|
|
-
|
|
|
try:
|
|
|
# Phase 1: PREPARE TRACE
|
|
|
trace, goal_tree, sequence = await self._prepare_trace(messages, config)
|
|
|
@@ -832,6 +883,7 @@ class AgentRunner:
|
|
|
start_history_length=0,
|
|
|
start_iteration=side_branch_data.get("start_iteration", 0),
|
|
|
max_turns=side_branch_data.get("max_turns", config.side_branch_max_turns),
|
|
|
+ turns_used=side_branch_data.get("turns_used", 0),
|
|
|
)
|
|
|
|
|
|
# Phase 2: BUILD HISTORY
|
|
|
@@ -851,19 +903,34 @@ class AgentRunner:
|
|
|
):
|
|
|
yield event
|
|
|
|
|
|
+ except asyncio.CancelledError:
|
|
|
+ if trace and config.approval_batch_id:
|
|
|
+ await asyncio.shield(
|
|
|
+ self._mark_approval_execution_unknown(trace.trace_id)
|
|
|
+ )
|
|
|
+ raise
|
|
|
except Exception as e:
|
|
|
self.log.error(f"Agent run failed: {e}")
|
|
|
# Preparation rejections (for example, attempting to resume an
|
|
|
# immutable Validator Trace) must not rewrite the existing record.
|
|
|
tid = trace.trace_id if trace else None
|
|
|
if self.trace_store and tid:
|
|
|
+ approval_unknown = (
|
|
|
+ await self._mark_approval_execution_unknown(tid)
|
|
|
+ if config.approval_batch_id
|
|
|
+ else False
|
|
|
+ )
|
|
|
# 读取当前 last_sequence 作为 head_sequence,确保续跑时能加载完整历史
|
|
|
current = await self.trace_store.get_trace(tid)
|
|
|
head_seq = current.last_sequence if current else None
|
|
|
updates: Dict[str, Any] = {
|
|
|
"status": "failed",
|
|
|
"head_sequence": head_seq,
|
|
|
- "error_message": str(e),
|
|
|
+ "error_message": (
|
|
|
+ "Tool execution outcome is unknown; automatic retry was refused"
|
|
|
+ if approval_unknown
|
|
|
+ else str(e)
|
|
|
+ ),
|
|
|
"completed_at": datetime.now(),
|
|
|
}
|
|
|
if isinstance(e, ResourceBudgetExceeded):
|
|
|
@@ -933,6 +1000,8 @@ class AgentRunner:
|
|
|
)
|
|
|
if not summary and stopped_recursively:
|
|
|
summary = "Agent execution stopped."
|
|
|
+ elif not summary and status == "waiting_confirmation":
|
|
|
+ summary = "Agent is waiting for local tool approval."
|
|
|
elif not summary:
|
|
|
status = "failed"
|
|
|
error = error or "Agent 没有产生 assistant 文本结果"
|
|
|
@@ -1179,6 +1248,16 @@ class AgentRunner:
|
|
|
trace_context = apply_policy_to_context(config.context, policy)
|
|
|
trace_context.setdefault("agent_depth", 0)
|
|
|
trace_context.setdefault("root_trace_id", trace_id)
|
|
|
+ memory_identity = (
|
|
|
+ compute_memory_identity(config.memory) if config.memory else None
|
|
|
+ )
|
|
|
+ if memory_identity is not None:
|
|
|
+ trace_context[MEMORY_IDENTITY_CONTEXT_KEY] = memory_identity
|
|
|
+ run_snapshot = RunConfigSnapshotV1.from_run_config(
|
|
|
+ config,
|
|
|
+ memory_identity=memory_identity,
|
|
|
+ )
|
|
|
+ persist_run_config_snapshot(trace_context, run_snapshot)
|
|
|
if policy.requires_task_protocol:
|
|
|
persist_root_task_anchor(trace_context, root_task_anchor)
|
|
|
persist_validation_policy(
|
|
|
@@ -1247,6 +1326,67 @@ class AgentRunner:
|
|
|
"Validator traces cannot be continued or rewound"
|
|
|
)
|
|
|
|
|
|
+ # Resume/rewind behavior is always recovered from the immutable snapshot.
|
|
|
+ # Old Recursive traces cannot be reconstructed safely; Legacy traces get
|
|
|
+ # one explicitly-marked, conservative inferred snapshot for compatibility.
|
|
|
+ if RUN_CONFIG_SNAPSHOT_CONTEXT_KEY in trace_obj.context:
|
|
|
+ try:
|
|
|
+ snapshot = load_run_config_snapshot(trace_obj.context)
|
|
|
+ except RunConfigSnapshotError as exc:
|
|
|
+ raise ValueError(str(exc)) from exc
|
|
|
+ else:
|
|
|
+ existing_policy = policy_from_context(trace_obj.context)
|
|
|
+ if existing_policy.mode is AgentMode.RECURSIVE:
|
|
|
+ raise ValueError(
|
|
|
+ "This Recursive trace predates RunConfig snapshots; create a new trace"
|
|
|
+ )
|
|
|
+ inferred = RunConfig(
|
|
|
+ model=trace_obj.model or "gpt-4o",
|
|
|
+ temperature=float((trace_obj.llm_params or {}).get("temperature", 0.3)),
|
|
|
+ tools=[
|
|
|
+ item.get("function", {}).get("name")
|
|
|
+ for item in (trace_obj.tools or [])
|
|
|
+ if item.get("function", {}).get("name")
|
|
|
+ ],
|
|
|
+ tool_groups=None,
|
|
|
+ agent_type=trace_obj.agent_type or "default",
|
|
|
+ uid=trace_obj.uid,
|
|
|
+ extra_llm_params={
|
|
|
+ key: value
|
|
|
+ for key, value in (trace_obj.llm_params or {}).items()
|
|
|
+ if key != "temperature"
|
|
|
+ },
|
|
|
+ context={
|
|
|
+ "project_name": trace_obj.context.get("project_name")
|
|
|
+ } if trace_obj.context.get("project_name") else {},
|
|
|
+ )
|
|
|
+ snapshot = RunConfigSnapshotV1.from_run_config(
|
|
|
+ inferred,
|
|
|
+ memory_identity=None,
|
|
|
+ legacy_inferred=True,
|
|
|
+ )
|
|
|
+ persist_run_config_snapshot(trace_obj.context, snapshot)
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_obj.trace_id,
|
|
|
+ context=trace_obj.context,
|
|
|
+ )
|
|
|
+ if snapshot.uid != trace_obj.uid or snapshot.agent_type != (trace_obj.agent_type or "default"):
|
|
|
+ raise ValueError("run config snapshot identity does not match Trace metadata")
|
|
|
+ config.apply_snapshot(snapshot)
|
|
|
+ persisted_memory_identity = trace_obj.context.get(
|
|
|
+ MEMORY_IDENTITY_CONTEXT_KEY
|
|
|
+ )
|
|
|
+ if persisted_memory_identity != snapshot.memory_identity:
|
|
|
+ raise ValueError(
|
|
|
+ "run config snapshot memory identity does not match Trace context"
|
|
|
+ )
|
|
|
+ if config.memory is not None and (
|
|
|
+ compute_memory_identity(config.memory) != snapshot.memory_identity
|
|
|
+ ):
|
|
|
+ raise ValueError(
|
|
|
+ "restored MemoryConfig does not match the persisted memory identity"
|
|
|
+ )
|
|
|
+
|
|
|
# Historical traces predate AGENT_MODE. They resume in safe Legacy mode
|
|
|
# and the choice is persisted immediately; environment changes never
|
|
|
# mutate an existing tree.
|
|
|
@@ -1381,14 +1521,31 @@ class AgentRunner:
|
|
|
trace_id, trace_obj.head_sequence
|
|
|
)
|
|
|
|
|
|
- # 修复 orphaned tool_calls(中断导致的 tool_call 无 tool_result)
|
|
|
- main_path, sequence = await self._heal_orphaned_tool_calls(
|
|
|
- main_path, trace_id, goal_tree, sequence,
|
|
|
- )
|
|
|
+ # An approved pending batch deliberately has orphaned calls;
|
|
|
+ # they are executed before the next model turn.
|
|
|
+ if not config.approval_batch_id:
|
|
|
+ main_path, sequence = await self._heal_orphaned_tool_calls(
|
|
|
+ main_path, trace_id, goal_tree, sequence,
|
|
|
+ )
|
|
|
|
|
|
history = [msg.to_llm_dict() for msg in main_path]
|
|
|
if main_path:
|
|
|
head_seq = main_path[-1].sequence
|
|
|
+ snapshot = load_run_config_snapshot(trace_obj.context)
|
|
|
+ if snapshot.system_prompt_hash:
|
|
|
+ persisted_system = next(
|
|
|
+ (
|
|
|
+ message.get("content")
|
|
|
+ for message in history
|
|
|
+ if message.get("role") == "system"
|
|
|
+ and isinstance(message.get("content"), str)
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if persisted_system is None or sha256(
|
|
|
+ persisted_system.encode("utf-8")
|
|
|
+ ).hexdigest() != snapshot.system_prompt_hash:
|
|
|
+ raise ValueError("persisted system prompt does not match RunConfig snapshot")
|
|
|
|
|
|
current_trace = (
|
|
|
await self.trace_store.get_trace(trace_id)
|
|
|
@@ -1497,6 +1654,32 @@ class AgentRunner:
|
|
|
# 5. 更新 trace 的 head_sequence
|
|
|
if self.trace_store and head_seq is not None:
|
|
|
await self.trace_store.update_trace(trace_id, head_sequence=head_seq)
|
|
|
+ persisted = await self.trace_store.get_trace(trace_id)
|
|
|
+ system_content = next(
|
|
|
+ (
|
|
|
+ message.get("content")
|
|
|
+ for message in history
|
|
|
+ if message.get("role") == "system"
|
|
|
+ and isinstance(message.get("content"), str)
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if persisted and system_content is not None:
|
|
|
+ snapshot = load_run_config_snapshot(persisted.context)
|
|
|
+ # Pre-created Recursive children enter through the resume path
|
|
|
+ # on their very first execution. Bind their actual persisted
|
|
|
+ # system prompt once, just like a newly-created root Trace.
|
|
|
+ if snapshot.system_prompt_hash is None:
|
|
|
+ snapshot = snapshot.model_copy(update={
|
|
|
+ "system_prompt_hash": sha256(
|
|
|
+ system_content.encode("utf-8")
|
|
|
+ ).hexdigest()
|
|
|
+ })
|
|
|
+ persist_run_config_snapshot(persisted.context, snapshot)
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ context=persisted.context,
|
|
|
+ )
|
|
|
|
|
|
return history, sequence, created_messages, head_seq or 0
|
|
|
|
|
|
@@ -1837,6 +2020,193 @@ class AgentRunner:
|
|
|
|
|
|
return None
|
|
|
|
|
|
+ async def _resume_approved_tool_batch(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ trace: Trace,
|
|
|
+ history: List[Dict],
|
|
|
+ goal_tree: Optional[GoalTree],
|
|
|
+ config: RunConfig,
|
|
|
+ sequence: int,
|
|
|
+ head_seq: int,
|
|
|
+ side_branch_ctx: Optional[SideBranchContext],
|
|
|
+ runtime_tool_names: set[str],
|
|
|
+ ) -> Tuple[List[Dict], int, int]:
|
|
|
+ """Execute one fully-decided persisted approval batch exactly once."""
|
|
|
+ if not config.approval_batch_id:
|
|
|
+ return history, sequence, head_seq
|
|
|
+ if not self.trace_store or not hasattr(
|
|
|
+ self.trace_store, "get_tool_approval_batch"
|
|
|
+ ):
|
|
|
+ raise RuntimeError("TraceStore does not support tool approvals")
|
|
|
+ batch = await self.trace_store.get_tool_approval_batch(trace.trace_id)
|
|
|
+ if batch is None or batch.batch_id != config.approval_batch_id:
|
|
|
+ raise RuntimeError("approved tool batch was not found")
|
|
|
+ if batch.status != "decided":
|
|
|
+ raise RuntimeError(f"tool approval batch is not executable: {batch.status}")
|
|
|
+ if any(call.decision == "pending" for call in batch.calls):
|
|
|
+ raise RuntimeError("tool approval batch still has pending decisions")
|
|
|
+ if any(call.execution_status == "executing" for call in batch.calls):
|
|
|
+ batch.status = "execution_unknown"
|
|
|
+ for call in batch.calls:
|
|
|
+ if call.execution_status == "executing":
|
|
|
+ call.execution_status = "execution_unknown"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace.trace_id, batch)
|
|
|
+ raise RuntimeError("tool execution outcome is unknown; refusing automatic retry")
|
|
|
+
|
|
|
+ batch.status = "executing"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace.trace_id, batch)
|
|
|
+ current_goal_id = goal_tree.current_id if goal_tree and goal_tree.current_id else None
|
|
|
+ trigger_event = None
|
|
|
+ if side_branch_ctx and side_branch_ctx.type == "knowledge_eval":
|
|
|
+ trigger_event = (trace.context.get("active_side_branch") or {}).get(
|
|
|
+ "trigger_event",
|
|
|
+ "unknown",
|
|
|
+ )
|
|
|
+
|
|
|
+ for call in batch.calls:
|
|
|
+ if call.execution_status in {"executed", "rejected"}:
|
|
|
+ continue
|
|
|
+ if call.decision == "rejected":
|
|
|
+ tool_result: Any = json.dumps({
|
|
|
+ "status": "rejected",
|
|
|
+ "error": "Tool call rejected by the local user",
|
|
|
+ }, ensure_ascii=False)
|
|
|
+ call.execution_status = "rejected"
|
|
|
+ elif call.decision in {"approved", "auto_approved"}:
|
|
|
+ call.execution_status = "executing"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(
|
|
|
+ trace.trace_id,
|
|
|
+ batch,
|
|
|
+ )
|
|
|
+ tool_result = await self.tools.execute(
|
|
|
+ call.tool_name,
|
|
|
+ call.effective_arguments,
|
|
|
+ uid=config.uid or "",
|
|
|
+ context=self._build_tool_context(
|
|
|
+ config=config,
|
|
|
+ trace=trace,
|
|
|
+ trace_id=trace.trace_id,
|
|
|
+ goal_id=current_goal_id,
|
|
|
+ goal_tree=goal_tree,
|
|
|
+ sequence=sequence,
|
|
|
+ side_branch_ctx=side_branch_ctx,
|
|
|
+ trigger_event=trigger_event,
|
|
|
+ ),
|
|
|
+ allowed_tool_names=runtime_tool_names,
|
|
|
+ tool_call_id=call.tool_call_id,
|
|
|
+ approval_grant=approval_grant(batch, call),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ raise RuntimeError(f"unsupported tool approval decision: {call.decision}")
|
|
|
+
|
|
|
+ if isinstance(tool_result, str):
|
|
|
+ normalized = {"text": tool_result}
|
|
|
+ elif isinstance(tool_result, dict):
|
|
|
+ normalized = tool_result
|
|
|
+ else:
|
|
|
+ normalized = {"text": str(tool_result)}
|
|
|
+ tool_text = normalized.get("text", str(normalized))
|
|
|
+ tool_images = normalized.get("images", [])
|
|
|
+ artifact_refs = normalized.get("artifact_refs", [])
|
|
|
+ tool_content: Any = tool_text
|
|
|
+ if tool_images:
|
|
|
+ tool_content = [{"type": "text", "text": tool_text}]
|
|
|
+ for image in tool_images:
|
|
|
+ if image.get("type") == "base64" and image.get("data"):
|
|
|
+ media_type = image.get("media_type", "image/png")
|
|
|
+ tool_content.append({
|
|
|
+ "type": "image_url",
|
|
|
+ "image_url": {
|
|
|
+ "url": f"data:{media_type};base64,{image['data']}"
|
|
|
+ },
|
|
|
+ })
|
|
|
+ elif image.get("type") == "url" and image.get("url"):
|
|
|
+ tool_content.append({
|
|
|
+ "type": "image_url",
|
|
|
+ "image_url": {"url": image["url"]},
|
|
|
+ })
|
|
|
+ tool_msg = Message.create(
|
|
|
+ trace_id=trace.trace_id,
|
|
|
+ role="tool",
|
|
|
+ sequence=sequence,
|
|
|
+ goal_id=current_goal_id,
|
|
|
+ parent_sequence=head_seq,
|
|
|
+ tool_call_id=call.tool_call_id,
|
|
|
+ branch_type=side_branch_ctx.type if side_branch_ctx else None,
|
|
|
+ branch_id=side_branch_ctx.branch_id if side_branch_ctx else None,
|
|
|
+ content={
|
|
|
+ "tool_name": call.tool_name,
|
|
|
+ "result": tool_content,
|
|
|
+ "artifact_refs": artifact_refs,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ await self.trace_store.add_message(tool_msg)
|
|
|
+ if tool_images and hasattr(self.trace_store, "write_message_attachment"):
|
|
|
+ import base64 as b64mod
|
|
|
+ for image in tool_images:
|
|
|
+ if image.get("data"):
|
|
|
+ await self.trace_store.write_message_attachment(
|
|
|
+ trace.trace_id,
|
|
|
+ tool_msg.message_id,
|
|
|
+ suffix=".png",
|
|
|
+ data=b64mod.b64decode(image["data"]),
|
|
|
+ )
|
|
|
+ break
|
|
|
+ call.result_message_id = tool_msg.message_id
|
|
|
+ if call.execution_status != "rejected":
|
|
|
+ call.execution_status = "executed"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace.trace_id, batch)
|
|
|
+ history.append({
|
|
|
+ "role": "tool",
|
|
|
+ "tool_call_id": call.tool_call_id,
|
|
|
+ "name": call.tool_name,
|
|
|
+ "content": tool_content,
|
|
|
+ "_message_id": tool_msg.message_id,
|
|
|
+ })
|
|
|
+ head_seq = sequence
|
|
|
+ sequence += 1
|
|
|
+
|
|
|
+ batch.status = "completed"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace.trace_id, batch)
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace.trace_id,
|
|
|
+ status="running",
|
|
|
+ head_sequence=head_seq,
|
|
|
+ )
|
|
|
+ config.approval_batch_id = None
|
|
|
+ return history, sequence, head_seq
|
|
|
+
|
|
|
+ async def _mark_approval_execution_unknown(self, trace_id: str) -> bool:
|
|
|
+ """Fail closed if an approved batch stopped after execution began."""
|
|
|
+ if not self.trace_store or not hasattr(
|
|
|
+ self.trace_store, "get_tool_approval_batch"
|
|
|
+ ):
|
|
|
+ return False
|
|
|
+ batch = await self.trace_store.get_tool_approval_batch(trace_id)
|
|
|
+ if batch is None or batch.status != "executing":
|
|
|
+ return False
|
|
|
+ batch.status = "execution_unknown"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ for call in batch.calls:
|
|
|
+ if call.execution_status == "executing":
|
|
|
+ call.execution_status = "execution_unknown"
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace_id, batch)
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ status="failed",
|
|
|
+ error_message=(
|
|
|
+ "Tool execution outcome is unknown; automatic retry was refused"
|
|
|
+ ),
|
|
|
+ completed_at=datetime.now(),
|
|
|
+ )
|
|
|
+ return True
|
|
|
+
|
|
|
async def _agent_loop(
|
|
|
self,
|
|
|
trace: Trace,
|
|
|
@@ -1872,13 +2242,29 @@ class AgentRunner:
|
|
|
branch_id = side_branch_data["branch_id"]
|
|
|
start_sequence = side_branch_data["start_sequence"]
|
|
|
|
|
|
- # 从数据库查询侧分支消息(按 sequence 范围)
|
|
|
+ # 只恢复同一持久分支;sequence 范围会把相邻分支混进来。
|
|
|
if self.trace_store:
|
|
|
all_messages = await self.trace_store.get_trace_messages(trace_id)
|
|
|
side_messages = [
|
|
|
m for m in all_messages
|
|
|
- if m.sequence >= start_sequence
|
|
|
+ if (
|
|
|
+ m.sequence >= start_sequence
|
|
|
+ and m.branch_id == branch_id
|
|
|
+ and m.branch_type == side_branch_data["type"]
|
|
|
+ and m.status == "active"
|
|
|
+ )
|
|
|
]
|
|
|
+ reconstructed_turns = sum(
|
|
|
+ 1 for message in side_messages if message.role == "assistant"
|
|
|
+ )
|
|
|
+ persisted_turns = int(side_branch_data.get("turns_used", 0))
|
|
|
+ turns_used = max(persisted_turns, reconstructed_turns)
|
|
|
+ if turns_used != persisted_turns or "turns_used" not in side_branch_data:
|
|
|
+ side_branch_data["turns_used"] = turns_used
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ context=trace.context,
|
|
|
+ )
|
|
|
|
|
|
# 恢复侧分支上下文
|
|
|
side_branch_ctx = SideBranchContext(
|
|
|
@@ -1889,6 +2275,7 @@ class AgentRunner:
|
|
|
start_history_length=0, # 稍后重新计算
|
|
|
start_iteration=side_branch_data.get("start_iteration", 0),
|
|
|
max_turns=side_branch_data.get("max_turns", config.side_branch_max_turns),
|
|
|
+ turns_used=int(turns_used),
|
|
|
)
|
|
|
|
|
|
self.log.info(
|
|
|
@@ -1903,6 +2290,49 @@ class AgentRunner:
|
|
|
# 重新计算 start_history_length
|
|
|
side_branch_ctx.start_history_length = len(history) - len(side_messages)
|
|
|
|
|
|
+ # The assistant Message is the durable turn record. A crash can
|
|
|
+ # happen after that Message is stored but before Trace.context is
|
|
|
+ # updated. If reconstruction shows the budget was already
|
|
|
+ # exhausted, fail the branch closed instead of buying one extra
|
|
|
+ # model turn after restart.
|
|
|
+ if side_branch_ctx.turns_used >= side_branch_ctx.max_turns:
|
|
|
+ self.log.warning(
|
|
|
+ "恢复的侧分支 %s 已用尽轮次 %d/%d,直接返回主路径",
|
|
|
+ side_branch_ctx.type,
|
|
|
+ side_branch_ctx.turns_used,
|
|
|
+ side_branch_ctx.max_turns,
|
|
|
+ )
|
|
|
+ main_path_messages = await self.trace_store.get_main_path_messages(
|
|
|
+ trace_id,
|
|
|
+ side_branch_ctx.start_head_seq,
|
|
|
+ )
|
|
|
+ history = [message.to_llm_dict() for message in main_path_messages]
|
|
|
+ head_seq = side_branch_ctx.start_head_seq
|
|
|
+ trace.context.pop("active_side_branch", None)
|
|
|
+ if config.force_side_branch:
|
|
|
+ if config.force_side_branch[0] == side_branch_ctx.type:
|
|
|
+ config.force_side_branch.pop(0)
|
|
|
+ if not config.force_side_branch:
|
|
|
+ config.force_side_branch = None
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ context=trace.context,
|
|
|
+ head_sequence=head_seq,
|
|
|
+ )
|
|
|
+ side_branch_ctx = None
|
|
|
+
|
|
|
+ if config.approval_batch_id:
|
|
|
+ history, sequence, head_seq = await self._resume_approved_tool_batch(
|
|
|
+ trace=trace,
|
|
|
+ history=history,
|
|
|
+ goal_tree=goal_tree,
|
|
|
+ config=config,
|
|
|
+ sequence=sequence,
|
|
|
+ head_seq=head_seq,
|
|
|
+ side_branch_ctx=side_branch_ctx,
|
|
|
+ runtime_tool_names=runtime_tool_names,
|
|
|
+ )
|
|
|
+
|
|
|
break_after_side_branch = False # 侧分支退出后是否 break 主循环
|
|
|
|
|
|
for iteration in range(config.max_iterations):
|
|
|
@@ -1979,6 +2409,7 @@ class AgentRunner:
|
|
|
start_history_length=len(history),
|
|
|
start_iteration=iteration,
|
|
|
max_turns=config.side_branch_max_turns,
|
|
|
+ turns_used=0,
|
|
|
)
|
|
|
|
|
|
# 持久化侧分支状态
|
|
|
@@ -1993,6 +2424,7 @@ class AgentRunner:
|
|
|
"start_sequence": side_branch_ctx.start_sequence,
|
|
|
"start_iteration": side_branch_ctx.start_iteration,
|
|
|
"max_turns": side_branch_ctx.max_turns,
|
|
|
+ "turns_used": side_branch_ctx.turns_used,
|
|
|
"started_at": datetime.now().isoformat(),
|
|
|
}
|
|
|
|
|
|
@@ -2161,10 +2593,7 @@ class AgentRunner:
|
|
|
not has_context_call
|
|
|
and not lifecycle_call_count
|
|
|
and not protocol_lifecycle_required
|
|
|
- and (
|
|
|
- runtime_policy.mode is not AgentMode.RECURSIVE
|
|
|
- or "get_current_context" in runtime_tool_names
|
|
|
- )
|
|
|
+ and "get_current_context" in runtime_tool_names
|
|
|
):
|
|
|
# 手动添加 get_current_context 工具调用
|
|
|
context_call_id = f"call_context_{uuid.uuid4().hex[:8]}"
|
|
|
@@ -2183,10 +2612,7 @@ class AgentRunner:
|
|
|
and iteration == 0
|
|
|
and not lifecycle_call_count
|
|
|
and not protocol_lifecycle_required
|
|
|
- and (
|
|
|
- runtime_policy.mode is not AgentMode.RECURSIVE
|
|
|
- or "skill" in runtime_tool_names
|
|
|
- )
|
|
|
+ and "skill" in runtime_tool_names
|
|
|
):
|
|
|
skills_to_inject = self._check_skills_need_injection(
|
|
|
trace, inject_skills, history, skill_recency_threshold
|
|
|
@@ -2304,7 +2730,18 @@ class AgentRunner:
|
|
|
)
|
|
|
self.log.info(f"[Knowledge Eval] 已写入 {len(eval_results.get('evaluations', []))} 条评估结果")
|
|
|
|
|
|
- # 如果在侧分支,记录到 assistant_msg(已持久化,不需要额外维护)
|
|
|
+ # 一轮以成功持久化的侧分支 assistant 回复为准;先落盘计数,
|
|
|
+ # 再 yield,确保停止/重启不会重新获得已消费轮次。
|
|
|
+ if side_branch_ctx:
|
|
|
+ side_branch_ctx.turns_used += 1
|
|
|
+ active_branch = trace.context.get("active_side_branch")
|
|
|
+ if isinstance(active_branch, dict):
|
|
|
+ active_branch["turns_used"] = side_branch_ctx.turns_used
|
|
|
+ if self.trace_store:
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ context=trace.context,
|
|
|
+ )
|
|
|
|
|
|
yield assistant_msg
|
|
|
head_seq = sequence
|
|
|
@@ -2325,8 +2762,7 @@ class AgentRunner:
|
|
|
|
|
|
# 检查侧分支是否应该退出
|
|
|
if side_branch_ctx:
|
|
|
- # 计算侧分支已执行的轮次
|
|
|
- turns_in_branch = iteration - side_branch_ctx.start_iteration
|
|
|
+ turns_in_branch = side_branch_ctx.turns_used
|
|
|
should_exit = turns_in_branch >= side_branch_ctx.max_turns or not tool_calls
|
|
|
|
|
|
if turns_in_branch >= side_branch_ctx.max_turns:
|
|
|
@@ -2353,7 +2789,12 @@ class AgentRunner:
|
|
|
all_messages = await self.trace_store.get_trace_messages(trace_id)
|
|
|
side_messages = [
|
|
|
m for m in all_messages
|
|
|
- if m.sequence >= side_branch_ctx.start_sequence
|
|
|
+ if (
|
|
|
+ m.sequence >= side_branch_ctx.start_sequence
|
|
|
+ and m.branch_id == side_branch_ctx.branch_id
|
|
|
+ and m.branch_type == side_branch_ctx.type
|
|
|
+ and m.status == "active"
|
|
|
+ )
|
|
|
]
|
|
|
for msg in reversed(side_messages):
|
|
|
if msg.role == "assistant" and isinstance(msg.content, dict):
|
|
|
@@ -2522,6 +2963,87 @@ class AgentRunner:
|
|
|
})
|
|
|
continue
|
|
|
|
|
|
+ if tool_calls and not config.approval_batch_id:
|
|
|
+ needs_confirmation = (
|
|
|
+ not config.auto_execute_tools
|
|
|
+ or self.tools.check_confirmation_required(tool_calls)
|
|
|
+ )
|
|
|
+ if needs_confirmation:
|
|
|
+ approval_calls: List[ToolApprovalCallV1] = []
|
|
|
+ for tool_call in tool_calls:
|
|
|
+ tool_name = tool_call.get("function", {}).get("name", "")
|
|
|
+ raw_arguments = tool_call.get("function", {}).get(
|
|
|
+ "arguments", {}
|
|
|
+ )
|
|
|
+ if isinstance(raw_arguments, str):
|
|
|
+ try:
|
|
|
+ parsed_arguments = (
|
|
|
+ json.loads(raw_arguments)
|
|
|
+ if raw_arguments.strip()
|
|
|
+ else {}
|
|
|
+ )
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ parsed_arguments = {"_raw": raw_arguments}
|
|
|
+ elif isinstance(raw_arguments, dict):
|
|
|
+ parsed_arguments = dict(raw_arguments)
|
|
|
+ else:
|
|
|
+ parsed_arguments = {}
|
|
|
+ policy = self.tools.get_runtime_policy(tool_name)
|
|
|
+ requires_confirmation = (
|
|
|
+ not config.auto_execute_tools
|
|
|
+ or bool(policy.get("requires_confirmation"))
|
|
|
+ )
|
|
|
+ decision = (
|
|
|
+ "pending" if requires_confirmation else "auto_approved"
|
|
|
+ )
|
|
|
+ call_id = str(tool_call.get("id") or "")
|
|
|
+ approval_calls.append(ToolApprovalCallV1(
|
|
|
+ tool_call_id=call_id,
|
|
|
+ tool_name=tool_name,
|
|
|
+ original_arguments=parsed_arguments,
|
|
|
+ effective_arguments=parsed_arguments,
|
|
|
+ argument_hash=tool_argument_hash(
|
|
|
+ tool_call_id=call_id,
|
|
|
+ tool_name=tool_name,
|
|
|
+ arguments=parsed_arguments,
|
|
|
+ ),
|
|
|
+ editable_params=list(policy.get("editable_params", [])),
|
|
|
+ requires_confirmation=requires_confirmation,
|
|
|
+ decision=decision,
|
|
|
+ ))
|
|
|
+ batch = ToolApprovalBatchV1.create(
|
|
|
+ trace_id=trace_id,
|
|
|
+ assistant_message_id=assistant_msg.message_id,
|
|
|
+ assistant_sequence=assistant_msg.sequence,
|
|
|
+ calls=approval_calls,
|
|
|
+ )
|
|
|
+ if not self.trace_store or not hasattr(
|
|
|
+ self.trace_store, "replace_tool_approval_batch"
|
|
|
+ ):
|
|
|
+ raise RuntimeError(
|
|
|
+ "persistent TraceStore tool approval support is required"
|
|
|
+ )
|
|
|
+ await self.trace_store.replace_tool_approval_batch(
|
|
|
+ trace_id,
|
|
|
+ batch,
|
|
|
+ )
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ status="waiting_confirmation",
|
|
|
+ head_sequence=head_seq,
|
|
|
+ )
|
|
|
+ trace.status = "waiting_confirmation"
|
|
|
+ try:
|
|
|
+ from cyber_agent.trace.websocket import broadcast_trace_status_changed
|
|
|
+ await broadcast_trace_status_changed(
|
|
|
+ trace_id,
|
|
|
+ "waiting_confirmation",
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ yield trace
|
|
|
+ return
|
|
|
+
|
|
|
if tool_calls and config.auto_execute_tools:
|
|
|
if (
|
|
|
runtime_policy.mode is AgentMode.RECURSIVE
|
|
|
@@ -2649,8 +3171,12 @@ class AgentRunner:
|
|
|
import base64 as b64mod
|
|
|
for img in tool_images:
|
|
|
if img.get("data"):
|
|
|
- png_path = self.trace_store._get_messages_dir(trace_id) / f"{tool_msg.message_id}.png"
|
|
|
- png_path.write_bytes(b64mod.b64decode(img["data"]))
|
|
|
+ await self.trace_store.write_message_attachment(
|
|
|
+ trace_id,
|
|
|
+ tool_msg.message_id,
|
|
|
+ suffix=".png",
|
|
|
+ data=b64mod.b64decode(img["data"]),
|
|
|
+ )
|
|
|
break
|
|
|
yield tool_msg
|
|
|
head_seq = sequence
|
|
|
@@ -2828,8 +3354,12 @@ class AgentRunner:
|
|
|
import base64 as b64mod
|
|
|
for img in tool_images:
|
|
|
if img.get("data"):
|
|
|
- png_path = self.trace_store._get_messages_dir(trace_id) / f"{tool_msg.message_id}.png"
|
|
|
- png_path.write_bytes(b64mod.b64decode(img["data"]))
|
|
|
+ png_path = await self.trace_store.write_message_attachment(
|
|
|
+ trace_id,
|
|
|
+ tool_msg.message_id,
|
|
|
+ suffix=".png",
|
|
|
+ data=b64mod.b64decode(img["data"]),
|
|
|
+ )
|
|
|
print(f"[Runner] 截图已保存: {png_path.name}")
|
|
|
break # 只存第一张
|
|
|
|
|
|
@@ -4235,12 +4765,14 @@ class AgentRunner:
|
|
|
exclude_tools: Optional[List[str]] = None,
|
|
|
) -> set[str]:
|
|
|
"""解析 RunConfig 的基础工具能力集合。"""
|
|
|
- if tool_groups is not None:
|
|
|
+ if tools is not None:
|
|
|
+ # Explicit tools are an exact capability set, not an addition to
|
|
|
+ # the default groups.
|
|
|
+ tool_names = set(tools)
|
|
|
+ elif tool_groups is not None:
|
|
|
tool_names = set(self.tools.get_tool_names(groups=tool_groups))
|
|
|
else:
|
|
|
tool_names = set(self.tools.get_tool_names())
|
|
|
- if tools is not None:
|
|
|
- tool_names |= set(tools)
|
|
|
if exclude_tools:
|
|
|
tool_names -= set(exclude_tools)
|
|
|
return tool_names
|
|
|
@@ -4310,8 +4842,8 @@ class AgentRunner:
|
|
|
) -> List[Dict]:
|
|
|
"""按 Trace 持久化模式和当前协议状态生成工具 Schema。
|
|
|
|
|
|
- 主循环把结果交给 LLM;Recursive revision 2 本地委托使用 ``task_brief``,
|
|
|
- 仍保留 ``remote_*`` 的 ``task``,Legacy 和 Recursive revision 1 继续使用 ``task``。
|
|
|
+ 主循环把结果交给 LLM;Recursive revision 2 只允许受治理的本地
|
|
|
+ ``task_brief`` 委托,Legacy 和 Recursive revision 1 继续使用 ``task``。
|
|
|
"""
|
|
|
tool_names = (
|
|
|
runtime_tool_names
|
|
|
@@ -4334,16 +4866,16 @@ class AgentRunner:
|
|
|
required = parameters.setdefault("required", [])
|
|
|
if policy.requires_task_protocol:
|
|
|
properties.pop("messages", None)
|
|
|
- if "task" in properties:
|
|
|
- properties["task"]["description"] = (
|
|
|
- "Only for agent_type=remote_*; local Recursive calls use task_brief."
|
|
|
- )
|
|
|
+ properties.pop("task", None)
|
|
|
if "task_brief" in properties:
|
|
|
properties["task_brief"]["description"] = (
|
|
|
- "Required for local Recursive delegation; not used by remote_* calls."
|
|
|
+ "Required structured contract for Recursive local delegation. "
|
|
|
+ "remote_* agents are not supported in Recursive revision 2."
|
|
|
)
|
|
|
if "task" in required:
|
|
|
required.remove("task")
|
|
|
+ if "task_brief" in properties and "task_brief" not in required:
|
|
|
+ required.append("task_brief")
|
|
|
else:
|
|
|
properties.pop("task_brief", None)
|
|
|
if "task" in properties and "task" not in required:
|
|
|
@@ -4373,6 +4905,16 @@ class AgentRunner:
|
|
|
"runner": self,
|
|
|
"goal_tree": goal_tree,
|
|
|
"knowledge_config": config.knowledge,
|
|
|
+ "memory_config": config.memory,
|
|
|
+ "dream_scope": (
|
|
|
+ DreamScope(
|
|
|
+ uid=trace.uid,
|
|
|
+ agent_type=trace.agent_type or config.agent_type,
|
|
|
+ memory_identity=compute_memory_identity(config.memory),
|
|
|
+ )
|
|
|
+ if config.memory is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
"sequence": sequence,
|
|
|
"side_branch": {
|
|
|
"type": side_branch_ctx.type,
|
|
|
@@ -4396,7 +4938,10 @@ class AgentRunner:
|
|
|
RECURSIVE_MAX_PARALLEL_CHILDREN_CONTEXT_KEY: config.max_parallel_children,
|
|
|
})
|
|
|
return context
|
|
|
- return {**framework_context, **(config.context or {})}
|
|
|
+ # Framework-owned hidden values are authoritative in every mode. A
|
|
|
+ # caller-provided custom context may add page/business metadata, but it
|
|
|
+ # cannot replace the store, Trace identity or per-run MemoryConfig.
|
|
|
+ return {**(config.context or {}), **framework_context}
|
|
|
|
|
|
# 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
|
|
|
# 注意:此常量已迁移到 cyber_agent.core.prompts,这里保留引用以保持向后兼容
|