Fix ordinary sub artifact callbacks

This commit is contained in:
elipitc
2026-05-29 20:33:04 +08:00
parent d0011a8c79
commit bef6f71bb2
6 changed files with 1949 additions and 76 deletions
+530
View File
@@ -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}
+438
View File
@@ -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
+190
View File
@@ -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
View File
@@ -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
View File
@@ -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",
}
+12 -12
View File
@@ -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: