Files
Agentswarm/orchestrator/autonomous_tasks.py
T
Songhaoz666andClaude Opus 4.8 f2662e4268 复审整改(PR #26):删死开关 helper、文档对齐"无条件"、swarm.health 改内部、#6 降为 Refs
回应 Fasthei 终审三点:

1) [P1 文档/代码冲突 + 死代码] 删除从不被调用的 *_enabled() helper(autonomous_tasks.proposals_enabled
   / task_competition.task_competition_enabled / convergence.convergence_report_enabled)及其
   import os;模块 docstring 与四份协议文档(autonomous-task-generation / task-competition-protocol
   / review-loop-protocol / convergence-protocol)从"默认关/未接入/待 PR/cutover 转无条件"全部改为
   "无条件接入(无开关)",删除引用死 helper 的过时集成代码样例;同步删除三个模块单测里的
   "flag default OFF" 断言。

2) [P1 验收] #6 "Closes" 降为 "Refs":#6 DoD 需 ARB 决策记录链接,当前只有 owner 指示断言、无链接。
   product-positioning.md 改为如实记录决策来源(owner 指示 + 本 PR + 文档)并把"补 ARB 记录链接(或
   owner 明确接受断言)"列为关闭 #6 的前置;纠正其"flag 门控、默认行为不变"的过时表述(重构已无条件)。

3) [P2 契约卫生] assess_swarm_health 不再 emit_event("swarm.health")(避免向订阅全部的 Manager 回调
   投递未注册事件);改为存 run.metadata["health"] + 内部 health_log。test-swarm-guard 相应断言
   "无 swarm.health 外发 + 内部 health_log 已记"。

本地受影响 11 套全绿。影响范围:agent_swarm(orchestrator 模块/文档/测试);不改 Manager↔Swarm 契约。

Refs #6
Refs #7
Refs #8
Refs #11
Refs #12
Refs #18

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:48:27 +08:00

395 lines
17 KiB
Python

"""Agent-proposed autonomous task generation (issue #7).
This module lets an *execution unit* (an agent) propose a NEW task it discovered while
working — e.g. it notices the shared run state is missing tests, docs, or a follow-up step —
and lets the orchestrator decide whether to accept, reject, or merge that proposal into the
runtime task graph.
Design boundaries (read before extending):
- This is the AGENT proposing, NOT a Master/planner generating work. The planner fallback
(``master_agent.plan`` / ``ENABLE_PLANNER_FALLBACK``) is a top-down decomposition done by a
controller. This path is strictly bottom-up: the proposal carries ``source="agent_proposed"``
and ``proposed_by_agent_id``, and ``review_proposal``/``ingest_accepted_proposal`` MUST NOT be
driven by master_agent. ``assert_not_master_origin`` enforces that at runtime.
- PURE module: no Redis, no WebSocket, no FastAPI imports. The functions take plain data in and
return plain data out, so the orchestrator integrator wires them into the live loop and the
hermetic test exercises them at the module level without any infrastructure.
- UNCONDITIONAL: agent task proposals are the swarm's ONLY decomposition path — there is no
enable flag (this repo is the swarm runtime; see docs/swarm/decentralized-rework-plan.md). The
WS ``task_proposal`` branch + ``handle_task_proposal`` in main.py are always active.
- HONESTY (rule #9): what this module implements — the proposal model, the review policy
(confidence/budget/dedup), the accepted->task-spec mapping with full lineage, and the
lifecycle-event builders. Wiring into the live loop is in main.py (see
``docs/swarm/autonomous-task-generation.md``).
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Dict, List, Optional, Sequence
# --- constants --------------------------------------------------------------------------
# The Task.source value a proposal-derived task carries. Distinct from "manual"/"planner"/
# "runtime_bridge"/"dynamic_handoff" so the Manager/audit can attribute bottom-up tasks.
PROPOSED_SOURCE = "agent_proposed"
# Lifecycle event types. Internal runtime events (NOT registered Heicode Manager events): an
# integrator either maps them onto an existing HM event or records them as telemetry. Kept here
# so producers and the collector share one vocabulary. See doc §事件.
EVENT_SUBMITTED = "task.proposal_submitted"
EVENT_ACCEPTED = "task.proposal_accepted"
EVENT_REJECTED = "task.proposal_rejected"
EVENT_MERGED = "task.proposal_merged"
class ProposalStatus(str, Enum):
"""Lifecycle status of an agent-submitted task proposal."""
PROPOSED = "proposed"
ACCEPTED = "accepted"
REJECTED = "rejected"
MERGED = "merged"
EXPIRED = "expired"
class ProposalDecision(str, Enum):
"""Outcome of the review policy for a single proposal."""
ACCEPT = "accept"
REJECT = "reject"
MERGE = "merge"
# --- data models ------------------------------------------------------------------------
@dataclass
class ProposalLineage:
"""Provenance of a proposal: where it came from and what triggered it.
This is what makes a proposed task auditable end-to-end: the originating task, the event
that prompted the agent to propose, and the shared-state snapshot the agent reasoned over.
"""
origin_task_id: Optional[str] = None
trigger_event: Optional[str] = None
# A redaction-safe snapshot of the shared run state the agent based the proposal on
# (e.g. completed task summaries, open gaps, pending task ids). Free-form on purpose; the
# dedup/policy logic only reads a few well-known keys (see review_proposal).
shared_state_snapshot: Dict[str, Any] = field(default_factory=dict)
@dataclass
class TaskProposal:
"""A NEW task an agent proposes based on shared run state.
``source`` is fixed to PROPOSED_SOURCE and ``proposed_by_agent_id`` is required: this is the
agent proposing, never a Master. ``proposal_confidence`` is the agent's own 0..1 estimate that
the task is worth doing; the orchestrator policy (review_proposal), not the agent, decides.
"""
proposed_by_agent_id: str
description: str
proposal_reason: str
proposal_confidence: float = 0.0
title: Optional[str] = None
agent_role: str = "general"
required_capabilities: List[str] = field(default_factory=list)
depends_on: List[str] = field(default_factory=list)
lineage: ProposalLineage = field(default_factory=ProposalLineage)
proposal_id: str = field(default_factory=lambda: f"prop-{uuid.uuid4().hex[:12]}")
source: str = PROPOSED_SOURCE
status: ProposalStatus = ProposalStatus.PROPOSED
created_at: float = field(default_factory=time.time)
# The id of an existing task this proposal was merged into (set on MERGE).
merged_into_task_id: Optional[str] = None
# Human/policy-readable note attached by review_proposal (why it was rejected, what it merged
# into, etc.). Carried into the lifecycle event payload.
decision_reason: Optional[str] = None
def __post_init__(self):
# Hard-pin the source: even if a caller passes something else, a TaskProposal is by
# definition agent-proposed. This keeps Task.source attribution honest.
self.source = PROPOSED_SOURCE
if not self.proposed_by_agent_id:
raise ValueError("TaskProposal requires proposed_by_agent_id (an agent, not a Master)")
# Clamp confidence into [0, 1] so a bad client value cannot defeat the threshold.
try:
self.proposal_confidence = max(0.0, min(1.0, float(self.proposal_confidence)))
except (TypeError, ValueError):
self.proposal_confidence = 0.0
def to_dict(self) -> Dict[str, Any]:
"""Serialize to a plain dict (enums -> values) for events / transport / storage."""
data = asdict(self)
data["status"] = self.status.value
return data
@dataclass
class ProposalPolicy:
"""Acceptance policy for the review step.
All thresholds are explicit so the integrator can source them from the run's
orchestration_plan/budget. Nothing here is read from the model — the agent only *proposes*.
"""
# Minimum agent confidence to consider accepting at all.
min_confidence: float = 0.6
# Confidence at/above which a unique proposal is accepted outright. Between min_confidence
# and this band, a non-duplicate proposal is still accepted; this field is kept so an
# integrator can split "auto-accept" from "queue for human review" later.
auto_accept_confidence: float = 0.6
# Remaining proposal budget for the run (how many more proposed tasks may be enqueued).
# 0 => budget exhausted => reject. Integrator decrements this as it enqueues.
remaining_proposal_budget: int = 3
# Similarity ratio (0..1) at/above which a proposal is treated as a duplicate of an existing
# task and MERGED rather than enqueued as new.
dedup_similarity_threshold: float = 0.8
@dataclass
class ExistingTaskRef:
"""The minimal view of an existing task the dedup check needs.
A plain dataclass (not the Pydantic Task) so this module stays infra-free; the integrator
builds these from task_queue.get_all_tasks().
"""
task_id: str
description: str
agent_role: str = "general"
status: str = "pending"
@dataclass
class ReviewOutcome:
"""Result of review_proposal: the decision plus the (mutated) proposal and a reason."""
decision: ProposalDecision
proposal: TaskProposal
reason: str
# Set when decision is MERGE: the existing task id the proposal folds into.
merge_target_task_id: Optional[str] = None
# --- dedup helper -----------------------------------------------------------------------
def _normalize_words(text: str) -> set:
"""Lowercase token set used for cheap, dependency-free description similarity."""
return {w for w in "".join(c.lower() if c.isalnum() else " " for c in (text or "")).split() if w}
def description_similarity(a: str, b: str) -> float:
"""Jaccard similarity of word sets in two descriptions (0..1).
Deliberately simple and deterministic (no embeddings, no model call) so the dedup decision is
reproducible and testable. Matches the spirit of the heuristic match score in decision_engine.
"""
wa, wb = _normalize_words(a), _normalize_words(b)
if not wa or not wb:
return 0.0
inter = len(wa & wb)
union = len(wa | wb)
return inter / union if union else 0.0
def find_duplicate(
proposal: TaskProposal,
existing_tasks: Sequence[ExistingTaskRef],
threshold: float,
) -> Optional[ExistingTaskRef]:
"""Return the most similar non-terminal existing task above ``threshold``, else None.
Only live tasks (pending/assigned/in_progress/blocked) are dedup targets — re-proposing work
similar to a *completed* task is allowed (the agent may legitimately want a follow-up round).
"""
live = {"pending", "assigned", "in_progress", "blocked"}
best: Optional[ExistingTaskRef] = None
best_sim = threshold
for task in existing_tasks:
if task.status not in live:
continue
sim = description_similarity(proposal.description, task.description)
if sim >= best_sim:
best_sim = sim
best = task
return best
# --- core policy ------------------------------------------------------------------------
def review_proposal(
proposal: TaskProposal,
policy: ProposalPolicy,
existing_tasks: Optional[Sequence[ExistingTaskRef]] = None,
) -> ReviewOutcome:
"""Decide whether to accept / reject / merge an agent-submitted proposal.
Pure function. Order of checks:
1. Confidence floor: below ``policy.min_confidence`` => REJECT.
2. Dedup: if it closely matches a live existing task => MERGE into that task.
3. Budget: if the run's remaining proposal budget is exhausted => REJECT.
4. Otherwise => ACCEPT.
Mutates and returns the proposal with its new status / decision_reason so the caller can
persist it and emit the matching lifecycle event. This NEVER calls a model or a Master — the
agent proposed; the orchestrator policy disposes.
"""
existing_tasks = existing_tasks or []
if proposal.proposal_confidence < policy.min_confidence:
proposal.status = ProposalStatus.REJECTED
proposal.decision_reason = (
f"confidence {proposal.proposal_confidence:.2f} < min {policy.min_confidence:.2f}"
)
return ReviewOutcome(ProposalDecision.REJECT, proposal, proposal.decision_reason)
duplicate = find_duplicate(proposal, existing_tasks, policy.dedup_similarity_threshold)
if duplicate is not None:
proposal.status = ProposalStatus.MERGED
proposal.merged_into_task_id = duplicate.task_id
proposal.decision_reason = f"duplicate of existing task {duplicate.task_id}"
return ReviewOutcome(
ProposalDecision.MERGE,
proposal,
proposal.decision_reason,
merge_target_task_id=duplicate.task_id,
)
if policy.remaining_proposal_budget <= 0:
proposal.status = ProposalStatus.REJECTED
proposal.decision_reason = "proposal budget exhausted for this run"
return ReviewOutcome(ProposalDecision.REJECT, proposal, proposal.decision_reason)
proposal.status = ProposalStatus.ACCEPTED
proposal.decision_reason = (
f"accepted (confidence {proposal.proposal_confidence:.2f} >= {policy.min_confidence:.2f})"
)
return ReviewOutcome(ProposalDecision.ACCEPT, proposal, proposal.decision_reason)
def ingest_accepted_proposal(
proposal: TaskProposal,
*,
swarm_id: Optional[str] = None,
root_task_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Map an ACCEPTED proposal into a pending-task spec for the integrator to enqueue.
Returns a kwargs-shaped dict aligned with ``task_queue.create_task`` (description, agent_role,
required_capabilities, depends_on, parent_task_id, root_task_id, source, context). The
integrator calls ``task_queue.create_task(**spec_to_create_task_kwargs(spec))`` (or maps it
through ``create_tasks_for_run``). The new task starts PENDING — create_task already sets that.
The full lineage is carried into ``context`` so the proposed task is auditable: which agent
proposed it, why, from which origin task, on which trigger, and the shared-state snapshot.
Raises if the proposal is not in ACCEPTED status — merged/rejected proposals never become tasks.
"""
if proposal.status != ProposalStatus.ACCEPTED:
raise ValueError(
f"ingest_accepted_proposal requires ACCEPTED status, got {proposal.status.value}"
)
assert_not_master_origin(proposal)
# parent/root: a proposal born from an origin task is a child of it by default; otherwise it
# is a new root. The integrator may override root_task_id to thread it into an existing run.
parent = proposal.lineage.origin_task_id
root = root_task_id or proposal.lineage.origin_task_id or None
return {
"task_id": None, # let create_task mint a uuid; integrator may prefix with swarm_id
"title": proposal.title or proposal.description[:80],
"description": proposal.description,
"agent_role": proposal.agent_role,
"required_capabilities": list(proposal.required_capabilities),
"depends_on": list(proposal.depends_on),
"parent_task_id": parent,
"root_task_id": root,
"source": PROPOSED_SOURCE,
"context": {
"source": PROPOSED_SOURCE,
"agent_role": proposal.agent_role,
"swarm_id": swarm_id,
"proposal": {
"proposal_id": proposal.proposal_id,
"proposed_by_agent_id": proposal.proposed_by_agent_id,
"proposal_reason": proposal.proposal_reason,
"proposal_confidence": proposal.proposal_confidence,
"origin_task_id": proposal.lineage.origin_task_id,
"trigger_event": proposal.lineage.trigger_event,
"shared_state_snapshot": dict(proposal.lineage.shared_state_snapshot),
},
},
}
# --- lifecycle events -------------------------------------------------------------------
def build_proposal_event(event_type: str, proposal: TaskProposal, **extra: Any) -> Dict[str, Any]:
"""Build one proposal-lifecycle event payload.
Returns a flat payload dict (the shape the orchestrator passes to
``swarm_runtime.emit_event(run, event_type, payload=...)``). ``event_type`` must be one of
the EVENT_* constants. The integrator owns wrapping this in the HM event envelope.
"""
valid = {EVENT_SUBMITTED, EVENT_ACCEPTED, EVENT_REJECTED, EVENT_MERGED}
if event_type not in valid:
raise ValueError(f"unknown proposal event_type {event_type!r}; expected one of {valid}")
payload: Dict[str, Any] = {
"event_type": event_type,
"proposal_id": proposal.proposal_id,
"proposed_by_agent_id": proposal.proposed_by_agent_id,
"source": PROPOSED_SOURCE,
"status": proposal.status.value,
"title": proposal.title or proposal.description[:80],
"proposal_reason": proposal.proposal_reason,
"proposal_confidence": proposal.proposal_confidence,
"origin_task_id": proposal.lineage.origin_task_id,
"trigger_event": proposal.lineage.trigger_event,
"decision_reason": proposal.decision_reason,
"merged_into_task_id": proposal.merged_into_task_id,
}
payload.update(extra)
return payload
def build_lifecycle_events_for_outcome(
proposal: TaskProposal,
outcome: ReviewOutcome,
) -> List[Dict[str, Any]]:
"""Build the ordered event list for a reviewed proposal: submitted -> decision event.
Always emits ``task.proposal_submitted`` first (the agent's act of proposing is itself an
auditable fact), then the decision event matching the policy outcome.
"""
events = [build_proposal_event(EVENT_SUBMITTED, proposal)]
decision_event = {
ProposalDecision.ACCEPT: EVENT_ACCEPTED,
ProposalDecision.REJECT: EVENT_REJECTED,
ProposalDecision.MERGE: EVENT_MERGED,
}[outcome.decision]
extra: Dict[str, Any] = {}
if outcome.merge_target_task_id:
extra["merge_target_task_id"] = outcome.merge_target_task_id
events.append(build_proposal_event(decision_event, proposal, **extra))
return events
# --- guardrail --------------------------------------------------------------------------
def assert_not_master_origin(proposal: TaskProposal) -> None:
"""Fail loudly if a proposal looks like it came from a Master/planner, not an agent.
Issue #7 is explicitly about the AGENT proposing. A Master generating tasks is the existing
planner path and must not be laundered through this module (it would mis-attribute source and
bypass the planner's own contract). We reject any proposer id / trigger that names a master or
planner. This is a cheap, explicit invariant — not a substitute for auth.
"""
proposer = (proposal.proposed_by_agent_id or "").lower()
trigger = (proposal.lineage.trigger_event or "").lower()
banned = ("master", "planner")
if any(tok in proposer for tok in banned) or any(tok in trigger for tok in banned):
raise ValueError(
"TaskProposal must originate from an executing agent, not a Master/planner "
f"(proposed_by_agent_id={proposal.proposed_by_agent_id!r}, "
f"trigger_event={proposal.lineage.trigger_event!r})"
)