Fix ordinary sub artifact callbacks
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
"""Runtime callback endpoints for Heicode sub-mode events."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import (
|
||||
AgentInstance,
|
||||
AuditLog,
|
||||
Deployment,
|
||||
DeploymentStatus as DBDeploymentStatus,
|
||||
Event,
|
||||
get_db,
|
||||
)
|
||||
from api.agnet.auth import verify_service_token
|
||||
from api.agnet.validators import validate_no_sensitive_fields
|
||||
from api.agnet.vault_client import vault_client
|
||||
from config.error_codes import ErrorCode
|
||||
from config.settings import settings
|
||||
|
||||
router = APIRouter(prefix="/callbacks", tags=["agnet-callbacks"])
|
||||
user_router = APIRouter(prefix="/user/deployments", tags=["agnet-user-observability"])
|
||||
|
||||
|
||||
PHASES = {
|
||||
"requirements",
|
||||
"planning",
|
||||
"design",
|
||||
"backend",
|
||||
"frontend",
|
||||
"development",
|
||||
"review",
|
||||
"test",
|
||||
"testing",
|
||||
"fixing",
|
||||
"deploy",
|
||||
"deployment",
|
||||
"done",
|
||||
"failed",
|
||||
}
|
||||
|
||||
STATUS_BY_EVENT = {
|
||||
"deployment.started": DBDeploymentStatus.RUNNING,
|
||||
"deployment.status_changed": None,
|
||||
"deployment.stopped": DBDeploymentStatus.STOPPED,
|
||||
"deployment.failed": DBDeploymentStatus.FAILED,
|
||||
"agent.crashed": DBDeploymentStatus.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def _parse_datetime(value: Optional[str]) -> datetime:
|
||||
"""Parse Runtime timestamps, falling back to now."""
|
||||
if not value:
|
||||
return datetime.utcnow()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
except ValueError:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def _extract_secret_name(ref: Optional[str]) -> Optional[str]:
|
||||
"""Return the Azure Key Vault secret name from azkv:// refs."""
|
||||
if not ref or not ref.startswith("azkv://"):
|
||||
return None
|
||||
return ref.rstrip("/").split("/")[-1]
|
||||
|
||||
|
||||
def _constant_time_any_signature(
|
||||
provided: str,
|
||||
payload: bytes,
|
||||
timestamp: str,
|
||||
event_id: str,
|
||||
candidate_secrets: list[str],
|
||||
) -> bool:
|
||||
"""Validate HMAC against one or more transition-period secret candidates."""
|
||||
if provided.startswith("sha256="):
|
||||
provided = provided[len("sha256="):]
|
||||
signature_payload = timestamp.encode() + b"." + event_id.encode() + b"." + payload
|
||||
for secret in candidate_secrets:
|
||||
digest = hmac.new(secret.encode(), signature_payload, hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(digest, provided):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _timestamp_in_window(timestamp: str, window_seconds: int = 300) -> bool:
|
||||
"""Validate callback timestamp freshness using Unix milliseconds."""
|
||||
try:
|
||||
timestamp_ms = int(timestamp)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
return abs(now_ms - timestamp_ms) <= window_seconds * 1000
|
||||
|
||||
|
||||
def _find_callback_signing_ref(db: Session, deployment_id: str) -> Optional[str]:
|
||||
"""Read the callback signing ref stored when the deployment was accepted."""
|
||||
accepted = (
|
||||
db.query(Event)
|
||||
.filter(
|
||||
Event.deployment_id == deployment_id,
|
||||
Event.event_type == "deployment.accepted",
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.first()
|
||||
)
|
||||
if not accepted or not isinstance(accepted.payload, dict):
|
||||
return None
|
||||
return accepted.payload.get("callback_signing_secret_ref")
|
||||
|
||||
|
||||
async def _verify_callback_auth(
|
||||
request: Request,
|
||||
db: Session,
|
||||
raw_body: bytes,
|
||||
body: Dict[str, Any],
|
||||
event_id: str,
|
||||
) -> None:
|
||||
"""Accept v2.1 HMAC callbacks and legacy service-token callbacks."""
|
||||
signature = request.headers.get("X-Agnet-Signature")
|
||||
timestamp = request.headers.get("X-Agnet-Timestamp")
|
||||
|
||||
if signature and timestamp:
|
||||
if not _timestamp_in_window(timestamp):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"success": False, "error": {"code": ErrorCode.UNAUTHORIZED, "message": "Callback timestamp is outside the allowed window"}},
|
||||
)
|
||||
|
||||
deployment_id = body.get("deployment_id") or body.get("swarm_id")
|
||||
if not deployment_id:
|
||||
raise HTTPException(status_code=422, detail={"error": {"code": ErrorCode.INVALID_REQUEST, "message": "deployment_id is required"}})
|
||||
|
||||
signing_ref = _find_callback_signing_ref(db, deployment_id)
|
||||
secrets = []
|
||||
if signing_ref:
|
||||
secret = await vault_client.get_secret(signing_ref)
|
||||
if secret:
|
||||
secrets.append(str(secret))
|
||||
secret_name = _extract_secret_name(signing_ref)
|
||||
if secret_name:
|
||||
secrets.append(f"mock-secret-azkv-{secret_name}")
|
||||
|
||||
if not secrets:
|
||||
secrets.append(settings.HEICODE_SERVICE_TOKEN)
|
||||
|
||||
if not _constant_time_any_signature(signature, raw_body, timestamp, event_id, secrets):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"success": False, "error": {"code": ErrorCode.UNAUTHORIZED, "message": "Invalid callback signature"}},
|
||||
)
|
||||
return
|
||||
|
||||
legacy_token = request.headers.get("X-Agnet-Service-Token")
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
bearer_token = auth_header[7:] if auth_header.lower().startswith("bearer ") else None
|
||||
if legacy_token == settings.HEICODE_SERVICE_TOKEN or bearer_token == settings.HEICODE_SERVICE_TOKEN:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"success": False, "error": {"code": ErrorCode.UNAUTHORIZED, "message": "Missing callback signature or service token"}},
|
||||
)
|
||||
|
||||
|
||||
def _payload_from_event(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize v2.1 payload plus legacy top-level artifact fields."""
|
||||
payload = body.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
normalized = dict(payload)
|
||||
else:
|
||||
normalized = {}
|
||||
|
||||
if body.get("artifact") and isinstance(body["artifact"], dict):
|
||||
normalized.setdefault("artifact", body["artifact"])
|
||||
for key, value in body["artifact"].items():
|
||||
normalized.setdefault(key, value)
|
||||
|
||||
for key in ("swarm_id", "stage", "checkpoint", "title", "summary", "severity", "next_action"):
|
||||
if key in body and key not in normalized:
|
||||
normalized[key] = body[key]
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _update_projection_state(
|
||||
db: Session,
|
||||
deployment: Deployment,
|
||||
event_type: str,
|
||||
agent_instance_id: Optional[str],
|
||||
payload: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Project important callback fields onto deployment/agent status."""
|
||||
phase = payload.get("phase") or payload.get("stage")
|
||||
if event_type == "phase.changed":
|
||||
phase = payload.get("phase") or payload.get("to_phase") or payload.get("stage")
|
||||
if phase in PHASES:
|
||||
deployment.phase = phase
|
||||
|
||||
if event_type == "deployment.status_changed":
|
||||
status_value = payload.get("status") or payload.get("to_status")
|
||||
if status_value in DBDeploymentStatus._value2member_map_:
|
||||
deployment.status = DBDeploymentStatus(status_value)
|
||||
elif event_type in STATUS_BY_EVENT and STATUS_BY_EVENT[event_type] is not None:
|
||||
deployment.status = STATUS_BY_EVENT[event_type]
|
||||
|
||||
if agent_instance_id:
|
||||
agent = db.query(AgentInstance).filter(AgentInstance.agent_instance_id == agent_instance_id).first()
|
||||
if agent:
|
||||
if phase:
|
||||
agent.phase = phase
|
||||
if event_type == "agent.started":
|
||||
agent.status = DBDeploymentStatus.RUNNING
|
||||
elif event_type in {"agent.completed"}:
|
||||
agent.status = DBDeploymentStatus.STOPPED
|
||||
elif event_type in {"agent.crashed", "sk_tool.failed"}:
|
||||
agent.status = DBDeploymentStatus.FAILED
|
||||
|
||||
deployment.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
def _audit_approval_request(db: Session, deployment: Deployment, event_id: str, payload: Dict[str, Any]) -> None:
|
||||
"""Create an audit marker for approval.requested callbacks."""
|
||||
db.add(
|
||||
AuditLog(
|
||||
audit_id=f"aud_{event_id}",
|
||||
actor="agnet-runtime",
|
||||
user_id=deployment.user_id,
|
||||
binding_scope=deployment.binding_scope,
|
||||
action="approval_requested",
|
||||
resource_type="deployment",
|
||||
resource_id=deployment.deployment_id,
|
||||
request_payload=payload,
|
||||
result="pending",
|
||||
correlation_id=deployment.correlation_id,
|
||||
occurred_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/swarm-events")
|
||||
async def receive_swarm_event(request: Request, db: Session = Depends(get_db)):
|
||||
"""Receive Runtime events using the HEICODE_API_INTEGRATION v2.1 contract."""
|
||||
raw_body = await request.body()
|
||||
try:
|
||||
body = json.loads(raw_body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=422, detail={"error": {"code": ErrorCode.INVALID_REQUEST, "message": "Invalid JSON body"}})
|
||||
|
||||
header_event_id = request.headers.get("X-Agnet-Event-Id")
|
||||
event_id = header_event_id or body.get("event_id")
|
||||
if not event_id:
|
||||
raise HTTPException(status_code=422, detail={"error": {"code": ErrorCode.INVALID_REQUEST, "message": "event_id is required"}})
|
||||
|
||||
await _verify_callback_auth(request, db, raw_body, body, event_id)
|
||||
|
||||
existing = db.query(Event).filter(Event.event_id == event_id).first()
|
||||
if existing:
|
||||
return {"success": True, "event_id": event_id, "deduplicated": True}
|
||||
|
||||
deployment_id = body.get("deployment_id") or body.get("swarm_id")
|
||||
event_type = body.get("event_type") or body.get("type")
|
||||
if not deployment_id or not event_type:
|
||||
raise HTTPException(status_code=422, detail={"error": {"code": ErrorCode.INVALID_REQUEST, "message": "deployment_id and event_type are required"}})
|
||||
|
||||
deployment = db.query(Deployment).filter(Deployment.deployment_id == deployment_id).first()
|
||||
if not deployment:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": ErrorCode.DEPLOYMENT_NOT_FOUND, "message": f"Deployment {deployment_id} not found"}})
|
||||
|
||||
payload = _payload_from_event(body)
|
||||
if body.get("swarm_id"):
|
||||
payload["swarm_id"] = body["swarm_id"]
|
||||
if body.get("agent_instance_id"):
|
||||
payload["agent_instance_id"] = body["agent_instance_id"]
|
||||
|
||||
validate_no_sensitive_fields({"payload": payload})
|
||||
|
||||
event = Event(
|
||||
event_id=event_id,
|
||||
deployment_id=deployment.deployment_id,
|
||||
agent_instance_id=body.get("agent_instance_id"),
|
||||
event_type=event_type,
|
||||
correlation_id=body.get("correlation_id") or request.headers.get("X-Correlation-ID") or deployment.correlation_id,
|
||||
payload=payload,
|
||||
occurred_at=_parse_datetime(body.get("occurred_at")),
|
||||
)
|
||||
|
||||
db.add(event)
|
||||
_update_projection_state(db, deployment, event_type, body.get("agent_instance_id"), payload)
|
||||
if event_type == "approval.requested":
|
||||
_audit_approval_request(db, deployment, event_id, payload)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return {"success": True, "event_id": event_id, "deduplicated": True}
|
||||
|
||||
return {"success": True, "event_id": event_id, "deduplicated": False}
|
||||
|
||||
|
||||
@router.get("/swarm-events/schema")
|
||||
async def get_swarm_event_schema():
|
||||
"""Expose callback contract metadata for Agent Manager联调."""
|
||||
event_types = {
|
||||
"deployment.status_changed": {
|
||||
"category": "status",
|
||||
"required_payload_fields": ["status"],
|
||||
},
|
||||
"phase.changed": {
|
||||
"category": "phase",
|
||||
"required_payload_fields": ["stage", "checkpoint"],
|
||||
},
|
||||
"timeline.updated": {
|
||||
"category": "timeline",
|
||||
"required_payload_fields": ["title", "summary", "stage", "checkpoint"],
|
||||
},
|
||||
"artifact.created": {
|
||||
"category": "artifact",
|
||||
"required_payload_fields": ["artifact_id", "artifact_type", "title"],
|
||||
},
|
||||
"approval.requested": {
|
||||
"category": "approval",
|
||||
"required_payload_fields": ["approval_id", "operation", "risk_level", "reason"],
|
||||
},
|
||||
"budget.alert": {
|
||||
"category": "budget",
|
||||
"required_payload_fields": ["consumed_usd", "threshold_pct", "severity"],
|
||||
},
|
||||
"agent.started": {
|
||||
"category": "agent",
|
||||
"required_payload_fields": ["agent_role", "status"],
|
||||
},
|
||||
"agent.completed": {
|
||||
"category": "agent",
|
||||
"required_payload_fields": ["status"],
|
||||
},
|
||||
"agent.crashed": {
|
||||
"category": "agent",
|
||||
"required_payload_fields": ["error_message"],
|
||||
},
|
||||
"task.completed": {
|
||||
"category": "task",
|
||||
"required_payload_fields": ["task_id", "status", "summary"],
|
||||
},
|
||||
"task.failed": {
|
||||
"category": "task",
|
||||
"required_payload_fields": ["task_id", "status", "summary"],
|
||||
},
|
||||
"task.blocked": {
|
||||
"category": "task",
|
||||
"required_payload_fields": ["task_id", "status", "summary"],
|
||||
},
|
||||
"sk_tool.called": {
|
||||
"category": "sk_tool",
|
||||
"required_payload_fields": ["tool_name", "tool_invocation_id"],
|
||||
},
|
||||
"sk_tool.completed": {
|
||||
"category": "sk_tool",
|
||||
"required_payload_fields": ["tool_name", "tool_invocation_id"],
|
||||
},
|
||||
"sk_tool.failed": {
|
||||
"category": "sk_tool",
|
||||
"required_payload_fields": ["tool_name", "error_message"],
|
||||
},
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"endpoint": "/api/agnet/callbacks/swarm-events",
|
||||
"headers": {
|
||||
"X-Agnet-Event-Id": "required for idempotency",
|
||||
"X-Agnet-Timestamp": "required for HMAC, Unix milliseconds",
|
||||
"X-Agnet-Signature": "required for HMAC, sha256=<hex>",
|
||||
"X-Correlation-ID": "recommended",
|
||||
},
|
||||
"body_required_fields": ["event_id", "event_type", "deployment_id", "occurred_at", "payload"],
|
||||
"event_types": event_types,
|
||||
"stages": ["planning", "design", "development", "testing", "fixing", "deployment", "review", "done", "failed"],
|
||||
"artifact_types": ["code_patch", "document", "test_report", "deployment_manifest", "log_bundle", "other"],
|
||||
}
|
||||
|
||||
|
||||
def _ensure_deployment(db: Session, deployment_id: str) -> Deployment:
|
||||
"""Load a deployment or return the standard not-found error."""
|
||||
deployment = db.query(Deployment).filter(Deployment.deployment_id == deployment_id).first()
|
||||
if not deployment:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": {"code": ErrorCode.DEPLOYMENT_NOT_FOUND, "message": f"Deployment {deployment_id} not found"}},
|
||||
)
|
||||
return deployment
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/artifacts")
|
||||
async def list_deployment_artifacts(
|
||||
deployment_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Return artifacts projected from v2.1 artifact.created callbacks."""
|
||||
_ensure_deployment(db, deployment_id)
|
||||
events = (
|
||||
db.query(Event)
|
||||
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
artifacts = []
|
||||
for event in events:
|
||||
payload = event.payload or {}
|
||||
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
|
||||
artifacts.append({
|
||||
"event_id": event.event_id,
|
||||
"artifact_id": artifact.get("artifact_id") or event.event_id,
|
||||
"artifact_type": artifact.get("artifact_type") or artifact.get("type") or "other",
|
||||
"title": artifact.get("title"),
|
||||
"summary": artifact.get("summary"),
|
||||
"uri": artifact.get("uri"),
|
||||
"mime_type": artifact.get("mime_type"),
|
||||
"size_bytes": artifact.get("size_bytes"),
|
||||
"stage": artifact.get("stage"),
|
||||
"checkpoint": artifact.get("checkpoint"),
|
||||
"metadata": artifact.get("metadata") or {},
|
||||
"created_at": event.occurred_at,
|
||||
})
|
||||
|
||||
return {"success": True, "deployment_id": deployment_id, "artifacts": artifacts}
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/timeline")
|
||||
async def list_deployment_timeline(
|
||||
deployment_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Return a merged timeline from callback events."""
|
||||
_ensure_deployment(db, deployment_id)
|
||||
timeline_event_types = {
|
||||
"timeline.updated",
|
||||
"phase.changed",
|
||||
"deployment.status_changed",
|
||||
"agent.started",
|
||||
"agent.completed",
|
||||
"agent.crashed",
|
||||
"task.completed",
|
||||
"task.failed",
|
||||
"task.blocked",
|
||||
"approval.requested",
|
||||
"budget.alert",
|
||||
"artifact.created",
|
||||
"sk_tool.called",
|
||||
"sk_tool.completed",
|
||||
"sk_tool.failed",
|
||||
}
|
||||
events = (
|
||||
db.query(Event)
|
||||
.filter(Event.deployment_id == deployment_id, Event.event_type.in_(timeline_event_types))
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
for event in events:
|
||||
payload = event.payload or {}
|
||||
items.append({
|
||||
"event_id": event.event_id,
|
||||
"event_type": event.event_type,
|
||||
"occurred_at": event.occurred_at,
|
||||
"agent_instance_id": event.agent_instance_id or payload.get("agent_instance_id"),
|
||||
"title": payload.get("title") or event.event_type,
|
||||
"summary": payload.get("summary"),
|
||||
"stage": payload.get("stage") or payload.get("phase"),
|
||||
"checkpoint": payload.get("checkpoint"),
|
||||
"severity": payload.get("severity") or ("error" if event.event_type.endswith(".failed") or event.event_type == "agent.crashed" else "info"),
|
||||
"next_action": payload.get("next_action"),
|
||||
"payload": payload,
|
||||
})
|
||||
|
||||
return {"success": True, "deployment_id": deployment_id, "timeline": items}
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/sk-snapshots")
|
||||
async def list_deployment_sk_snapshots(
|
||||
deployment_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Return SK snapshots projected from Runtime callback payloads."""
|
||||
_ensure_deployment(db, deployment_id)
|
||||
events = (
|
||||
db.query(Event)
|
||||
.filter(
|
||||
Event.deployment_id == deployment_id,
|
||||
Event.event_type.in_(("sk_tool.called", "sk_tool.completed", "sk_tool.failed", "artifact.created")),
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
snapshots = []
|
||||
for event in events:
|
||||
payload = event.payload or {}
|
||||
snapshot = payload.get("sk_snapshot") if isinstance(payload.get("sk_snapshot"), dict) else payload
|
||||
if not any(snapshot.get(key) for key in ("snapshot_id", "content_hash", "tool_invocation_id", "tool_name")):
|
||||
continue
|
||||
snapshots.append({
|
||||
"event_id": event.event_id,
|
||||
"snapshot_id": snapshot.get("snapshot_id") or f"sks_{event.event_id}",
|
||||
"deployment_id": deployment_id,
|
||||
"agent_instance_id": event.agent_instance_id or snapshot.get("agent_instance_id"),
|
||||
"agent_role": snapshot.get("agent_role"),
|
||||
"source_type": snapshot.get("source_type"),
|
||||
"source_ref": snapshot.get("source_ref"),
|
||||
"content_hash": snapshot.get("content_hash"),
|
||||
"tool_name": snapshot.get("tool_name"),
|
||||
"tool_invocation_id": snapshot.get("tool_invocation_id"),
|
||||
"created_at": snapshot.get("created_at") or event.occurred_at,
|
||||
"metadata": snapshot.get("metadata") or {},
|
||||
})
|
||||
|
||||
return {"success": True, "deployment_id": deployment_id, "sk_snapshots": snapshots}
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Pydantic models for Heicode integration API."""
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BillingProvider(str, Enum):
|
||||
"""Model gateway provider."""
|
||||
NEWAPI = "newapi"
|
||||
LITELLM = "litellm"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
"""Deployment risk level."""
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class DeploymentStatus(str, Enum):
|
||||
"""Deployment status."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
STOPPED = "stopped"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
DEFAULT_CALLBACK_EVENTS = [
|
||||
"deployment.status_changed",
|
||||
"phase.changed",
|
||||
"agent.started",
|
||||
"agent.completed",
|
||||
"agent.crashed",
|
||||
"task.completed",
|
||||
"task.failed",
|
||||
"task.blocked",
|
||||
"sk_tool.called",
|
||||
"sk_tool.completed",
|
||||
"sk_tool.failed",
|
||||
"approval.requested",
|
||||
"budget.alert",
|
||||
"artifact.created",
|
||||
"timeline.updated",
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request Models
|
||||
# ============================================================================
|
||||
|
||||
class SKSource(BaseModel):
|
||||
"""SK (Skill/Knowledge) source configuration."""
|
||||
type: str = Field(..., description="Source type: git, upload")
|
||||
url: Optional[str] = Field(None, description="Git repository URL")
|
||||
ref: Optional[str] = Field("main", description="Git ref (branch/tag)")
|
||||
path: Optional[str] = Field(None, description="Path within repository")
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Agent configuration in deployment request."""
|
||||
role: str = Field(..., description="Agent role (e.g., data-analyst)")
|
||||
image: str = Field(default="agnettaiji.azurecr.io/ai-agents/a2a-litellm-agent:v1.2.0", description="Container image")
|
||||
sk_sources: Optional[List[SKSource]] = Field(default_factory=list, description="SK sources")
|
||||
resource_grants: List[Dict[str, Any]] = Field(default_factory=list, description="Agent-scoped resource grants")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_sub_mode_agent(cls, data: Any) -> Any:
|
||||
"""Accept Manager sub-mode agent fields such as role_template."""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
data = dict(data)
|
||||
if not data.get("role"):
|
||||
data["role"] = data.get("role_template") or data.get("target_role") or "worker"
|
||||
return data
|
||||
|
||||
|
||||
class BudgetConfig(BaseModel):
|
||||
"""Budget configuration."""
|
||||
max_usd: Optional[float] = Field(None, description="Maximum budget in USD")
|
||||
max_cost_usd: Optional[float] = Field(None, description="Maximum budget in USD for sub mode")
|
||||
max_tokens: Optional[int] = Field(None, description="Maximum token budget")
|
||||
max_duration_sec: Optional[int] = Field(None, description="Maximum runtime in seconds")
|
||||
alert_threshold_pct: int = Field(80, description="Alert threshold percentage")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_cost_budget(self) -> "BudgetConfig":
|
||||
"""Accept both legacy max_usd and sub-mode max_cost_usd."""
|
||||
if self.max_usd is None:
|
||||
self.max_usd = self.max_cost_usd
|
||||
if self.max_cost_usd is None:
|
||||
self.max_cost_usd = self.max_usd
|
||||
if self.max_usd is None:
|
||||
raise ValueError("budget.max_usd or budget.max_cost_usd is required")
|
||||
return self
|
||||
|
||||
|
||||
class BillingContext(BaseModel):
|
||||
"""Billing and model gateway configuration."""
|
||||
provider: BillingProvider = Field(..., description="Model gateway provider")
|
||||
default_model_id: Optional[str] = Field(None, description="Default model ID")
|
||||
allowed_model_ids: List[str] = Field(default_factory=list, description="Allowed model IDs")
|
||||
secret_ref: Optional[str] = Field(None, description="Azure Key Vault reference to model gateway token")
|
||||
newapi_user_ref: Optional[str] = Field(None, description="Legacy NewAPI user reference")
|
||||
newapi_group: Optional[str] = Field(None, description="Legacy NewAPI group")
|
||||
quota_ref: Optional[str] = Field(None, description="Legacy quota reference")
|
||||
|
||||
@field_validator("secret_ref")
|
||||
@classmethod
|
||||
def validate_secret_ref(cls, value: Optional[str]) -> Optional[str]:
|
||||
"""Require v2.1 secret refs to use Azure Key Vault."""
|
||||
if value and not value.startswith("azkv://"):
|
||||
raise ValueError("billing_context.secret_ref must start with azkv://")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_legacy_billing_context(self) -> "BillingContext":
|
||||
"""Keep old provider-only billing payloads acceptable for transition."""
|
||||
if self.default_model_id is None:
|
||||
self.default_model_id = "default"
|
||||
if not self.allowed_model_ids:
|
||||
self.allowed_model_ids = [self.default_model_id]
|
||||
return self
|
||||
|
||||
|
||||
class ResourceGrant(BaseModel):
|
||||
"""Resource grant configuration."""
|
||||
type: Optional[str] = Field(None, description="Resource type: database, storage, api")
|
||||
ref: Optional[str] = Field(None, description="Secret reference to resource credentials")
|
||||
permissions: List[str] = Field(default_factory=list, description="Permissions: read, write, delete")
|
||||
grant_id: Optional[str] = None
|
||||
resource_id: Optional[str] = None
|
||||
resource_type: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
binding_scope: Optional[str] = None
|
||||
target_role: Optional[str] = None
|
||||
target_agent_ref: Optional[str] = None
|
||||
permission_scope: List[str] = Field(default_factory=list)
|
||||
constraints: Dict[str, Any] = Field(default_factory=dict)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
status: Optional[str] = None
|
||||
secret_ref: Optional[str] = None
|
||||
audit: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_sub_mode_grant(self) -> "ResourceGrant":
|
||||
"""Accept Heicode sub-mode grant field names."""
|
||||
if self.type is None:
|
||||
self.type = self.resource_type
|
||||
if self.ref is None:
|
||||
self.ref = self.secret_ref
|
||||
if not self.permissions and self.permission_scope:
|
||||
self.permissions = self.permission_scope
|
||||
if self.type is None:
|
||||
raise ValueError("resource grant type/resource_type is required")
|
||||
if self.ref is None:
|
||||
raise ValueError("resource grant ref/secret_ref is required")
|
||||
if not self.ref.startswith(("azkv://", "vault:")):
|
||||
raise ValueError("resource grant ref/secret_ref must be a secret reference")
|
||||
if self.secret_ref and not self.secret_ref.startswith("azkv://"):
|
||||
raise ValueError("resource grant secret_ref must start with azkv://")
|
||||
return self
|
||||
|
||||
|
||||
class CallbackConfig(BaseModel):
|
||||
"""Callback configuration for Agnet -> Heicode event delivery."""
|
||||
url: str = Field(..., description="HTTPS callback endpoint")
|
||||
signing_secret_ref: str = Field(..., description="Vault path to callback signing secret")
|
||||
subscribed_events: List[str] = Field(
|
||||
default_factory=lambda: list(DEFAULT_CALLBACK_EVENTS),
|
||||
description="Event types to deliver; omitted means all events"
|
||||
)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_https_url(cls, value: str) -> str:
|
||||
"""Require HTTPS callback endpoints for production-safe delivery."""
|
||||
if not value.startswith("https://"):
|
||||
raise ValueError("callback.url must use https://")
|
||||
return value
|
||||
|
||||
@field_validator("signing_secret_ref")
|
||||
@classmethod
|
||||
def validate_signing_secret_ref(cls, value: str) -> str:
|
||||
"""Require callback signing secrets to be referenced from a secret store."""
|
||||
if not value.startswith("azkv://"):
|
||||
raise ValueError("callback.signing_secret_ref must start with azkv://")
|
||||
return value
|
||||
|
||||
|
||||
class CreateDeploymentRequest(BaseModel):
|
||||
"""Request to create a new deployment."""
|
||||
orchestration_plan: Union[str, Dict[str, Any]] = Field(..., description="Natural language or structured deployment plan")
|
||||
agents: List[AgentConfig] = Field(default_factory=list, description="Agent configurations")
|
||||
risk_level: RiskLevel = Field(default=RiskLevel.MEDIUM, description="Deployment risk level")
|
||||
approval_token: Optional[str] = Field(None, description="JWT approval token (required for high risk)")
|
||||
budget: Optional[BudgetConfig] = Field(None, description="Budget configuration")
|
||||
billing_context: Optional[BillingContext] = Field(None, description="Billing and model gateway config")
|
||||
resource_grants: List[ResourceGrant] = Field(default_factory=list, description="Resource grants")
|
||||
callback: Optional[CallbackConfig] = Field(None, description="Agnet -> Heicode callback configuration")
|
||||
agile_context: Optional[Dict[str, Any]] = Field(None, description="Heicode sub-mode agile context")
|
||||
sub_mode: Optional[str] = Field(None, description="Heicode sub mode: agile or waterfall")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_structured_orchestration_plan(cls, data: Any) -> Any:
|
||||
"""Lift Heicode sub-mode fields from orchestration_plan into legacy fields."""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
plan = data.get("orchestration_plan")
|
||||
if not isinstance(plan, dict):
|
||||
return data
|
||||
|
||||
data = dict(data)
|
||||
data.setdefault("agents", plan.get("agents", []))
|
||||
data.setdefault("risk_level", plan.get("risk_level", "medium"))
|
||||
data.setdefault("budget", plan.get("budget"))
|
||||
data.setdefault("billing_context", plan.get("billing_context"))
|
||||
data.setdefault("resource_grants", plan.get("resource_grants", []))
|
||||
data.setdefault("metadata", plan.get("metadata", {}))
|
||||
data.setdefault("agile_context", plan.get("agile_context"))
|
||||
data.setdefault("sub_mode", plan.get("sub_mode", "agile"))
|
||||
|
||||
if data.get("callback") is None and isinstance(plan.get("callback"), dict):
|
||||
data["callback"] = plan["callback"]
|
||||
|
||||
if not data.get("resource_grants"):
|
||||
agent_grants = []
|
||||
for agent in plan.get("agents", []) or []:
|
||||
if isinstance(agent, dict):
|
||||
agent_grants.extend(agent.get("resource_grants") or [])
|
||||
if agent_grants:
|
||||
data["resource_grants"] = agent_grants
|
||||
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_sub_mode_request(self) -> "CreateDeploymentRequest":
|
||||
"""Validate sub-mode defaults and required runtime contexts."""
|
||||
if self.sub_mode is None and isinstance(self.orchestration_plan, dict):
|
||||
self.sub_mode = self.orchestration_plan.get("sub_mode", "agile")
|
||||
if self.sub_mode is None:
|
||||
self.sub_mode = "agile"
|
||||
if self.sub_mode not in {"agile", "waterfall"}:
|
||||
raise ValueError("sub_mode must be agile or waterfall")
|
||||
if self.budget is None:
|
||||
raise ValueError("budget is required")
|
||||
if self.billing_context is None:
|
||||
raise ValueError("billing_context is required")
|
||||
return self
|
||||
|
||||
|
||||
class StopDeploymentRequest(BaseModel):
|
||||
"""Request to stop a deployment."""
|
||||
reason: str = Field(..., description="Reason for stopping")
|
||||
approval_token: Optional[str] = Field(None, description="JWT approval token (required for high risk)")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Response Models
|
||||
# ============================================================================
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response."""
|
||||
success: bool = False
|
||||
error: Dict[str, Any]
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Standard success response."""
|
||||
success: bool = True
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
success: bool = True
|
||||
data: Dict[str, str]
|
||||
|
||||
|
||||
class AgentInstanceResponse(BaseModel):
|
||||
"""Agent instance in response."""
|
||||
agent_instance_id: str
|
||||
role: str
|
||||
status: str
|
||||
phase: Optional[str] = None
|
||||
|
||||
|
||||
class CreateDeploymentResponse(BaseModel):
|
||||
"""Response for deployment creation."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
swarm_id: Optional[str] = None
|
||||
status: str
|
||||
agent_instances: List[AgentInstanceResponse]
|
||||
created_at: datetime
|
||||
estimated_ready_at: Optional[datetime] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BudgetSummary(BaseModel):
|
||||
"""Budget summary."""
|
||||
max_usd: float
|
||||
consumed_usd: float
|
||||
remaining_usd: float
|
||||
|
||||
|
||||
class DeploymentSummary(BaseModel):
|
||||
"""Deployment summary for list response."""
|
||||
deployment_id: str
|
||||
status: str
|
||||
risk_level: str
|
||||
budget: BudgetSummary
|
||||
created_at: datetime
|
||||
agent_instances_count: int
|
||||
|
||||
|
||||
class PaginationInfo(BaseModel):
|
||||
"""Pagination information."""
|
||||
next_cursor: Optional[str] = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class ListDeploymentsResponse(BaseModel):
|
||||
"""Response for listing deployments."""
|
||||
success: bool = True
|
||||
deployments: List[DeploymentSummary]
|
||||
pagination: PaginationInfo
|
||||
|
||||
|
||||
class DeploymentDetail(BaseModel):
|
||||
"""Detailed deployment information."""
|
||||
deployment_id: str
|
||||
user_id: str
|
||||
binding_scope: str
|
||||
status: str
|
||||
phase: Optional[str]
|
||||
orchestration_plan: str
|
||||
risk_level: str
|
||||
budget: BudgetSummary
|
||||
billing_context: Dict[str, Any]
|
||||
agent_instances: List[AgentInstanceResponse]
|
||||
resource_grants: List[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class GetDeploymentResponse(BaseModel):
|
||||
"""Response for getting deployment details."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
user_id: str
|
||||
binding_scope: str
|
||||
status: str
|
||||
phase: Optional[str]
|
||||
orchestration_plan: str
|
||||
risk_level: str
|
||||
budget: BudgetSummary
|
||||
billing_context: Dict[str, Any]
|
||||
agent_instances: List[AgentInstanceResponse]
|
||||
resource_grants: List[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StopDeploymentResponse(BaseModel):
|
||||
"""Response for stopping deployment."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
status: str
|
||||
stopped_at: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Observability Models
|
||||
# ============================================================================
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
"""Single log entry."""
|
||||
timestamp: datetime
|
||||
agent_instance_id: str
|
||||
level: str
|
||||
message: str
|
||||
source: str = "stdout"
|
||||
|
||||
|
||||
class GetLogsResponse(BaseModel):
|
||||
"""Response for getting deployment logs."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
logs: List[LogEntry]
|
||||
pagination: PaginationInfo
|
||||
|
||||
|
||||
class EventEntry(BaseModel):
|
||||
"""Single event entry."""
|
||||
event_id: str
|
||||
event_type: str
|
||||
agent_instance_id: Optional[str] = None
|
||||
occurred_at: datetime
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
class GetEventsResponse(BaseModel):
|
||||
"""Response for getting deployment events."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
events: List[EventEntry]
|
||||
pagination: PaginationInfo
|
||||
|
||||
|
||||
class ResourceMetrics(BaseModel):
|
||||
"""Resource usage metrics."""
|
||||
cpu_usage_cores: float
|
||||
memory_usage_mb: float
|
||||
network_rx_bytes: int
|
||||
network_tx_bytes: int
|
||||
|
||||
|
||||
class AgentMetrics(BaseModel):
|
||||
"""Metrics for a single agent instance."""
|
||||
agent_instance_id: str
|
||||
role: str
|
||||
status: str
|
||||
resources: ResourceMetrics
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
class GetMetricsResponse(BaseModel):
|
||||
"""Response for getting deployment metrics."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
timestamp: datetime
|
||||
agent_metrics: List[AgentMetrics]
|
||||
total_resources: ResourceMetrics
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Heicode Manager callback delivery for swarm runtime events."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_CALLBACK_EVENTS = {
|
||||
"deployment.status_changed",
|
||||
"phase.changed",
|
||||
"agent.started",
|
||||
"agent.completed",
|
||||
"agent.crashed",
|
||||
"task.completed",
|
||||
"task.failed",
|
||||
"task.blocked",
|
||||
"sk_tool.called",
|
||||
"sk_tool.completed",
|
||||
"sk_tool.failed",
|
||||
"approval.requested",
|
||||
"budget.alert",
|
||||
"artifact.created",
|
||||
"timeline.updated",
|
||||
}
|
||||
|
||||
|
||||
def _utc_iso() -> str:
|
||||
"""Return UTC timestamp in v2.1 callback format."""
|
||||
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def _env_name_from_secret_ref(secret_ref: str) -> str:
|
||||
"""Map azkv secret names to ENV names, e.g. agnet-callback-key."""
|
||||
secret_name = secret_ref.rstrip("/").split("/")[-1]
|
||||
return secret_name.upper().replace("-", "_")
|
||||
|
||||
|
||||
class CallbackDeliveryClient:
|
||||
"""Send signed callback events to Heicode Manager."""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]]):
|
||||
self.config = config or {}
|
||||
self.url = self.config.get("url")
|
||||
self.signing_secret_ref = self.config.get("signing_secret_ref")
|
||||
self.subscribed_events = set(self.config.get("subscribed_events") or DEFAULT_CALLBACK_EVENTS)
|
||||
self._secret_cache: Optional[str] = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.url)
|
||||
|
||||
def is_subscribed(self, event_type: str) -> bool:
|
||||
return not self.subscribed_events or event_type in self.subscribed_events
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
event_type: str,
|
||||
deployment_id: str,
|
||||
*,
|
||||
swarm_id: Optional[str] = None,
|
||||
agent_instance_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Send a v2.1 callback event if configured and subscribed."""
|
||||
if not self.enabled or not self.is_subscribed(event_type):
|
||||
return
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex}"
|
||||
body = {
|
||||
"event_id": event_id,
|
||||
"event_type": event_type,
|
||||
"deployment_id": deployment_id,
|
||||
"swarm_id": swarm_id or deployment_id,
|
||||
"occurred_at": _utc_iso(),
|
||||
"correlation_id": correlation_id or f"swarm_{deployment_id}",
|
||||
"source": self.config.get("source") or "agent-manager",
|
||||
"payload": payload or {},
|
||||
}
|
||||
if agent_instance_id:
|
||||
body["agent_instance_id"] = agent_instance_id
|
||||
|
||||
raw_body = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
secret = await self._resolve_signing_secret()
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
timestamp.encode("utf-8") + b"." + event_id.encode("utf-8") + b"." + raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Agnet-Event-Id": event_id,
|
||||
"X-Agnet-Timestamp": timestamp,
|
||||
"X-Agnet-Signature": f"sha256={signature}",
|
||||
"X-Correlation-ID": body["correlation_id"],
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(self.url, content=raw_body, headers=headers)
|
||||
response.raise_for_status()
|
||||
logger.info("Delivered callback event %s for swarm %s", event_type, deployment_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to deliver callback event %s for swarm %s: %s",
|
||||
event_type,
|
||||
deployment_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _resolve_signing_secret(self) -> str:
|
||||
"""Resolve callback signing secret from env or Azure Key Vault."""
|
||||
if self._secret_cache:
|
||||
return self._secret_cache
|
||||
|
||||
env_candidates = [
|
||||
"AGNET_CALLBACK_SIGNING_KEY",
|
||||
"HEICODE_CALLBACK_SIGNING_SECRET",
|
||||
"CALLBACK_SIGNING_SECRET",
|
||||
]
|
||||
if self.signing_secret_ref:
|
||||
env_candidates.insert(0, _env_name_from_secret_ref(self.signing_secret_ref))
|
||||
|
||||
for name in env_candidates:
|
||||
value = os.getenv(name)
|
||||
if value:
|
||||
self._secret_cache = value
|
||||
return value
|
||||
|
||||
if self.signing_secret_ref and self.signing_secret_ref.startswith("azkv://"):
|
||||
secret = await self._fetch_azure_key_vault_secret(self.signing_secret_ref)
|
||||
if secret:
|
||||
self._secret_cache = secret
|
||||
return secret
|
||||
|
||||
fallback = os.getenv("HEICODE_SERVICE_TOKEN") or "dev-token-change-in-production"
|
||||
logger.warning("Callback signing secret not resolved; using HEICODE_SERVICE_TOKEN fallback")
|
||||
self._secret_cache = fallback
|
||||
return fallback
|
||||
|
||||
async def _fetch_azure_key_vault_secret(self, secret_ref: str) -> Optional[str]:
|
||||
"""Fetch azkv://<vault>/secrets/<name> via client credentials."""
|
||||
parsed = urlparse(secret_ref)
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if parsed.scheme != "azkv" or not parsed.netloc or len(parts) < 2 or parts[0] != "secrets":
|
||||
logger.warning("Invalid Azure Key Vault secret ref: %s", secret_ref)
|
||||
return None
|
||||
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
client_secret = os.getenv("AZURE_CLIENT_SECRET")
|
||||
if not (tenant_id and client_id and client_secret):
|
||||
logger.warning("Azure credentials unavailable for callback signing secret")
|
||||
return None
|
||||
|
||||
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
token_data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": "https://vault.azure.net/.default",
|
||||
}
|
||||
secret_url = f"https://{parsed.netloc}/secrets/{parts[1]}?api-version=7.4"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
token_response = await client.post(token_url, data=token_data)
|
||||
token_response.raise_for_status()
|
||||
access_token = token_response.json()["access_token"]
|
||||
secret_response = await client.get(
|
||||
secret_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
secret_response.raise_for_status()
|
||||
return secret_response.json().get("value")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch callback signing secret from Azure Key Vault: %s", exc)
|
||||
return None
|
||||
+462
-9
@@ -3,6 +3,7 @@ Swarm Orchestrator - Core logic for multi-agent collaboration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
@@ -11,10 +12,22 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from database import Swarm, SwarmAgent, SwarmMessage, SwarmStatus, SwarmAgentStatus
|
||||
from .agent_client import SwarmAgentClient
|
||||
from .callback_client import CallbackDeliveryClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PHASE_MAP = {
|
||||
"planning": ("requirements", "agent_running"),
|
||||
"coding": ("backend", "agent_running"),
|
||||
"reviewing": ("review", "ready_for_test"),
|
||||
"executing": ("backend", "agent_running"),
|
||||
"parallel_execution": ("backend", "agent_running"),
|
||||
"completed": ("deploy", "completed"),
|
||||
"failed": ("review", "failed"),
|
||||
}
|
||||
|
||||
|
||||
class SwarmOrchestrator:
|
||||
"""Swarm orchestrator for managing multi-agent collaboration"""
|
||||
|
||||
@@ -30,6 +43,8 @@ class SwarmOrchestrator:
|
||||
self.db = db
|
||||
self.agents: Dict[str, SwarmAgentClient] = {}
|
||||
self.swarm: Optional[Swarm] = None
|
||||
self.callback: Optional[CallbackDeliveryClient] = None
|
||||
self.correlation_id: Optional[str] = None
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""
|
||||
@@ -48,9 +63,14 @@ class SwarmOrchestrator:
|
||||
logger.error(f"Swarm {self.swarm_id} not found")
|
||||
return False
|
||||
|
||||
project_context = self.swarm.project_context or {}
|
||||
self.callback = CallbackDeliveryClient(project_context.get("_callback"))
|
||||
self.correlation_id = project_context.get("correlation_id") or f"swarm_{self.swarm_id}"
|
||||
|
||||
# Update status to initializing
|
||||
self.swarm.status = SwarmStatus.INITIALIZING
|
||||
self.db.commit()
|
||||
await self._emit_status("initializing")
|
||||
|
||||
logger.info(f"Initializing swarm {self.swarm_id}")
|
||||
|
||||
@@ -68,11 +88,23 @@ class SwarmOrchestrator:
|
||||
# Update agent status to running
|
||||
agent.status = SwarmAgentStatus.RUNNING
|
||||
self.db.commit()
|
||||
await self._emit_agent_event(
|
||||
"agent.started",
|
||||
agent.agent_id,
|
||||
{
|
||||
"agent_role": agent.role,
|
||||
"status": "running",
|
||||
"service_url": agent.service_url,
|
||||
},
|
||||
)
|
||||
|
||||
# Update swarm status to running
|
||||
self.swarm.status = SwarmStatus.RUNNING
|
||||
self.swarm.phase = "planning"
|
||||
self.db.commit()
|
||||
await self._emit_status("running")
|
||||
await self._emit_phase("planning", "Swarm initialized and planning started")
|
||||
await self._emit_usage()
|
||||
|
||||
logger.info(f"Swarm {self.swarm_id} initialized with {len(self.agents)} agents")
|
||||
return True
|
||||
@@ -110,7 +142,12 @@ class SwarmOrchestrator:
|
||||
self.swarm.completed_at = datetime.utcnow()
|
||||
self.swarm.progress = 100
|
||||
self.swarm.artifacts = result.get("artifacts", [])
|
||||
self._finalize_running_agents(SwarmAgentStatus.COMPLETED)
|
||||
self.db.commit()
|
||||
await self._emit_artifacts(result.get("artifacts", []))
|
||||
await self._emit_phase("completed", "Swarm execution completed")
|
||||
await self._emit_status("completed")
|
||||
await self._emit_usage()
|
||||
|
||||
return result
|
||||
|
||||
@@ -119,7 +156,9 @@ class SwarmOrchestrator:
|
||||
if self.swarm:
|
||||
self.swarm.status = SwarmStatus.FAILED
|
||||
self.swarm.error_message = str(e)
|
||||
self._finalize_running_agents(SwarmAgentStatus.FAILED, str(e))
|
||||
self.db.commit()
|
||||
await self._emit_status("failed", {"error": str(e)})
|
||||
raise
|
||||
|
||||
async def _execute_sequential(self) -> Dict[str, Any]:
|
||||
@@ -142,13 +181,16 @@ class SwarmOrchestrator:
|
||||
self.swarm.phase = "planning"
|
||||
self.swarm.progress = 10
|
||||
self.db.commit()
|
||||
await self._emit_phase("planning", "Architecture planning started")
|
||||
|
||||
design = await self._send_task(
|
||||
architect,
|
||||
f"Design the architecture for: {self.swarm.task_description}"
|
||||
)
|
||||
results["phases"].append({"phase": "planning", "result": design})
|
||||
results["artifacts"].append({"type": "design", "content": design})
|
||||
architect_record = self._get_agent_record(architect.agent_id)
|
||||
if architect_record:
|
||||
results["artifacts"].append(self._build_artifact(architect_record, design, len(results["artifacts"])))
|
||||
|
||||
# Phase 2: Coders implement
|
||||
coders = self._get_agents_by_role("coder")
|
||||
@@ -156,6 +198,7 @@ class SwarmOrchestrator:
|
||||
self.swarm.phase = "coding"
|
||||
self.swarm.progress = 40
|
||||
self.db.commit()
|
||||
await self._emit_phase("coding", "Implementation started")
|
||||
|
||||
code_results = await asyncio.gather(*[
|
||||
self._send_task(coder, f"Implement: {self.swarm.task_description}")
|
||||
@@ -163,7 +206,9 @@ class SwarmOrchestrator:
|
||||
])
|
||||
results["phases"].append({"phase": "coding", "results": code_results})
|
||||
for i, code in enumerate(code_results):
|
||||
results["artifacts"].append({"type": "code", "agent": i, "content": code})
|
||||
coder_record = self._get_agent_record(coders[i].agent_id)
|
||||
if coder_record:
|
||||
results["artifacts"].append(self._build_artifact(coder_record, code, len(results["artifacts"])))
|
||||
|
||||
# Phase 3: Reviewer reviews
|
||||
reviewer = self._get_agent_by_role("reviewer")
|
||||
@@ -171,13 +216,16 @@ class SwarmOrchestrator:
|
||||
self.swarm.phase = "reviewing"
|
||||
self.swarm.progress = 80
|
||||
self.db.commit()
|
||||
await self._emit_phase("reviewing", "Review started")
|
||||
|
||||
review = await self._send_task(
|
||||
reviewer,
|
||||
f"Review the implementation: {code_results if coders else 'No code generated'}"
|
||||
)
|
||||
results["phases"].append({"phase": "reviewing", "result": review})
|
||||
results["artifacts"].append({"type": "review", "content": review})
|
||||
reviewer_record = self._get_agent_record(reviewer.agent_id)
|
||||
if reviewer_record:
|
||||
results["artifacts"].append(self._build_artifact(reviewer_record, review, len(results["artifacts"])))
|
||||
|
||||
return results
|
||||
|
||||
@@ -193,11 +241,13 @@ class SwarmOrchestrator:
|
||||
self.swarm.phase = "executing"
|
||||
self.swarm.progress = 50
|
||||
self.db.commit()
|
||||
await self._emit_phase("executing", "Parallel execution started")
|
||||
|
||||
# Send task to all agents in parallel
|
||||
executable_agents = list(self.agents.values())
|
||||
tasks = [
|
||||
self._send_task(client, self.swarm.task_description)
|
||||
for client in self.agents.values()
|
||||
self._send_task(client, self._build_agent_task(self._get_agent_record(client.agent_id)))
|
||||
for client in executable_agents
|
||||
]
|
||||
|
||||
results_list = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -209,7 +259,11 @@ class SwarmOrchestrator:
|
||||
|
||||
for i, result in enumerate(results_list):
|
||||
if not isinstance(result, Exception):
|
||||
results["artifacts"].append({"type": "output", "agent": i, "content": result})
|
||||
agent_record = self._get_agent_record(executable_agents[i].agent_id)
|
||||
if agent_record:
|
||||
results["artifacts"].append(
|
||||
self._build_artifact(agent_record, result, i)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -221,9 +275,82 @@ class SwarmOrchestrator:
|
||||
Execution results
|
||||
"""
|
||||
logger.info(f"Executing hybrid strategy for swarm {self.swarm_id}")
|
||||
# For now, default to sequential
|
||||
roles = {agent.role for agent in self.db.query(SwarmAgent).filter(SwarmAgent.swarm_id == self.swarm_id).all()}
|
||||
project_context = self._callback_context()
|
||||
if project_context.get("sub_mode") or not roles.intersection({"architect", "coder", "reviewer"}):
|
||||
return await self._execute_sub_mode_agents()
|
||||
return await self._execute_sequential()
|
||||
|
||||
async def _execute_sub_mode_agents(self) -> Dict[str, Any]:
|
||||
"""Execute ordinary sub-mode agents using their actual configured roles."""
|
||||
logger.info(f"Executing ordinary sub-mode workflow for swarm {self.swarm_id}")
|
||||
|
||||
self.swarm.phase = "development"
|
||||
self.swarm.progress = 30
|
||||
self.db.commit()
|
||||
await self._emit_phase("executing", "Ordinary sub-mode execution started")
|
||||
|
||||
executable_agent_records = [
|
||||
agent
|
||||
for agent in self.db.query(SwarmAgent).filter(SwarmAgent.swarm_id == self.swarm_id).all()
|
||||
if agent.agent_id in self.agents
|
||||
]
|
||||
tasks = [
|
||||
self._send_task(self.agents[agent.agent_id], self._build_agent_task(agent))
|
||||
for agent in executable_agent_records
|
||||
]
|
||||
results_list = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
artifacts: List[Dict[str, Any]] = []
|
||||
phases: List[Dict[str, Any]] = []
|
||||
for index, result in enumerate(results_list):
|
||||
agent_record = executable_agent_records[index]
|
||||
if isinstance(result, Exception):
|
||||
phases.append(
|
||||
{
|
||||
"phase": "development",
|
||||
"agent_id": agent_record.agent_id,
|
||||
"role": agent_record.role,
|
||||
"status": "failed",
|
||||
"error": str(result),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
phases.append(
|
||||
{
|
||||
"phase": "development",
|
||||
"agent_id": agent_record.agent_id,
|
||||
"role": agent_record.role,
|
||||
"status": "completed",
|
||||
"summary": self._response_summary(result),
|
||||
}
|
||||
)
|
||||
artifacts.append(self._build_artifact(agent_record, result, index))
|
||||
|
||||
if not artifacts:
|
||||
artifacts.append(
|
||||
{
|
||||
"artifact_id": f"art_{self.swarm_id}_summary",
|
||||
"artifact_type": "document",
|
||||
"title": "Runtime execution summary",
|
||||
"summary": self.swarm.error_message or "Runtime completed without per-agent artifacts; review swarm logs for details.",
|
||||
"uri": f"runtime://{self.swarm_id}/artifacts/summary",
|
||||
"agent_instance_id": None,
|
||||
"stage": "development",
|
||||
"checkpoint": "artifact_ready",
|
||||
"metadata": {
|
||||
"redacted": True,
|
||||
"source": "agent-manager-swarm",
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.swarm.progress = 85
|
||||
self.db.commit()
|
||||
return {"artifacts": artifacts, "phases": phases}
|
||||
|
||||
async def _send_task(self, client: SwarmAgentClient, task: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Send task to agent via A2A protocol.
|
||||
@@ -236,6 +363,12 @@ class SwarmOrchestrator:
|
||||
Agent response
|
||||
"""
|
||||
try:
|
||||
agent_record = self._get_agent_record(client.agent_id)
|
||||
if agent_record:
|
||||
agent_record.current_task = task
|
||||
agent_record.status = SwarmAgentStatus.RUNNING
|
||||
self.db.commit()
|
||||
|
||||
# Record message to database
|
||||
message = SwarmMessage(
|
||||
message_id=str(uuid.uuid4()),
|
||||
@@ -244,7 +377,7 @@ class SwarmOrchestrator:
|
||||
to_agent_id=client.agent_id,
|
||||
message_type="task",
|
||||
content=task,
|
||||
metadata={}
|
||||
message_metadata={}
|
||||
)
|
||||
self.db.add(message)
|
||||
self.db.commit()
|
||||
@@ -252,9 +385,34 @@ class SwarmOrchestrator:
|
||||
# Update message count
|
||||
self.swarm.total_messages += 1
|
||||
self.db.commit()
|
||||
await self._emit_tool_event(
|
||||
"sk_tool.called",
|
||||
client.agent_id,
|
||||
{
|
||||
"tool_name": "agent_task",
|
||||
"tool_invocation_id": message.message_id,
|
||||
"summary": "Dispatching task to agent",
|
||||
"arguments_redacted": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Send message to agent
|
||||
response = await client.send_message({"text": task})
|
||||
usage = self._extract_usage(response)
|
||||
if self.swarm and usage["total_tokens"]:
|
||||
self.swarm.tokens_used += usage["total_tokens"]
|
||||
self.db.commit()
|
||||
await self._emit_tool_event(
|
||||
"sk_tool.completed",
|
||||
client.agent_id,
|
||||
{
|
||||
"tool_name": "agent_task",
|
||||
"tool_invocation_id": message.message_id,
|
||||
"summary": "Agent task completed",
|
||||
"result_preview": str(response)[:500],
|
||||
"model_usage": usage,
|
||||
},
|
||||
)
|
||||
|
||||
# Record response
|
||||
response_message = SwarmMessage(
|
||||
@@ -264,18 +422,67 @@ class SwarmOrchestrator:
|
||||
to_agent_id=None, # To orchestrator
|
||||
message_type="response",
|
||||
content=str(response),
|
||||
metadata={}
|
||||
message_metadata={}
|
||||
)
|
||||
self.db.add(response_message)
|
||||
self.db.commit()
|
||||
|
||||
self.swarm.total_messages += 1
|
||||
if agent_record:
|
||||
agent_record.status = SwarmAgentStatus.COMPLETED
|
||||
agent_record.output = self._response_summary(response)
|
||||
agent_record.current_task = None
|
||||
self.db.commit()
|
||||
await self._emit_agent_event(
|
||||
"agent.completed",
|
||||
client.agent_id,
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "Agent task completed",
|
||||
},
|
||||
)
|
||||
await self._emit(
|
||||
"task.completed",
|
||||
agent_instance_id=client.agent_id,
|
||||
payload={
|
||||
"task_id": message.message_id,
|
||||
"agent_role": agent_record.role if agent_record else None,
|
||||
"status": "completed",
|
||||
"summary": self._response_summary(response),
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending task to agent {client.agent_id}: {e}")
|
||||
agent_record = self._get_agent_record(client.agent_id)
|
||||
if agent_record:
|
||||
agent_record.status = SwarmAgentStatus.FAILED
|
||||
agent_record.output = str(e)
|
||||
agent_record.current_task = None
|
||||
self.db.commit()
|
||||
await self._emit_tool_event(
|
||||
"sk_tool.failed",
|
||||
client.agent_id,
|
||||
{
|
||||
"tool_name": "agent_task",
|
||||
"summary": "Agent task failed",
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
await self._emit(
|
||||
"task.failed",
|
||||
agent_instance_id=client.agent_id,
|
||||
payload={
|
||||
"task_id": message.message_id if "message" in locals() else f"task_{client.agent_id}",
|
||||
"agent_role": agent_record.role if agent_record else None,
|
||||
"status": "failed",
|
||||
"summary": str(e),
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_agent_by_role(self, role: str) -> Optional[SwarmAgentClient]:
|
||||
@@ -314,6 +521,7 @@ class SwarmOrchestrator:
|
||||
self.swarm.status = SwarmStatus.STOPPED
|
||||
self.swarm.error_message = reason
|
||||
self.db.commit()
|
||||
await self._emit_status("stopped", {"reason": reason})
|
||||
|
||||
# Close all agent clients
|
||||
for client in self.agents.values():
|
||||
@@ -330,3 +538,248 @@ class SwarmOrchestrator:
|
||||
for client in self.agents.values():
|
||||
await client.close()
|
||||
self.agents.clear()
|
||||
|
||||
def _callback_context(self) -> Dict[str, Any]:
|
||||
"""Return callback context stored on the swarm."""
|
||||
if not self.swarm:
|
||||
return {}
|
||||
return self.swarm.project_context or {}
|
||||
|
||||
async def _emit_status(self, status: str, extra_payload: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Emit deployment.status_changed callback."""
|
||||
payload = {"status": status, **(extra_payload or {})}
|
||||
await self._emit("deployment.status_changed", payload=payload)
|
||||
|
||||
async def _emit_phase(self, internal_phase: str, summary: str) -> None:
|
||||
"""Emit phase.changed and timeline.updated callbacks."""
|
||||
stage, checkpoint = PHASE_MAP.get(internal_phase, (internal_phase, "agent_running"))
|
||||
payload = {
|
||||
"stage": stage,
|
||||
"phase": stage,
|
||||
"checkpoint": checkpoint,
|
||||
"progress_pct": self.swarm.progress if self.swarm else 0,
|
||||
"summary": summary,
|
||||
"internal_phase": internal_phase,
|
||||
}
|
||||
await self._emit("phase.changed", payload=payload)
|
||||
await self._emit(
|
||||
"timeline.updated",
|
||||
payload={
|
||||
"title": summary,
|
||||
"summary": summary,
|
||||
"stage": stage,
|
||||
"checkpoint": checkpoint,
|
||||
"progress_pct": self.swarm.progress if self.swarm else 0,
|
||||
"severity": "success" if checkpoint == "completed" else "info",
|
||||
"next_action": "continue" if checkpoint != "completed" else "stop",
|
||||
},
|
||||
)
|
||||
|
||||
async def _emit_agent_event(self, event_type: str, agent_id: str, payload: Dict[str, Any]) -> None:
|
||||
"""Emit agent lifecycle callback."""
|
||||
await self._emit(event_type, agent_instance_id=agent_id, payload=payload)
|
||||
|
||||
async def _emit_tool_event(self, event_type: str, agent_id: str, payload: Dict[str, Any]) -> None:
|
||||
"""Emit SK/tool callback."""
|
||||
await self._emit(event_type, agent_instance_id=agent_id, payload=payload)
|
||||
|
||||
async def _emit_artifacts(self, artifacts: List[Dict[str, Any]]) -> None:
|
||||
"""Emit artifact.created callbacks for generated outputs."""
|
||||
for index, artifact in enumerate(artifacts):
|
||||
artifact_type = artifact.get("artifact_type") or artifact.get("type") or "other"
|
||||
await self._emit(
|
||||
"artifact.created",
|
||||
agent_instance_id=artifact.get("agent_instance_id"),
|
||||
payload={
|
||||
"artifact_id": artifact.get("artifact_id") or f"art_{self.swarm_id}_{index}",
|
||||
"artifact_type": artifact_type,
|
||||
"title": artifact.get("title") or f"{artifact_type} artifact",
|
||||
"summary": artifact.get("summary") or str(artifact.get("content", ""))[:300],
|
||||
"uri": artifact.get("uri") or f"runtime://{self.swarm_id}/artifacts/{artifact.get('artifact_id') or index}",
|
||||
"mime_type": artifact.get("mime_type") or "text/plain",
|
||||
"size_bytes": artifact.get("size_bytes"),
|
||||
"stage": self._callback_context().get("agile_context", {}).get("stage") or "development",
|
||||
"checkpoint": artifact.get("checkpoint") or "artifact_ready",
|
||||
"metadata": {
|
||||
"redacted": True,
|
||||
"source": "agent-manager-swarm",
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
**(artifact.get("metadata") or {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _emit_usage(self) -> None:
|
||||
"""Emit a minimal usage/cost event for Manager attribution."""
|
||||
context = self._callback_context()
|
||||
budget = context.get("budget") or {}
|
||||
billing_context = context.get("billing_context") or {}
|
||||
await self._emit(
|
||||
"budget.alert",
|
||||
payload={
|
||||
"model_id": billing_context.get("default_model_id") or "unknown",
|
||||
"model_tokens": self.swarm.tokens_used if self.swarm else 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"model_cost_usd": 0,
|
||||
"runtime_seconds": 0,
|
||||
"cpu_core_seconds": 0,
|
||||
"memory_mb_seconds": 0,
|
||||
"billing_source": billing_context.get("provider") or "newapi",
|
||||
"consumed_usd": 0,
|
||||
"max_cost_usd": budget.get("max_cost_usd"),
|
||||
"threshold_pct": budget.get("alert_threshold_pct", 80),
|
||||
"severity": "info",
|
||||
"budget": {
|
||||
"max_tokens": budget.get("max_tokens"),
|
||||
"max_cost_usd": budget.get("max_cost_usd"),
|
||||
"consumed_usd": 0,
|
||||
"remaining_usd": budget.get("max_cost_usd"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
event_type: str,
|
||||
*,
|
||||
agent_instance_id: Optional[str] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Emit callback if delivery is configured."""
|
||||
if not self.callback or not self.swarm:
|
||||
return
|
||||
callback_context = self._callback_context()
|
||||
await self.callback.emit(
|
||||
event_type,
|
||||
callback_context.get("manager_deployment_id")
|
||||
or callback_context.get("heicode_deployment_id")
|
||||
or self.swarm.swarm_id,
|
||||
swarm_id=self.swarm.swarm_id,
|
||||
agent_instance_id=agent_instance_id,
|
||||
correlation_id=self.correlation_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def _get_agent_record(self, agent_id: str) -> Optional[SwarmAgent]:
|
||||
"""Load a swarm agent record by runtime agent ID."""
|
||||
return (
|
||||
self.db.query(SwarmAgent)
|
||||
.filter(SwarmAgent.swarm_id == self.swarm_id, SwarmAgent.agent_id == agent_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _build_agent_task(self, agent: Optional[SwarmAgent]) -> str:
|
||||
"""Create a role-aware task prompt for ordinary sub-mode agents."""
|
||||
if not agent or not self.swarm:
|
||||
return self.swarm.task_description if self.swarm else ""
|
||||
project_context = self._callback_context()
|
||||
prompt_parts = [
|
||||
f"Task objective: {self.swarm.task_description}",
|
||||
f"Your role: {agent.role}",
|
||||
"Return a concrete deliverable summary suitable for Heicode Manager artifacts.",
|
||||
]
|
||||
if project_context.get("repo_url"):
|
||||
prompt_parts.append(f"Repository: {project_context['repo_url']}")
|
||||
if project_context.get("branch"):
|
||||
prompt_parts.append(f"Branch: {project_context['branch']}")
|
||||
if project_context.get("agile_context"):
|
||||
prompt_parts.append(
|
||||
"Agile context: "
|
||||
+ json.dumps(project_context["agile_context"], ensure_ascii=False, sort_keys=True)
|
||||
)
|
||||
return "\n".join(prompt_parts)
|
||||
|
||||
def _response_summary(self, response: Any) -> str:
|
||||
"""Return a readable response summary for logs, artifacts, and callbacks."""
|
||||
text = self._find_text(response)
|
||||
if text:
|
||||
return text[:1000]
|
||||
if isinstance(response, (dict, list)):
|
||||
return json.dumps(response, ensure_ascii=False)[:1000]
|
||||
return str(response)[:1000]
|
||||
|
||||
def _find_text(self, value: Any) -> Optional[str]:
|
||||
"""Recursively extract the first meaningful text payload from agent responses."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
if isinstance(value, dict):
|
||||
for key in ("text", "content", "message", "output", "result"):
|
||||
if key in value:
|
||||
nested = self._find_text(value[key])
|
||||
if nested:
|
||||
return nested
|
||||
for nested_value in value.values():
|
||||
nested = self._find_text(nested_value)
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
nested = self._find_text(item)
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
|
||||
def _extract_usage(self, response: Any) -> Dict[str, int]:
|
||||
"""Extract best-effort token usage from nested agent responses."""
|
||||
usage = self._find_usage_dict(response) or {}
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
|
||||
total_tokens = int(usage.get("total_tokens") or prompt_tokens + completion_tokens)
|
||||
return {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
|
||||
def _find_usage_dict(self, value: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Find a nested usage-like dict containing token counters."""
|
||||
if isinstance(value, dict):
|
||||
keys = set(value.keys())
|
||||
if keys.intersection({"prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens"}):
|
||||
return value
|
||||
for nested in value.values():
|
||||
usage = self._find_usage_dict(nested)
|
||||
if usage:
|
||||
return usage
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
usage = self._find_usage_dict(item)
|
||||
if usage:
|
||||
return usage
|
||||
return None
|
||||
|
||||
def _build_artifact(self, agent: SwarmAgent, response: Any, index: int) -> Dict[str, Any]:
|
||||
"""Convert an agent response into a Heicode-visible artifact record."""
|
||||
role = agent.role or "worker"
|
||||
artifact_type = "code_patch" if role in {"backend", "frontend", "coder", "engineer"} else "document"
|
||||
return {
|
||||
"artifact_id": f"art_{self.swarm_id}_{role}_{index + 1}",
|
||||
"artifact_type": artifact_type,
|
||||
"title": f"{role} task delivery",
|
||||
"summary": self._response_summary(response),
|
||||
"uri": f"runtime://{self.swarm_id}/artifacts/{role}-{index + 1}",
|
||||
"agent_instance_id": agent.agent_id,
|
||||
"mime_type": "text/plain",
|
||||
"stage": "development",
|
||||
"checkpoint": "artifact_ready",
|
||||
"metadata": {
|
||||
"redacted": True,
|
||||
"agent_role": role,
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
},
|
||||
}
|
||||
|
||||
def _finalize_running_agents(self, terminal_status: SwarmAgentStatus, output: Optional[str] = None) -> None:
|
||||
"""Ensure swarm-level completion/failure matches per-agent terminal states."""
|
||||
agents = self.db.query(SwarmAgent).filter(SwarmAgent.swarm_id == self.swarm_id).all()
|
||||
for agent in agents:
|
||||
if agent.status == SwarmAgentStatus.RUNNING:
|
||||
agent.status = terminal_status
|
||||
agent.current_task = None
|
||||
if output and not agent.output:
|
||||
agent.output = output[:1000]
|
||||
|
||||
+317
-55
@@ -6,8 +6,8 @@ import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import AsyncGenerator
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from typing import AsyncGenerator, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,11 +17,13 @@ from database import (
|
||||
)
|
||||
from .models import (
|
||||
SwarmCreateRequest, SwarmCreateResponse, SwarmStatusResponse,
|
||||
SwarmStopRequest, SwarmStopResponse, SwarmAgentInfo, SwarmMetrics
|
||||
SwarmStopRequest, SwarmStopResponse, SwarmAgentInfo, SwarmMetrics,
|
||||
ApprovalDecisionRequest
|
||||
)
|
||||
from .orchestrator import SwarmOrchestrator
|
||||
|
||||
router = APIRouter(prefix="/api/swarm", tags=["swarm"])
|
||||
swarms_router = APIRouter(prefix="/api/swarms", tags=["swarms"])
|
||||
|
||||
|
||||
def generate_swarm_id() -> str:
|
||||
@@ -34,6 +36,69 @@ def generate_agent_id(role: str) -> str:
|
||||
return f"agi_{role}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _agent_infos_for_swarm(db: Session, swarm_id: str) -> list[SwarmAgentInfo]:
|
||||
"""Build response agent summaries for a swarm."""
|
||||
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
|
||||
return [
|
||||
SwarmAgentInfo(
|
||||
agent_id=agent.agent_id,
|
||||
role=agent.role,
|
||||
status=agent.status.value,
|
||||
namespace=agent.namespace,
|
||||
service_url=agent.service_url,
|
||||
current_task=agent.current_task,
|
||||
output=agent.output,
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
|
||||
|
||||
def _build_swarm_status_response(db: Session, swarm: Swarm) -> SwarmStatusResponse:
|
||||
"""Return a standard status payload for /api/swarm and /api/swarms."""
|
||||
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
|
||||
return SwarmStatusResponse(
|
||||
deployment_id=swarm.swarm_id,
|
||||
swarm_id=swarm.swarm_id,
|
||||
status=swarm.status.value,
|
||||
phase=swarm.phase,
|
||||
progress=swarm.progress,
|
||||
agents=_agent_infos_for_swarm(db, swarm.swarm_id),
|
||||
metrics=SwarmMetrics(
|
||||
total_messages=swarm.total_messages,
|
||||
tokens_used=swarm.tokens_used,
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
),
|
||||
artifacts=swarm.artifacts,
|
||||
error_message=swarm.error_message,
|
||||
created_at=swarm.created_at,
|
||||
updated_at=swarm.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _stop_swarm_record(db: Session, swarm_id: str, request: SwarmStopRequest) -> SwarmStopResponse:
|
||||
"""Idempotently stop a swarm database record."""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
|
||||
stopped_at = datetime.utcnow()
|
||||
if swarm.status != SwarmStatus.STOPPED:
|
||||
swarm.status = SwarmStatus.STOPPED
|
||||
swarm.error_message = request.reason
|
||||
swarm.updated_at = stopped_at
|
||||
db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).update(
|
||||
{"status": SwarmAgentStatus.FAILED if request.reason else SwarmAgentStatus.COMPLETED}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return SwarmStopResponse(
|
||||
deployment_id=swarm_id,
|
||||
swarm_id=swarm_id,
|
||||
status=SwarmStatus.STOPPED.value,
|
||||
stopped_at=stopped_at,
|
||||
)
|
||||
|
||||
|
||||
async def initialize_and_execute_swarm(swarm_id: str, db_url: str):
|
||||
"""
|
||||
Background task to initialize and execute swarm.
|
||||
@@ -87,11 +152,15 @@ async def create_swarm(
|
||||
"""
|
||||
swarm_id = generate_swarm_id()
|
||||
|
||||
project_context = request.project_context.dict() if request.project_context else {}
|
||||
if request.callback:
|
||||
project_context["_callback"] = request.callback.dict(exclude_none=True)
|
||||
|
||||
# Create swarm record
|
||||
swarm = Swarm(
|
||||
swarm_id=swarm_id,
|
||||
task_description=request.task_description,
|
||||
project_context=request.project_context.dict() if request.project_context else {},
|
||||
project_context=project_context,
|
||||
orchestration_strategy=request.orchestration.strategy,
|
||||
max_iterations=request.orchestration.max_iterations,
|
||||
timeout_minutes=request.orchestration.timeout_minutes,
|
||||
@@ -143,6 +212,7 @@ async def create_swarm(
|
||||
background_tasks.add_task(initialize_and_execute_swarm, swarm_id, DATABASE_URL)
|
||||
|
||||
return SwarmCreateResponse(
|
||||
deployment_id=swarm_id,
|
||||
swarm_id=swarm_id,
|
||||
status=swarm.status.value,
|
||||
agents=agent_infos,
|
||||
@@ -151,6 +221,103 @@ async def create_swarm(
|
||||
)
|
||||
|
||||
|
||||
@swarms_router.post("", response_model=SwarmCreateResponse)
|
||||
async def create_swarm_compat(
|
||||
payload: Dict[str, Any],
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Compatibility entrypoint for Heicode sub-mode Runtime adapters.
|
||||
|
||||
Accepts the Manager's structured orchestration_plan payload and maps it to
|
||||
the existing swarm creation path without forcing Manager to call
|
||||
/api/swarm/create directly.
|
||||
"""
|
||||
if payload.get("dry_run") is True:
|
||||
raise HTTPException(status_code=422, detail="dry_run is not supported by Runtime create; no swarm was created")
|
||||
|
||||
plan = payload.get("orchestration_plan")
|
||||
if not isinstance(plan, dict):
|
||||
raise HTTPException(status_code=422, detail="orchestration_plan must be an object")
|
||||
if not plan.get("sub_mode"):
|
||||
raise HTTPException(status_code=422, detail="orchestration_plan.sub_mode is required")
|
||||
if plan.get("sub_mode") not in {"agile", "waterfall"}:
|
||||
raise HTTPException(status_code=422, detail="orchestration_plan.sub_mode must be agile or waterfall")
|
||||
if not isinstance(plan.get("user_context"), dict) or not plan["user_context"].get("user_id"):
|
||||
raise HTTPException(status_code=422, detail="orchestration_plan.user_context.user_id is required")
|
||||
|
||||
callback = payload.get("callback") or plan.get("callback")
|
||||
if not isinstance(callback, dict) or not callback.get("url"):
|
||||
raise HTTPException(status_code=422, detail="callback.url is required")
|
||||
|
||||
idempotency_key = request.headers.get("X-Idempotency-Key") or request.headers.get("Idempotency-Key")
|
||||
if idempotency_key:
|
||||
existing = None
|
||||
for candidate in db.query(Swarm).order_by(Swarm.created_at.asc()).all():
|
||||
context = candidate.project_context or {}
|
||||
if context.get("idempotency_key") == idempotency_key:
|
||||
existing = candidate
|
||||
break
|
||||
if existing:
|
||||
return _build_swarm_status_response(db, existing)
|
||||
|
||||
agents = plan.get("agents") or payload.get("agents") or [
|
||||
{"role": "backend", "capabilities": ["code", "test"]},
|
||||
{"role": "frontend", "capabilities": ["ui", "test"]},
|
||||
{"role": "reviewer", "capabilities": ["review"]},
|
||||
]
|
||||
budget = plan.get("budget") or {}
|
||||
agile_context = plan.get("agile_context") or payload.get("agile_context") or {}
|
||||
user_context = plan.get("user_context") or {}
|
||||
metadata = plan.get("metadata") or payload.get("metadata") or {}
|
||||
project_context = plan.get("project_context") or {}
|
||||
if not isinstance(project_context, dict):
|
||||
project_context = {}
|
||||
project_context = {
|
||||
**project_context,
|
||||
"intent_id": plan.get("intent_id"),
|
||||
"template_hint": plan.get("template_hint"),
|
||||
"binding_scope": user_context.get("binding_scope"),
|
||||
"sub_mode": plan.get("sub_mode", "agile"),
|
||||
"agile_context": agile_context,
|
||||
"budget": budget,
|
||||
"billing_context": plan.get("billing_context") or payload.get("billing_context") or {},
|
||||
"resource_grants": plan.get("resource_grants") or payload.get("resource_grants") or [],
|
||||
"idempotency_key": idempotency_key,
|
||||
"correlation_id": metadata.get("correlation_id") or payload.get("correlation_id"),
|
||||
"manager_deployment_id": payload.get("deployment_id")
|
||||
or metadata.get("manager_deployment_id")
|
||||
or metadata.get("heicode_deployment_id"),
|
||||
"heicode_deployment_id": payload.get("deployment_id")
|
||||
or metadata.get("heicode_deployment_id")
|
||||
or metadata.get("manager_deployment_id"),
|
||||
}
|
||||
|
||||
swarm_request = SwarmCreateRequest(
|
||||
task_description=plan.get("objective") or plan.get("intent_id") or "Heicode sub-mode task",
|
||||
project_context=project_context,
|
||||
agents=agents,
|
||||
orchestration={
|
||||
"strategy": "sequential" if plan.get("sub_mode", "agile") == "waterfall" else "hybrid",
|
||||
"max_iterations": agile_context.get("max_iterations", 3),
|
||||
"timeout_minutes": max(1, int((budget.get("max_duration_sec") or 1800) / 60)),
|
||||
},
|
||||
callback=callback,
|
||||
owner_id=str(user_context.get("user_id") or payload.get("owner_id") or "default"),
|
||||
)
|
||||
return await create_swarm(swarm_request, background_tasks, db)
|
||||
|
||||
|
||||
@swarms_router.get("/{swarm_id}", response_model=SwarmStatusResponse)
|
||||
async def get_swarm_detail_compat(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"""Compatibility detail endpoint for Manager Runtime bridge."""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
return _build_swarm_status_response(db, swarm)
|
||||
|
||||
|
||||
@router.get("/{swarm_id}/status", response_model=SwarmStatusResponse)
|
||||
async def get_swarm_status(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
@@ -167,38 +334,16 @@ async def get_swarm_status(swarm_id: str, db: Session = Depends(get_db)):
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
|
||||
# Get all agents
|
||||
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
|
||||
agent_infos = [
|
||||
SwarmAgentInfo(
|
||||
agent_id=agent.agent_id,
|
||||
role=agent.role,
|
||||
status=agent.status.value,
|
||||
namespace=agent.namespace,
|
||||
service_url=agent.service_url,
|
||||
current_task=agent.current_task,
|
||||
output=agent.output
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
return _build_swarm_status_response(db, swarm)
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
|
||||
|
||||
return SwarmStatusResponse(
|
||||
swarm_id=swarm.swarm_id,
|
||||
status=swarm.status.value,
|
||||
phase=swarm.phase,
|
||||
progress=swarm.progress,
|
||||
agents=agent_infos,
|
||||
metrics=SwarmMetrics(
|
||||
total_messages=swarm.total_messages,
|
||||
tokens_used=swarm.tokens_used,
|
||||
elapsed_seconds=elapsed_seconds
|
||||
),
|
||||
artifacts=swarm.artifacts,
|
||||
error_message=swarm.error_message
|
||||
)
|
||||
@swarms_router.get("/{swarm_id}/status", response_model=SwarmStatusResponse)
|
||||
async def get_swarm_status_compat(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"""Compatibility status endpoint for Manager Runtime bridge."""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
return _build_swarm_status_response(db, swarm)
|
||||
|
||||
|
||||
@router.get("/{swarm_id}/results")
|
||||
@@ -296,27 +441,17 @@ async def stop_swarm(
|
||||
Returns:
|
||||
Stop response
|
||||
"""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
return _stop_swarm_record(db, swarm_id, request)
|
||||
|
||||
# Update swarm status
|
||||
swarm.status = SwarmStatus.STOPPED
|
||||
swarm.error_message = request.reason
|
||||
stopped_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: If cleanup=True, delete K8s resources
|
||||
if request.cleanup:
|
||||
# This will be implemented in Phase 2 with K8s manager
|
||||
pass
|
||||
|
||||
return SwarmStopResponse(
|
||||
swarm_id=swarm_id,
|
||||
status=swarm.status.value,
|
||||
stopped_at=stopped_at
|
||||
)
|
||||
@swarms_router.post("/{swarm_id}/stop", response_model=SwarmStopResponse)
|
||||
async def stop_swarm_compat(
|
||||
swarm_id: str,
|
||||
request: SwarmStopRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Compatibility stop endpoint used by Heicode Manager."""
|
||||
return _stop_swarm_record(db, swarm_id, request)
|
||||
|
||||
|
||||
@router.get("/{swarm_id}/logs")
|
||||
@@ -342,14 +477,141 @@ async def get_swarm_logs(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"agents": []
|
||||
}
|
||||
|
||||
messages_by_agent = {}
|
||||
swarm_messages = (
|
||||
db.query(SwarmMessage)
|
||||
.filter(SwarmMessage.swarm_id == swarm_id)
|
||||
.order_by(SwarmMessage.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
for message in swarm_messages:
|
||||
related_agent_id = message.from_agent_id or message.to_agent_id
|
||||
if not related_agent_id:
|
||||
continue
|
||||
messages_by_agent.setdefault(related_agent_id, []).append(message)
|
||||
|
||||
for agent in agents:
|
||||
# TODO: Fetch actual logs from K8s
|
||||
agent_messages = messages_by_agent.get(agent.agent_id, [])
|
||||
log_lines = [
|
||||
f"status={agent.status.value}",
|
||||
f"role={agent.role}",
|
||||
]
|
||||
if agent.current_task:
|
||||
log_lines.append(f"current_task={agent.current_task}")
|
||||
if agent.output:
|
||||
log_lines.append(f"last_output={agent.output[:500]}")
|
||||
if agent_messages:
|
||||
log_lines.extend(
|
||||
f"{message.created_at.isoformat()} {message.message_type}: {message.content[:300]}"
|
||||
for message in agent_messages[-5:]
|
||||
)
|
||||
else:
|
||||
log_lines.append("no_runtime_messages_recorded")
|
||||
|
||||
logs["agents"].append({
|
||||
"agent_id": agent.agent_id,
|
||||
"role": agent.role,
|
||||
"namespace": agent.namespace,
|
||||
"pod_name": agent.pod_name,
|
||||
"logs": "Logs will be fetched from K8s in Phase 2"
|
||||
"logs": "\n".join(log_lines),
|
||||
})
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
@swarms_router.get("/{swarm_id}/logs")
|
||||
async def get_swarm_logs_compat(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"""Compatibility logs endpoint under /api/swarms."""
|
||||
return await get_swarm_logs(swarm_id, db)
|
||||
|
||||
|
||||
@swarms_router.get("/{swarm_id}/events")
|
||||
async def get_swarm_events_compat(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"""Return swarm messages as Runtime events for Manager polling fallback."""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
|
||||
messages = (
|
||||
db.query(SwarmMessage)
|
||||
.filter(SwarmMessage.swarm_id == swarm_id)
|
||||
.order_by(SwarmMessage.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
events = [
|
||||
{
|
||||
"event_id": message.message_id,
|
||||
"event_type": f"swarm.message.{message.message_type}",
|
||||
"swarm_id": swarm_id,
|
||||
"agent_instance_id": message.from_agent_id or message.to_agent_id,
|
||||
"occurred_at": message.created_at,
|
||||
"payload": {
|
||||
"from_agent_id": message.from_agent_id,
|
||||
"to_agent_id": message.to_agent_id,
|
||||
"message_type": message.message_type,
|
||||
"summary": message.content[:300] if message.content else None,
|
||||
"metadata": message.message_metadata or {},
|
||||
},
|
||||
}
|
||||
for message in messages
|
||||
]
|
||||
return {"success": True, "swarm_id": swarm_id, "events": events}
|
||||
|
||||
|
||||
@swarms_router.get("/{swarm_id}/metrics")
|
||||
async def get_swarm_metrics_compat(swarm_id: str, db: Session = Depends(get_db)):
|
||||
"""Return basic Runtime metrics for Manager polling fallback."""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
|
||||
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
|
||||
return {
|
||||
"success": True,
|
||||
"swarm_id": swarm_id,
|
||||
"status": swarm.status.value,
|
||||
"stage": swarm.phase,
|
||||
"checkpoint": "completed" if swarm.status == SwarmStatus.COMPLETED else "agent_running",
|
||||
"metrics": {
|
||||
"tokens_used": swarm.tokens_used,
|
||||
"duration_ms": elapsed_seconds * 1000,
|
||||
"artifact_count": len(swarm.artifacts or []),
|
||||
"total_messages": swarm.total_messages,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@swarms_router.post("/{swarm_id}/approvals/{approval_id}")
|
||||
async def receive_swarm_approval_decision(
|
||||
swarm_id: str,
|
||||
approval_id: str,
|
||||
request: ApprovalDecisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Accept Manager approval decisions for paused high-risk swarm actions."""
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(status_code=404, detail="Swarm not found")
|
||||
if request.approval_id != approval_id:
|
||||
raise HTTPException(status_code=422, detail="approval_id path/body mismatch")
|
||||
if request.decision not in {"approved", "rejected"}:
|
||||
raise HTTPException(status_code=422, detail="decision must be approved or rejected")
|
||||
|
||||
message = SwarmMessage(
|
||||
message_id=f"appr_{uuid.uuid4().hex[:12]}",
|
||||
swarm_id=swarm_id,
|
||||
from_agent_id=None,
|
||||
to_agent_id=None,
|
||||
message_type="approval_decision",
|
||||
content=request.decision,
|
||||
message_metadata=request.model_dump(exclude_none=True),
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"swarm_id": swarm_id,
|
||||
"approval_id": approval_id,
|
||||
"decision": request.decision,
|
||||
"status": "accepted",
|
||||
}
|
||||
|
||||
@@ -18,20 +18,9 @@ spec:
|
||||
# 使用专用的 ServiceAccount
|
||||
serviceAccountName: agent-manager
|
||||
|
||||
# ARM 架构节点选择器
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
|
||||
# 容忍度(如果需要)
|
||||
tolerations:
|
||||
- key: "kubernetes.io/arch"
|
||||
operator: "Equal"
|
||||
value: "arm64"
|
||||
effect: "NoSchedule"
|
||||
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:latest
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-20260529120632
|
||||
imagePullPolicy: Always
|
||||
|
||||
ports:
|
||||
@@ -46,6 +35,11 @@ spec:
|
||||
|
||||
# 环境变量 - 从 Secret
|
||||
env:
|
||||
- name: HEICODE_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: agent-manager-secret
|
||||
key: HEICODE_SERVICE_TOKEN
|
||||
- name: AZURE_TENANT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -72,6 +66,12 @@ spec:
|
||||
secretKeyRef:
|
||||
name: agent-manager-secret
|
||||
key: GITEE_PASSWORD
|
||||
# Vault 凭据 (Phase 5)
|
||||
- name: VAULT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: agent-manager-secret
|
||||
key: VAULT_TOKEN
|
||||
|
||||
# 挂载 kubeconfig(用于管理其他 Agent)
|
||||
volumeMounts:
|
||||
|
||||
Reference in New Issue
Block a user