SNAPKITTYWEST's picture
push from SNAPKITTYWEST/sovereign-engine-v2
9abace2 verified
Raw
History Blame Contribute Delete
8.72 kB
"""
Stage 10 + 11: AgentDispatch + MergeOutput
AgentDispatch:
Takes the sparse routing weights and fires the active experts.
Each expert is an async callable: (input, context) -> output.
Experts run concurrently (asyncio.gather).
Timeout per expert — stalled experts are dropped, not waited on.
MergeOutput:
Combines expert outputs using their routing weights.
Strategy options:
- weighted_concat: concatenate with weight annotation
- weighted_avg: average numeric fields by weight
- highest_weight: return only the top-weighted output
- ensemble_text: join text outputs with weight-proportional prominence
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
from .sparse import RoutingWeights
# ==========================================
# Expert type
# ==========================================
Expert = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
# ==========================================
# Dispatch
# ==========================================
@dataclass
class ExpertResult:
expert_name: str
weight: float
output: dict[str, Any] | None
success: bool
error: str | None = None
latency_ms: float = 0.0
@dataclass
class DispatchResult:
expert_results: list[ExpertResult]
active_count: int
success_count: int
failed_experts: list[str]
routing_weights: RoutingWeights
merged_output: dict[str, Any]
class AgentDispatch:
"""
Stage 10: Fire active experts concurrently.
"""
def __init__(
self,
experts: dict[str, Expert],
timeout_ms: int = 30000,
merge_strategy: str = "weighted_concat"
):
self.experts = experts
self.timeout_s = timeout_ms / 1000.0
self.merge_strategy = merge_strategy
self._merger = MergeOutput()
async def dispatch(
self,
input_text: str,
context: dict[str, Any],
routing: RoutingWeights
) -> DispatchResult:
active = routing.active_experts
tasks = []
for expert_name in active:
weight = routing.weights.get(expert_name, 0.0)
if weight < 1e-6:
continue
expert_fn = self.experts.get(expert_name)
if expert_fn is None:
continue
task = self._run_expert(expert_name, weight, expert_fn, input_text, context)
tasks.append(task)
if not tasks:
return DispatchResult(
expert_results=[],
active_count=0,
success_count=0,
failed_experts=[],
routing_weights=routing,
merged_output={"error": "No active experts after dispatch"}
)
results: list[ExpertResult] = await asyncio.gather(*tasks)
failed = [r.expert_name for r in results if not r.success]
succeeded = [r for r in results if r.success]
merged = self._merger.merge(succeeded, self.merge_strategy)
return DispatchResult(
expert_results=results,
active_count=len(tasks),
success_count=len(succeeded),
failed_experts=failed,
routing_weights=routing,
merged_output=merged
)
async def _run_expert(
self,
name: str,
weight: float,
fn: Expert,
input_text: str,
context: dict[str, Any]
) -> ExpertResult:
import time
t0 = time.monotonic()
try:
output = await asyncio.wait_for(
fn(input_text, context),
timeout=self.timeout_s
)
latency = (time.monotonic() - t0) * 1000
return ExpertResult(
expert_name=name,
weight=weight,
output=output,
success=True,
latency_ms=latency
)
except asyncio.TimeoutError:
return ExpertResult(
expert_name=name,
weight=weight,
output=None,
success=False,
error=f"Timeout after {self.timeout_s}s"
)
except Exception as e:
return ExpertResult(
expert_name=name,
weight=weight,
output=None,
success=False,
error=str(e)
)
# ==========================================
# Merge Output
# ==========================================
class MergeOutput:
"""
Stage 11: Combine expert outputs using routing weights.
"""
def merge(
self,
results: list[ExpertResult],
strategy: str = "weighted_concat"
) -> dict[str, Any]:
if not results:
return {"output": None, "experts": []}
if strategy == "highest_weight":
return self._highest_weight(results)
elif strategy == "weighted_avg":
return self._weighted_avg(results)
elif strategy == "ensemble_text":
return self._ensemble_text(results)
else:
return self._weighted_concat(results)
def _weighted_concat(self, results: list[ExpertResult]) -> dict[str, Any]:
"""
Return all outputs annotated with their weights.
Agents downstream can decide how to use them.
"""
total_weight = sum(r.weight for r in results)
outputs = []
for r in sorted(results, key=lambda x: x.weight, reverse=True):
normalized_weight = r.weight / max(total_weight, 1e-6)
outputs.append({
"expert": r.expert_name,
"weight": normalized_weight,
"output": r.output,
"latency_ms": r.latency_ms
})
# Primary output = highest weighted expert's output
primary = results[0].output if results else {}
return {
"output": primary,
"expert_outputs": outputs,
"strategy": "weighted_concat",
"expert_count": len(results)
}
def _highest_weight(self, results: list[ExpertResult]) -> dict[str, Any]:
best = max(results, key=lambda r: r.weight)
return {
"output": best.output,
"expert": best.expert_name,
"weight": best.weight,
"strategy": "highest_weight"
}
def _weighted_avg(self, results: list[ExpertResult]) -> dict[str, Any]:
"""
Average numeric values across outputs, weighted by routing weight.
Non-numeric keys taken from highest-weight expert.
"""
total_weight = sum(r.weight for r in results) or 1.0
merged: dict[str, Any] = {}
# Collect all keys
all_keys: set[str] = set()
for r in results:
if r.output:
all_keys.update(r.output.keys())
for key in all_keys:
# Try weighted average of numeric values
numeric_vals = []
for r in results:
if r.output and key in r.output:
val = r.output[key]
if isinstance(val, (int, float)):
numeric_vals.append((val, r.weight))
if numeric_vals:
merged[key] = sum(v * w for v, w in numeric_vals) / total_weight
else:
# Non-numeric: take from highest-weight expert
best = max(results, key=lambda r: r.weight)
if best.output and key in best.output:
merged[key] = best.output[key]
merged["strategy"] = "weighted_avg"
return merged
def _ensemble_text(self, results: list[ExpertResult]) -> dict[str, Any]:
"""
Join text outputs in weight order.
Higher-weight experts appear first.
"""
sorted_results = sorted(results, key=lambda r: r.weight, reverse=True)
parts = []
for r in sorted_results:
if r.output:
text = r.output.get("text") or r.output.get("content") or str(r.output)
pct = int(r.weight * 100)
parts.append(f"[{r.expert_name} {pct}%]: {text}")
return {
"output": "\n\n".join(parts),
"strategy": "ensemble_text",
"expert_count": len(results)
}