Просмотр исходного кода

增加 recursive TaskBrief、TaskReport 与 TaskReview

SamLee 1 день назад
Родитель
Сommit
57c515d148
3 измененных файлов с 464 добавлено и 3 удалено
  1. 191 0
      cyber_agent/core/task_protocol.py
  2. 212 0
      cyber_agent/tools/builtin/task_protocol.py
  3. 61 3
      cyber_agent/tools/schema.py

+ 191 - 0
cyber_agent/core/task_protocol.py

@@ -0,0 +1,191 @@
+"""Recursive revision 2 parent/child task protocol."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import datetime
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+
+TaskOutcome = Literal["satisfied", "partial", "failed", "protocol_error"]
+NextStepDirection = Literal["DESCEND", "REFINE", "ASCEND", "NONE"]
+ReviewDecision = Literal[
+    "ACCEPT_REFINE",
+    "REJECT_REFINE",
+    "REVISE_CHILD",
+    "DESCEND_AGAIN",
+    "ASCEND",
+    "FAIL",
+]
+
+
+class StrictProtocolModel(BaseModel):
+    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
+
+
+class TaskBrief(StrictProtocolModel):
+    objective: str = Field(min_length=1)
+    completion_criteria: list[str] = Field(min_length=1)
+    context: dict[str, Any] | None = None
+    constraints: list[str] = Field(default_factory=list)
+
+    @field_validator("completion_criteria", "constraints")
+    @classmethod
+    def validate_non_empty_items(cls, items: list[str]) -> list[str]:
+        if any(not item.strip() for item in items):
+            raise ValueError("items must be non-empty strings")
+        return items
+
+
+class Validation(StrictProtocolModel):
+    hard_passed: bool
+    open_issues: list[str] = Field(default_factory=list)
+
+    @field_validator("open_issues")
+    @classmethod
+    def validate_open_issues(cls, items: list[str]) -> list[str]:
+        if any(not item.strip() for item in items):
+            raise ValueError("open_issues must contain non-empty strings")
+        return items
+
+
+class NextStepSuggestion(StrictProtocolModel):
+    direction: NextStepDirection
+    reason: str = Field(min_length=1)
+    suggested_next_task: TaskBrief | None = None
+
+    @model_validator(mode="after")
+    def validate_suggested_task(self) -> "NextStepSuggestion":
+        if self.direction in {"DESCEND", "REFINE"} and not self.suggested_next_task:
+            raise ValueError("DESCEND/REFINE requires suggested_next_task")
+        return self
+
+
+class TaskReportSubmission(StrictProtocolModel):
+    summary: str = Field(min_length=1)
+    outcome: TaskOutcome
+    validation: Validation
+    next_step_suggestion: NextStepSuggestion
+    outputs: list[dict[str, Any]] = Field(default_factory=list)
+    evidence: list[dict[str, Any]] = Field(default_factory=list)
+    remaining_issues: list[str] = Field(default_factory=list)
+
+    @field_validator("remaining_issues")
+    @classmethod
+    def validate_remaining_issues(cls, items: list[str]) -> list[str]:
+        if any(not item.strip() for item in items):
+            raise ValueError("remaining_issues must contain non-empty strings")
+        return items
+
+    @model_validator(mode="after")
+    def validate_outcome_consistency(self) -> "TaskReportSubmission":
+        if self.outcome == "satisfied" and (
+            not self.validation.hard_passed
+            or self.validation.open_issues
+            or self.remaining_issues
+        ):
+            raise ValueError(
+                "satisfied requires hard_passed with no open or remaining issues"
+            )
+        if self.outcome in {"failed", "protocol_error"} and self.validation.hard_passed:
+            raise ValueError(f"{self.outcome} cannot have hard_passed=true")
+        return self
+
+
+class TaskReport(TaskReportSubmission):
+    child_trace_id: str = Field(min_length=1)
+
+
+class TaskReview(StrictProtocolModel):
+    parent_trace_id: str = Field(min_length=1)
+    child_trace_id: str = Field(min_length=1)
+    decision: ReviewDecision
+    reason: str = Field(min_length=1)
+    approved_next_task: TaskBrief | None = None
+
+    @model_validator(mode="after")
+    def validate_approved_next_task(self) -> "TaskReview":
+        if self.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
+            if not self.approved_next_task:
+                raise ValueError(f"{self.decision} requires approved_next_task")
+        elif self.decision != "REVISE_CHILD" and self.approved_next_task:
+            raise ValueError(
+                f"{self.decision} does not accept approved_next_task"
+            )
+        return self
+
+
+def new_task_protocol(task_brief: TaskBrief | dict | None = None) -> dict[str, Any]:
+    brief = TaskBrief.model_validate(task_brief).model_dump() if task_brief else None
+    return {
+        "task_brief": brief,
+        "task_report": None,
+        "task_report_submitted_at_sequence": None,
+        "report_history": [],
+        "pending_reviews": {},
+        "reviews": [],
+        "next_actions": [],
+        "protocol_correction_attempts": 0,
+    }
+
+
+def ensure_task_protocol(context: dict[str, Any]) -> dict[str, Any]:
+    state = context.get("task_protocol")
+    if not isinstance(state, dict):
+        state = new_task_protocol()
+        context["task_protocol"] = state
+    defaults = new_task_protocol()
+    for key, value in defaults.items():
+        state.setdefault(key, deepcopy(value))
+    return state
+
+
+def protocol_error_report(child_trace_id: str, reason: str) -> TaskReport:
+    return TaskReport(
+        child_trace_id=child_trace_id,
+        summary="Task protocol failed before a valid report was submitted.",
+        outcome="protocol_error",
+        validation=Validation(hard_passed=False, open_issues=[reason]),
+        next_step_suggestion=NextStepSuggestion(
+            direction="ASCEND",
+            reason="Parent review is required to revise or fail this task.",
+        ),
+        remaining_issues=[reason],
+    )
+
+
+def pending_review_entry(
+    *,
+    goal_id: str | None,
+    report: TaskReport,
+    received_at_sequence: int,
+) -> dict[str, Any]:
+    return {
+        "goal_id": goal_id,
+        "task_report": report.model_dump(),
+        "received_at_sequence": received_at_sequence,
+        "received_at": datetime.now().isoformat(),
+    }
+
+
+def format_task_brief(task_brief: TaskBrief) -> str:
+    criteria = "\n".join(
+        f"{index}. {item}"
+        for index, item in enumerate(task_brief.completion_criteria, start=1)
+    )
+    constraints = "\n".join(f"- {item}" for item in task_brief.constraints)
+    parts = [
+        "# Task Brief",
+        f"\n## Objective\n{task_brief.objective}",
+        f"\n## Completion Criteria\n{criteria}",
+    ]
+    if task_brief.context:
+        parts.append(f"\n## Context\n{task_brief.context}")
+    if constraints:
+        parts.append(f"\n## Constraints\n{constraints}")
+    parts.append(
+        "\nSubmit a validated TaskReport with submit_task_report before finishing."
+    )
+    return "\n".join(parts)

+ 212 - 0
cyber_agent/tools/builtin/task_protocol.py

@@ -0,0 +1,212 @@
+"""Tools for Recursive revision 2 task reporting and parent review."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from pydantic import ValidationError
+
+from cyber_agent.core.agent_mode import policy_from_context
+from cyber_agent.core.task_protocol import (
+    TaskReport,
+    TaskReportSubmission,
+    TaskReview,
+    ReviewDecision,
+    TaskBrief,
+    ensure_task_protocol,
+)
+from cyber_agent.tools import tool
+
+
+def _error(message: str) -> dict[str, Any]:
+    return {"status": "failed", "error": message}
+
+
+@tool(
+    description="Submit the current Recursive child Agent's structured task report before finishing.",
+    hidden_params=["context"],
+    groups=["core"],
+)
+async def submit_task_report(
+    task_report: TaskReportSubmission,
+    context: dict | None = None,
+) -> dict[str, Any]:
+    if not context:
+        return _error("context is required")
+    store = context.get("store")
+    trace_id = context.get("trace_id")
+    if not store or not trace_id:
+        return _error("store and trace_id are required")
+
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        return _error(f"Trace not found: {trace_id}")
+    policy = policy_from_context(trace.context)
+    if not policy.requires_task_protocol:
+        return _error("submit_task_report is only available in Recursive revision 2")
+    if not trace.parent_trace_id:
+        return _error("Root Agent does not submit a TaskReport")
+
+    state = ensure_task_protocol(trace.context)
+    if state["pending_reviews"]:
+        return _error("Review all child TaskReports before submitting this TaskReport")
+    if state["task_report"] is not None:
+        return _error("A TaskReport has already been submitted")
+
+    try:
+        submission = TaskReportSubmission.model_validate(task_report)
+        report = TaskReport(
+            child_trace_id=trace_id,
+            **submission.model_dump(),
+        )
+    except ValidationError as exc:
+        return _error(f"Invalid TaskReport: {exc}")
+
+    state["task_report"] = report.model_dump()
+    state["task_report_submitted_at_sequence"] = context.get("sequence", 0)
+    state["protocol_correction_attempts"] = 0
+    await store.update_trace(trace_id, context=trace.context)
+    goal_id = context.get("goal_id")
+    if goal_id:
+        goal_status = (
+            "failed"
+            if report.outcome in {"failed", "protocol_error"}
+            else "completed"
+        )
+        await store.update_goal(
+            trace_id,
+            goal_id,
+            cascade_completion=False,
+            status=goal_status,
+            summary=report.summary,
+        )
+        tree = context.get("goal_tree")
+        goal = tree.find(goal_id) if tree else None
+        if goal:
+            goal.status = goal_status
+            goal.summary = report.summary
+    return {"status": "completed", "task_report": report.model_dump()}
+
+
+@tool(
+    description="Review one pending direct child's TaskReport and approve the parent's next action.",
+    hidden_params=["context"],
+    groups=["core"],
+)
+async def review_task_result(
+    child_trace_id: str,
+    decision: ReviewDecision,
+    reason: str,
+    approved_next_task: TaskBrief | None = None,
+    context: dict | None = None,
+) -> dict[str, Any]:
+    if not context:
+        return _error("context is required")
+    store = context.get("store")
+    parent_trace_id = context.get("trace_id")
+    if not store or not parent_trace_id:
+        return _error("store and trace_id are required")
+
+    parent = await store.get_trace(parent_trace_id)
+    child = await store.get_trace(child_trace_id)
+    if not parent or not child:
+        return _error("Parent or child Trace not found")
+    policy = policy_from_context(parent.context)
+    if not policy.requires_task_protocol:
+        return _error("review_task_result is only available in Recursive revision 2")
+    if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
+        return _error("TaskReport must belong to a direct child with the same owner")
+
+    state = ensure_task_protocol(parent.context)
+    pending = state["pending_reviews"]
+    entry = pending.get(child_trace_id)
+    if not entry:
+        return _error("This direct child has no pending TaskReport")
+
+    try:
+        review = TaskReview.model_validate({
+            "parent_trace_id": parent_trace_id,
+            "child_trace_id": child_trace_id,
+            "decision": decision,
+            "reason": reason,
+            "approved_next_task": approved_next_task,
+        })
+        report = TaskReport.model_validate(entry["task_report"])
+    except ValidationError as exc:
+        return _error(f"Invalid TaskReview: {exc}")
+
+    if report.outcome in {"failed", "protocol_error"} and review.decision not in {
+        "REVISE_CHILD",
+        "FAIL",
+    }:
+        return _error("Failed/protocol_error reports only allow REVISE_CHILD or FAIL")
+    if review.decision in {"ASCEND", "FAIL"} and len(pending) != 1:
+        return _error("ASCEND/FAIL is only allowed for the final pending report")
+
+    pending.pop(child_trace_id)
+    review_record = {
+        **review.model_dump(),
+        "reviewed_at": datetime.now().isoformat(),
+        "reviewed_at_sequence": context.get("sequence", 0),
+    }
+    state["reviews"].append(review_record)
+
+    if review.decision == "REVISE_CHILD":
+        child_state = ensure_task_protocol(child.context)
+        old_report = child_state.get("task_report")
+        if old_report:
+            child_state["report_history"].append(old_report)
+        child_state["task_report"] = None
+        child_state["task_report_submitted_at_sequence"] = None
+        child_state["protocol_correction_attempts"] = 0
+        if review.approved_next_task:
+            child_state["task_brief"] = review.approved_next_task.model_dump()
+        await store.update_trace(child_trace_id, context=child.context)
+
+    if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN", "REVISE_CHILD"}:
+        state["next_actions"].append({
+            "decision": review.decision,
+            "child_trace_id": child_trace_id,
+            "task_brief": (
+                review.approved_next_task.model_dump()
+                if review.approved_next_task
+                else None
+            ),
+            "created_at_sequence": context.get("sequence", 0),
+        })
+
+    await store.update_trace(parent_trace_id, context=parent.context)
+
+    goal_id = entry.get("goal_id")
+    if goal_id:
+        if pending:
+            next_status = "pending_review"
+        elif review.decision == "ASCEND":
+            next_status = "completed"
+        elif review.decision == "FAIL":
+            next_status = "failed"
+        else:
+            next_status = "in_progress"
+        await store.update_goal(
+            parent_trace_id,
+            goal_id,
+            cascade_completion=False,
+            status=next_status,
+            summary=reason if next_status in {"completed", "failed"} else None,
+        )
+        tree = context.get("goal_tree")
+        goal = tree.find(goal_id) if tree else None
+        if goal:
+            goal.status = next_status
+            if next_status in {"completed", "failed"}:
+                goal.summary = reason
+                if tree.current_id == goal_id:
+                    tree.current_id = goal.parent_id
+
+    return {
+        "status": "completed",
+        "task_review": review.model_dump(),
+        "pending_review_count": len(pending),
+        "next_action_count": len(state["next_actions"]),
+    }

+ 61 - 3
cyber_agent/tools/schema.py

@@ -11,7 +11,19 @@ Schema Generator - 从函数签名自动生成 OpenAI Tool Schema
 
 
 import inspect
 import inspect
 import logging
 import logging
-from typing import Any, Dict, List, Literal, Optional, Union, get_args, get_origin
+from copy import deepcopy
+from types import UnionType
+from typing import (
+    Any,
+    Dict,
+    List,
+    Literal,
+    Optional,
+    Union,
+    get_args,
+    get_origin,
+    get_type_hints,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -84,6 +96,11 @@ class SchemaGenerator:
         # 解析函数签名
         # 解析函数签名
         sig = inspect.signature(func)
         sig = inspect.signature(func)
         func_name = func.__name__
         func_name = func.__name__
+        try:
+            resolved_hints = get_type_hints(func)
+        except (NameError, TypeError):
+            # 局部定义或可选依赖缺失时,保留原有降级行为。
+            resolved_hints = {}
 
 
         # 解析 docstring
         # 解析 docstring
         if HAS_DOCSTRING_PARSER:
         if HAS_DOCSTRING_PARSER:
@@ -109,7 +126,10 @@ class SchemaGenerator:
                 continue
                 continue
 
 
             # 获取类型注解
             # 获取类型注解
-            param_type = param.annotation if param.annotation != inspect.Parameter.empty else str
+            param_type = resolved_hints.get(
+                param_name,
+                param.annotation if param.annotation != inspect.Parameter.empty else str,
+            )
 
 
             # 生成参数 Schema
             # 生成参数 Schema
             param_schema = cls._type_to_schema(param_type)
             param_schema = cls._type_to_schema(param_type)
@@ -161,7 +181,7 @@ class SchemaGenerator:
             return {"enum": values}
             return {"enum": values}
 
 
         # 处理 Union[T, ...] 和 Optional[T]
         # 处理 Union[T, ...] 和 Optional[T]
-        if origin is Union:
+        if origin in (Union, UnionType):
             if len(args) == 2 and type(None) in args:
             if len(args) == 2 and type(None) in args:
                 # Optional[T] = Union[T, None]
                 # Optional[T] = Union[T, None]
                 inner = args[0] if args[1] is type(None) else args[1]
                 inner = args[0] if args[1] is type(None) else args[1]
@@ -183,6 +203,14 @@ class SchemaGenerator:
         if origin is dict or origin is Dict:
         if origin is dict or origin is Dict:
             return {"type": "object"}
             return {"type": "object"}
 
 
+        # Pydantic 模型直接使用其严格 JSON Schema。
+        try:
+            from pydantic import BaseModel
+            if isinstance(python_type, type) and issubclass(python_type, BaseModel):
+                return cls._inline_pydantic_refs(python_type.model_json_schema())
+        except (ImportError, TypeError):
+            pass
+
         # 处理基础类型
         # 处理基础类型
         if python_type in cls.TYPE_MAP:
         if python_type in cls.TYPE_MAP:
             return {"type": cls.TYPE_MAP[python_type]}
             return {"type": cls.TYPE_MAP[python_type]}
@@ -197,3 +225,33 @@ class SchemaGenerator:
         # 默认为 string
         # 默认为 string
         logger.debug(f"Unknown type {python_type}, defaulting to string")
         logger.debug(f"Unknown type {python_type}, defaulting to string")
         return {"type": "string"}
         return {"type": "string"}
+
+    @classmethod
+    def _inline_pydantic_refs(cls, schema: Dict[str, Any]) -> Dict[str, Any]:
+        """Inline local Pydantic $refs so a tool parameter is self-contained."""
+        definitions = schema.get("$defs", {})
+
+        def resolve(value: Any, stack: tuple[str, ...] = ()) -> Any:
+            if isinstance(value, list):
+                return [resolve(item, stack) for item in value]
+            if not isinstance(value, dict):
+                return value
+
+            ref = value.get("$ref")
+            if isinstance(ref, str) and ref.startswith("#/$defs/"):
+                name = ref.rsplit("/", 1)[-1]
+                if name in stack or name not in definitions:
+                    return value
+                resolved = resolve(deepcopy(definitions[name]), (*stack, name))
+                extras = {key: item for key, item in value.items() if key != "$ref"}
+                if extras:
+                    resolved.update(resolve(extras, stack))
+                return resolved
+
+            return {
+                key: resolve(item, stack)
+                for key, item in value.items()
+                if key != "$defs"
+            }
+
+        return resolve(schema)