chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
# OmniRoute A2A Server
|
||||
|
||||
> **Agent-to-Agent Protocol v0.3** — Enables any AI agent to use OmniRoute as an intelligent routing agent via JSON-RPC 2.0.
|
||||
|
||||
The A2A Server exposes OmniRoute as a **first-class agent** that other agents can discover, delegate tasks to, and collaborate with using the [A2A Protocol](https://google.github.io/A2A/).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Orchestrator Agent │
|
||||
│ (LangChain, CrewAI, AutoGen, Custom Agent) │
|
||||
└──────────────────────┬───────────────────────────────────────────┘
|
||||
│ 1. GET /.well-known/agent.json (discover)
|
||||
│ 2. POST /a2a (JSON-RPC 2.0)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ OmniRoute A2A Server │
|
||||
│ ┌────────────────┐ ┌────────────────┐ ┌───────────────────┐ │
|
||||
│ │ Task Manager │ │ Skill Engine │ │ SSE Streaming │ │
|
||||
│ │ (lifecycle) │──│ (registry) │──│ (real-time) │ │
|
||||
│ └────────────────┘ └────────┬───────┘ └───────────────────┘ │
|
||||
│ │ │
|
||||
│ Skills: │ │
|
||||
│ ├─ smart-routing ──────────┤ ┌────────────────────────────┐ │
|
||||
│ └─ quota-management ───────┘ │ Routing Decision Logger │ │
|
||||
│ └────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ OmniRoute Gateway (internal)
|
||||
/v1/chat/completions, /api/combos, /api/usage/quota
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Agent Discovery
|
||||
|
||||
Every A2A-compatible agent exposes an **Agent Card** at `/.well-known/agent.json`:
|
||||
|
||||
```bash
|
||||
curl http://localhost:20128/.well-known/agent.json
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "OmniRoute",
|
||||
"description": "Intelligent AI gateway with auto-routing across 50+ providers",
|
||||
"url": "http://localhost:20128/a2a",
|
||||
"version": "1.8.1",
|
||||
"capabilities": {
|
||||
"streaming": true,
|
||||
"pushNotifications": false
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"id": "smart-routing",
|
||||
"name": "Smart Routing",
|
||||
"description": "Routes prompts through OmniRoute intelligent pipeline",
|
||||
"tags": ["routing", "llm", "multi-provider", "cost-optimization"],
|
||||
"examples": [
|
||||
"Write a hello world in Python",
|
||||
"Explain quantum computing using the cheapest provider"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "quota-management",
|
||||
"name": "Quota Management",
|
||||
"description": "Natural-language queries about provider quotas",
|
||||
"tags": ["quota", "analytics", "cost"],
|
||||
"examples": [
|
||||
"Which provider has the most quota remaining?",
|
||||
"Suggest a free combo for coding"
|
||||
]
|
||||
}
|
||||
],
|
||||
"authentication": {
|
||||
"schemes": ["bearer"],
|
||||
"apiKeyHeader": "Authorization"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JSON-RPC 2.0 Methods
|
||||
|
||||
### `message/send` — Synchronous Execution
|
||||
|
||||
Send a message to a skill and receive the complete response.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"skill": "smart-routing",
|
||||
"messages": [{"role": "user", "content": "Write a Python hello world"}],
|
||||
"metadata": {"model": "auto", "combo": "fast-coding"}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"result": {
|
||||
"task": { "id": "a1b2c3d4-...", "state": "completed" },
|
||||
"artifacts": [{ "type": "text", "content": "print('Hello, World!')" }],
|
||||
"metadata": {
|
||||
"routing_explanation": "Selected claude-sonnet via provider \"anthropic\" (latency: 1200ms, cost: $0.0030)",
|
||||
"cost_envelope": { "estimated": 0.005, "actual": 0.003, "currency": "USD" },
|
||||
"resilience_trace": [
|
||||
{ "event": "primary_selected", "provider": "anthropic", "timestamp": "2026-03-04T..." }
|
||||
],
|
||||
"policy_verdict": { "allowed": true, "reason": "within budget and quota limits" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `message/stream` — SSE Streaming
|
||||
|
||||
Same as `message/send` but returns Server-Sent Events for real-time streaming.
|
||||
|
||||
```bash
|
||||
curl -N -X POST http://localhost:20128/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "message/stream",
|
||||
"params": {
|
||||
"skill": "smart-routing",
|
||||
"messages": [{"role": "user", "content": "Explain quantum computing"}]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**SSE Events:**
|
||||
|
||||
```
|
||||
data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","state":"working"},"chunk":{"type":"text","content":"Quantum computing..."}}}
|
||||
|
||||
: heartbeat 2026-03-04T21:00:00Z
|
||||
|
||||
data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","state":"completed"},"metadata":{...}}}
|
||||
```
|
||||
|
||||
### `tasks/get` — Query Task Status
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-d '{"jsonrpc":"2.0","id":"2","method":"tasks/get","params":{"taskId":"TASK_UUID"}}'
|
||||
```
|
||||
|
||||
### `tasks/cancel` — Cancel a Running Task
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-d '{"jsonrpc":"2.0","id":"3","method":"tasks/cancel","params":{"taskId":"TASK_UUID"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skills Reference
|
||||
|
||||
### `smart-routing`
|
||||
|
||||
Routes prompts through OmniRoute's intelligent pipeline with full observability.
|
||||
|
||||
**Parameters (in `metadata`):**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| --------- | -------- | ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| `model` | `string` | `"auto"` | Target model (e.g., `claude-sonnet-4`, `gpt-4o`, `auto`) |
|
||||
| `combo` | `string` | active combo | Specific combo to route through |
|
||||
| `budget` | `number` | none | Maximum cost in USD for this request |
|
||||
| `role` | `string` | none | Task role hint: `coding`, `review`, `planning`, `analysis`, `debugging`, `documentation` |
|
||||
|
||||
**Returns:**
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------------ | --------------------------------------------------------- |
|
||||
| `artifacts[].content` | The LLM response text |
|
||||
| `metadata.routing_explanation` | Human-readable explanation of routing decision |
|
||||
| `metadata.cost_envelope` | Estimated vs actual cost with currency |
|
||||
| `metadata.resilience_trace` | Array of events (primary_selected, fallback_needed, etc.) |
|
||||
| `metadata.policy_verdict` | Whether the request was allowed and why |
|
||||
|
||||
### `quota-management`
|
||||
|
||||
Answers natural-language queries about provider quotas.
|
||||
|
||||
**Query types (inferred from message content):**
|
||||
|
||||
| Query Pattern | Response Type |
|
||||
| ---------------------------------------------- | -------------------------------------------------------- |
|
||||
| Contains `"ranking"`, `"most quota"`, `"best"` | Providers ranked by remaining quota |
|
||||
| Contains `"free"`, `"suggest"` | Lists free combos or suggests free-tier providers |
|
||||
| Default | Full quota summary with warnings for low-quota providers |
|
||||
|
||||
---
|
||||
|
||||
## Task Lifecycle
|
||||
|
||||
```
|
||||
submitted ──→ working ──→ completed
|
||||
──→ failed
|
||||
──────────→ cancelled
|
||||
```
|
||||
|
||||
| State | Description |
|
||||
| ----------- | ----------------------------------------------------- |
|
||||
| `submitted` | Task created, queued for execution |
|
||||
| `working` | Skill handler is executing |
|
||||
| `completed` | Execution succeeded, artifacts available |
|
||||
| `failed` | Execution failed or task expired (TTL: 5 min default) |
|
||||
| `cancelled` | Cancelled by client via `tasks/cancel` |
|
||||
|
||||
- Terminal states: `completed`, `failed`, `cancelled` (no further transitions)
|
||||
- Expired tasks in `submitted` or `working` are auto-marked as `failed`
|
||||
- Tasks are garbage-collected after 2× TTL
|
||||
|
||||
---
|
||||
|
||||
## Client Examples
|
||||
|
||||
### Python — Orchestrator Agent
|
||||
|
||||
```python
|
||||
"""
|
||||
A2A Client — Python example.
|
||||
Discovers OmniRoute agent, sends a task, and processes the result.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
BASE_URL = "http://localhost:20128"
|
||||
API_KEY = "your-api-key"
|
||||
HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
}
|
||||
|
||||
# 1. Discover agent capabilities
|
||||
agent_card = requests.get(f"{BASE_URL}/.well-known/agent.json").json()
|
||||
print(f"Agent: {agent_card['name']} v{agent_card['version']}")
|
||||
print(f"Skills: {[s['id'] for s in agent_card['skills']]}")
|
||||
|
||||
# 2. Send a smart-routing task
|
||||
response = requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "task-1",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"skill": "smart-routing",
|
||||
"messages": [{"role": "user", "content": "Write a Python quicksort implementation"}],
|
||||
"metadata": {
|
||||
"model": "auto",
|
||||
"combo": "fast-coding",
|
||||
"budget": 0.10,
|
||||
}
|
||||
}
|
||||
})
|
||||
result = response.json()["result"]
|
||||
print(f"\n📝 Response: {result['artifacts'][0]['content'][:200]}...")
|
||||
print(f"🔀 Routing: {result['metadata']['routing_explanation']}")
|
||||
print(f"💰 Cost: ${result['metadata']['cost_envelope']['actual']}")
|
||||
print(f"🛡️ Policy: {result['metadata']['policy_verdict']['reason']}")
|
||||
|
||||
# 3. Query quota status
|
||||
quota_resp = requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "task-2",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"skill": "quota-management",
|
||||
"messages": [{"role": "user", "content": "Which provider has the most quota remaining?"}],
|
||||
}
|
||||
})
|
||||
quota_result = quota_resp.json()["result"]
|
||||
print(f"\n📊 Quota: {quota_result['artifacts'][0]['content']}")
|
||||
```
|
||||
|
||||
### TypeScript — Multi-Agent Orchestrator
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* A2A Client — TypeScript example.
|
||||
* Shows agent discovery, task delegation, and streaming.
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://localhost:20128";
|
||||
const API_KEY = "your-api-key";
|
||||
|
||||
interface JsonRpcResponse<T = any> {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
result?: T;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
async function a2aCall<T>(method: string, params: Record<string, any>): Promise<T> {
|
||||
const resp = await fetch(`${BASE_URL}/a2a`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: `${method}-${Date.now()}`,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
const json: JsonRpcResponse<T> = await resp.json();
|
||||
if (json.error) throw new Error(`[${json.error.code}] ${json.error.message}`);
|
||||
return json.result!;
|
||||
}
|
||||
|
||||
// ── Agent Discovery ──
|
||||
const agentCard = await fetch(`${BASE_URL}/.well-known/agent.json`).then((r) => r.json());
|
||||
console.log(`Connected to: ${agentCard.name} (${agentCard.skills.length} skills)`);
|
||||
|
||||
// ── Smart Routing: Send a coding task ──
|
||||
const routingResult = await a2aCall("message/send", {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "Implement a Redis cache wrapper in TypeScript" }],
|
||||
metadata: { model: "claude-sonnet-4", role: "coding" },
|
||||
});
|
||||
console.log("Response:", routingResult.artifacts[0].content);
|
||||
console.log("Provider:", routingResult.metadata.routing_explanation);
|
||||
|
||||
// ── Quota Management: Find free alternatives ──
|
||||
const quotaResult = await a2aCall("message/send", {
|
||||
skill: "quota-management",
|
||||
messages: [{ role: "user", content: "Suggest free combos for documentation" }],
|
||||
});
|
||||
console.log("Free combos:", quotaResult.artifacts[0].content);
|
||||
|
||||
// ── Streaming: Real-time response ──
|
||||
const streamResp = await fetch(`${BASE_URL}/a2a`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "stream-1",
|
||||
method: "message/stream",
|
||||
params: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "Explain microservices architecture" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const reader = streamResp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
for (const line of chunk.split("\n")) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.params.chunk) {
|
||||
process.stdout.write(event.params.chunk.content);
|
||||
}
|
||||
if (event.params.task.state === "completed") {
|
||||
console.log("\n✅ Stream completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python — LangChain A2A Integration
|
||||
|
||||
```python
|
||||
"""
|
||||
LangChain integration — Use OmniRoute A2A as a custom LLM.
|
||||
"""
|
||||
from langchain.llms.base import BaseLLM
|
||||
from langchain.schema import LLMResult, Generation
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
|
||||
class OmniRouteA2A(BaseLLM):
|
||||
base_url: str = "http://localhost:20128"
|
||||
api_key: str = ""
|
||||
model: str = "auto"
|
||||
combo: Optional[str] = None
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "omniroute-a2a"
|
||||
|
||||
def _call(self, prompt: str, stop: Optional[List[str]] = None, **kwargs) -> str:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/a2a",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "langchain-1",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"skill": "smart-routing",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"metadata": {
|
||||
"model": self.model,
|
||||
**({"combo": self.combo} if self.combo else {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = response.json()["result"]
|
||||
return result["artifacts"][0]["content"]
|
||||
|
||||
def _generate(self, prompts: List[str], stop=None, **kwargs) -> LLMResult:
|
||||
return LLMResult(
|
||||
generations=[[Generation(text=self._call(p, stop))] for p in prompts]
|
||||
)
|
||||
|
||||
# Usage
|
||||
llm = OmniRouteA2A(
|
||||
base_url="http://localhost:20128",
|
||||
api_key="your-key",
|
||||
model="auto",
|
||||
combo="fast-coding",
|
||||
)
|
||||
result = llm("Write a Python function to merge two sorted lists")
|
||||
print(result)
|
||||
```
|
||||
|
||||
### Go — A2A Client
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const baseURL = "http://localhost:20128"
|
||||
const apiKey = "your-api-key"
|
||||
|
||||
type JsonRpcRequest struct {
|
||||
Jsonrpc string `json:"jsonrpc"`
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params"`
|
||||
}
|
||||
|
||||
type JsonRpcResponse struct {
|
||||
Jsonrpc string `json:"jsonrpc"`
|
||||
ID string `json:"id"`
|
||||
Result interface{} `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func a2aCall(method string, params interface{}) (*JsonRpcResponse, error) {
|
||||
body, _ := json.Marshal(JsonRpcRequest{
|
||||
Jsonrpc: "2.0",
|
||||
ID: "go-1",
|
||||
Method: method,
|
||||
Params: params,
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", baseURL+"/a2a", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result JsonRpcResponse
|
||||
json.Unmarshal(data, &result)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Discover agent
|
||||
resp, _ := http.Get(baseURL + "/.well-known/agent.json")
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Println("Agent Card:", string(body))
|
||||
|
||||
// Send smart-routing task
|
||||
result, _ := a2aCall("message/send", map[string]interface{}{
|
||||
"skill": "smart-routing",
|
||||
"messages": []map[string]string{{"role": "user", "content": "Hello from Go!"}},
|
||||
"metadata": map[string]interface{}{"model": "auto"},
|
||||
})
|
||||
out, _ := json.MarshalIndent(result.Result, "", " ")
|
||||
fmt.Println("Result:", string(out))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 🤖 Use Case 1: Multi-Agent Coding Pipeline
|
||||
|
||||
An orchestrator agent delegates code generation to OmniRoute, then passes the output to a review agent.
|
||||
|
||||
```python
|
||||
def coding_pipeline(task: str):
|
||||
# Step 1: Generate code via OmniRoute A2A
|
||||
code_result = a2a_send("smart-routing", [
|
||||
{"role": "user", "content": f"Write production-quality code: {task}"}
|
||||
], metadata={"model": "auto", "role": "coding"})
|
||||
code = code_result["artifacts"][0]["content"]
|
||||
|
||||
# Step 2: Review the code via OmniRoute A2A (different model)
|
||||
review_result = a2a_send("smart-routing", [
|
||||
{"role": "user", "content": f"Review this code for bugs and improvements:\n\n{code}"}
|
||||
], metadata={"model": "auto", "role": "review"})
|
||||
review = review_result["artifacts"][0]["content"]
|
||||
|
||||
# Step 3: Check costs
|
||||
print(f"Code cost: ${code_result['metadata']['cost_envelope']['actual']}")
|
||||
print(f"Review cost: ${review_result['metadata']['cost_envelope']['actual']}")
|
||||
|
||||
return {"code": code, "review": review}
|
||||
```
|
||||
|
||||
### 💡 Use Case 2: Quota-Aware Agent Swarm
|
||||
|
||||
Multiple agents share quota through OmniRoute, using the quota skill to coordinate.
|
||||
|
||||
```python
|
||||
async def quota_aware_agent(agent_name: str, task: str):
|
||||
# Check quota before starting
|
||||
quota = a2a_send("quota-management", [
|
||||
{"role": "user", "content": "Which provider has the most quota remaining?"}
|
||||
])
|
||||
print(f"[{agent_name}] {quota['artifacts'][0]['content']}")
|
||||
|
||||
# Send request with budget constraint
|
||||
result = a2a_send("smart-routing", [
|
||||
{"role": "user", "content": task}
|
||||
], metadata={"budget": 0.05})
|
||||
|
||||
policy = result["metadata"]["policy_verdict"]
|
||||
if not policy["allowed"]:
|
||||
print(f"[{agent_name}] ⚠️ Budget exceeded: {policy['reason']}")
|
||||
# Fall back to free combo
|
||||
quota = a2a_send("quota-management", [
|
||||
{"role": "user", "content": "Suggest free combos"}
|
||||
])
|
||||
print(f"[{agent_name}] Free alternatives: {quota['artifacts'][0]['content']}")
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 📊 Use Case 3: Real-Time Streaming Dashboard
|
||||
|
||||
A monitoring agent streams responses and displays progress in real-time.
|
||||
|
||||
```typescript
|
||||
async function streamingDashboard(prompt: string) {
|
||||
const response = await fetch(`${BASE_URL}/a2a`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "dash-1",
|
||||
method: "message/stream",
|
||||
params: { skill: "smart-routing", messages: [{ role: "user", content: prompt }] },
|
||||
}),
|
||||
});
|
||||
|
||||
let totalChunks = 0;
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
for (const line of decoder.decode(value).split("\n")) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
const state = event.params.task.state;
|
||||
|
||||
if (state === "working" && event.params.chunk) {
|
||||
totalChunks++;
|
||||
process.stdout.write(
|
||||
`\r[Chunk ${totalChunks}] ${event.params.chunk.content.slice(0, 50)}...`
|
||||
);
|
||||
}
|
||||
if (state === "completed") {
|
||||
const meta = event.params.metadata;
|
||||
console.log(
|
||||
`\n✅ Done | Cost: $${meta?.cost_envelope?.actual || 0} | Route: ${meta?.routing_explanation || "N/A"}`
|
||||
);
|
||||
}
|
||||
if (state === "failed") {
|
||||
console.error(`\n❌ Failed: ${event.params.metadata?.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔁 Use Case 4: Task Polling Pattern
|
||||
|
||||
For long-running tasks, poll the task status instead of waiting synchronously.
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def poll_task(task_id: str, timeout: int = 60):
|
||||
"""Poll task status until completion or timeout."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
result = requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "poll-1",
|
||||
"method": "tasks/get",
|
||||
"params": {"taskId": task_id},
|
||||
}).json()
|
||||
|
||||
task = result["result"]["task"]
|
||||
state = task["state"]
|
||||
print(f" Task {task_id[:8]}... state={state}")
|
||||
|
||||
if state in ("completed", "failed", "cancelled"):
|
||||
return task
|
||||
time.sleep(2)
|
||||
|
||||
# Timeout — cancel the task
|
||||
requests.post(f"{BASE_URL}/a2a", headers=HEADERS, json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "cancel-1",
|
||||
"method": "tasks/cancel",
|
||||
"params": {"taskId": task_id},
|
||||
})
|
||||
raise TimeoutError(f"Task {task_id} timed out after {timeout}s")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Constant | Meaning |
|
||||
| ------ | ------------------------ | ---------------------------------------- |
|
||||
| -32700 | — | Parse error (invalid JSON) |
|
||||
| -32600 | `INVALID_REQUEST` | Invalid JSON-RPC request or unauthorized |
|
||||
| -32601 | `METHOD_NOT_FOUND` | Unknown method or skill |
|
||||
| -32602 | `INVALID_PARAMS` | Missing or invalid parameters |
|
||||
| -32603 | `INTERNAL_ERROR` | Skill execution failed |
|
||||
| -32001 | `TASK_NOT_FOUND` | Task ID not found |
|
||||
| -32002 | `TASK_ALREADY_COMPLETED` | Cannot modify a completed task |
|
||||
| -32003 | `UNAUTHORIZED` | Invalid or missing API key |
|
||||
| -32004 | `BUDGET_EXCEEDED` | Request exceeds configured budget |
|
||||
| -32005 | `PROVIDER_UNAVAILABLE` | No available providers |
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All `/a2a` requests require a Bearer token via the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_OMNIROUTE_API_KEY
|
||||
```
|
||||
|
||||
If no API key is configured on the server (`OMNIROUTE_API_KEY` is empty), authentication is bypassed.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/lib/a2a/
|
||||
├── taskManager.ts # Task lifecycle (create/update/cancel/list), TTL, cleanup
|
||||
├── taskExecution.ts # Generic task executor with state management
|
||||
├── streaming.ts # SSE stream formatting, heartbeat, chunk/completion events
|
||||
├── routingLogger.ts # Routing decision logger (stats, history, retention)
|
||||
└── skills/
|
||||
├── smartRouting.ts # Smart routing skill (routes via /v1/chat/completions)
|
||||
└── quotaManagement.ts # Quota management skill (natural-language quota queries)
|
||||
|
||||
src/app/a2a/
|
||||
└── route.ts # Next.js API route handler (JSON-RPC 2.0 dispatch)
|
||||
|
||||
open-sse/mcp-server/
|
||||
└── schemas/a2a.ts # Zod schemas (AgentCard, Task, JSON-RPC, SSE events)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: MCP vs A2A
|
||||
|
||||
| Feature | MCP Server | A2A Server |
|
||||
| ----------------- | ---------------------------- | ------------------------------------------------- |
|
||||
| **Protocol** | Model Context Protocol | Agent-to-Agent Protocol v0.3 |
|
||||
| **Transport** | stdio / HTTP | HTTP (JSON-RPC 2.0) |
|
||||
| **Discovery** | Tool listing via MCP | `/.well-known/agent.json` |
|
||||
| **Granularity** | 16 individual tools | 2 high-level skills |
|
||||
| **Best for** | IDE agents (Cursor, VS Code) | Multi-agent systems (LangChain, CrewAI) |
|
||||
| **Streaming** | Not supported | SSE via `message/stream` |
|
||||
| **Task tracking** | No | Full lifecycle (submitted → completed) |
|
||||
| **Observability** | Audit log per tool call | Cost envelope + resilience trace + policy verdict |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Part of [OmniRoute](https://github.com/diegosouzapw/OmniRoute) — MIT License.
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* A2A Routing Decision Logger
|
||||
*
|
||||
* Records every routing decision to the `routing_decisions` SQLite table.
|
||||
* Used by `omniroute_explain_route` (T03) and future learning router.
|
||||
* Retention: 7 days default.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export interface RoutingFactor {
|
||||
name: string; // "quota", "health", "cost", "latency", "task_fit"
|
||||
value: number;
|
||||
weight: number;
|
||||
contribution: number;
|
||||
}
|
||||
|
||||
export interface FallbackEntry {
|
||||
provider: string;
|
||||
reason: string; // "circuit_breaker_open", "quota_exceeded", "timeout"
|
||||
}
|
||||
|
||||
export interface RoutingDecision {
|
||||
requestId: string;
|
||||
taskType: string;
|
||||
comboId: string;
|
||||
providerSelected: string;
|
||||
modelUsed: string;
|
||||
score: number;
|
||||
factors: RoutingFactor[];
|
||||
fallbacksTriggered: FallbackEntry[];
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
cost: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// In-memory log (production would use SQLite via routing_decisions table)
|
||||
const decisions: RoutingDecision[] = [];
|
||||
const MAX_DECISIONS = 1000;
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Log a routing decision.
|
||||
*/
|
||||
export function logRoutingDecision(
|
||||
params: Omit<RoutingDecision, "requestId" | "timestamp">
|
||||
): RoutingDecision {
|
||||
const decision: RoutingDecision = {
|
||||
...params,
|
||||
requestId: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
decisions.push(decision);
|
||||
|
||||
// Cleanup: cap + TTL
|
||||
if (decisions.length > MAX_DECISIONS) {
|
||||
const cutoff = new Date(Date.now() - RETENTION_MS);
|
||||
const validIdx = decisions.findIndex((d) => new Date(d.timestamp) > cutoff);
|
||||
if (validIdx > 0) decisions.splice(0, validIdx);
|
||||
else if (decisions.length > MAX_DECISIONS)
|
||||
decisions.splice(0, decisions.length - MAX_DECISIONS);
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* A2A Skill: Cost Analysis
|
||||
*
|
||||
* Summarizes usage cost, provider/model spend distribution, and savings opportunities.
|
||||
*/
|
||||
|
||||
import type { A2ATask, TaskArtifact } from "../taskManager";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
import { formatCost } from "@/shared/utils/formatting";
|
||||
|
||||
type AnalyticsRecord = Record<string, unknown>;
|
||||
|
||||
type CostEntry = {
|
||||
id: string;
|
||||
requests: number;
|
||||
cost: number;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
||||
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
|
||||
function detectRange(task: A2ATask): string {
|
||||
const metadataRange = task.input.metadata?.range;
|
||||
if (typeof metadataRange === "string" && metadataRange.trim()) return metadataRange;
|
||||
|
||||
const query = task.input.messages.at(-1)?.content?.toLowerCase() || "";
|
||||
if (query.includes("today") || query.includes("24h")) return "1d";
|
||||
if (query.includes("week") || query.includes("7d")) return "7d";
|
||||
if (query.includes("quarter") || query.includes("90d")) return "90d";
|
||||
if (query.includes("year") || query.includes("ytd")) return "ytd";
|
||||
return "30d";
|
||||
}
|
||||
|
||||
async function costFetch(path: string): Promise<AnalyticsRecord> {
|
||||
const url = `${OMNIROUTE_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
|
||||
};
|
||||
const response = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`API [${response.status}]: ${await response.text().catch(() => "error")}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function toCostEntries(value: unknown): CostEntry[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
|
||||
return Object.entries(value as Record<string, AnalyticsRecord>)
|
||||
.map(([id, raw]) => ({
|
||||
id,
|
||||
requests: toNumber(raw.requests ?? raw.count ?? raw.totalRequests),
|
||||
cost: toNumber(raw.cost ?? raw.totalCost),
|
||||
tokens:
|
||||
toNumber(raw.tokens ?? raw.totalTokens ?? raw.promptTokens) +
|
||||
toNumber(raw.completionTokens),
|
||||
}))
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
}
|
||||
|
||||
function buildSavings(
|
||||
providerCosts: CostEntry[],
|
||||
modelCosts: CostEntry[],
|
||||
fallbackRatePct: number
|
||||
) {
|
||||
const suggestions: string[] = [];
|
||||
const topProvider = providerCosts[0];
|
||||
const topModel = modelCosts[0];
|
||||
|
||||
if (topProvider?.cost > 0) {
|
||||
suggestions.push(
|
||||
`Review ${topProvider.id}: it is the largest provider cost at ${formatCost(topProvider.cost)}.`
|
||||
);
|
||||
}
|
||||
if (topModel?.cost > 0) {
|
||||
suggestions.push(
|
||||
`Check model ${topModel.id}: it is the largest model cost at ${formatCost(topModel.cost)}.`
|
||||
);
|
||||
}
|
||||
if (fallbackRatePct > 10) {
|
||||
suggestions.push(
|
||||
`Fallback rate is ${fallbackRatePct.toFixed(1)}%; tune combo priority or quota strategy to avoid expensive fallback paths.`
|
||||
);
|
||||
}
|
||||
if (suggestions.length === 0) {
|
||||
suggestions.push("No obvious cost-saving opportunity was detected in this range.");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export interface CostAnalysisResult {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: {
|
||||
range: string;
|
||||
totalCost: number;
|
||||
totalRequests: number;
|
||||
providerCosts: CostEntry[];
|
||||
modelCosts: CostEntry[];
|
||||
savings: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeCostAnalysis(task: A2ATask): Promise<CostAnalysisResult> {
|
||||
const range = detectRange(task);
|
||||
const analytics = await costFetch(
|
||||
`/api/usage/analytics?range=${encodeURIComponent(range)}&presets=1d,7d,30d,90d,ytd`
|
||||
);
|
||||
const summary = (analytics.summary || {}) as AnalyticsRecord;
|
||||
const providerCosts = toCostEntries(analytics.byProvider).slice(0, 10);
|
||||
const modelCosts = toCostEntries(analytics.byModel).slice(0, 10);
|
||||
const totalCost = toNumber(summary.totalCost);
|
||||
const totalRequests = toNumber(summary.totalRequests ?? summary.requests);
|
||||
const fallbackRatePct = toNumber(summary.fallbackRatePct);
|
||||
const savings = buildSavings(providerCosts, modelCosts, fallbackRatePct);
|
||||
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content: [
|
||||
`Cost analysis for range: ${range}`,
|
||||
`Total cost: ${formatCost(totalCost)}`,
|
||||
`Total requests: ${totalRequests.toLocaleString()}`,
|
||||
`Fallback rate: ${fallbackRatePct.toFixed(2)}%`,
|
||||
"",
|
||||
"Top providers:",
|
||||
...(providerCosts.length
|
||||
? providerCosts
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.id} - ${formatCost(entry.cost)} (${entry.requests.toLocaleString()} requests)`
|
||||
)
|
||||
: ["No provider cost data available."]),
|
||||
"",
|
||||
"Savings opportunities:",
|
||||
...savings.map((suggestion) => `- ${suggestion}`),
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
range,
|
||||
totalCost,
|
||||
totalRequests,
|
||||
providerCosts,
|
||||
modelCosts,
|
||||
savings,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* A2A Skill: Health Report
|
||||
*
|
||||
* Produces a structured health summary for orchestrating agents.
|
||||
*/
|
||||
|
||||
import type { A2ATask, TaskArtifact } from "../taskManager";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type ProviderHealthEntry = {
|
||||
state?: string;
|
||||
failures?: number;
|
||||
retryAfterMs?: number;
|
||||
lastFailure?: string | null;
|
||||
};
|
||||
|
||||
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
||||
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
|
||||
async function healthFetch(path: string): Promise<JsonRecord> {
|
||||
const url = `${OMNIROUTE_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
|
||||
};
|
||||
const response = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`API [${response.status}]: ${await response.text().catch(() => "error")}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function summarizeProviderHealth(providerHealth: unknown) {
|
||||
const entries = Object.entries(asRecord(providerHealth)).map(([provider, raw]) => ({
|
||||
provider,
|
||||
...(asRecord(raw) as ProviderHealthEntry),
|
||||
}));
|
||||
const degraded = entries.filter((entry) => entry.state && entry.state !== "CLOSED");
|
||||
|
||||
return {
|
||||
total: entries.length,
|
||||
healthy: entries.filter((entry) => entry.state === "CLOSED").length,
|
||||
degraded,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRateLimits(rateLimitStatus: unknown) {
|
||||
return Object.entries(asRecord(rateLimitStatus))
|
||||
.map(([key, raw]) => {
|
||||
const status = asRecord(raw);
|
||||
return {
|
||||
key,
|
||||
queued: toNumber(status.queued),
|
||||
running: toNumber(status.running),
|
||||
maxConcurrent: toNumber(status.maxConcurrent),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.queued > 0 || entry.running > 0)
|
||||
.sort((left, right) => right.queued + right.running - (left.queued + left.running));
|
||||
}
|
||||
|
||||
export interface HealthReportResult {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: {
|
||||
status: string;
|
||||
providerSummary: {
|
||||
total: number;
|
||||
healthy: number;
|
||||
degradedCount: number;
|
||||
};
|
||||
degradedProviders: Array<ProviderHealthEntry & { provider: string }>;
|
||||
activeRateLimits: Array<{
|
||||
key: string;
|
||||
queued: number;
|
||||
running: number;
|
||||
maxConcurrent: number;
|
||||
}>;
|
||||
lockoutCount: number;
|
||||
telemetry: JsonRecord;
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeHealthReport(_task: A2ATask): Promise<HealthReportResult> {
|
||||
const [healthResult, telemetryResult] = await Promise.allSettled([
|
||||
healthFetch("/api/monitoring/health"),
|
||||
healthFetch("/api/telemetry/summary"),
|
||||
]);
|
||||
|
||||
if (healthResult.status === "rejected") {
|
||||
throw healthResult.reason;
|
||||
}
|
||||
|
||||
const health = healthResult.value;
|
||||
const telemetry = telemetryResult.status === "fulfilled" ? telemetryResult.value : {};
|
||||
const providerSummary = summarizeProviderHealth(health.providerHealth);
|
||||
const activeRateLimits = summarizeRateLimits(health.rateLimitStatus).slice(0, 10);
|
||||
const lockouts = asRecord(health.lockouts);
|
||||
const lockoutCount = Object.keys(lockouts).length;
|
||||
const status =
|
||||
providerSummary.degraded.length > 0 || activeRateLimits.length > 0 || lockoutCount > 0
|
||||
? "degraded"
|
||||
: String(health.status || "healthy");
|
||||
|
||||
const degradedLines =
|
||||
providerSummary.degraded.length > 0
|
||||
? providerSummary.degraded.slice(0, 8).map((entry) => {
|
||||
const retry =
|
||||
typeof entry.retryAfterMs === "number" && entry.retryAfterMs > 0
|
||||
? `, retry in ${Math.round(entry.retryAfterMs / 1000)}s`
|
||||
: "";
|
||||
return `- ${entry.provider}: ${entry.state || "unknown"} (${entry.failures || 0} failures${retry})`;
|
||||
})
|
||||
: ["- No degraded providers."];
|
||||
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content: [
|
||||
`Health report: ${status}`,
|
||||
`Providers healthy: ${providerSummary.healthy}/${providerSummary.total}`,
|
||||
`Active rate limit queues: ${activeRateLimits.length}`,
|
||||
`Active lockouts: ${lockoutCount}`,
|
||||
`Recent requests: ${toNumber(telemetry.totalRequests).toLocaleString()}`,
|
||||
`p95 latency: ${Math.round(toNumber(telemetry.p95))}ms`,
|
||||
"",
|
||||
"Degraded providers:",
|
||||
...degradedLines,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
status,
|
||||
providerSummary: {
|
||||
total: providerSummary.total,
|
||||
healthy: providerSummary.healthy,
|
||||
degradedCount: providerSummary.degraded.length,
|
||||
},
|
||||
degradedProviders: providerSummary.degraded,
|
||||
activeRateLimits,
|
||||
lockoutCount,
|
||||
telemetry,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* A2A Skill: List Capabilities
|
||||
*
|
||||
* Returns the full catalog of OmniRoute agent skills (22 API + 20 CLI + config)
|
||||
* as a markdown table with raw SKILL.md URLs for orchestrating agents.
|
||||
*/
|
||||
|
||||
import type { A2ATask, TaskArtifact } from "../taskManager";
|
||||
import { getCatalog, computeCoverage } from "@/lib/agentSkills/catalog";
|
||||
import type { AgentSkill } from "@/lib/agentSkills/types";
|
||||
|
||||
export interface ListCapabilitiesResult {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: {
|
||||
coverage: {
|
||||
api: { have: number; total: 23 };
|
||||
cli: { have: number; total: 20 };
|
||||
};
|
||||
totalSkills: number;
|
||||
generatedAt: string;
|
||||
source: "agent-skills-catalog";
|
||||
};
|
||||
}
|
||||
|
||||
function buildMarkdownTable(skills: AgentSkill[]): string {
|
||||
const header = "| ID | Name | Category | Area | Endpoints/Commands | Raw URL |";
|
||||
const separator = "| --- | --- | --- | --- | --- | --- |";
|
||||
|
||||
const rows = skills.map((skill) => {
|
||||
const endpointsOrCommands =
|
||||
skill.category === "api"
|
||||
? (skill.endpoints ?? []).join(", ") || "—"
|
||||
: (skill.cliCommands ?? []).join(", ") || "—";
|
||||
|
||||
return `| ${skill.id} | ${skill.name} | ${skill.category} | ${skill.area} | ${endpointsOrCommands} | ${skill.rawUrl} |`;
|
||||
});
|
||||
|
||||
return [header, separator, ...rows].join("\n");
|
||||
}
|
||||
|
||||
export async function executeListCapabilities(_task: A2ATask): Promise<ListCapabilitiesResult> {
|
||||
const catalog = getCatalog();
|
||||
const coverage = computeCoverage();
|
||||
|
||||
const table = buildMarkdownTable(catalog);
|
||||
|
||||
const content = [
|
||||
`# OmniRoute Agent Skills Catalog`,
|
||||
``,
|
||||
`Total: ${catalog.length} skills (${coverage.api.total} API + ${coverage.cli.total} CLI)`,
|
||||
``,
|
||||
table,
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
coverage: {
|
||||
api: { have: coverage.api.have, total: 23 },
|
||||
cli: { have: coverage.cli.have, total: 20 },
|
||||
},
|
||||
totalSkills: catalog.length,
|
||||
generatedAt: coverage.generatedAt,
|
||||
source: "agent-skills-catalog",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* A2A Skill: Provider Discovery
|
||||
*
|
||||
* Answers provider capability, availability, and routing-fit questions for agents.
|
||||
*/
|
||||
|
||||
import type { A2ATask, TaskArtifact } from "../taskManager";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
AUDIO_ONLY_PROVIDERS,
|
||||
EMBEDDING_RERANK_PROVIDER_IDS,
|
||||
IMAGE_ONLY_PROVIDER_IDS,
|
||||
LOCAL_PROVIDERS,
|
||||
SEARCH_PROVIDERS,
|
||||
VIDEO_PROVIDER_IDS,
|
||||
} from "@/shared/constants/providers";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
|
||||
type ProviderConnectionLike = {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
name?: string | null;
|
||||
isActive?: boolean | null;
|
||||
};
|
||||
|
||||
type CircuitBreakerLike = {
|
||||
name?: string;
|
||||
state?: string;
|
||||
failureCount?: number;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
|
||||
type ProviderCandidate = {
|
||||
id: string;
|
||||
name: string;
|
||||
capabilities: string[];
|
||||
configured: boolean;
|
||||
active: boolean;
|
||||
health: "healthy" | "recovering" | "down" | "unknown";
|
||||
modelCount: number;
|
||||
};
|
||||
|
||||
function detectCapability(task: A2ATask): string {
|
||||
const metadataCapability = task.input.metadata?.capability;
|
||||
if (typeof metadataCapability === "string" && metadataCapability.trim()) {
|
||||
return metadataCapability.toLowerCase();
|
||||
}
|
||||
|
||||
const query = task.input.messages.at(-1)?.content?.toLowerCase() || "";
|
||||
if (query.includes("image") || query.includes("vision")) return "images";
|
||||
if (query.includes("video")) return "video";
|
||||
if (query.includes("audio") || query.includes("transcription") || query.includes("speech")) {
|
||||
return "audio";
|
||||
}
|
||||
if (query.includes("embed")) return "embeddings";
|
||||
if (query.includes("rerank")) return "rerank";
|
||||
if (query.includes("search") || query.includes("web")) return "search";
|
||||
if (query.includes("local") || query.includes("self-hosted")) return "local";
|
||||
return "chat";
|
||||
}
|
||||
|
||||
function providerCapabilities(providerId: string): string[] {
|
||||
const capabilities = new Set<string>();
|
||||
const registryEntry = REGISTRY[providerId];
|
||||
|
||||
if (registryEntry) capabilities.add("chat");
|
||||
if (IMAGE_ONLY_PROVIDER_IDS.has(providerId)) capabilities.add("images");
|
||||
if (VIDEO_PROVIDER_IDS.has(providerId)) capabilities.add("video");
|
||||
if (EMBEDDING_RERANK_PROVIDER_IDS.has(providerId)) {
|
||||
capabilities.add("embeddings");
|
||||
capabilities.add("rerank");
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(SEARCH_PROVIDERS, providerId)) {
|
||||
capabilities.add("search");
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(AUDIO_ONLY_PROVIDERS, providerId)) {
|
||||
capabilities.add("audio");
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(LOCAL_PROVIDERS, providerId)) {
|
||||
capabilities.add("local");
|
||||
capabilities.add("chat");
|
||||
}
|
||||
if (registryEntry?.models?.some((model) => model.supportsVision)) {
|
||||
capabilities.add("vision");
|
||||
}
|
||||
|
||||
return [...capabilities].sort();
|
||||
}
|
||||
|
||||
function healthFromBreaker(breaker?: CircuitBreakerLike): ProviderCandidate["health"] {
|
||||
if (!breaker?.state) return "unknown";
|
||||
if (breaker.state === "CLOSED") return "healthy";
|
||||
if (breaker.state === "HALF_OPEN") return "recovering";
|
||||
return "down";
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate: ProviderCandidate, requestedCapability: string): number {
|
||||
let score = 0;
|
||||
if (candidate.capabilities.includes(requestedCapability)) score += 100;
|
||||
if (requestedCapability === "chat" && candidate.capabilities.includes("chat")) score += 40;
|
||||
if (candidate.active) score += 30;
|
||||
if (candidate.configured) score += 20;
|
||||
if (candidate.health === "healthy") score += 20;
|
||||
if (candidate.health === "recovering") score += 5;
|
||||
if (candidate.health === "down") score -= 100;
|
||||
score += Math.min(candidate.modelCount, 25);
|
||||
return score;
|
||||
}
|
||||
|
||||
export interface ProviderDiscoveryResult {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: {
|
||||
capability: string;
|
||||
totalCandidates: number;
|
||||
configuredCandidates: number;
|
||||
recommendedProvider: string | null;
|
||||
candidates: ProviderCandidate[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeProviderDiscovery(task: A2ATask): Promise<ProviderDiscoveryResult> {
|
||||
const [{ getProviderConnections }, { getAllCircuitBreakerStatuses }] = await Promise.all([
|
||||
import("@/lib/localDb"),
|
||||
import("@/shared/utils/circuitBreaker"),
|
||||
]);
|
||||
|
||||
const requestedCapability = detectCapability(task);
|
||||
const connections = ((await getProviderConnections().catch(() => [])) ||
|
||||
[]) as ProviderConnectionLike[];
|
||||
const activeProviders = new Set(
|
||||
connections
|
||||
.filter((connection) => connection.provider && connection.isActive !== false)
|
||||
.map((connection) => connection.provider as string)
|
||||
);
|
||||
const configuredProviders = new Set(
|
||||
connections
|
||||
.filter((connection) => connection.provider)
|
||||
.map((connection) => connection.provider as string)
|
||||
);
|
||||
const breakers = new Map(
|
||||
getAllCircuitBreakerStatuses().map((breaker: CircuitBreakerLike) => [
|
||||
breaker.name || "",
|
||||
breaker,
|
||||
])
|
||||
);
|
||||
|
||||
const candidates = Object.entries(AI_PROVIDERS)
|
||||
.map(([providerId, provider]) => {
|
||||
const capabilities = providerCapabilities(providerId);
|
||||
return {
|
||||
id: providerId,
|
||||
name: provider.name || providerId,
|
||||
capabilities,
|
||||
configured: configuredProviders.has(providerId),
|
||||
active: activeProviders.has(providerId),
|
||||
health: healthFromBreaker(breakers.get(providerId)),
|
||||
modelCount: REGISTRY[providerId]?.models?.length || 0,
|
||||
} satisfies ProviderCandidate;
|
||||
})
|
||||
.filter((candidate) =>
|
||||
requestedCapability === "chat"
|
||||
? candidate.capabilities.includes("chat")
|
||||
: candidate.capabilities.includes(requestedCapability)
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
scoreCandidate(right, requestedCapability) - scoreCandidate(left, requestedCapability)
|
||||
);
|
||||
|
||||
const top = candidates.slice(0, 10);
|
||||
const recommended = top[0] || null;
|
||||
const configuredCount = candidates.filter((candidate) => candidate.configured).length;
|
||||
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
top.length > 0
|
||||
? [
|
||||
`Provider discovery for capability: ${requestedCapability}`,
|
||||
recommended ? `Recommended provider: ${recommended.name} (${recommended.id})` : "",
|
||||
"",
|
||||
...top.map(
|
||||
(candidate, index) =>
|
||||
`${index + 1}. ${candidate.name} (${candidate.id}) - ${candidate.health}, ` +
|
||||
`${candidate.configured ? "configured" : "not configured"}, ` +
|
||||
`${candidate.modelCount} catalog models`
|
||||
),
|
||||
].join("\n")
|
||||
: `No providers matched capability: ${requestedCapability}`,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
capability: requestedCapability,
|
||||
totalCandidates: candidates.length,
|
||||
configuredCandidates: configuredCount,
|
||||
recommendedProvider: recommended?.id || null,
|
||||
candidates: top,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* A2A Skill: Quota Management
|
||||
*
|
||||
* Handles natural-language queries about provider quotas and
|
||||
* returns structured responses with actionable data.
|
||||
*/
|
||||
|
||||
import type { A2ATask, TaskArtifact } from "../taskManager";
|
||||
import { normalizeQuotaResponse } from "@/shared/contracts/quota";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
|
||||
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
||||
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
|
||||
async function quotaFetch(path: string): Promise<any> {
|
||||
const url = `${OMNIROUTE_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
|
||||
};
|
||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) throw new Error(`API [${res.status}]`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface QuotaManagementResult {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function executeQuotaManagement(task: A2ATask): Promise<QuotaManagementResult> {
|
||||
const query = task.input.messages[task.input.messages.length - 1]?.content?.toLowerCase() || "";
|
||||
|
||||
const [quotaRaw, combosRaw] = await Promise.allSettled([
|
||||
quotaFetch("/api/usage/quota"),
|
||||
quotaFetch("/api/combos"),
|
||||
]);
|
||||
|
||||
const quota =
|
||||
quotaRaw.status === "fulfilled"
|
||||
? normalizeQuotaResponse(quotaRaw.value)
|
||||
: normalizeQuotaResponse({});
|
||||
const combos =
|
||||
combosRaw.status === "fulfilled"
|
||||
? Array.isArray((combosRaw.value as any)?.combos)
|
||||
? (combosRaw.value as any).combos
|
||||
: Array.isArray(combosRaw.value)
|
||||
? (combosRaw.value as any[])
|
||||
: []
|
||||
: [];
|
||||
const providers = quota.providers;
|
||||
|
||||
const availableQuota = (provider: { quotaTotal: number | null; quotaUsed: number }) => {
|
||||
if (provider.quotaTotal === null) return Number.POSITIVE_INFINITY;
|
||||
return provider.quotaTotal - provider.quotaUsed;
|
||||
};
|
||||
|
||||
// Query classification
|
||||
if (query.includes("ranking") || query.includes("most quota") || query.includes("best")) {
|
||||
const sorted = [...providers].sort((a, b) => availableQuota(b) - availableQuota(a));
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content: `**Quota Ranking (most available first):**\n${sorted
|
||||
.map((p, i) => {
|
||||
const remaining = availableQuota(p);
|
||||
const remainingLabel =
|
||||
remaining === Number.POSITIVE_INFINITY ? "unlimited" : remaining.toLocaleString();
|
||||
const percentLabel =
|
||||
p.quotaTotal === null ? "n/a" : `${Math.round(p.percentRemaining)}%`;
|
||||
return `${i + 1}. **${p.provider}** — ${remainingLabel} remaining (${percentLabel})`;
|
||||
})
|
||||
.join("\n")}`,
|
||||
},
|
||||
],
|
||||
metadata: { queryType: "ranking", providers: sorted.map((p) => p.provider) },
|
||||
};
|
||||
}
|
||||
|
||||
if (query.includes("free") || query.includes("suggest")) {
|
||||
const freeCombos = combos.filter((c: any) => {
|
||||
const name = (c.name || "").toLowerCase();
|
||||
return name.includes("free") || name.includes("gratis");
|
||||
});
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content:
|
||||
freeCombos.length > 0
|
||||
? `**Free combos available:**\n${freeCombos.map((c: any) => `- **${c.name}** (ID: ${c.id})`).join("\n")}`
|
||||
: "No free combos configured. Consider adding providers with free tiers (Gemini, Groq, etc.).",
|
||||
},
|
||||
],
|
||||
metadata: { queryType: "free_suggestion", freeCombos: freeCombos.length },
|
||||
};
|
||||
}
|
||||
|
||||
// Default: general quota summary
|
||||
const totalUsed = providers.reduce((sum, p) => sum + (p.quotaUsed || 0), 0);
|
||||
const totalAvailable = providers.reduce((sum, p) => sum + (p.quotaTotal || 0), 0);
|
||||
const warnings = providers.filter((p) => p.percentRemaining <= 10);
|
||||
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
type: "text",
|
||||
content: `**Quota Summary (${providers.length} providers):**\n- Total used: ${totalUsed.toLocaleString()} / ${totalAvailable.toLocaleString()}\n${providers
|
||||
.map(
|
||||
(p) =>
|
||||
`- **${p.provider}:** ${p.quotaUsed.toLocaleString()} / ${p.quotaTotal?.toLocaleString() || "∞"} (${p.tokenStatus})`
|
||||
)
|
||||
.join(
|
||||
"\n"
|
||||
)}${warnings.length > 0 ? `\n\n⚠️ **Warning:** ${warnings.map((w) => w.provider).join(", ")} at or below 10% remaining` : ""}`,
|
||||
},
|
||||
],
|
||||
metadata: { queryType: "summary", providerCount: providers.length, warnings: warnings.length },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* A2A Skill: Smart Routing
|
||||
*
|
||||
* Receives a prompt + metadata → routes via OmniRoute pipeline →
|
||||
* returns response with routing_explanation, cost_envelope, resilience_trace, policy_verdict.
|
||||
*/
|
||||
|
||||
import type { A2ATask, TaskArtifact } from "../taskManager";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
|
||||
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
||||
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
|
||||
async function routeFetch(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const url = `${OMNIROUTE_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) throw new Error(`API [${res.status}]: ${await res.text().catch(() => "error")}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SmartRoutingResult {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: {
|
||||
routing_explanation: string;
|
||||
cost_envelope: { estimated: number; actual: number; currency: string };
|
||||
resilience_trace: Array<{ event: string; provider: string; timestamp: string }>;
|
||||
policy_verdict: { allowed: boolean; reason: string };
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeSmartRouting(task: A2ATask): Promise<SmartRoutingResult> {
|
||||
const messages = task.input.messages;
|
||||
const model = (task.input.metadata?.model as string) || "auto";
|
||||
const combo = task.input.metadata?.combo as string | undefined;
|
||||
const budget = task.input.metadata?.budget as number | undefined;
|
||||
|
||||
const start = Date.now();
|
||||
const body: Record<string, unknown> = { model, messages, stream: false };
|
||||
if (combo) body["x-combo"] = combo;
|
||||
|
||||
const raw = await routeFetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
const content = raw?.choices?.[0]?.message?.content || "";
|
||||
const provider = raw?.provider || "unknown";
|
||||
const actualCost = raw?.cost || 0;
|
||||
const promptTokens = raw?.usage?.prompt_tokens || 0;
|
||||
const estimatedCost = (promptTokens / 1_000_000) * 3.0; // rough estimate
|
||||
|
||||
// Budget policy check
|
||||
const withinBudget = budget ? actualCost <= budget : true;
|
||||
|
||||
return {
|
||||
artifacts: [{ type: "text", content }],
|
||||
metadata: {
|
||||
routing_explanation: `Selected ${raw?.model || model} via provider "${provider}" (latency: ${latencyMs}ms, cost: $${actualCost.toFixed(4)})`,
|
||||
cost_envelope: {
|
||||
estimated: Math.round(estimatedCost * 10000) / 10000,
|
||||
actual: Math.round(actualCost * 10000) / 10000,
|
||||
currency: "USD",
|
||||
},
|
||||
resilience_trace: [
|
||||
{ event: "primary_selected", provider, timestamp: new Date().toISOString() },
|
||||
...(raw?.fallbacksTriggered
|
||||
? [
|
||||
{
|
||||
event: "fallback_needed",
|
||||
provider: "secondary",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
policy_verdict: {
|
||||
allowed: withinBudget,
|
||||
reason: withinBudget
|
||||
? "within budget and quota limits"
|
||||
: `cost $${actualCost} exceeds budget $${budget}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* A2A SSE Streaming Support
|
||||
*
|
||||
* Provides SSE event formatting for A2A `message/stream` responses.
|
||||
* Features: heartbeat (15s), chunk emission, metadata final event, cancellation.
|
||||
*/
|
||||
|
||||
import type { A2ATask } from "./taskManager";
|
||||
|
||||
export interface SSEChunkEvent {
|
||||
jsonrpc: "2.0";
|
||||
method: "message/stream";
|
||||
params: {
|
||||
task: { id: string; state: string };
|
||||
chunk?: { type: string; content: string };
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an SSE event line.
|
||||
*/
|
||||
export function formatSSE(event: SSEChunkEvent): string {
|
||||
return `data: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a chunk event for streaming text content.
|
||||
*/
|
||||
export function createChunkEvent(taskId: string, content: string): string {
|
||||
return formatSSE({
|
||||
jsonrpc: "2.0",
|
||||
method: "message/stream",
|
||||
params: {
|
||||
task: { id: taskId, state: "working" },
|
||||
chunk: { type: "text", content },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the final completion event with metadata.
|
||||
*/
|
||||
export function createCompletionEvent(taskId: string, metadata: Record<string, unknown>): string {
|
||||
return formatSSE({
|
||||
jsonrpc: "2.0",
|
||||
method: "message/stream",
|
||||
params: {
|
||||
task: { id: taskId, state: "completed" },
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a heartbeat event to keep the connection alive.
|
||||
*/
|
||||
export function createHeartbeat(taskId: string): string {
|
||||
return `: heartbeat ${new Date().toISOString()}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failure event.
|
||||
*/
|
||||
export function createFailureEvent(taskId: string, error: string): string {
|
||||
return formatSSE({
|
||||
jsonrpc: "2.0",
|
||||
method: "message/stream",
|
||||
params: {
|
||||
task: { id: taskId, state: "failed" },
|
||||
metadata: { error },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE response headers for A2A streaming.
|
||||
*/
|
||||
export const SSE_HEADERS = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Create a streaming SSE handler that wraps a fetch-based LLM call.
|
||||
* Returns a ReadableStream suitable for a Response object.
|
||||
*/
|
||||
export function createA2AStream(
|
||||
task: A2ATask,
|
||||
executeSkill: (
|
||||
task: A2ATask
|
||||
) => Promise<{ artifacts: Array<{ content: string }>; metadata: Record<string, unknown> }>,
|
||||
abortSignal?: AbortSignal,
|
||||
lifecycle?: {
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
lifecycle?.onStart?.();
|
||||
// Heartbeat interval
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(createHeartbeat(task.id)));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
try {
|
||||
// Check for cancellation
|
||||
if (abortSignal?.aborted) {
|
||||
controller.enqueue(encoder.encode(createFailureEvent(task.id, "Cancelled")));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the skill
|
||||
const result = await executeSkill(task);
|
||||
|
||||
// Emit content as chunks (simulated streaming for non-streaming skills)
|
||||
for (const artifact of result.artifacts) {
|
||||
if (abortSignal?.aborted) break;
|
||||
controller.enqueue(encoder.encode(createChunkEvent(task.id, artifact.content)));
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
controller.enqueue(encoder.encode(createFailureEvent(task.id, "Cancelled")));
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit completion with metadata
|
||||
controller.enqueue(encoder.encode(createCompletionEvent(task.id, result.metadata)));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
controller.enqueue(encoder.encode(createFailureEvent(task.id, msg)));
|
||||
} finally {
|
||||
clearInterval(heartbeatInterval);
|
||||
lifecycle?.onEnd?.();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { A2ATask, TaskArtifact } from "./taskManager";
|
||||
|
||||
type TaskManagerLike = {
|
||||
updateTask: (
|
||||
taskId: string,
|
||||
state: "completed" | "failed",
|
||||
artifacts?: Array<{ type: string; content: string }>,
|
||||
message?: string
|
||||
) => unknown;
|
||||
};
|
||||
|
||||
type StreamTaskResult = {
|
||||
artifacts: TaskArtifact[];
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type A2ASkillHandler = (task: A2ATask) => Promise<StreamTaskResult>;
|
||||
|
||||
export const A2A_SKILL_HANDLERS: Record<string, A2ASkillHandler> = {
|
||||
"smart-routing": async (task) => {
|
||||
const skillModule = await import("./skills/smartRouting");
|
||||
return skillModule.executeSmartRouting(task);
|
||||
},
|
||||
"quota-management": async (task) => {
|
||||
const skillModule = await import("./skills/quotaManagement");
|
||||
return skillModule.executeQuotaManagement(task);
|
||||
},
|
||||
"provider-discovery": async (task) => {
|
||||
const skillModule = await import("./skills/providerDiscovery");
|
||||
return skillModule.executeProviderDiscovery(task);
|
||||
},
|
||||
"cost-analysis": async (task) => {
|
||||
const skillModule = await import("./skills/costAnalysis");
|
||||
return skillModule.executeCostAnalysis(task);
|
||||
},
|
||||
"health-report": async (task) => {
|
||||
const skillModule = await import("./skills/healthReport");
|
||||
return skillModule.executeHealthReport(task);
|
||||
},
|
||||
"list-capabilities": async (task) => {
|
||||
const skillModule = await import("./skills/listCapabilities");
|
||||
return skillModule.executeListCapabilities(task);
|
||||
},
|
||||
};
|
||||
|
||||
export async function executeA2ATaskWithState(
|
||||
tm: TaskManagerLike,
|
||||
task: A2ATask,
|
||||
handler: (task: A2ATask) => Promise<StreamTaskResult>
|
||||
) {
|
||||
try {
|
||||
const result = await handler(task);
|
||||
tm.updateTask(task.id, "completed", result.artifacts);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
|
||||
} catch {
|
||||
// Task may already be terminal (e.g., cancelled). Preserve original error.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* A2A Task Manager — Full lifecycle management for A2A tasks.
|
||||
*
|
||||
* State machine: submitted → working → completed | failed | cancelled
|
||||
*
|
||||
* Features:
|
||||
* - UUID v4 task IDs
|
||||
* - In-memory storage with optional SQLite persistence
|
||||
* - Event logging for each state transition
|
||||
* - TTL with configurable expiration (default 5 min)
|
||||
* - Concurrent task limit
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export type TaskState = "submitted" | "working" | "completed" | "failed" | "cancelled";
|
||||
|
||||
export interface TaskInput {
|
||||
skill: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TaskArtifact {
|
||||
type: "text" | "json" | "error";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TaskEvent {
|
||||
timestamp: string;
|
||||
state: TaskState;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface A2ATask {
|
||||
id: string;
|
||||
skill: string;
|
||||
state: TaskState;
|
||||
input: TaskInput;
|
||||
artifacts: TaskArtifact[];
|
||||
events: TaskEvent[];
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface TaskListFilter {
|
||||
state?: TaskState;
|
||||
skill?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface A2ATaskStats {
|
||||
counts: Record<TaskState, number>;
|
||||
total: number;
|
||||
activeStreams: number;
|
||||
lastTaskAt: string | null;
|
||||
}
|
||||
|
||||
// ============ Valid Transitions ============
|
||||
|
||||
const VALID_TRANSITIONS: Record<TaskState, TaskState[]> = {
|
||||
submitted: ["working", "failed", "cancelled"],
|
||||
working: ["completed", "failed", "cancelled"],
|
||||
completed: [],
|
||||
failed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
// ============ Task Manager ============
|
||||
|
||||
export class A2ATaskManager {
|
||||
private tasks = new Map<string, A2ATask>();
|
||||
private readonly ttlMs: number;
|
||||
private cleanupInterval: ReturnType<typeof setInterval>;
|
||||
private activeStreams = 0;
|
||||
|
||||
constructor(ttlMinutes: number = 5) {
|
||||
this.ttlMs = ttlMinutes * 60 * 1000;
|
||||
this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60_000);
|
||||
if (
|
||||
this.cleanupInterval &&
|
||||
typeof this.cleanupInterval === "object" &&
|
||||
"unref" in this.cleanupInterval
|
||||
) {
|
||||
(this.cleanupInterval as { unref?: () => void }).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
createTask(input: TaskInput): A2ATask {
|
||||
const now = new Date();
|
||||
const task: A2ATask = {
|
||||
id: randomUUID(),
|
||||
skill: input.skill,
|
||||
state: "submitted",
|
||||
input,
|
||||
artifacts: [],
|
||||
events: [{ timestamp: now.toISOString(), state: "submitted" }],
|
||||
metadata: input.metadata || {},
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),
|
||||
};
|
||||
this.tasks.set(task.id, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
getTask(taskId: string): A2ATask | undefined {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (task && new Date(task.expiresAt) < new Date()) {
|
||||
if (task.state === "submitted" || task.state === "working") {
|
||||
this.updateTask(taskId, "failed", undefined, "Task expired");
|
||||
}
|
||||
}
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
updateTask(
|
||||
taskId: string,
|
||||
state: TaskState,
|
||||
artifacts?: TaskArtifact[],
|
||||
message?: string
|
||||
): A2ATask {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) throw new Error(`Task ${taskId} not found`);
|
||||
|
||||
const valid = VALID_TRANSITIONS[task.state];
|
||||
if (!valid.includes(state)) {
|
||||
throw new Error(`Invalid transition: ${task.state} → ${state}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
task.state = state;
|
||||
task.updatedAt = now;
|
||||
task.events.push({ timestamp: now, state, message });
|
||||
if (artifacts) task.artifacts.push(...artifacts);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): A2ATask {
|
||||
return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
|
||||
}
|
||||
|
||||
countTasks(filter?: Pick<TaskListFilter, "state" | "skill">): number {
|
||||
let tasks = [...this.tasks.values()];
|
||||
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
|
||||
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
|
||||
return tasks.length;
|
||||
}
|
||||
|
||||
listTasks(filter?: TaskListFilter): A2ATask[] {
|
||||
let tasks = [...this.tasks.values()];
|
||||
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
|
||||
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
|
||||
tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const offset = Math.max(0, filter?.offset || 0);
|
||||
const limit =
|
||||
typeof filter?.limit === "number" && Number.isFinite(filter.limit)
|
||||
? Math.max(1, Math.floor(filter.limit))
|
||||
: 50;
|
||||
return tasks.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
beginStream() {
|
||||
this.activeStreams += 1;
|
||||
}
|
||||
|
||||
endStream() {
|
||||
this.activeStreams = Math.max(0, this.activeStreams - 1);
|
||||
}
|
||||
|
||||
getStats(): A2ATaskStats {
|
||||
const counts: Record<TaskState, number> = {
|
||||
submitted: 0,
|
||||
working: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
};
|
||||
|
||||
let lastTaskAt: string | null = null;
|
||||
for (const task of this.tasks.values()) {
|
||||
counts[task.state] += 1;
|
||||
const updatedAt = new Date(task.updatedAt).getTime();
|
||||
if (!Number.isFinite(updatedAt)) continue;
|
||||
if (!lastTaskAt || updatedAt > new Date(lastTaskAt).getTime()) {
|
||||
lastTaskAt = task.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counts,
|
||||
total: this.tasks.size,
|
||||
activeStreams: this.activeStreams,
|
||||
lastTaskAt,
|
||||
};
|
||||
}
|
||||
|
||||
private cleanupExpired() {
|
||||
const now = new Date();
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (
|
||||
new Date(task.expiresAt) < now &&
|
||||
task.state !== "completed" &&
|
||||
task.state !== "failed" &&
|
||||
task.state !== "cancelled"
|
||||
) {
|
||||
task.state = "failed";
|
||||
task.updatedAt = now.toISOString();
|
||||
task.events.push({ timestamp: now.toISOString(), state: "failed", message: "TTL expired" });
|
||||
}
|
||||
// Remove terminal tasks older than 2x TTL
|
||||
if (
|
||||
["completed", "failed", "cancelled"].includes(task.state) &&
|
||||
now.getTime() - new Date(task.updatedAt).getTime() > this.ttlMs * 2
|
||||
) {
|
||||
this.tasks.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const globalForA2A = globalThis as unknown as { _a2aTaskManager?: A2ATaskManager };
|
||||
|
||||
export function getTaskManager(): A2ATaskManager {
|
||||
if (!globalForA2A._a2aTaskManager) {
|
||||
globalForA2A._a2aTaskManager = new A2ATaskManager();
|
||||
}
|
||||
return globalForA2A._a2aTaskManager;
|
||||
}
|
||||
Reference in New Issue
Block a user