Jelajahi Sumber

运行时:接入显式验证策略与受保护工具执行

RunConfig 新增 completion_policy,并在 explicit_validation 下强制装配 Coordinator、关闭并发 Tool Call、按 preset 解析角色和工具权限。Schema 暴露与真实执行共用授权结果,框架身份上下文覆盖 Host 输入;Runner 支持 terminal tool、Trace result_summary、角色契约和旧 Trace 的 legacy 缺省兼容。
SamLee 4 hari lalu
induk
melakukan
8f79efb8fa
2 mengubah file dengan 211 tambahan dan 33 penghapusan
  1. 209 33
      agent/agent/core/runner.py
  2. 2 0
      agent/agent/trace/models.py

+ 209 - 33
agent/agent/core/runner.py

@@ -38,6 +38,15 @@ from agent.skill.skill_loader import load_skills_from_dir
 from agent.tools import ToolRegistry, get_tool_registry
 from agent.tools.builtin.knowledge import KnowledgeConfig
 from agent.core.memory import MemoryConfig
+from agent.core.presets import get_preset
+from agent.orchestration.models import AgentRole, CompletionPolicy
+from agent.orchestration.policy import (
+    DefaultToolPolicy,
+    ResolvedAgentPolicy,
+    PLANNER_TOOLS,
+    WORKER_TOOLS,
+    VALIDATOR_TOOLS,
+)
 from agent.core.prompts import (
     DEFAULT_SYSTEM_PREFIX,
     TRUNCATION_HINT,
@@ -50,6 +59,7 @@ from agent.core.prompts import (
     build_summary_header,
     build_tool_interrupted_message,
     build_agent_continue_hint,
+    ROLE_CONTRACTS,
 )
 
 logger = logging.getLogger(__name__)
@@ -124,9 +134,11 @@ class RunConfig:
     name: Optional[str] = None                 # 显示名称(空则由 utility_llm 自动生成)
     enable_prompt_caching: bool = True         # 启用 Anthropic Prompt Caching(仅 Claude 模型有效)
     parallel_tool_execution: bool = False      # 是否启用并发 Tool Call 执行(慎用,需确保无资源冲突)
+    completion_policy: CompletionPolicy = CompletionPolicy.LEGACY_AUTO
 
     # --- Trace 控制 ---
     trace_id: Optional[str] = None             # None = 新建
+    new_trace_id: Optional[str] = None         # 框架内部:为新 Sub-Trace 预分配 ID
     parent_trace_id: Optional[str] = None      # 子 Agent 专用
     parent_goal_id: Optional[str] = None
 
@@ -188,6 +200,8 @@ class AgentRunner:
         goal_tree: Optional[GoalTree] = None,
         debug: bool = False,
         logger_name: Optional[str] = None,
+        task_coordinator: Optional[Any] = None,
+        tool_policy: Optional[Any] = None,
     ):
         """
         初始化 AgentRunner
@@ -227,6 +241,8 @@ class AgentRunner:
         # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
         # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
         self._current_memory_config: Optional[MemoryConfig] = None
+        self.task_coordinator = task_coordinator
+        self.tool_policy = tool_policy or DefaultToolPolicy()
 
     # ===== 核心公开方法 =====
 
@@ -291,12 +307,27 @@ class AgentRunner:
         config = config or RunConfig()
         trace = None
 
+        resolved_policy = self._resolve_run_policy(config)
+        if resolved_policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
+            if self.task_coordinator is None:
+                raise RuntimeError(
+                    "explicit_validation requires a TaskCoordinator; call wire_orchestration() first"
+                )
+            config.parallel_tool_execution = False
+            config.max_iterations = resolved_policy.max_iterations
+            config.temperature = resolved_policy.temperature
+
         # Memory 模式开关(dream 工具会读取此字段)
         self._current_memory_config = config.memory
 
         try:
             # Phase 1: PREPARE TRACE
             trace, goal_tree, sequence = await self._prepare_trace(messages, config)
+            if (
+                resolved_policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION
+                and resolved_policy.role == AgentRole.PLANNER
+            ):
+                await self.task_coordinator.ensure_ledger(trace.trace_id, trace.task or "")
             # 注册取消事件
             self._cancel_events[trace.trace_id] = asyncio.Event()
             yield trace
@@ -397,9 +428,9 @@ class AgentRunner:
 
         status = final_trace.status if final_trace else "unknown"
         error = final_trace.error_message if final_trace else None
-        summary = last_assistant_text
+        summary = last_assistant_text or (final_trace.result_summary if final_trace else "")
 
-        if not summary:
+        if not summary and status != "completed":
             status = "failed"
             error = error or "Agent 没有产生 assistant 文本结果"
 
@@ -509,26 +540,28 @@ class AgentRunner:
         config: RunConfig,
     ) -> Tuple[Trace, Optional[GoalTree], int]:
         """创建新 Trace"""
-        trace_id = str(uuid.uuid4())
+        trace_id = config.new_trace_id or str(uuid.uuid4())
 
         # 生成任务名称
         task_name = config.name or await self._generate_task_name(messages)
 
         # 准备工具 Schema
-        tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups, config.exclude_tools)
+        resolved_policy = self._resolve_run_policy(config)
+        tool_schemas = self._get_run_tool_schemas(config, resolved_policy)
 
         trace_obj = Trace(
             trace_id=trace_id,
             mode="agent",
             task=task_name,
             agent_type=config.agent_type,
+            agent_role=resolved_policy.role.value,
             parent_trace_id=config.parent_trace_id,
             parent_goal_id=config.parent_goal_id,
             uid=config.uid,
             model=config.model,
             tools=tool_schemas,
             llm_params={"temperature": config.temperature, **config.extra_llm_params},
-            context=config.context,
+            context=dict(config.context),
             status="running",
         )
 
@@ -1030,7 +1063,7 @@ class AgentRunner:
                 attempt += '}' * max(0, open_braces) + ']' * max(0, open_brackets)
                 result = json.loads(attempt)
                 if isinstance(result, dict):
-                    self.log.info(f"[JSON Fix] 成功修复 JSON (suffix={repr(suffix)})")
+                    logger.info(f"[JSON Fix] 成功修复 JSON (suffix={repr(suffix)})")
                     return result
             except json.JSONDecodeError:
                 continue
@@ -1049,7 +1082,8 @@ class AgentRunner:
     ) -> AsyncIterator[Union[Trace, Message]]:
         """ReAct 循环"""
         trace_id = trace.trace_id
-        tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups, config.exclude_tools)
+        resolved_policy = self._resolve_run_policy(config, trace)
+        tool_schemas = self._get_run_tool_schemas(config, resolved_policy)
 
         # 当前主路径头节点的 sequence(用于设置 parent_sequence)
         head_seq = trace.head_sequence
@@ -1278,7 +1312,14 @@ class AgentRunner:
             cache_read_tokens = result.get("cache_read_tokens")
 
             # 周期性自动注入上下文(仅主路径)
-            if not side_branch_ctx and iteration % CONTEXT_INJECTION_INTERVAL == 0:
+            if (
+                not side_branch_ctx
+                and iteration % CONTEXT_INJECTION_INTERVAL == 0
+                and (
+                    resolved_policy.completion_policy == CompletionPolicy.LEGACY_AUTO
+                    or "get_current_context" in resolved_policy.effective_tools
+                )
+            ):
                 # 检查是否已经调用了 get_current_context
                 if tool_calls:
                     has_context_call = any(
@@ -1320,7 +1361,13 @@ class AgentRunner:
                         self.log.info(f"[Skill 指定注入] 自动添加 skill(\"{skill_name}\") 工具调用")
 
             # 按需自动创建 root goal(仅主路径)
-            if not side_branch_ctx and goal_tree and not goal_tree.goals and tool_calls:
+            if (
+                resolved_policy.completion_policy == CompletionPolicy.LEGACY_AUTO
+                and not side_branch_ctx
+                and goal_tree
+                and not goal_tree.goals
+                and tool_calls
+            ):
                 has_goal_call = any(
                     tc.get("function", {}).get("name") == "goal"
                     for tc in tool_calls
@@ -1622,6 +1669,9 @@ class AgentRunner:
                     })
                 continue
 
+            terminal_run = False
+            terminal_summary: Optional[str] = None
+
             if tool_calls and config.auto_execute_tools:
                 history.append({
                     "role": "assistant",
@@ -1664,9 +1714,21 @@ class AgentRunner:
                             except ImportError:
                                 pass
                         try:
-                            tool_result = await self.tools.execute(
-                                tool_name, tool_args, uid=config.uid or "",
-                                context={"store": self.trace_store, "trace_id": trace_id, "goal_id": current_goal_id, "runner": self, "goal_tree": goal_tree, "knowledge_config": config.knowledge, "sequence": sequence, "side_branch": {"type": side_branch_ctx.type, "branch_id": side_branch_ctx.branch_id, "is_side_branch": True, "max_turns": side_branch_ctx.max_turns, "trigger_event": trigger_event_for_tool} if side_branch_ctx else None, **(config.context or {})}
+                            tool_result = await self._execute_authorized_tool(
+                                tool_name,
+                                tool_args,
+                                tc["id"],
+                                config,
+                                trace,
+                                goal_tree,
+                                sequence,
+                                side_branch={
+                                    "type": side_branch_ctx.type,
+                                    "branch_id": side_branch_ctx.branch_id,
+                                    "is_side_branch": True,
+                                    "max_turns": side_branch_ctx.max_turns,
+                                    "trigger_event": trigger_event_for_tool,
+                                } if side_branch_ctx else None,
                             )
                             return (tc, tool_args, tool_result)
                         except Exception as e:
@@ -1786,30 +1848,29 @@ class AgentRunner:
                             except ImportError:
                                 pass
     
-                        tool_result = await self.tools.execute(
+                        tool_result = await self._execute_authorized_tool(
                             tool_name,
                             tool_args,
-                            uid=config.uid or "",
-                            context={
-                                "store": self.trace_store,
-                                "trace_id": trace_id,
-                                "goal_id": current_goal_id,
-                                "runner": self,
-                                "goal_tree": goal_tree,
-                                "knowledge_config": config.knowledge,
-                                "sequence": sequence,  # 添加sequence用于知识注入记录
-                                # 新增:侧分支信息
-                                "side_branch": {
-                                    "type": side_branch_ctx.type,
-                                    "branch_id": side_branch_ctx.branch_id,
-                                    "is_side_branch": True,
-                                    "max_turns": side_branch_ctx.max_turns,
-                                    "trigger_event": trigger_event_for_tool,
-                                } if side_branch_ctx else None,
-                                # 合并用户自定义 context(RunConfig.context)
-                                **(config.context or {}),
-                            },
+                            tc["id"],
+                            config,
+                            trace,
+                            goal_tree,
+                            sequence,
+                            side_branch={
+                                "type": side_branch_ctx.type,
+                                "branch_id": side_branch_ctx.branch_id,
+                                "is_side_branch": True,
+                                "max_turns": side_branch_ctx.max_turns,
+                                "trigger_event": trigger_event_for_tool,
+                            } if side_branch_ctx else None,
                         )
+
+                        terminal_control = None
+                        if isinstance(tool_result, dict):
+                            terminal_control = tool_result.get("_control")
+                            if terminal_control:
+                                tool_result = dict(tool_result)
+                                tool_result.pop("_control", None)
     
                         # 如果是 goal 工具,记录执行后的状态
                         if tool_name == "goal" and goal_tree:
@@ -1924,6 +1985,20 @@ class AgentRunner:
                             except Exception as e:
                                 self.log.warning(f"[Skill 指定注入] 记录追踪失败: {e}")
 
+                        if terminal_control and terminal_control.get("terminate_run"):
+                            terminal_run = True
+                            terminal_summary = terminal_control.get("result_summary") or tool_text
+                            if self.trace_store:
+                                await self.trace_store.update_trace(
+                                    trace_id,
+                                    result_summary=terminal_summary,
+                                    head_sequence=head_seq,
+                                )
+                            break
+
+                if terminal_run:
+                    break
+
                 # on_complete 模式:goal(done=...) 后立即压缩该 goal 的消息
                 if (
                     not side_branch_ctx
@@ -2978,6 +3053,100 @@ class AgentRunner:
         )
         return messages
 
+    def _resolve_run_policy(
+        self,
+        config: RunConfig,
+        trace: Optional[Trace] = None,
+    ) -> ResolvedAgentPolicy:
+        """Resolve role and effective tools from the preset, never caller context."""
+        preset = get_preset(config.agent_type)
+        policy = self.tool_policy.resolve(config, preset, self.tools)
+        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
+            if policy.role == AgentRole.LEGACY:
+                raise ValueError(
+                    f"Preset '{config.agent_type}' has legacy role and cannot run explicit_validation"
+                )
+            if trace and trace.agent_role not in (policy.role.value, "legacy"):
+                raise ValueError(
+                    f"Trace role mismatch: stored={trace.agent_role}, requested={policy.role.value}"
+                )
+        return policy
+
+    def _get_run_tool_schemas(
+        self,
+        config: RunConfig,
+        policy: Optional[ResolvedAgentPolicy] = None,
+    ) -> List[Dict]:
+        policy = policy or self._resolve_run_policy(config)
+        if policy.completion_policy == CompletionPolicy.LEGACY_AUTO:
+            schemas = self._get_tool_schemas(config.tools, config.tool_groups, config.exclude_tools)
+            explicit_only = PLANNER_TOOLS | WORKER_TOOLS | VALIDATOR_TOOLS
+            return [
+                schema for schema in schemas
+                if schema.get("function", {}).get("name") not in explicit_only
+            ]
+        return self.tools.get_schemas(sorted(policy.effective_tools))
+
+    def _build_protected_tool_context(
+        self,
+        config: RunConfig,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+        sequence: int,
+        side_branch: Optional[Dict[str, Any]] = None,
+    ) -> Dict[str, Any]:
+        """Merge host context first; framework identity fields always win."""
+        context = dict(config.context or {})
+        trace_context = trace.context or {}
+        context.update({
+            "store": self.trace_store,
+            "trace_id": trace.trace_id,
+            "root_trace_id": trace_context.get("root_trace_id", trace.trace_id),
+            "goal_id": goal_tree.current_id if goal_tree else None,
+            "runner": self,
+            "goal_tree": goal_tree,
+            "knowledge_config": config.knowledge,
+            "sequence": sequence,
+            "side_branch": side_branch,
+            "role": trace.agent_role,
+            "completion_policy": CompletionPolicy(config.completion_policy).value,
+            "task_id": trace_context.get("task_id"),
+            "spec_version": trace_context.get("spec_version"),
+            "attempt_id": trace_context.get("attempt_id"),
+            "snapshot_id": trace_context.get("snapshot_id"),
+            "validation_id": trace_context.get("validation_id"),
+            "coordinator": self.task_coordinator,
+        })
+        return context
+
+    async def _execute_authorized_tool(
+        self,
+        tool_name: str,
+        tool_args: Dict[str, Any],
+        tool_call_id: str,
+        config: RunConfig,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+        sequence: int,
+        side_branch: Optional[Dict[str, Any]] = None,
+    ) -> Any:
+        policy = self._resolve_run_policy(config, trace)
+        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
+            authorization = self.tool_policy.authorize(policy.role, tool_name, policy)
+            if not authorization.allowed:
+                self.log.warning("Rejected unauthorized tool call: %s", authorization.reason)
+                return {"text": f"Error: {authorization.reason}", "error": "unauthorized_tool"}
+        context = self._build_protected_tool_context(
+            config, trace, goal_tree, sequence, side_branch=side_branch
+        )
+        context["tool_call_id"] = tool_call_id
+        return await self.tools.execute(
+            tool_name,
+            tool_args,
+            uid=config.uid or "",
+            context=context,
+        )
+
     def _get_tool_schemas(
         self,
         tools: Optional[List[str]] = None,
@@ -3077,6 +3246,13 @@ class AgentRunner:
             except Exception as e:
                 self.log.warning(f"[Memory] 加载记忆失败,跳过注入: {e}")
 
+        # 框架角色契约最后追加,Host prompt、skills 和 memory 都不能覆盖不变量。
+        policy = self._resolve_run_policy(config)
+        if policy.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION:
+            contract = ROLE_CONTRACTS.get(policy.role.value)
+            if contract:
+                system_prompt += f"\n\n{contract}"
+
         return system_prompt
 
     async def _generate_task_name(self, messages: List[Dict]) -> str:

+ 2 - 0
agent/agent/trace/models.py

@@ -45,6 +45,7 @@ class Trace:
     # Agent 模式特有
     task: Optional[str] = None
     agent_type: Optional[str] = None
+    agent_role: str = "legacy"
 
     # 父子关系(Sub-Trace 特有)
     parent_trace_id: Optional[str] = None     # 父 Trace ID
@@ -129,6 +130,7 @@ class Trace:
             "prompt_name": self.prompt_name,
             "task": self.task,
             "agent_type": self.agent_type,
+            "agent_role": self.agent_role,
             "parent_trace_id": self.parent_trace_id,
             "parent_goal_id": self.parent_goal_id,
             "status": self.status,