arcisvlm / agents /protocol.py
Hardik Sanghvi
feat: integrate Gemma 4 E2B backbone for production-quality VLM inference
7a564e3
Raw
History Blame Contribute Delete
12.9 kB
"""
Agent Communication Protocol — message types, queues, and metrics for ArcisVLM agents.
Provides:
- TaskMessage / ResultMessage / HeartbeatMessage for inter-agent communication
- JSON serialization round-tripping
- MessageQueue interface with an in-memory priority-queue implementation
- AgentMetrics for tracking latency, throughput, queue depth, and expert activation
"""
from __future__ import annotations
import json
import time
import heapq
import threading
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from typing import Any
from agents.base import Task, Result, PRIORITY_ORDER
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Message types
# ---------------------------------------------------------------------------
@dataclass
class TaskMessage:
"""Wraps a Task for routing between agents."""
task: Task
sender_id: str
target_agent_type: str # expert type the task should be routed to
timestamp: float = field(default_factory=time.time)
message_id: str = ""
def __post_init__(self) -> None:
if not self.message_id:
self.message_id = f"tmsg-{int(self.timestamp * 1000)}-{id(self) % 0xFFFF:04x}"
def to_json(self) -> str:
return json.dumps({
"type": "task",
"message_id": self.message_id,
"sender_id": self.sender_id,
"target_agent_type": self.target_agent_type,
"timestamp": self.timestamp,
"task": {
"task_id": self.task.task_id,
"type": self.task.type,
"payload": self.task.payload,
"priority": self.task.priority,
"deadline_ms": self.task.deadline_ms,
"source": self.task.source,
"expert_hint": self.task.expert_hint,
"created_at": self.task.created_at,
},
})
@classmethod
def from_json(cls, raw: str) -> TaskMessage:
d = json.loads(raw)
task_data = d["task"]
task = Task(
type=task_data["type"],
payload=task_data["payload"],
priority=task_data["priority"],
deadline_ms=task_data["deadline_ms"],
source=task_data["source"],
expert_hint=task_data["expert_hint"],
task_id=task_data["task_id"],
created_at=task_data["created_at"],
)
return cls(
task=task,
sender_id=d["sender_id"],
target_agent_type=d["target_agent_type"],
timestamp=d["timestamp"],
message_id=d["message_id"],
)
@dataclass
class ResultMessage:
"""Wraps a Result returned from a child agent back to the Mother orchestrator."""
result: Result
sender_id: str
timestamp: float = field(default_factory=time.time)
message_id: str = ""
def __post_init__(self) -> None:
if not self.message_id:
self.message_id = f"rmsg-{int(self.timestamp * 1000)}-{id(self) % 0xFFFF:04x}"
def to_json(self) -> str:
return json.dumps({
"type": "result",
"message_id": self.message_id,
"sender_id": self.sender_id,
"timestamp": self.timestamp,
"result": {
"answer": self.result.answer,
"confidence": self.result.confidence,
"processing_time_ms": self.result.processing_time_ms,
"expert_used": self.result.expert_used,
"metadata": self.result.metadata,
"task_id": self.result.task_id,
"agent_id": self.result.agent_id,
"error": self.result.error,
},
})
@classmethod
def from_json(cls, raw: str) -> ResultMessage:
d = json.loads(raw)
rd = d["result"]
result = Result(
answer=rd["answer"],
confidence=rd["confidence"],
processing_time_ms=rd["processing_time_ms"],
expert_used=rd["expert_used"],
metadata=rd["metadata"],
task_id=rd["task_id"],
agent_id=rd["agent_id"],
error=rd.get("error"),
)
return cls(
result=result,
sender_id=d["sender_id"],
timestamp=d["timestamp"],
message_id=d["message_id"],
)
@dataclass
class HeartbeatMessage:
"""Periodic health signal from an agent."""
agent_id: str
agent_type: str
status: str # "idle" | "processing" | "error"
tasks_processed: int = 0
avg_latency_ms: float = 0.0
timestamp: float = field(default_factory=time.time)
message_id: str = ""
def __post_init__(self) -> None:
if not self.message_id:
self.message_id = f"hb-{int(self.timestamp * 1000)}-{id(self) % 0xFFFF:04x}"
def to_json(self) -> str:
return json.dumps({
"type": "heartbeat",
"message_id": self.message_id,
"agent_id": self.agent_id,
"agent_type": self.agent_type,
"status": self.status,
"tasks_processed": self.tasks_processed,
"avg_latency_ms": self.avg_latency_ms,
"timestamp": self.timestamp,
})
@classmethod
def from_json(cls, raw: str) -> HeartbeatMessage:
d = json.loads(raw)
return cls(
agent_id=d["agent_id"],
agent_type=d["agent_type"],
status=d["status"],
tasks_processed=d["tasks_processed"],
avg_latency_ms=d["avg_latency_ms"],
timestamp=d["timestamp"],
message_id=d["message_id"],
)
# ---------------------------------------------------------------------------
# Generic message deserialization
# ---------------------------------------------------------------------------
def deserialize_message(raw: str) -> TaskMessage | ResultMessage | HeartbeatMessage:
"""Deserialize a JSON string into the appropriate message type."""
d = json.loads(raw)
msg_type = d.get("type")
if msg_type == "task":
return TaskMessage.from_json(raw)
elif msg_type == "result":
return ResultMessage.from_json(raw)
elif msg_type == "heartbeat":
return HeartbeatMessage.from_json(raw)
else:
raise ValueError(f"Unknown message type: {msg_type}")
# ---------------------------------------------------------------------------
# Message Queue — abstract interface + in-memory implementation
# ---------------------------------------------------------------------------
class MessageQueue(ABC):
"""Abstract message queue that routes TaskMessages between agents."""
@abstractmethod
def put(self, message: TaskMessage) -> None:
"""Enqueue a task message."""
...
@abstractmethod
def get(self, agent_type: str, timeout_s: float | None = None) -> TaskMessage | None:
"""
Dequeue the highest-priority TaskMessage for the given agent type.
Returns None if no message is available within the timeout.
"""
...
@abstractmethod
def depth(self, agent_type: str | None = None) -> int:
"""
Return number of pending messages.
If agent_type is specified, return depth for that type only.
"""
...
class InMemoryQueue(MessageQueue):
"""
Thread-safe, priority-ordered in-memory message queue.
Messages are stored per agent_type in min-heaps keyed by (priority_rank, timestamp).
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._condition = threading.Condition(self._lock)
# agent_type -> list of (priority_rank, timestamp, TaskMessage)
self._queues: dict[str, list[tuple[int, float, TaskMessage]]] = {}
self._total_enqueued = 0
self._total_dequeued = 0
def put(self, message: TaskMessage) -> None:
with self._condition:
agent_type = message.target_agent_type
if agent_type not in self._queues:
self._queues[agent_type] = []
priority_rank = PRIORITY_ORDER.get(message.task.priority, 1)
heapq.heappush(
self._queues[agent_type],
(priority_rank, message.timestamp, message),
)
self._total_enqueued += 1
self._condition.notify_all()
def get(self, agent_type: str, timeout_s: float | None = None) -> TaskMessage | None:
deadline = None if timeout_s is None else time.monotonic() + timeout_s
with self._condition:
while True:
q = self._queues.get(agent_type, [])
if q:
_, _, message = heapq.heappop(q)
self._total_dequeued += 1
return message
if timeout_s is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
self._condition.wait(timeout=remaining)
else:
return None
def depth(self, agent_type: str | None = None) -> int:
with self._lock:
if agent_type is not None:
return len(self._queues.get(agent_type, []))
return sum(len(q) for q in self._queues.values())
@property
def total_enqueued(self) -> int:
return self._total_enqueued
@property
def total_dequeued(self) -> int:
return self._total_dequeued
# ---------------------------------------------------------------------------
# Metrics tracker
# ---------------------------------------------------------------------------
class AgentMetrics:
"""
Collects operational metrics across the agent system.
Thread-safe. Intended to be a singleton owned by the Mother orchestrator.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
# Per-agent latency tracking: agent_id -> list of latencies in ms
self._latencies: dict[str, list[float]] = {}
# Per-expert activation counts
self._expert_activations: dict[str, int] = {}
# Task counters
self._tasks_submitted = 0
self._tasks_completed = 0
self._tasks_failed = 0
# -- Recording -----------------------------------------------------------
def record_task_submitted(self) -> None:
with self._lock:
self._tasks_submitted += 1
def record_task_completed(self, agent_id: str, expert_used: str, latency_ms: float) -> None:
with self._lock:
self._tasks_completed += 1
self._latencies.setdefault(agent_id, []).append(latency_ms)
self._expert_activations[expert_used] = self._expert_activations.get(expert_used, 0) + 1
def record_task_failed(self) -> None:
with self._lock:
self._tasks_failed += 1
# -- Queries -------------------------------------------------------------
def avg_latency_ms(self, agent_id: str | None = None) -> float:
"""Average latency. If agent_id is None, compute across all agents."""
with self._lock:
if agent_id is not None:
lats = self._latencies.get(agent_id, [])
else:
lats = [l for vals in self._latencies.values() for l in vals]
return sum(lats) / len(lats) if lats else 0.0
def p95_latency_ms(self, agent_id: str | None = None) -> float:
"""P95 latency."""
with self._lock:
if agent_id is not None:
lats = sorted(self._latencies.get(agent_id, []))
else:
lats = sorted(l for vals in self._latencies.values() for l in vals)
if not lats:
return 0.0
idx = int(0.95 * len(lats))
return lats[min(idx, len(lats) - 1)]
def expert_activation_counts(self) -> dict[str, int]:
with self._lock:
return dict(self._expert_activations)
def queue_depth(self, queue: MessageQueue, agent_type: str | None = None) -> int:
return queue.depth(agent_type)
def summary(self, queue: MessageQueue | None = None) -> dict[str, Any]:
"""Return a snapshot dict of all metrics."""
with self._lock:
result: dict[str, Any] = {
"tasks_submitted": self._tasks_submitted,
"tasks_completed": self._tasks_completed,
"tasks_failed": self._tasks_failed,
"avg_latency_ms": self.avg_latency_ms(),
"p95_latency_ms": self.p95_latency_ms(),
"expert_activations": dict(self._expert_activations),
}
if queue is not None:
result["queue_depth"] = queue.depth()
return result