|
|
@@ -68,6 +68,21 @@ _FAILURE_DISPOSITION_PRIORITY = {
|
|
|
}
|
|
|
|
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
+class NoProgressPolicy:
|
|
|
+ """Fail closed when an explicit Agent repeats work without semantic progress."""
|
|
|
+
|
|
|
+ same_failure_limit: int = 2
|
|
|
+ planner_stall_limit: int = 3
|
|
|
+ enabled_in_legacy: bool = False
|
|
|
+
|
|
|
+ def __post_init__(self) -> None:
|
|
|
+ if self.same_failure_limit < 1:
|
|
|
+ raise ValueError("same_failure_limit must be positive")
|
|
|
+ if self.planner_stall_limit < 1:
|
|
|
+ raise ValueError("planner_stall_limit must be positive")
|
|
|
+
|
|
|
+
|
|
|
@dataclass
|
|
|
class ContextUsage:
|
|
|
"""Context 使用情况"""
|
|
|
@@ -157,6 +172,7 @@ class RunConfig:
|
|
|
root_task_spec: Optional[Dict[str, Any]] = (
|
|
|
None # explicit Planner 新 Trace 的 Root Task 草案
|
|
|
)
|
|
|
+ no_progress: NoProgressPolicy = field(default_factory=NoProgressPolicy)
|
|
|
|
|
|
# --- Trace 控制 ---
|
|
|
trace_id: Optional[str] = None # None = 新建
|
|
|
@@ -1304,6 +1320,12 @@ class AgentRunner:
|
|
|
yield trace_obj
|
|
|
return
|
|
|
|
|
|
+ if explicit_role == AgentRole.PLANNER and not side_branch_ctx:
|
|
|
+ stalled = await self._observe_planner_progress(trace, config)
|
|
|
+ if stalled is not None:
|
|
|
+ terminal_failure = stalled
|
|
|
+ break
|
|
|
+
|
|
|
# 检查Goal完成触发的知识评估
|
|
|
if not side_branch_ctx and self.trace_store:
|
|
|
trace = await self.trace_store.get_trace(trace_id)
|
|
|
@@ -2178,6 +2200,16 @@ class AgentRunner:
|
|
|
last_failure = self._stronger_failure(
|
|
|
last_failure, observed_failure
|
|
|
)
|
|
|
+ repeated = await self._observe_failure_progress(
|
|
|
+ trace,
|
|
|
+ config,
|
|
|
+ explicit_role,
|
|
|
+ observed_failure,
|
|
|
+ )
|
|
|
+ if repeated is not None:
|
|
|
+ terminal_failure = repeated
|
|
|
+ terminal_run = True
|
|
|
+ continue
|
|
|
if self._failure_terminates_role(
|
|
|
observed_failure, explicit_role
|
|
|
):
|
|
|
@@ -2185,11 +2217,18 @@ class AgentRunner:
|
|
|
terminal_failure, observed_failure
|
|
|
)
|
|
|
terminal_run = True
|
|
|
- elif terminal_control and terminal_control.get("terminate_run"):
|
|
|
- terminal_summary = (
|
|
|
- terminal_control.get("result_summary") or tool_text
|
|
|
+ else:
|
|
|
+ await self._observe_failure_progress(
|
|
|
+ trace,
|
|
|
+ config,
|
|
|
+ explicit_role,
|
|
|
+ None,
|
|
|
)
|
|
|
- terminal_run = True
|
|
|
+ if terminal_control and terminal_control.get("terminate_run"):
|
|
|
+ terminal_summary = (
|
|
|
+ terminal_control.get("result_summary") or tool_text
|
|
|
+ )
|
|
|
+ terminal_run = True
|
|
|
if terminal_summary and terminal_failure is None and self.trace_store:
|
|
|
await self.trace_store.update_trace(
|
|
|
trace_id,
|
|
|
@@ -2453,24 +2492,41 @@ class AgentRunner:
|
|
|
observed_failure = self._control_failure(terminal_control)
|
|
|
if observed_failure is not None:
|
|
|
last_failure = observed_failure
|
|
|
+ repeated = await self._observe_failure_progress(
|
|
|
+ trace,
|
|
|
+ config,
|
|
|
+ explicit_role,
|
|
|
+ observed_failure,
|
|
|
+ )
|
|
|
+ if repeated is not None:
|
|
|
+ terminal_failure = repeated
|
|
|
+ terminal_run = True
|
|
|
+ break
|
|
|
if self._failure_terminates_role(
|
|
|
observed_failure, explicit_role
|
|
|
):
|
|
|
terminal_failure = observed_failure
|
|
|
terminal_run = True
|
|
|
break
|
|
|
- elif terminal_control and terminal_control.get("terminate_run"):
|
|
|
- terminal_run = True
|
|
|
- terminal_summary = (
|
|
|
- terminal_control.get("result_summary") or tool_text
|
|
|
+ else:
|
|
|
+ await self._observe_failure_progress(
|
|
|
+ trace,
|
|
|
+ config,
|
|
|
+ explicit_role,
|
|
|
+ None,
|
|
|
)
|
|
|
- if self.trace_store:
|
|
|
- await self.trace_store.update_trace(
|
|
|
- trace_id,
|
|
|
- result_summary=terminal_summary,
|
|
|
- head_sequence=head_seq,
|
|
|
+ if terminal_control and terminal_control.get("terminate_run"):
|
|
|
+ terminal_run = True
|
|
|
+ terminal_summary = (
|
|
|
+ terminal_control.get("result_summary") or tool_text
|
|
|
)
|
|
|
- break
|
|
|
+ if self.trace_store:
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ result_summary=terminal_summary,
|
|
|
+ head_sequence=head_seq,
|
|
|
+ )
|
|
|
+ break
|
|
|
|
|
|
if terminal_run:
|
|
|
if (
|
|
|
@@ -2588,7 +2644,12 @@ class AgentRunner:
|
|
|
final_failure: Optional[FailureDetail] = None
|
|
|
|
|
|
if terminal_failure is not None:
|
|
|
- final_status = "failed"
|
|
|
+ final_status = (
|
|
|
+ "incomplete"
|
|
|
+ if terminal_failure.code == "NO_PROGRESS"
|
|
|
+ and explicit_role == AgentRole.PLANNER
|
|
|
+ else "failed"
|
|
|
+ )
|
|
|
final_failure = terminal_failure
|
|
|
final_error = self._failure_text(terminal_failure)
|
|
|
elif explicit_role == AgentRole.PLANNER:
|
|
|
@@ -2683,6 +2744,118 @@ class AgentRunner:
|
|
|
def _failure_text(failure: FailureDetail) -> str:
|
|
|
return f"{failure.code}: {failure.message}"
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _no_progress_enabled(
|
|
|
+ config: RunConfig,
|
|
|
+ role: Optional[AgentRole],
|
|
|
+ ) -> bool:
|
|
|
+ return role is not None or config.no_progress.enabled_in_legacy
|
|
|
+
|
|
|
+ async def _observe_failure_progress(
|
|
|
+ self,
|
|
|
+ trace: Trace,
|
|
|
+ config: RunConfig,
|
|
|
+ role: Optional[AgentRole],
|
|
|
+ failure: Optional[FailureDetail],
|
|
|
+ ) -> Optional[FailureDetail]:
|
|
|
+ """Persist consecutive failure state and return a breaker failure."""
|
|
|
+
|
|
|
+ if not self._no_progress_enabled(config, role):
|
|
|
+ return None
|
|
|
+ runtime = trace.runtime_state.setdefault("no_progress", {})
|
|
|
+ if failure is None:
|
|
|
+ runtime["failure_fingerprint"] = None
|
|
|
+ runtime["failure_count"] = 0
|
|
|
+ await self._persist_runtime_state(trace)
|
|
|
+ return None
|
|
|
+
|
|
|
+ fingerprint = failure.fingerprint()
|
|
|
+ if runtime.get("failure_fingerprint") == fingerprint:
|
|
|
+ count = int(runtime.get("failure_count", 0)) + 1
|
|
|
+ else:
|
|
|
+ count = 1
|
|
|
+ runtime["failure_fingerprint"] = fingerprint
|
|
|
+ runtime["failure_count"] = count
|
|
|
+ runtime["last_failure"] = failure.to_dict()
|
|
|
+ await self._persist_runtime_state(trace)
|
|
|
+ if count < config.no_progress.same_failure_limit:
|
|
|
+ return None
|
|
|
+ return await self._emit_no_progress(
|
|
|
+ trace,
|
|
|
+ reason="same_failure_repeated",
|
|
|
+ details={"count": count, "last_failure": failure.to_dict()},
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _observe_planner_progress(
|
|
|
+ self,
|
|
|
+ trace: Trace,
|
|
|
+ config: RunConfig,
|
|
|
+ ) -> Optional[FailureDetail]:
|
|
|
+ """Compare the durable TaskLedger semantics at Planner turn boundaries."""
|
|
|
+
|
|
|
+ if self.task_coordinator is None:
|
|
|
+ return None
|
|
|
+ method = getattr(self.task_coordinator, "progress_snapshot", None)
|
|
|
+ if method is None:
|
|
|
+ return None
|
|
|
+ root_trace_id = (trace.context or {}).get("root_trace_id", trace.trace_id)
|
|
|
+ snapshot = await method(root_trace_id)
|
|
|
+ fingerprint = hashlib.sha256(
|
|
|
+ json.dumps(
|
|
|
+ snapshot,
|
|
|
+ ensure_ascii=False,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ ).encode("utf-8")
|
|
|
+ ).hexdigest()
|
|
|
+ runtime = trace.runtime_state.setdefault("no_progress", {})
|
|
|
+ previous = runtime.get("planner_fingerprint")
|
|
|
+ if previous is None or previous != fingerprint:
|
|
|
+ count = 0
|
|
|
+ else:
|
|
|
+ count = int(runtime.get("planner_stall_count", 0)) + 1
|
|
|
+ runtime["planner_fingerprint"] = fingerprint
|
|
|
+ runtime["planner_stall_count"] = count
|
|
|
+ await self._persist_runtime_state(trace)
|
|
|
+ if count < config.no_progress.planner_stall_limit:
|
|
|
+ return None
|
|
|
+ return await self._emit_no_progress(
|
|
|
+ trace,
|
|
|
+ reason="planner_state_unchanged",
|
|
|
+ details={
|
|
|
+ "count": count,
|
|
|
+ "last_failure": runtime.get("last_failure"),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _persist_runtime_state(self, trace: Trace) -> None:
|
|
|
+ if self.trace_store:
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace.trace_id,
|
|
|
+ runtime_state=trace.runtime_state,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _emit_no_progress(
|
|
|
+ self,
|
|
|
+ trace: Trace,
|
|
|
+ *,
|
|
|
+ reason: str,
|
|
|
+ details: Dict[str, Any],
|
|
|
+ ) -> FailureDetail:
|
|
|
+ failure = FailureDetail(
|
|
|
+ code="NO_PROGRESS",
|
|
|
+ message="Agent stopped because no semantic progress was detected",
|
|
|
+ disposition=FailureDisposition.ABORT_RUN,
|
|
|
+ details={"reason": reason, **details},
|
|
|
+ )
|
|
|
+ if self.trace_store:
|
|
|
+ await self.trace_store.append_event(
|
|
|
+ trace.trace_id,
|
|
|
+ "no_progress_detected",
|
|
|
+ failure.to_dict(),
|
|
|
+ )
|
|
|
+ return failure
|
|
|
+
|
|
|
async def _get_mission_completion(self, root_trace_id: str) -> Dict[str, Any]:
|
|
|
"""Read the coordinator-owned mission completion snapshot."""
|
|
|
method = getattr(self.task_coordinator, "root_completion", None)
|