|
|
@@ -1686,6 +1686,36 @@ class AgentRunner:
|
|
|
"Application runs do not allow caller-provided system messages"
|
|
|
)
|
|
|
if config.trace_id:
|
|
|
+ if self.trace_store and hasattr(
|
|
|
+ self.trace_store,
|
|
|
+ "get_tool_approval_batch",
|
|
|
+ ):
|
|
|
+ batch = await self.trace_store.get_tool_approval_batch(
|
|
|
+ config.trace_id
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ batch is not None
|
|
|
+ and batch.status == "executing"
|
|
|
+ and await self._recover_candidate_approval_if_durable(
|
|
|
+ config.trace_id,
|
|
|
+ batch,
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ batch = await self.trace_store.get_tool_approval_batch(
|
|
|
+ config.trace_id
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ batch is not None
|
|
|
+ and batch.status == "recoverable_candidate_command"
|
|
|
+ and (
|
|
|
+ messages
|
|
|
+ or config.after_sequence is not None
|
|
|
+ or config.force_side_branch
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise ValueError(
|
|
|
+ "recoverable_candidate_command_must_resume_first"
|
|
|
+ )
|
|
|
return await self._prepare_existing_trace(config)
|
|
|
else:
|
|
|
return await self._prepare_new_trace(messages, config)
|
|
|
@@ -2017,6 +2047,19 @@ class AgentRunner:
|
|
|
role = self.application_binding.role(snapshot.role_id)
|
|
|
config.system_prompt = role.system_prompt
|
|
|
config.skills = []
|
|
|
+ if (
|
|
|
+ config.approval_batch_id is None
|
|
|
+ and hasattr(self.trace_store, "get_tool_approval_batch")
|
|
|
+ ):
|
|
|
+ approval_batch = await self.trace_store.get_tool_approval_batch(
|
|
|
+ trace_obj.trace_id
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ approval_batch is not None
|
|
|
+ and approval_batch.status == "recoverable_candidate_command"
|
|
|
+ ):
|
|
|
+ self._validate_recoverable_candidate_approval(approval_batch)
|
|
|
+ config.approval_batch_id = approval_batch.batch_id
|
|
|
persisted_memory_identity = trace_obj.context.get(
|
|
|
MEMORY_IDENTITY_CONTEXT_KEY
|
|
|
)
|
|
|
@@ -2857,8 +2900,10 @@ class AgentRunner:
|
|
|
batch = await self.trace_store.get_tool_approval_batch(trace.trace_id)
|
|
|
if batch is None or batch.batch_id != config.approval_batch_id:
|
|
|
raise RuntimeError("approved tool batch was not found")
|
|
|
- if batch.status != "decided":
|
|
|
+ if batch.status not in {"decided", "recoverable_candidate_command"}:
|
|
|
raise RuntimeError(f"tool approval batch is not executable: {batch.status}")
|
|
|
+ if batch.status == "recoverable_candidate_command":
|
|
|
+ self._validate_recoverable_candidate_approval(batch)
|
|
|
if any(call.decision == "pending" for call in batch.calls):
|
|
|
raise RuntimeError("tool approval batch still has pending decisions")
|
|
|
if any(call.execution_status == "executing" for call in batch.calls):
|
|
|
@@ -2897,26 +2942,41 @@ class AgentRunner:
|
|
|
trace.trace_id,
|
|
|
batch,
|
|
|
)
|
|
|
- tool_result = await self.tools.execute(
|
|
|
- call.tool_name,
|
|
|
- call.effective_arguments,
|
|
|
- uid=config.uid or "",
|
|
|
- context=self._build_tool_context(
|
|
|
- config=config,
|
|
|
- trace=trace,
|
|
|
- trace_id=trace.trace_id,
|
|
|
- goal_id=current_goal_id,
|
|
|
- goal_tree=goal_tree,
|
|
|
- sequence=sequence,
|
|
|
- head_sequence=head_seq,
|
|
|
+ try:
|
|
|
+ tool_result = await self.tools.execute(
|
|
|
+ call.tool_name,
|
|
|
+ call.effective_arguments,
|
|
|
+ uid=config.uid or "",
|
|
|
+ context=self._build_tool_context(
|
|
|
+ config=config,
|
|
|
+ trace=trace,
|
|
|
+ trace_id=trace.trace_id,
|
|
|
+ goal_id=current_goal_id,
|
|
|
+ goal_tree=goal_tree,
|
|
|
+ sequence=sequence,
|
|
|
+ head_sequence=head_seq,
|
|
|
+ tool_call_id=call.tool_call_id,
|
|
|
+ side_branch_ctx=side_branch_ctx,
|
|
|
+ trigger_event=trigger_event,
|
|
|
+ ),
|
|
|
+ allowed_tool_names=runtime_tool_names,
|
|
|
tool_call_id=call.tool_call_id,
|
|
|
- side_branch_ctx=side_branch_ctx,
|
|
|
- trigger_event=trigger_event,
|
|
|
- ),
|
|
|
- allowed_tool_names=runtime_tool_names,
|
|
|
- tool_call_id=call.tool_call_id,
|
|
|
- approval_grant=approval_grant(batch, call),
|
|
|
- )
|
|
|
+ approval_grant=approval_grant(batch, call),
|
|
|
+ )
|
|
|
+ except RecoverableToolExecutionError:
|
|
|
+ if len(batch.calls) != 1 or call.tool_name != "manage_candidate":
|
|
|
+ raise
|
|
|
+ # CandidateService only raises this signal after its durable,
|
|
|
+ # idempotent intent exists. Preserve the original human grant
|
|
|
+ # and call ID so a restart can publish the ledger terminal state.
|
|
|
+ call.execution_status = "not_started"
|
|
|
+ batch.status = "recoverable_candidate_command"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(
|
|
|
+ trace.trace_id,
|
|
|
+ batch,
|
|
|
+ )
|
|
|
+ raise
|
|
|
else:
|
|
|
raise RuntimeError(f"unsupported tool approval decision: {call.decision}")
|
|
|
|
|
|
@@ -2999,6 +3059,23 @@ class AgentRunner:
|
|
|
config.approval_batch_id = None
|
|
|
return history, sequence, head_seq
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _validate_recoverable_candidate_approval(batch: Any) -> None:
|
|
|
+ """Accept only the exact approved candidate command safe for replay."""
|
|
|
+ if len(batch.calls) != 1:
|
|
|
+ raise RuntimeError(
|
|
|
+ "recoverable candidate approval must contain exactly one call"
|
|
|
+ )
|
|
|
+ call = batch.calls[0]
|
|
|
+ if (
|
|
|
+ call.tool_name != "manage_candidate"
|
|
|
+ or call.decision not in {"approved", "auto_approved"}
|
|
|
+ or call.execution_status != "not_started"
|
|
|
+ ):
|
|
|
+ raise RuntimeError(
|
|
|
+ "recoverable candidate approval does not contain a replayable grant"
|
|
|
+ )
|
|
|
+
|
|
|
async def _mark_approval_execution_unknown(self, trace_id: str) -> bool:
|
|
|
"""Fail closed if an approved batch stopped after execution began."""
|
|
|
if not self.trace_store or not hasattr(
|
|
|
@@ -3008,6 +3085,8 @@ class AgentRunner:
|
|
|
batch = await self.trace_store.get_tool_approval_batch(trace_id)
|
|
|
if batch is None or batch.status != "executing":
|
|
|
return False
|
|
|
+ if await self._recover_candidate_approval_if_durable(trace_id, batch):
|
|
|
+ return False
|
|
|
batch.status = "execution_unknown"
|
|
|
batch.updated_at = datetime.now().isoformat()
|
|
|
for call in batch.calls:
|
|
|
@@ -3024,6 +3103,61 @@ class AgentRunner:
|
|
|
)
|
|
|
return True
|
|
|
|
|
|
+ async def _recover_candidate_approval_if_durable(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ batch: Any,
|
|
|
+ ) -> bool:
|
|
|
+ """Recover only an exact approved candidate command with a durable intent."""
|
|
|
+ if self.candidate_service is None or len(batch.calls) != 1:
|
|
|
+ return False
|
|
|
+ call = batch.calls[0]
|
|
|
+ if (
|
|
|
+ call.tool_name != "manage_candidate"
|
|
|
+ or call.decision not in {"approved", "auto_approved"}
|
|
|
+ or call.execution_status not in {"executing", "execution_unknown"}
|
|
|
+ ):
|
|
|
+ return False
|
|
|
+ messages = await self.trace_store.get_trace_messages(trace_id)
|
|
|
+ persisted_results = [
|
|
|
+ message
|
|
|
+ for message in messages
|
|
|
+ if message.role == "tool"
|
|
|
+ and message.tool_call_id == call.tool_call_id
|
|
|
+ ]
|
|
|
+ if persisted_results:
|
|
|
+ if len(persisted_results) != 1:
|
|
|
+ return False
|
|
|
+ result_message = persisted_results[0]
|
|
|
+ content = result_message.content
|
|
|
+ if (
|
|
|
+ result_message.parent_sequence != batch.assistant_sequence
|
|
|
+ or not isinstance(content, dict)
|
|
|
+ or content.get("tool_name") != "manage_candidate"
|
|
|
+ ):
|
|
|
+ return False
|
|
|
+ call.result_message_id = result_message.message_id
|
|
|
+ call.execution_status = "executed"
|
|
|
+ batch.status = "completed"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace_id, batch)
|
|
|
+ await self.trace_store.update_trace(
|
|
|
+ trace_id,
|
|
|
+ head_sequence=result_message.sequence,
|
|
|
+ )
|
|
|
+ return True
|
|
|
+ if not await self.candidate_service.has_replayable_version_command(
|
|
|
+ trace_id,
|
|
|
+ command_id=call.tool_call_id,
|
|
|
+ arguments=call.effective_arguments,
|
|
|
+ ):
|
|
|
+ return False
|
|
|
+ call.execution_status = "not_started"
|
|
|
+ batch.status = "recoverable_candidate_command"
|
|
|
+ batch.updated_at = datetime.now().isoformat()
|
|
|
+ await self.trace_store.replace_tool_approval_batch(trace_id, batch)
|
|
|
+ return True
|
|
|
+
|
|
|
async def _agent_loop(
|
|
|
self,
|
|
|
trace: Trace,
|
|
|
@@ -4886,7 +5020,16 @@ class AgentRunner:
|
|
|
raise RuntimeError(
|
|
|
"Orphaned lifecycle replay returned a non-JSON result"
|
|
|
) from exc
|
|
|
- if not isinstance(payload, dict) or payload.get("status") != "completed":
|
|
|
+ replay_completed = (
|
|
|
+ isinstance(payload, dict)
|
|
|
+ and payload.get("status") == "completed"
|
|
|
+ )
|
|
|
+ replay_failed_safely = (
|
|
|
+ tool_name == "manage_candidate"
|
|
|
+ and isinstance(payload, dict)
|
|
|
+ and payload.get("status") == "failed"
|
|
|
+ )
|
|
|
+ if not replay_completed and not replay_failed_safely:
|
|
|
reason = payload.get("error") if isinstance(payload, dict) else None
|
|
|
raise RuntimeError(
|
|
|
"Orphaned lifecycle replay failed closed: "
|