fix(company): make delivery-review self-evolution survive the hardened runtime

Approving (or sending feedback on) the delivery review card runs employee
self-evolution as company work items, but the finalizer required the
turn's FINAL chat text to be bare JSON. The hardened native runtime
appends a verification status line to every final text and the manager
dispatch guard displaces the final message with a justification, so every
reflection died with invalid_self_evolution_json even when the model
produced valid patches on all three attempts, and the FAILED items
polluted the delivered run's terminal verdict.

- Add a submit_self_evolution_patches tool as the authoritative result
  channel (exposed only on self-evolution work items, approval-exempt).
  The text parser becomes a fallback that scans fenced blocks and
  balanced JSON objects, and retry feedback now carries the concrete
  parse failure plus the tool instruction.
- Settle abandoned reflections as CANCELLED (self_evolution_abandoned)
  and exclude kind=self_evolution from run-lifecycle settlement so an
  opt-in reflection can never dirty a delivered run.
- Claim the review card with a consuming CAS before spawning (duplicate
  approve/feedback replies answer idempotently instead of re-entering),
  bound the reflection run with a 40-minute deadline that cancels
  leftover self-evolution items, and hand the claim back to pending when
  the consumed run crashes mid-flight.

Verified live on real runs: the unfixed code failed the approve path in
90s with zero patches recorded; with the fix both the approve and the
feedback paths recorded patches end-to-end (CEO->COO and CEO->CMO
cascades, zero retries, human feedback reflected in patch content).
tests/: 1924 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-29 00:36:53 +08:00
parent d14f3920e0
commit 6c5acbf10e
6 changed files with 719 additions and 30 deletions
+98 -1
View File
@@ -14,7 +14,10 @@ from typing import Any
from loguru import logger
from opc.core.company_tools import COMPANY_COLLABORATION_TOOL_NAMES
from opc.core.company_tools import (
COMPANY_COLLABORATION_TOOL_NAMES,
is_self_evolution_work_item,
)
from opc.core.models import (
AgentMessage,
CommsSemanticType,
@@ -861,6 +864,16 @@ _EXTERNAL_BRIDGE_ARGUMENT_EXAMPLES: dict[str, dict[str, Any]] = {
"summary": "The user accepted this delivery and no further internal work is required.",
"user_message": "Acknowledged. I am closing the human review for this delivery.",
},
"submit_self_evolution_patches": {
"patches": [
{
"summary": "One-paragraph lesson from the delivered work cycle.",
"strengths": ["Concrete behavior worth repeating"],
"adjustments": ["Concrete behavior to change next time"],
"confidence": 0.8,
}
],
},
"send_dm": {
"to_agent": "reviewer",
"subject": "Need review",
@@ -913,6 +926,10 @@ _EXTERNAL_BRIDGE_ARGUMENT_NOTES: dict[str, tuple[str, ...]] = {
"Use only when you decide the owner-facing delivery review is complete and should be closed.",
"Do not call this for requested changes; revise the board, delegate work, or respond instead.",
),
"submit_self_evolution_patches": (
"Available only on self-evolution work items; the recorded patches are the authoritative result of the turn.",
"Pass `patches: []` when no experience update is needed; omit `employee_id` to target this work item's assigned employee.",
),
}
@@ -923,6 +940,7 @@ _EXTERNAL_CLI_KEY_ARGUMENTS: dict[str, tuple[str, ...]] = {
"inbox": ("action", "message_ids", "limit"),
"manager_board_read": ("parent_work_item_id", "include_children"),
"close_human_review": ("summary", "user_message"),
"submit_self_evolution_patches": ("patches",),
"send_dm": ("to_agent", "subject", "body", "blocking", "timeout_action", "timeout_seconds"),
"reply_message": ("message_id", "body", "subject"),
"broadcast_issue": ("to_agents", "subject", "body", "blocking", "timeout_action", "timeout_seconds"),
@@ -1602,6 +1620,51 @@ def create_collaboration_tools(
"user_message": close_user_message,
}
async def submit_self_evolution_patches(
patches: list[dict[str, Any]] | None = None,
task: Task | None = None,
) -> dict[str, Any]:
role_id = _active_role(task)
if not task or not role_id:
raise ValueError("submit_self_evolution_patches requires an active assigned task")
if not is_self_evolution_work_item(task):
raise ValueError(
"submit_self_evolution_patches is only available on self-evolution work items"
)
normalized: list[dict[str, Any]] = []
for index, patch in enumerate(list(patches or [])):
if not isinstance(patch, dict):
raise ValueError(f"patches[{index}] must be a JSON object")
employee_id = str(patch.get("employee_id", "") or "").strip()
if not employee_id:
assignment = dict(task.metadata.get("employee_assignment", {}) or {})
employee_id = str(assignment.get("employee_id", "") or "").strip()
if not employee_id:
raise ValueError(
f"patches[{index}] needs an employee_id and this work item has no assigned employee"
)
normalized.append({**patch, "employee_id": employee_id})
task.metadata = dict(task.metadata or {})
task.context_snapshot = dict(task.context_snapshot or {})
record = {
"patches": normalized,
"submitted_at": datetime.now().isoformat(),
"submitted_by_role": role_id,
}
task.metadata["self_evolution_submitted_patch"] = record
task.context_snapshot["self_evolution_submitted_patch"] = dict(record)
store = getattr(communication, "store", None)
if store is not None and hasattr(store, "save_task"):
await store.save_task(task)
return {
"status": "recorded",
"patch_count": len(normalized),
"note": (
"Patches recorded for this self-evolution work item. "
"Finish the turn with a short completion note."
),
}
async def delegate_work(
items: list[dict[str, Any]],
planning_context: str = "",
@@ -3064,6 +3127,40 @@ def create_collaboration_tools(
func=close_human_review,
category="collaboration",
),
ToolDefinition(
name="submit_self_evolution_patches",
description=(
"Record employee experience patches for the current self-evolution work item. "
"This tool is the authoritative channel for self-evolution results: patches recorded "
"here are applied regardless of what the final turn text says. Pass an empty patches "
"list when no experience update is needed for this role. Each patch targets this work "
"item's assigned employee (employee_id is filled in automatically when omitted)."
),
parameters={
"type": "object",
"properties": {
"patches": {
"type": "array",
"items": {
"type": "object",
"properties": {
"employee_id": {"type": "string"},
"summary": {"type": "string"},
"strengths": {"type": "array", "items": {"type": "string"}},
"adjustments": {"type": "array", "items": {"type": "string"}},
"avoid_next_time": {"type": "array", "items": {"type": "string"}},
"routing_notes": {"type": "string"},
"evidence_task_ids": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "number"},
},
},
},
},
"required": ["patches"],
},
func=submit_self_evolution_patches,
category="collaboration",
),
ToolDefinition(
name="delegate_work",
description=(