|
|
@@ -20,9 +20,12 @@ from cyber_agent.core.agent_mode import (
|
|
|
AgentMode,
|
|
|
AgentPolicy,
|
|
|
RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY,
|
|
|
+ RECURSIVE_CHILD_EXECUTION_MODE_CONTEXT_KEY,
|
|
|
+ RECURSIVE_MAX_PARALLEL_CHILDREN_CONTEXT_KEY,
|
|
|
apply_policy_to_context,
|
|
|
assert_removed_config_absent,
|
|
|
policy_from_context,
|
|
|
+ validate_recursive_child_execution,
|
|
|
)
|
|
|
from cyber_agent.core.task_protocol import (
|
|
|
TaskBrief,
|
|
|
@@ -31,6 +34,7 @@ from cyber_agent.core.task_protocol import (
|
|
|
format_task_brief,
|
|
|
pending_review_entry,
|
|
|
protocol_error_report,
|
|
|
+ stopped_task_report,
|
|
|
)
|
|
|
from cyber_agent.trace.models import Trace, Messages
|
|
|
from cyber_agent.trace.trace_id import generate_sub_trace_id
|
|
|
@@ -223,6 +227,10 @@ async def _load_or_create_task_report(
|
|
|
return protocol_error_report(child_trace_id, fallback_reason)
|
|
|
state = ensure_task_protocol(child.context)
|
|
|
report_data = state.get("task_report")
|
|
|
+ execution_stopped = (
|
|
|
+ child_result_status == "stopped"
|
|
|
+ or child.status == "stopped"
|
|
|
+ )
|
|
|
execution_failed = (
|
|
|
child_result_status != "completed"
|
|
|
or child.status != "completed"
|
|
|
@@ -231,22 +239,34 @@ async def _load_or_create_task_report(
|
|
|
try:
|
|
|
report = TaskReport.model_validate(report_data)
|
|
|
if report.child_trace_id != child_trace_id:
|
|
|
- state["report_history"].append(report.model_dump())
|
|
|
+ if report.model_dump() not in state["report_history"]:
|
|
|
+ state["report_history"].append(report.model_dump())
|
|
|
fallback_reason = (
|
|
|
"Child persisted a TaskReport whose child_trace_id does not "
|
|
|
"match its Trace"
|
|
|
)
|
|
|
+ elif execution_stopped:
|
|
|
+ if report.outcome in {"failed", "protocol_error"}:
|
|
|
+ return report
|
|
|
+ if report.model_dump() not in state["report_history"]:
|
|
|
+ state["report_history"].append(report.model_dump())
|
|
|
+ fallback_reason = "Child Agent execution was stopped"
|
|
|
elif not execution_failed or report.outcome in {"failed", "protocol_error"}:
|
|
|
return report
|
|
|
else:
|
|
|
- state["report_history"].append(report.model_dump())
|
|
|
+ if report.model_dump() not in state["report_history"]:
|
|
|
+ state["report_history"].append(report.model_dump())
|
|
|
fallback_reason = (
|
|
|
f"Child execution ended as {child_result_status} after submitting "
|
|
|
f"a {report.outcome} TaskReport: {fallback_reason}"
|
|
|
)
|
|
|
except ValidationError:
|
|
|
fallback_reason = "Child persisted an invalid TaskReport"
|
|
|
- report = protocol_error_report(child_trace_id, fallback_reason)
|
|
|
+ report = (
|
|
|
+ stopped_task_report(child_trace_id, fallback_reason)
|
|
|
+ if execution_stopped
|
|
|
+ else protocol_error_report(child_trace_id, fallback_reason)
|
|
|
+ )
|
|
|
state["task_report"] = report.model_dump()
|
|
|
state["task_report_submitted_at_sequence"] = generated_at_sequence
|
|
|
await store.update_trace(child_trace_id, context=child.context)
|
|
|
@@ -315,6 +335,69 @@ def _aggregate_stats(results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
}
|
|
|
|
|
|
|
|
|
+async def _stopped_child_result(store, runner, trace_id: str) -> Dict[str, Any]:
|
|
|
+ """停止尚未启动的预创建孩子,不产生任何模型调用或消息统计。"""
|
|
|
+ await store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ status="stopped",
|
|
|
+ completed_at=datetime.now(),
|
|
|
+ )
|
|
|
+ event = getattr(runner, "_cancel_events", {}).get(trace_id)
|
|
|
+ release_trace = getattr(runner, "release_recursive_trace", None)
|
|
|
+ if release_trace:
|
|
|
+ release_trace(trace_id, event)
|
|
|
+ return {
|
|
|
+ "status": "stopped",
|
|
|
+ "summary": "Agent execution stopped.",
|
|
|
+ "error": "Child Agent execution was stopped",
|
|
|
+ "saved_knowledge_ids": [],
|
|
|
+ "stats": {"total_messages": 0, "total_tokens": 0, "total_cost": 0.0},
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+async def _execute_child_spec(
|
|
|
+ spec: Dict[str, Any],
|
|
|
+ runner,
|
|
|
+ store,
|
|
|
+ semaphore: Optional[asyncio.Semaphore] = None,
|
|
|
+) -> Dict[str, Any]:
|
|
|
+ """延迟启动一个孩子;排队期间收到停止信号时不进入 Runner。"""
|
|
|
+ trace_id = spec["trace_id"]
|
|
|
+
|
|
|
+ async def execute() -> Dict[str, Any]:
|
|
|
+ is_cancelled = getattr(runner, "is_cancel_requested", lambda _tid: False)
|
|
|
+ if spec["recursive"] and is_cancelled(trace_id):
|
|
|
+ return await _stopped_child_result(store, runner, trace_id)
|
|
|
+ return await runner.run_result(
|
|
|
+ messages=spec["messages"],
|
|
|
+ config=spec["config"],
|
|
|
+ on_event=spec["on_event"],
|
|
|
+ )
|
|
|
+
|
|
|
+ if semaphore is None:
|
|
|
+ return await execute()
|
|
|
+
|
|
|
+ is_cancelled = getattr(runner, "is_cancel_requested", lambda _tid: False)
|
|
|
+ if spec["recursive"] and is_cancelled(trace_id):
|
|
|
+ return await _stopped_child_result(store, runner, trace_id)
|
|
|
+ async with semaphore:
|
|
|
+ return await execute()
|
|
|
+
|
|
|
+
|
|
|
+def _get_recursive_parent_capabilities(context: dict) -> Optional[set[str]]:
|
|
|
+ runner = context.get("runner")
|
|
|
+ capabilities = context.get(RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY)
|
|
|
+ if (
|
|
|
+ not runner
|
|
|
+ or not hasattr(runner, "tools")
|
|
|
+ or not hasattr(runner.tools, "get_tool_names")
|
|
|
+ or not isinstance(capabilities, list)
|
|
|
+ or any(not isinstance(name, str) for name in capabilities)
|
|
|
+ ):
|
|
|
+ return None
|
|
|
+ return set(runner.tools.get_tool_names()) & set(capabilities)
|
|
|
+
|
|
|
+
|
|
|
def _get_allowed_tools(
|
|
|
context: dict,
|
|
|
agent_depth: int,
|
|
|
@@ -325,14 +408,9 @@ def _get_allowed_tools(
|
|
|
if runner and hasattr(runner, "tools") and hasattr(runner.tools, "get_tool_names"):
|
|
|
registered_tools = set(runner.tools.get_tool_names())
|
|
|
if policy.mode is AgentMode.RECURSIVE:
|
|
|
- parent_capabilities = context.get(
|
|
|
- RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY
|
|
|
- )
|
|
|
- if not isinstance(parent_capabilities, list) or any(
|
|
|
- not isinstance(name, str) for name in parent_capabilities
|
|
|
- ):
|
|
|
+ allowed_tools = _get_recursive_parent_capabilities(context)
|
|
|
+ if allowed_tools is None:
|
|
|
return None
|
|
|
- allowed_tools = registered_tools & set(parent_capabilities)
|
|
|
else:
|
|
|
# Legacy 保留原行为:从全局 Registry 取工具全集。
|
|
|
allowed_tools = registered_tools
|
|
|
@@ -572,11 +650,14 @@ def _make_interactive_handler(runner, sub_trace_id: str, parent_trace_id: str, d
|
|
|
return
|
|
|
cmd = check_fn()
|
|
|
if cmd in ('pause', 'quit'):
|
|
|
- # cancel_event.set() 是同步操作,可以在同步回调中直接调用
|
|
|
- for tid in (sub_trace_id, parent_trace_id):
|
|
|
- ev = runner._cancel_events.get(tid)
|
|
|
- if ev:
|
|
|
- ev.set()
|
|
|
+ request_stop = getattr(runner, "request_stop", None)
|
|
|
+ if request_stop:
|
|
|
+ request_stop(parent_trace_id)
|
|
|
+ else:
|
|
|
+ for tid in (sub_trace_id, parent_trace_id):
|
|
|
+ ev = runner._cancel_events.get(tid)
|
|
|
+ if ev:
|
|
|
+ ev.set()
|
|
|
|
|
|
return on_event
|
|
|
|
|
|
@@ -612,13 +693,32 @@ async def _run_agents(
|
|
|
|
|
|
if (
|
|
|
policy.mode is AgentMode.RECURSIVE
|
|
|
- and _get_allowed_tools(context, 0, policy) is None
|
|
|
+ and _get_recursive_parent_capabilities(context) is None
|
|
|
):
|
|
|
return {
|
|
|
"status": "failed",
|
|
|
"error": "Recursive agent capability context is missing or invalid",
|
|
|
}
|
|
|
|
|
|
+ child_execution_mode = "parallel"
|
|
|
+ max_parallel_children = 2
|
|
|
+ if policy.mode is AgentMode.RECURSIVE:
|
|
|
+ try:
|
|
|
+ child_execution_mode, max_parallel_children = (
|
|
|
+ validate_recursive_child_execution(
|
|
|
+ context.get(
|
|
|
+ RECURSIVE_CHILD_EXECUTION_MODE_CONTEXT_KEY,
|
|
|
+ "sequential",
|
|
|
+ ),
|
|
|
+ context.get(
|
|
|
+ RECURSIVE_MAX_PARALLEL_CHILDREN_CONTEXT_KEY,
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ except ValueError as exc:
|
|
|
+ return {"status": "failed", "error": str(exc)}
|
|
|
+
|
|
|
protocol_state = None
|
|
|
approved_action = None
|
|
|
if policy.requires_task_protocol:
|
|
|
@@ -851,9 +951,9 @@ async def _run_agents(
|
|
|
protocol_state["protocol_correction_attempts"] = 0
|
|
|
await store.update_trace(trace_id, context=parent_trace.context)
|
|
|
|
|
|
- # 创建执行协程。Trace 在上面已经按批次预创建,
|
|
|
- # 因此孩子配额不会被并发调用绕过。
|
|
|
- coros = []
|
|
|
+ # 创建延迟执行规格。Trace 已按批次预创建,但不会提前创建 coroutine,
|
|
|
+ # 因而排队孩子被停止时不会产生未 await coroutine 或模型调用。
|
|
|
+ execution_specs = []
|
|
|
for i, (task_item, msgs, child_record) in enumerate(
|
|
|
zip(tasks, per_agent_msgs, child_records)
|
|
|
):
|
|
|
@@ -888,24 +988,30 @@ async def _run_agents(
|
|
|
runner, cur_stid, trace_id, debug_printer=debug_printer
|
|
|
)
|
|
|
|
|
|
- coro = runner.run_result(
|
|
|
- messages=agent_msgs,
|
|
|
- config=_make_run_config(
|
|
|
- trace_id=cur_stid,
|
|
|
- agent_type=agent_type or ("delegate" if single else "explore"),
|
|
|
- max_iterations=50 if single else 50,
|
|
|
- model=parent_trace.model if parent_trace else "gpt-4o",
|
|
|
- uid=parent_trace.uid if parent_trace else None,
|
|
|
- tools=allowed_tools,
|
|
|
- tool_groups=[], # tools 是精确白名单,不再合并默认 core 组
|
|
|
- name=task_label[:50],
|
|
|
- skills=skills,
|
|
|
- knowledge=context.get("knowledge_config"),
|
|
|
- context=child_record["context"],
|
|
|
- ),
|
|
|
- on_event=on_event,
|
|
|
+ child_config = _make_run_config(
|
|
|
+ trace_id=cur_stid,
|
|
|
+ agent_type=agent_type or ("delegate" if single else "explore"),
|
|
|
+ max_iterations=50,
|
|
|
+ model=parent_trace.model if parent_trace else "gpt-4o",
|
|
|
+ uid=parent_trace.uid if parent_trace else None,
|
|
|
+ tools=allowed_tools,
|
|
|
+ tool_groups=[], # tools 是精确白名单,不再合并默认 core 组
|
|
|
+ name=task_label[:50],
|
|
|
+ skills=skills,
|
|
|
+ knowledge=context.get("knowledge_config"),
|
|
|
+ context=child_record["context"],
|
|
|
+ child_execution_mode=child_execution_mode,
|
|
|
+ max_parallel_children=max_parallel_children,
|
|
|
)
|
|
|
- coros.append((i, cur_stid, collab_name, coro))
|
|
|
+ execution_specs.append({
|
|
|
+ "index": i,
|
|
|
+ "trace_id": cur_stid,
|
|
|
+ "collaborator": collab_name,
|
|
|
+ "messages": agent_msgs,
|
|
|
+ "config": child_config,
|
|
|
+ "on_event": on_event,
|
|
|
+ "recursive": policy.mode is AgentMode.RECURSIVE,
|
|
|
+ })
|
|
|
|
|
|
# continue_from 不进入新建临界区,但仍需要恢复 Goal 的运行状态。
|
|
|
if not goal_started:
|
|
|
@@ -919,12 +1025,20 @@ async def _run_agents(
|
|
|
if policy.requires_task_protocol:
|
|
|
_project_goal_status(context, goal_id, "waiting_children")
|
|
|
|
|
|
+ if policy.mode is AgentMode.RECURSIVE:
|
|
|
+ register_child = getattr(runner, "register_recursive_child", None)
|
|
|
+ if register_child:
|
|
|
+ for spec in execution_specs:
|
|
|
+ register_child(trace_id, spec["trace_id"])
|
|
|
+
|
|
|
# 执行
|
|
|
if single:
|
|
|
# 单任务直接执行(带异常处理)
|
|
|
- _, stid, collab_name, coro = coros[0]
|
|
|
+ spec = execution_specs[0]
|
|
|
+ stid = spec["trace_id"]
|
|
|
+ collab_name = spec["collaborator"]
|
|
|
try:
|
|
|
- result = await coro
|
|
|
+ result = await _execute_child_spec(spec, runner, store)
|
|
|
|
|
|
await broadcast_sub_trace_completed(
|
|
|
trace_id, stid,
|
|
|
@@ -992,28 +1106,35 @@ async def _run_agents(
|
|
|
)
|
|
|
return failed_result
|
|
|
else:
|
|
|
- # 检查父 Agent 是否开启了并发配置
|
|
|
- is_parallel = getattr(runner.config, "parallel_tool_execution", True) if runner and hasattr(runner, "config") else True
|
|
|
-
|
|
|
- if is_parallel:
|
|
|
- # 多任务并行执行
|
|
|
- raw_results = await asyncio.gather(
|
|
|
- *(coro for _, _, _, coro in coros),
|
|
|
- return_exceptions=True,
|
|
|
- )
|
|
|
+ async def execute_captured(
|
|
|
+ spec: Dict[str, Any],
|
|
|
+ semaphore: Optional[asyncio.Semaphore] = None,
|
|
|
+ ):
|
|
|
+ try:
|
|
|
+ return await _execute_child_spec(spec, runner, store, semaphore)
|
|
|
+ except Exception as exc:
|
|
|
+ return exc
|
|
|
+
|
|
|
+ if policy.mode is AgentMode.LEGACY:
|
|
|
+ # Legacy 冻结旧行为:多任务整批并行,不读取新配置。
|
|
|
+ raw_results = await asyncio.gather(*(
|
|
|
+ execute_captured(spec) for spec in execution_specs
|
|
|
+ ))
|
|
|
+ elif child_execution_mode == "parallel":
|
|
|
+ semaphore = asyncio.Semaphore(max_parallel_children)
|
|
|
+ raw_results = await asyncio.gather(*(
|
|
|
+ execute_captured(spec, semaphore) for spec in execution_specs
|
|
|
+ ))
|
|
|
else:
|
|
|
- # 多任务串行执行(为了省显存/内存)
|
|
|
raw_results = []
|
|
|
- for _, _, _, coro in coros:
|
|
|
- try:
|
|
|
- res = await coro
|
|
|
- raw_results.append(res)
|
|
|
- except Exception as e:
|
|
|
- raw_results.append(e)
|
|
|
+ for spec in execution_specs:
|
|
|
+ raw_results.append(await execute_captured(spec))
|
|
|
|
|
|
processed_results = []
|
|
|
for idx, raw in enumerate(raw_results):
|
|
|
- _, stid, collab_name, _ = coros[idx]
|
|
|
+ spec = execution_specs[idx]
|
|
|
+ stid = spec["trace_id"]
|
|
|
+ collab_name = spec["collaborator"]
|
|
|
if isinstance(raw, Exception):
|
|
|
error_result = {
|
|
|
"status": "failed",
|