|
|
@@ -56,6 +56,7 @@ from cyber_agent.core.run_snapshot import (
|
|
|
RUN_CONFIG_SNAPSHOT_CONTEXT_KEY,
|
|
|
RunConfigSnapshotError,
|
|
|
RunConfigSnapshotV1,
|
|
|
+ RunConfigSnapshotV2,
|
|
|
load_run_config_snapshot,
|
|
|
persist_run_config_snapshot,
|
|
|
)
|
|
|
@@ -248,7 +249,16 @@ class RunConfig:
|
|
|
# --- 一次性恢复动作(不进入持久化 RunConfigSnapshot) ---
|
|
|
approval_batch_id: Optional[str] = None
|
|
|
|
|
|
- def apply_snapshot(self, snapshot: RunConfigSnapshotV1) -> None:
|
|
|
+ # --- ApplicationRuntime 固化身份(只由框架装配) ---
|
|
|
+ application_ref: Any = None
|
|
|
+ role_id: Optional[str] = None
|
|
|
+ role_hash: Optional[str] = None
|
|
|
+ effective_run_limits: Dict[str, Any] = field(default_factory=dict)
|
|
|
+
|
|
|
+ def apply_snapshot(
|
|
|
+ self,
|
|
|
+ snapshot: RunConfigSnapshotV1 | RunConfigSnapshotV2,
|
|
|
+ ) -> None:
|
|
|
"""Restore all persisted behavior fields while retaining invocation routing."""
|
|
|
self.model = snapshot.model
|
|
|
self.temperature = snapshot.temperature
|
|
|
@@ -274,6 +284,11 @@ class RunConfig:
|
|
|
self.enable_prompt_caching = snapshot.enable_prompt_caching
|
|
|
self.enable_research_flow = snapshot.enable_research_flow
|
|
|
self.context = dict(snapshot.custom_context)
|
|
|
+ if isinstance(snapshot, RunConfigSnapshotV2):
|
|
|
+ self.application_ref = dict(snapshot.application_ref)
|
|
|
+ self.role_id = snapshot.role_id
|
|
|
+ self.role_hash = snapshot.role_hash
|
|
|
+ self.effective_run_limits = dict(snapshot.effective_run_limits)
|
|
|
|
|
|
|
|
|
# BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
|
|
|
@@ -320,6 +335,8 @@ class AgentRunner:
|
|
|
validator_search_provider: Optional[ValidatorWebSearchProvider] = None,
|
|
|
validator_page_fetcher: Optional[PageFetcher] = None,
|
|
|
artifact_resolver: Optional[ArtifactResolver] = None,
|
|
|
+ application_binding: Any = None,
|
|
|
+ context_provider: Any = None,
|
|
|
):
|
|
|
"""
|
|
|
初始化 AgentRunner
|
|
|
@@ -350,6 +367,8 @@ class AgentRunner:
|
|
|
self.validator_search_provider = validator_search_provider
|
|
|
self.validator_page_fetcher = validator_page_fetcher
|
|
|
self.artifact_resolver = artifact_resolver
|
|
|
+ self.application_binding = application_binding
|
|
|
+ self.context_provider = context_provider
|
|
|
self.stdin_check: Optional[Callable] = None # 由外部设置,用于子 agent 执行期间检查 stdin
|
|
|
self._cancel_events: Dict[str, asyncio.Event] = {} # trace_id → cancel event
|
|
|
self._recursive_active_traces: Dict[str, asyncio.Event] = {}
|
|
|
@@ -1253,11 +1272,100 @@ class AgentRunner:
|
|
|
(trace, goal_tree, next_sequence)
|
|
|
"""
|
|
|
assert_removed_config_absent()
|
|
|
+ if self.application_binding is not None and any(
|
|
|
+ message.get("role") == "system" for message in messages
|
|
|
+ ):
|
|
|
+ raise ValueError(
|
|
|
+ "Application runs do not allow caller-provided system messages"
|
|
|
+ )
|
|
|
if config.trace_id:
|
|
|
return await self._prepare_existing_trace(config)
|
|
|
else:
|
|
|
return await self._prepare_new_trace(messages, config)
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _application_ref_dict(value: Any) -> dict[str, Any] | None:
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
+ if hasattr(value, "model_dump"):
|
|
|
+ return value.model_dump(mode="json")
|
|
|
+ return dict(value) if isinstance(value, dict) else None
|
|
|
+
|
|
|
+ def _validate_application_config(self, config: RunConfig) -> None:
|
|
|
+ """Reject a new application RunConfig that diverges from its Binding."""
|
|
|
+ binding = self.application_binding
|
|
|
+ if binding is None:
|
|
|
+ raise ValueError("Application binding is unavailable")
|
|
|
+ expected_ref = binding.application_ref.model_dump(mode="json")
|
|
|
+ if self._application_ref_dict(config.application_ref) != expected_ref:
|
|
|
+ raise ValueError("RunConfig ApplicationRef does not match Runner binding")
|
|
|
+ if not config.role_id:
|
|
|
+ raise ValueError("Application role_id is required")
|
|
|
+ role = binding.role(config.role_id)
|
|
|
+ if config.role_hash != role.role_hash:
|
|
|
+ raise ValueError("RunConfig role hash does not match Runner binding")
|
|
|
+ if (
|
|
|
+ config.model != role.role.model
|
|
|
+ or config.temperature != role.role.temperature
|
|
|
+ or config.extra_llm_params != role.role.model_parameters
|
|
|
+ or not set(config.tools or []).issubset(set(role.tool_names))
|
|
|
+ or config.system_prompt != role.system_prompt
|
|
|
+ or config.skills != []
|
|
|
+ ):
|
|
|
+ raise ValueError("RunConfig behavior does not match the bound application role")
|
|
|
+ limits = dict(config.effective_run_limits)
|
|
|
+ if not limits:
|
|
|
+ raise ValueError("Application effective_run_limits are required")
|
|
|
+ allowed_limits = role.effective_limits.model_dump(mode="json")
|
|
|
+ for name, allowed in allowed_limits.items():
|
|
|
+ value = limits.get(name)
|
|
|
+ if value is None or value > allowed:
|
|
|
+ raise ValueError(
|
|
|
+ f"Application run limit exceeds the bound role: {name}"
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ config.max_iterations != limits["max_iterations"]
|
|
|
+ or config.max_parallel_children != limits["max_parallel_children"]
|
|
|
+ ):
|
|
|
+ raise ValueError("RunConfig limits do not match effective_run_limits")
|
|
|
+
|
|
|
+ def _validate_application_snapshot(
|
|
|
+ self,
|
|
|
+ snapshot: RunConfigSnapshotV1 | RunConfigSnapshotV2,
|
|
|
+ trace: Trace,
|
|
|
+ ) -> None:
|
|
|
+ """Perform the Runner-side binding gate before any resume mutation."""
|
|
|
+ if isinstance(snapshot, RunConfigSnapshotV1) and not isinstance(
|
|
|
+ snapshot,
|
|
|
+ RunConfigSnapshotV2,
|
|
|
+ ):
|
|
|
+ if self.application_binding is not None:
|
|
|
+ raise ValueError("Application-bound Runner cannot resume a V1 Trace")
|
|
|
+ return
|
|
|
+ if self.application_binding is None:
|
|
|
+ raise ValueError(
|
|
|
+ "Application Trace requires an ApplicationRuntime-bound Runner"
|
|
|
+ )
|
|
|
+ expected_ref = self.application_binding.application_ref.model_dump(mode="json")
|
|
|
+ if snapshot.application_ref != expected_ref:
|
|
|
+ raise ValueError("ApplicationRef does not match Runner binding")
|
|
|
+ role = self.application_binding.role(snapshot.role_id)
|
|
|
+ if snapshot.role_hash != role.role_hash:
|
|
|
+ raise ValueError("Application role hash does not match Runner binding")
|
|
|
+ if (
|
|
|
+ trace.context.get("application_ref") != snapshot.application_ref
|
|
|
+ or trace.context.get("application_role_id") != snapshot.role_id
|
|
|
+ or trace.context.get("application_role_hash") != snapshot.role_hash
|
|
|
+ or trace.context.get("effective_run_limits")
|
|
|
+ != snapshot.effective_run_limits
|
|
|
+ ):
|
|
|
+ raise ValueError("Application snapshot does not match Trace context")
|
|
|
+ restored = RunConfig()
|
|
|
+ restored.apply_snapshot(snapshot)
|
|
|
+ restored.system_prompt = role.system_prompt
|
|
|
+ restored.skills = []
|
|
|
+ self._validate_application_config(restored)
|
|
|
+
|
|
|
async def _prepare_new_trace(
|
|
|
self,
|
|
|
messages: List[Dict],
|
|
|
@@ -1267,6 +1375,12 @@ class AgentRunner:
|
|
|
|
|
|
``run`` 首次执行时调用;Recursive 会在此固化根任务锚点和树级预算。
|
|
|
"""
|
|
|
+ if self.application_binding is not None:
|
|
|
+ self._validate_application_config(config)
|
|
|
+ elif config.application_ref is not None:
|
|
|
+ raise ValueError(
|
|
|
+ "Application runs require an ApplicationRuntime-bound Runner"
|
|
|
+ )
|
|
|
# 在任何标题生成/LLM 调用前完成模式校验。
|
|
|
policy = policy_from_environment(
|
|
|
recursive_revision=CURRENT_RECURSIVE_REVISION,
|
|
|
@@ -1286,7 +1400,29 @@ class AgentRunner:
|
|
|
)
|
|
|
except ContextPolicyError as exc:
|
|
|
raise ValueError(str(exc)) from exc
|
|
|
- budget = ResourceBudget.from_environment()
|
|
|
+ deployment_budget = ResourceBudget.from_environment()
|
|
|
+ if self.application_binding is not None:
|
|
|
+ limits = config.effective_run_limits
|
|
|
+ budget = ResourceBudget(
|
|
|
+ enabled=deployment_budget.enabled,
|
|
|
+ max_total_agents=int(limits["max_total_agents"]),
|
|
|
+ max_llm_calls=int(limits["max_llm_calls"]),
|
|
|
+ max_total_tokens=int(limits["max_total_tokens"]),
|
|
|
+ max_total_cost_usd=float(limits["max_total_cost_usd"]),
|
|
|
+ max_duration_seconds=int(limits["max_duration_seconds"]),
|
|
|
+ reserved_final_calls=min(
|
|
|
+ deployment_budget.reserved_final_calls,
|
|
|
+ int(limits["max_llm_calls"]) - 1,
|
|
|
+ ),
|
|
|
+ max_validation_tool_calls=int(
|
|
|
+ limits["max_validation_tool_calls"]
|
|
|
+ ),
|
|
|
+ max_validation_material_chars=int(
|
|
|
+ limits["max_validation_material_chars"]
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ budget = deployment_budget
|
|
|
validator_settings = ValidatorSettings.from_environment()
|
|
|
trace_id = str(uuid.uuid4())
|
|
|
|
|
|
@@ -1308,10 +1444,22 @@ class AgentRunner:
|
|
|
)
|
|
|
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,
|
|
|
- )
|
|
|
+ if self.application_binding is not None:
|
|
|
+ run_snapshot = RunConfigSnapshotV2.from_run_config(
|
|
|
+ config,
|
|
|
+ memory_identity=memory_identity,
|
|
|
+ )
|
|
|
+ trace_context.update({
|
|
|
+ "application_ref": run_snapshot.application_ref,
|
|
|
+ "application_role_id": run_snapshot.role_id,
|
|
|
+ "application_role_hash": run_snapshot.role_hash,
|
|
|
+ "effective_run_limits": run_snapshot.effective_run_limits,
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ 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)
|
|
|
@@ -1335,6 +1483,26 @@ class AgentRunner:
|
|
|
root_task_anchor=root_task_anchor,
|
|
|
task_brief=state.get("task_brief"),
|
|
|
)
|
|
|
+ if self.application_binding is not None and self.context_provider is not None:
|
|
|
+ from cyber_agent.application.context import load_application_context
|
|
|
+ from cyber_agent.application.ports import ContextRequest
|
|
|
+
|
|
|
+ await load_application_context(
|
|
|
+ self.application_binding,
|
|
|
+ trace_context,
|
|
|
+ ContextRequest(
|
|
|
+ application_ref=self.application_binding.application_ref,
|
|
|
+ root_trace_id=trace_id,
|
|
|
+ trace_id=trace_id,
|
|
|
+ uid=config.uid,
|
|
|
+ role_id=config.role_id,
|
|
|
+ task_brief=None,
|
|
|
+ task_brief_revision=0,
|
|
|
+ ),
|
|
|
+ root_task_anchor=root_task_anchor,
|
|
|
+ task_brief=None,
|
|
|
+ granted_at_sequence=0,
|
|
|
+ )
|
|
|
|
|
|
trace_obj = Trace(
|
|
|
trace_id=trace_id,
|
|
|
@@ -1435,7 +1603,12 @@ class AgentRunner:
|
|
|
)
|
|
|
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")
|
|
|
+ self._validate_application_snapshot(snapshot, trace_obj)
|
|
|
config.apply_snapshot(snapshot)
|
|
|
+ if isinstance(snapshot, RunConfigSnapshotV2):
|
|
|
+ role = self.application_binding.role(snapshot.role_id)
|
|
|
+ config.system_prompt = role.system_prompt
|
|
|
+ config.skills = []
|
|
|
persisted_memory_identity = trace_obj.context.get(
|
|
|
MEMORY_IDENTITY_CONTEXT_KEY
|
|
|
)
|
|
|
@@ -4939,6 +5112,20 @@ class AgentRunner:
|
|
|
available.discard("evaluate")
|
|
|
return available
|
|
|
|
|
|
+ if self.application_binding is not None and trace.context.get(
|
|
|
+ "application_ref"
|
|
|
+ ):
|
|
|
+ role = self.application_binding.role(
|
|
|
+ trace.context.get("application_role_id")
|
|
|
+ )
|
|
|
+ limits = trace.context.get("effective_run_limits") or {}
|
|
|
+ depth = int(trace.context.get("agent_depth", 0) or 0)
|
|
|
+ if (
|
|
|
+ not role.role.allowed_child_roles
|
|
|
+ or depth >= int(limits.get("max_depth", policy.max_depth))
|
|
|
+ ):
|
|
|
+ tool_names.discard("agent")
|
|
|
+
|
|
|
state = ensure_task_protocol(trace.context)
|
|
|
tool_names.discard("evaluate")
|
|
|
if state["pending_reviews"]:
|
|
|
@@ -4991,12 +5178,30 @@ class AgentRunner:
|
|
|
if "task_brief" in properties:
|
|
|
properties["task_brief"]["description"] = (
|
|
|
"Required structured contract for Recursive local delegation. "
|
|
|
- "remote_* agents are not supported in Recursive revision 2."
|
|
|
+ "remote_* agents are not supported in Recursive revision 3."
|
|
|
)
|
|
|
if "task" in required:
|
|
|
required.remove("task")
|
|
|
if "task_brief" in properties and "task_brief" not in required:
|
|
|
required.append("task_brief")
|
|
|
+ if self.application_binding is not None and trace.context.get(
|
|
|
+ "application_ref"
|
|
|
+ ):
|
|
|
+ role = self.application_binding.role(
|
|
|
+ trace.context.get("application_role_id")
|
|
|
+ )
|
|
|
+ properties.pop("skills", None)
|
|
|
+ if "skills" in required:
|
|
|
+ required.remove("skills")
|
|
|
+ if "agent_type" in properties:
|
|
|
+ properties["agent_type"]["enum"] = list(
|
|
|
+ role.role.allowed_child_roles
|
|
|
+ )
|
|
|
+ properties["agent_type"]["description"] = (
|
|
|
+ "Required application role for the direct child."
|
|
|
+ )
|
|
|
+ if "agent_type" not in required:
|
|
|
+ required.append("agent_type")
|
|
|
else:
|
|
|
properties.pop("task_brief", None)
|
|
|
if "task" in properties and "task" not in required:
|