按 juejin 协作聚合模型重构收敛(取代 best-of-N 选优): - 删蜂后选优(queen.py/test-queen.py) - 新增聚合节点 result_aggregator.py:共享池收集→同文件 LLM/AST 整合→沙箱验证→单次落 main - 质量驱动闭环:不达标打回迭代(AGGREGATE_ACCEPTANCE_THRESHOLD + MAX_REVIEW_CYCLES) - agent 停 git 工作分支,产出走 task.result.files 共享池(AGENT_GIT_PUSH_ENABLED 默认 false) - sandbox_runner 改用 pytest(原生支持 pytest 风格 class),修 stdlib runner 收集失败 - 文档同步重写为协作聚合模型 本地验证:产物仓单分支 main + 三函数完整 + pytest 12/12 pass_rate=100 一次达标。 影响:Swarm 收敛/聚合层;Manager/客户端契约不变(artifact字段/sequence/状态机;契约测试全过)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
756 lines
34 KiB
Python
756 lines
34 KiB
Python
"""Agent main loop with WebSocket connection to orchestrator.
|
||
|
||
Merged from agent_swarm_v4:
|
||
- Bounded concurrency, duplicate protection, capacity reporting (available_slots)
|
||
- Serialized websocket sends (safe_send) and reconnect-with-reregister logic
|
||
- Per-task timeout and graceful task cancellation
|
||
- Separate repository root from per-task execution workspace (see git_operations)
|
||
- Peer collaboration: request/reply routing fixed relative to v4 (peer_waiters is now
|
||
initialized and inbound peer replies are routed back to the waiting coroutine; inbound
|
||
peer queries are answered with a lightweight, cost-free acknowledgement)
|
||
- OpenAI-only task executor (see task_executor)
|
||
- Preserves heicode handoff wiring, Manager-facing metrics, and env-var entrypoint
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import signal
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import websockets
|
||
from dotenv import load_dotenv
|
||
from pydantic import BaseModel, Field, ValidationError
|
||
from prometheus_client import Counter, Gauge, Histogram, start_http_server
|
||
from websockets.exceptions import ConnectionClosed
|
||
|
||
from .git_operations import GitOperations
|
||
from .task_executor import TaskExecutor
|
||
|
||
|
||
load_dotenv()
|
||
|
||
|
||
# load_dotenv() above reads a local .env (gitignored) so credentials need not be passed on
|
||
# the command line. These defaults just guarantee the vars exist; an empty OPENAI_API_KEY is
|
||
# still falsy, so TaskExecutor raises a clear error rather than silently using no key.
|
||
# Real deployments supply the key via the environment / secret_ref, NOT a committed file.
|
||
os.environ.setdefault("OPENAI_API_KEY", "")
|
||
os.environ.setdefault("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||
os.environ.setdefault("OPENAI_MODEL", "gpt-4o-mini")
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TASKS_EXECUTED = Counter("agent_tasks_executed_total", "Total tasks executed")
|
||
TASKS_FAILED = Counter("agent_tasks_failed_total", "Total tasks failed")
|
||
TASK_DURATION = Histogram("agent_task_duration_seconds", "Task execution duration")
|
||
HANDOFFS_INITIATED = Counter("agent_handoffs_initiated_total", "Total handoffs initiated")
|
||
AGENT_STATUS = Gauge("agent_status", "Agent status (0=idle, 1=busy, 2=failed)")
|
||
WEBSOCKET_RECONNECTS = Counter("agent_websocket_reconnects_total", "WebSocket reconnection attempts")
|
||
TASKS_REJECTED = Counter("agent_tasks_rejected_total", "Total tasks rejected due to capacity")
|
||
TASKS_DUPLICATE = Counter("agent_tasks_duplicate_total", "Total duplicate task assignments")
|
||
ACTIVE_TASKS = Gauge("agent_active_tasks", "Current number of active tasks")
|
||
|
||
|
||
class TaskAssignment(BaseModel):
|
||
task_id: str
|
||
description: str
|
||
context: dict = Field(default_factory=dict)
|
||
|
||
|
||
class AgentRuntimeDisconnected(RuntimeError):
|
||
"""Raised when the runtime attempts to send without an active websocket."""
|
||
|
||
|
||
class Agent:
|
||
"""Agent that connects to orchestrator and executes tasks."""
|
||
|
||
# An agent may run several tasks at once; each executes in its OWN git worktree cut from this
|
||
# agent's clone (see _execute_assignment / GitOperations.add_task_worktree), so concurrent tasks
|
||
# never share a checkout/index. Swarm parallelism = many agents × this per-agent concurrency.
|
||
MAX_CONCURRENT_TASKS = int(os.getenv("MAX_CONCURRENT_TASKS", "4"))
|
||
# Per-task execution timeout. Default raised 60->300 (agent_swarm#70): 60s reliably killed
|
||
# generation-class tasks before the model finished. The Swarm launcher transmits an explicit
|
||
# TASK_TIMEOUT_SECONDS into each agent's env (capped at the run budget.duration_seconds), so
|
||
# this default only applies to externally-launched / standalone agents.
|
||
TASK_TIMEOUT_SECONDS = int(os.getenv("TASK_TIMEOUT_SECONDS", "300"))
|
||
PEER_REPLY_TIMEOUT_SECONDS = int(os.getenv("PEER_REPLY_TIMEOUT_SECONDS", "20"))
|
||
HEARTBEAT_INTERVAL_SECONDS = 15
|
||
|
||
def __init__(
|
||
self,
|
||
orchestrator_url: str,
|
||
agent_id: Optional[str] = None,
|
||
capabilities: Optional[list[str]] = None,
|
||
workspace_dir: str = "/workspace",
|
||
git_repo_url: Optional[str] = None,
|
||
user_id: Optional[str] = None,
|
||
):
|
||
# Initialize connection state, execution limits, and workspace helpers for this agent runtime.
|
||
self.agent_id = agent_id or f"agent-{uuid.uuid4().hex[:8]}"
|
||
self.orchestrator_url = orchestrator_url.rstrip("/")
|
||
self.capabilities = capabilities or ["general"]
|
||
# The owning user; the orchestrator caps concurrent agents per user (MAX_AGENTS_PER_USER).
|
||
# The agent platform sets this; falls back to env. None => unbound (not subject to the cap).
|
||
self.user_id = user_id or os.getenv("HEICODE_USER_ID")
|
||
self.workspace_dir = Path(workspace_dir)
|
||
self.git_repo_url = git_repo_url
|
||
|
||
self.websocket: Optional[websockets.WebSocketClientProtocol] = None
|
||
self.running = False
|
||
self.current_task_id: Optional[str] = None
|
||
self.send_lock = asyncio.Lock()
|
||
self.task_semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_TASKS)
|
||
self.active_tasks: dict[str, asyncio.Task] = {}
|
||
self.heartbeat_task: Optional[asyncio.Task] = None
|
||
# Outstanding peer-collaboration requests awaiting a reply, keyed by correlation_id.
|
||
self.peer_waiters: dict[str, asyncio.Future] = {}
|
||
# Summary of this agent's most recently completed task, shared when peers consult it.
|
||
self.last_summary: Optional[str] = None
|
||
# Lazily-created executor used to compose substantive peer replies.
|
||
self._peer_executor: Optional[TaskExecutor] = None
|
||
|
||
self.workspace_git = GitOperations(str(self.workspace_dir), self.agent_id)
|
||
# Serializes only the brief shared-repo git plumbing (worktree add/remove + fetch) so
|
||
# concurrent tasks don't race on .git locks. Task EXECUTION stays parallel.
|
||
self._git_admin_lock = asyncio.Lock()
|
||
|
||
def available_slots(self) -> int:
|
||
# Return how many additional tasks this agent can currently accept.
|
||
return max(0, self.MAX_CONCURRENT_TASKS - len(self.active_tasks))
|
||
|
||
def task_workspace(self, task_id: str) -> Path:
|
||
# Per-task git worktree path. Kept OUTSIDE the repo root (default /tmp/agent-worktrees) so
|
||
# the main checkout never sees it as untracked, and namespaced by agent_id to avoid
|
||
# collisions. `git worktree add` requires the leaf to not pre-exist, so we don't mkdir it.
|
||
base = Path(os.getenv("AGENT_WORKTREE_BASE", "/tmp/agent-worktrees"))
|
||
return base / self.agent_id / task_id.replace("/", "-")
|
||
|
||
async def safe_send(self, payload: dict):
|
||
# Serialize and send a websocket message while holding a lock to prevent concurrent writes.
|
||
if not self.websocket:
|
||
raise AgentRuntimeDisconnected("websocket unavailable")
|
||
async with self.send_lock:
|
||
await self.websocket.send(json.dumps(payload))
|
||
|
||
async def connect(self) -> bool:
|
||
# Open a websocket connection to the orchestrator and cache the live socket on success.
|
||
try:
|
||
self.websocket = await websockets.connect(f"{self.orchestrator_url}/ws/{self.agent_id}")
|
||
logger.info(f"Connected to orchestrator at {self.orchestrator_url}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Failed to connect to orchestrator: {e}")
|
||
return False
|
||
|
||
async def connect_with_retry(self) -> bool:
|
||
# Repeatedly attempt to connect with exponential backoff until connected or shutting down.
|
||
delay = 1
|
||
while self.running:
|
||
if await self.connect():
|
||
return True
|
||
WEBSOCKET_RECONNECTS.inc()
|
||
await asyncio.sleep(min(delay, 60))
|
||
delay = min(delay * 2, 60)
|
||
return False
|
||
|
||
async def register(self):
|
||
# Announce this agent and its current capacity to the orchestrator after connecting.
|
||
try:
|
||
register_msg = {
|
||
"type": "register",
|
||
"agent_id": self.agent_id,
|
||
"capabilities": self.capabilities,
|
||
# Backward-compatible extra fields; older orchestrators ignore them.
|
||
"available_slots": self.available_slots(),
|
||
"active_task_ids": list(self.active_tasks.keys()),
|
||
}
|
||
if self.user_id:
|
||
register_msg["user_id"] = self.user_id # subjects this agent to the per-user cap
|
||
await self.safe_send(register_msg)
|
||
logger.info(f"Registered agent {self.agent_id} with capabilities: {self.capabilities}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Failed to register: {e}")
|
||
return False
|
||
|
||
async def send_heartbeat(self):
|
||
# Send a periodic liveness and capacity update to the orchestrator.
|
||
await self.safe_send({
|
||
"type": "heartbeat",
|
||
"agent_id": self.agent_id,
|
||
"timestamp": time.time(),
|
||
"active_tasks": len(self.active_tasks),
|
||
"available_slots": self.available_slots(),
|
||
})
|
||
|
||
async def heartbeat_loop(self):
|
||
# Keep sending heartbeat messages until the connection closes or the agent stops running.
|
||
while self.running:
|
||
try:
|
||
await self.send_heartbeat()
|
||
except (ConnectionClosed, AgentRuntimeDisconnected):
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"Failed to send heartbeat: {e}")
|
||
return
|
||
await asyncio.sleep(self.HEARTBEAT_INTERVAL_SECONDS)
|
||
|
||
async def send_status_update(self, status: str, task_id: Optional[str] = None, message: str = ""):
|
||
# Report a human-readable task or agent status transition to the orchestrator.
|
||
try:
|
||
await self.safe_send({
|
||
"type": "status_update",
|
||
"agent_id": self.agent_id,
|
||
"status": status,
|
||
"task_id": task_id,
|
||
"message": message,
|
||
"timestamp": time.time(),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Failed to send status update: {e}")
|
||
|
||
async def request_peer_collaboration(
|
||
self,
|
||
task_id: str,
|
||
target_agent_id: str,
|
||
content: str,
|
||
timeout_seconds: float = 20.0,
|
||
):
|
||
# Ask a peer agent (via the orchestrator) for guidance and wait for its reply.
|
||
correlation_id = f"peer-{task_id}-{uuid.uuid4().hex[:8]}"
|
||
loop = asyncio.get_running_loop()
|
||
waiter = loop.create_future()
|
||
self.peer_waiters[correlation_id] = waiter
|
||
try:
|
||
await self.safe_send({
|
||
"type": "peer_message",
|
||
"agent_id": self.agent_id,
|
||
"target_agent_id": target_agent_id,
|
||
"task_id": task_id,
|
||
"content": content,
|
||
"correlation_id": correlation_id,
|
||
"is_reply": False,
|
||
"timestamp": time.time(),
|
||
})
|
||
return await asyncio.wait_for(waiter, timeout=timeout_seconds)
|
||
finally:
|
||
self.peer_waiters.pop(correlation_id, None)
|
||
|
||
@staticmethod
|
||
def _summarize_result(result: dict) -> Optional[str]:
|
||
# Extract a short, human-readable summary from an execution result.
|
||
if not isinstance(result, dict):
|
||
return None
|
||
for subtask_result in reversed(result.get("subtasks", []) or []):
|
||
summary = subtask_result.get("summary")
|
||
if isinstance(summary, str) and summary.strip():
|
||
return summary.strip()
|
||
summary = result.get("summary")
|
||
return summary.strip() if isinstance(summary, str) and summary.strip() else None
|
||
|
||
def _get_peer_executor(self) -> TaskExecutor:
|
||
# Lazily create a TaskExecutor for peer replies (reuses the model client / workspace).
|
||
# Raises if no model key is configured; callers fall back to the cached summary.
|
||
if self._peer_executor is None:
|
||
self._peer_executor = TaskExecutor(agent_id=self.agent_id, workspace_dir=str(self.workspace_dir))
|
||
return self._peer_executor
|
||
|
||
def _peer_fallback_reply(self) -> dict:
|
||
# Cheap, no-LLM reply used when the model is unavailable or errors.
|
||
shared = (
|
||
f"My latest result: {self.last_summary}. "
|
||
if self.last_summary
|
||
else "No completed result yet. "
|
||
)
|
||
content = (
|
||
f"From {self.agent_id} (capabilities: {', '.join(self.capabilities)}). {shared}"
|
||
"Treat implementation artifacts as the source of truth for behavior and exception semantics."
|
||
)
|
||
return {"content": content, "stance": "info", "evidence": self.last_summary or "", "refs": []}
|
||
|
||
async def _build_peer_reply(self, query: str, task_id: Optional[str]) -> dict:
|
||
# Produce a substantive, query-scoped reply grounded in this agent's own work.
|
||
# Falls back to the cached summary if there is no query, no model key, or the call fails.
|
||
if not query:
|
||
return self._peer_fallback_reply()
|
||
try:
|
||
executor = self._get_peer_executor()
|
||
except Exception as e:
|
||
logger.warning(f"peer reply executor unavailable ({e}); using cached summary")
|
||
return self._peer_fallback_reply()
|
||
try:
|
||
return await asyncio.wait_for(
|
||
executor.peer_reply(
|
||
query=query,
|
||
capabilities=self.capabilities,
|
||
last_summary=self.last_summary,
|
||
),
|
||
timeout=self.PEER_REPLY_TIMEOUT_SECONDS,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"peer reply LLM failed ({e}); using cached summary")
|
||
return self._peer_fallback_reply()
|
||
|
||
async def answer_peer_query(self, message: dict):
|
||
# Respond to an inbound peer query with a substantive, grounded reply (LLM, with fallback).
|
||
requester = message.get("from_agent_id") or message.get("agent_id")
|
||
correlation_id = message.get("correlation_id")
|
||
if not requester or not correlation_id:
|
||
return
|
||
reply = await self._build_peer_reply((message.get("content") or "").strip(), message.get("task_id"))
|
||
try:
|
||
await self.safe_send({
|
||
"type": "peer_message",
|
||
"agent_id": self.agent_id,
|
||
"target_agent_id": requester,
|
||
"task_id": message.get("task_id"),
|
||
"content": reply.get("content", ""),
|
||
"stance": reply.get("stance"),
|
||
"evidence": reply.get("evidence"),
|
||
"refs": reply.get("refs"),
|
||
"correlation_id": correlation_id,
|
||
"is_reply": True,
|
||
"timestamp": time.time(),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Failed to answer peer query: {e}")
|
||
|
||
async def send_task_result(self, task_id: str, success: bool, result: dict):
|
||
# Send a completion or failure payload for a finished task execution.
|
||
try:
|
||
payload = {
|
||
"type": "task_complete" if success else "task_failed",
|
||
"agent_id": self.agent_id,
|
||
"task_id": task_id,
|
||
"timestamp": time.time(),
|
||
}
|
||
if success:
|
||
payload["result"] = result
|
||
else:
|
||
payload["reason"] = result.get("error", "Task failed")
|
||
payload["result"] = result
|
||
await self.safe_send(payload)
|
||
except Exception as e:
|
||
logger.error(f"Failed to send task result: {e}")
|
||
|
||
async def send_blocked_on_handoff(self, task_id: str, result: dict):
|
||
# Report that a task is paused because execution delegated work to a child handoff task.
|
||
try:
|
||
child_task_id = None
|
||
for subtask_result in result.get("subtasks", []):
|
||
if subtask_result.get("status") == "handed_off":
|
||
child_task_id = subtask_result.get("child_task_id")
|
||
break
|
||
await self.safe_send({
|
||
"type": "blocked_on_handoff",
|
||
"agent_id": self.agent_id,
|
||
"task_id": task_id,
|
||
"child_task_id": child_task_id,
|
||
"reason": "Waiting on delegated child task",
|
||
"result": result,
|
||
"timestamp": time.time(),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Failed to send blocked_on_handoff: {e}")
|
||
|
||
async def request_handoff(self, task_id: str, subtask: dict, target_capabilities: list[str]):
|
||
# Ask the orchestrator to delegate a discovered subtask to another capable agent.
|
||
try:
|
||
await self.safe_send({
|
||
"type": "handoff_request",
|
||
"agent_id": self.agent_id,
|
||
"task_id": task_id,
|
||
"subtask": subtask,
|
||
"target_capabilities": target_capabilities,
|
||
"timestamp": time.time(),
|
||
})
|
||
HANDOFFS_INITIATED.inc()
|
||
except Exception as e:
|
||
logger.error(f"Failed to request handoff: {e}")
|
||
|
||
async def propose_task(self, *, description, reason, confidence, origin_task_id,
|
||
trigger_event, shared_state_snapshot, agent_role="general",
|
||
required_capabilities=None, depends_on=None, title=None):
|
||
# agent_swarm#7: propose a follow-up/subtask to the shared pool (bottom-up decomposition).
|
||
# The orchestrator reviews (confidence/dedup/budget) and, on accept, enqueues it as a real
|
||
# PENDING task that any capable peer can self-select. The swarm's canonical fan-out path.
|
||
try:
|
||
await self.safe_send({
|
||
"type": "task_proposal",
|
||
"agent_id": self.agent_id,
|
||
"title": title,
|
||
"description": description,
|
||
"reason": reason,
|
||
"proposal_reason": reason,
|
||
"confidence": confidence,
|
||
"proposal_confidence": confidence,
|
||
"agent_role": agent_role,
|
||
"required_capabilities": required_capabilities or [],
|
||
"depends_on": depends_on or [],
|
||
"origin_task_id": origin_task_id,
|
||
"trigger_event": trigger_event,
|
||
"shared_state_snapshot": shared_state_snapshot or {},
|
||
"timestamp": time.time(),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Failed to propose task: {e}")
|
||
|
||
@staticmethod
|
||
def _git_push_enabled() -> bool:
|
||
# New convergence architecture (feat/queen-convergence): worker agents no longer push a
|
||
# per-task git WORK branch into the delivery repo — intermediate artifacts ride in
|
||
# task.result["files"] and the orchestrator's aggregation node integrates them onto main.
|
||
# Default OFF. The legacy "commit + push agent/<id>/<task> branch" path runs ONLY when an
|
||
# operator explicitly opts in with AGENT_GIT_PUSH_ENABLED=true (e.g. for debugging).
|
||
return os.getenv("AGENT_GIT_PUSH_ENABLED", "false").lower() in {"1", "true", "yes"}
|
||
|
||
async def execute_assignment(self, assignment: TaskAssignment):
|
||
# Execute one accepted assignment, manage workspace/git flow, and publish lifecycle updates.
|
||
async with self.task_semaphore:
|
||
task_id = assignment.task_id
|
||
description = assignment.description
|
||
context = assignment.context or {}
|
||
self.current_task_id = task_id
|
||
ACTIVE_TASKS.set(len(self.active_tasks))
|
||
AGENT_STATUS.set(1)
|
||
|
||
await self.safe_send({
|
||
"type": "task_start",
|
||
"agent_id": self.agent_id,
|
||
"task_id": task_id,
|
||
"timestamp": time.time(),
|
||
})
|
||
await self.send_status_update("busy", task_id, "Starting task execution")
|
||
|
||
start_time = time.time()
|
||
task_workspace = self.task_workspace(task_id)
|
||
|
||
git_enabled = False
|
||
task_git = None
|
||
git_push_enabled = self._git_push_enabled()
|
||
try:
|
||
# Each task runs in its OWN git worktree cut from this agent's clone, so an agent can
|
||
# run several tasks concurrently without sharing a checkout/index. The worktree holds
|
||
# the full repo contents on a fresh result branch; the executor reads/writes the real
|
||
# source there and the per-task GitOperations (task_git) commits/pushes that branch.
|
||
# This fixes the prior empty per-task subdir → "Empty workspace: no files detected".
|
||
# Falls back to the repo root (no isolated branch) only if worktree creation fails.
|
||
#
|
||
# Convergence default (AGENT_GIT_PUSH_ENABLED unset/false): we do NOT cut a per-task
|
||
# result branch at all — there is nothing to commit/push because artifacts are
|
||
# returned in task.result["files"]. The task simply executes against the shared repo
|
||
# root for read context. The isolated worktree branch is created only in the legacy
|
||
# opt-in push path below.
|
||
if git_push_enabled and await self.workspace_git.is_git_workspace():
|
||
async with self._git_admin_lock: # serialize shared-repo git plumbing only
|
||
result_branch = await self.workspace_git.add_task_worktree(str(task_workspace), task_id)
|
||
if result_branch:
|
||
git_enabled = True
|
||
task_git = GitOperations(str(task_workspace), self.agent_id)
|
||
task_git.result_branch = result_branch
|
||
else:
|
||
logger.warning("Failed to create task worktree; executing on repo root without git push")
|
||
exec_dir = str(task_workspace) if git_enabled else str(self.workspace_dir)
|
||
executor = TaskExecutor(agent_id=self.agent_id, workspace_dir=exec_dir)
|
||
|
||
result = await asyncio.wait_for(
|
||
executor.execute_task(
|
||
task_id=task_id,
|
||
description=description,
|
||
context={
|
||
**context,
|
||
"workspace_dir": exec_dir,
|
||
"repo_workspace_dir": str(self.workspace_dir),
|
||
"git_repo_url": self.git_repo_url,
|
||
"agent_id": self.agent_id,
|
||
},
|
||
handoff_callback=self.request_handoff,
|
||
agent_capabilities=self.capabilities,
|
||
peer_collaboration_callback=self.request_peer_collaboration,
|
||
proposal_callback=self.propose_task,
|
||
),
|
||
timeout=self.TASK_TIMEOUT_SECONDS,
|
||
)
|
||
|
||
awaiting_handoff = result.get("awaiting_handoff", False)
|
||
if result.get("success"):
|
||
self.last_summary = self._summarize_result(result) or self.last_summary
|
||
if result.get("success") and not awaiting_handoff:
|
||
if not git_push_enabled:
|
||
# Convergence default: artifacts are carried in result["files"]; the
|
||
# orchestrator aggregation node integrates them. No work branch is pushed.
|
||
result["git_skipped"] = (
|
||
"git push disabled (AGENT_GIT_PUSH_ENABLED=false); "
|
||
"artifacts returned in task.result.files"
|
||
)
|
||
elif git_enabled and task_git:
|
||
commit_sha = await task_git.commit_changes(
|
||
message=f"Task {task_id}: {description[:50]}"
|
||
)
|
||
if commit_sha:
|
||
branch_name = await task_git.push_results()
|
||
result["git_branch"] = branch_name
|
||
result["commit_sha"] = commit_sha
|
||
else:
|
||
result["git_skipped"] = "No workspace changes to commit"
|
||
else:
|
||
result["git_skipped"] = "Workspace is not a Git checkout"
|
||
|
||
if awaiting_handoff:
|
||
await self.send_blocked_on_handoff(task_id, result)
|
||
else:
|
||
await self.send_task_result(task_id, result.get("success", False), result)
|
||
|
||
duration = time.time() - start_time
|
||
TASK_DURATION.observe(duration)
|
||
if result.get("success"):
|
||
TASKS_EXECUTED.inc()
|
||
else:
|
||
TASKS_FAILED.inc()
|
||
|
||
AGENT_STATUS.set(0)
|
||
if awaiting_handoff:
|
||
await self.send_status_update("handoff-pending", task_id, "Waiting for delegated child task")
|
||
await self.send_status_update("idle", None, "Delegated child task created")
|
||
else:
|
||
await self.send_status_update("idle", None, "Task completed")
|
||
|
||
except asyncio.TimeoutError:
|
||
TASKS_FAILED.inc()
|
||
AGENT_STATUS.set(2)
|
||
await self.send_task_result(task_id, False, {"error": "timeout", "success": False})
|
||
await self.send_status_update("idle", None, "Task failed: timeout")
|
||
except asyncio.CancelledError:
|
||
await self.send_task_result(task_id, False, {"error": "cancelled", "success": False})
|
||
await self.send_status_update("idle", None, "Task cancelled")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error executing task {task_id}: {e}")
|
||
TASKS_FAILED.inc()
|
||
AGENT_STATUS.set(2)
|
||
await self.send_task_result(task_id, False, {"error": str(e), "success": False})
|
||
await self.send_status_update("idle", None, f"Task failed: {e}")
|
||
finally:
|
||
if git_enabled:
|
||
async with self._git_admin_lock:
|
||
await self.workspace_git.remove_task_worktree(str(task_workspace))
|
||
self.active_tasks.pop(task_id, None)
|
||
ACTIVE_TASKS.set(len(self.active_tasks))
|
||
self.current_task_id = None
|
||
if not self.active_tasks:
|
||
AGENT_STATUS.set(0)
|
||
|
||
async def cancel_task(self, task_id: str):
|
||
# Cancel an actively running asyncio task if the orchestrator requests termination.
|
||
task = self.active_tasks.get(task_id)
|
||
if task:
|
||
task.cancel()
|
||
|
||
async def handle_task_assignment(self, message: dict):
|
||
# Validate an incoming assignment, reject duplicates/capacity overflow, and start execution.
|
||
try:
|
||
assignment = TaskAssignment(**message)
|
||
except ValidationError:
|
||
logger.warning(f"invalid task assignment: {message}")
|
||
return
|
||
|
||
if assignment.task_id in self.active_tasks:
|
||
TASKS_DUPLICATE.inc()
|
||
await self.safe_send({
|
||
"type": "task_accepted",
|
||
"task_id": assignment.task_id,
|
||
"status": "duplicate",
|
||
})
|
||
return
|
||
|
||
if len(self.active_tasks) >= self.MAX_CONCURRENT_TASKS:
|
||
TASKS_REJECTED.inc()
|
||
await self.safe_send({
|
||
"type": "task_rejected",
|
||
"task_id": assignment.task_id,
|
||
"reason": "at_capacity",
|
||
"available_slots": self.available_slots(),
|
||
})
|
||
return
|
||
|
||
await self.safe_send({
|
||
"type": "task_accepted",
|
||
"task_id": assignment.task_id,
|
||
"available_slots": self.available_slots() - 1,
|
||
})
|
||
|
||
task = asyncio.create_task(self.execute_assignment(assignment))
|
||
self.active_tasks[assignment.task_id] = task
|
||
ACTIVE_TASKS.set(len(self.active_tasks))
|
||
|
||
def _resolve_peer_reply(self, message: dict) -> bool:
|
||
# Resolve the waiting future for an inbound peer reply; return True if it was a reply.
|
||
correlation_id = message.get("correlation_id")
|
||
if not correlation_id:
|
||
return False
|
||
waiter = self.peer_waiters.get(correlation_id)
|
||
if waiter and not waiter.done():
|
||
waiter.set_result(message)
|
||
return True
|
||
# A correlation we own but already resolved/timed out: treat as handled reply.
|
||
return bool(message.get("is_reply"))
|
||
|
||
async def handle_peer_message(self, message: dict):
|
||
# Route a peer message: resolve our own pending request, or answer an inbound query.
|
||
if message.get("is_reply") or message.get("correlation_id") in self.peer_waiters:
|
||
handled = self._resolve_peer_reply(message)
|
||
if handled:
|
||
return
|
||
logger.info(
|
||
"Received peer query for task %s from %s",
|
||
message.get("task_id"),
|
||
message.get("from_agent_id") or message.get("agent_id"),
|
||
)
|
||
# Answer in the background so a slow (LLM) reply doesn't stall the message loop.
|
||
asyncio.create_task(self.answer_peer_query(message))
|
||
|
||
async def handle_message(self, message: dict):
|
||
# Route each inbound orchestrator message to the appropriate handler.
|
||
msg_type = message.get("type")
|
||
control_messages = {"registered", "heartbeat_ack", "task_completed", "task_failed_ack", "task_blocked_ack", "task_proposal_ack"}
|
||
|
||
if msg_type == "task_assignment":
|
||
await self.handle_task_assignment(message)
|
||
elif msg_type == "handoff_response":
|
||
logger.info(f"Handoff accepted for task {message.get('task_id')}")
|
||
elif msg_type == "cancel_task":
|
||
await self.cancel_task(message["task_id"])
|
||
elif msg_type == "ping":
|
||
await self.safe_send({"type": "pong"})
|
||
elif msg_type == "peer_message":
|
||
await self.handle_peer_message(message)
|
||
elif msg_type in control_messages:
|
||
logger.debug(f"Received control message: {msg_type}")
|
||
else:
|
||
logger.warning(f"Unknown message type: {msg_type}")
|
||
|
||
async def message_loop(self):
|
||
# Continuously receive websocket messages and dispatch them until the connection ends.
|
||
async for raw in self.websocket:
|
||
try:
|
||
data = json.loads(raw)
|
||
await self.handle_message(data)
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"Failed to parse message: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Error handling message: {e}")
|
||
|
||
async def shutdown_active_tasks(self):
|
||
# Cancel and await all active task coroutines during agent shutdown.
|
||
tasks = list(self.active_tasks.values())
|
||
for task in tasks:
|
||
task.cancel()
|
||
if tasks:
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
def install_signal_handlers(self):
|
||
# Register process signal handlers that trigger a graceful runtime shutdown.
|
||
def _handle_shutdown():
|
||
# Flip the runtime into shutdown mode and close the websocket asynchronously.
|
||
logger.info("shutdown signal received")
|
||
self.running = False
|
||
if self.websocket:
|
||
asyncio.create_task(self.websocket.close())
|
||
|
||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||
try:
|
||
asyncio.get_running_loop().add_signal_handler(sig, _handle_shutdown)
|
||
except NotImplementedError:
|
||
# add_signal_handler is unsupported on Windows event loops; skip gracefully.
|
||
pass
|
||
|
||
async def run(self):
|
||
# Run the full agent lifecycle: startup, registration, message processing, reconnect, and cleanup.
|
||
self.running = True
|
||
self.install_signal_handlers()
|
||
|
||
if os.getenv("METRICS_PORT"):
|
||
start_http_server(int(os.getenv("METRICS_PORT", "9000")))
|
||
|
||
if self.git_repo_url:
|
||
logger.info(f"Cloning workspace from {self.git_repo_url}")
|
||
if not await self.workspace_git.clone_workspace(self.git_repo_url):
|
||
logger.error("Failed to clone workspace, exiting")
|
||
return
|
||
|
||
while self.running:
|
||
connected = await self.connect_with_retry()
|
||
if not connected:
|
||
break
|
||
|
||
if not await self.register():
|
||
logger.error("Failed to register with orchestrator, exiting")
|
||
return
|
||
|
||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||
try:
|
||
await self.message_loop()
|
||
except ConnectionClosed:
|
||
logger.warning("connection closed; reconnecting")
|
||
except Exception as e:
|
||
logger.error(f"Error in message loop: {e}")
|
||
finally:
|
||
if self.heartbeat_task:
|
||
self.heartbeat_task.cancel()
|
||
try:
|
||
await self.heartbeat_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self.heartbeat_task = None
|
||
|
||
if self.websocket:
|
||
try:
|
||
await self.safe_send({"type": "deregister", "agent_id": self.agent_id})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
await self.websocket.close()
|
||
except Exception:
|
||
pass
|
||
self.websocket = None
|
||
|
||
if self.running:
|
||
await asyncio.sleep(1)
|
||
|
||
await self.shutdown_active_tasks()
|
||
logger.info("Agent shutdown complete")
|
||
|
||
|
||
async def main():
|
||
# Build an agent instance from environment configuration and start its runtime loop.
|
||
orchestrator_url = os.getenv("ORCHESTRATOR_URL", "ws://localhost:8000")
|
||
agent_id = os.getenv("AGENT_ID")
|
||
capabilities = os.getenv("AGENT_CAPABILITIES", "general").split(",")
|
||
workspace_dir = os.getenv("WORKSPACE_DIR", "/workspace")
|
||
git_repo_url = os.getenv("GIT_REPO_URL")
|
||
|
||
logger.info(f"Starting agent with ID: {agent_id or 'auto-generated'}")
|
||
logger.info(f"Capabilities: {capabilities}")
|
||
logger.info(f"Orchestrator URL: {orchestrator_url}")
|
||
|
||
agent = Agent(
|
||
orchestrator_url=orchestrator_url,
|
||
agent_id=agent_id,
|
||
capabilities=capabilities,
|
||
workspace_dir=workspace_dir,
|
||
git_repo_url=git_repo_url,
|
||
)
|
||
await agent.run()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|