mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 10:12:11 +03:00
Compare commits
9 Commits
fix/securi
...
feat/condu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
552c969759 | ||
|
|
4d6c855258 | ||
|
|
7b951761fc | ||
|
|
b97318d73b | ||
|
|
a75295f359 | ||
|
|
00e15af622 | ||
|
|
5eb10e896d | ||
|
|
33baf62b58 | ||
|
|
d42a58141b |
1
changelog.d/features/conductor-agent-card.md
Normal file
1
changelog.d/features/conductor-agent-card.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(a2a): the Agent Card (`/.well-known/agent.json`) now announces skills derived from the OmniConductor fleet (`GET /v1/runners` OASF capabilities — one skill per online CLI profile + declared fleet skills), cached ~60s and fail-open when the hub is unset/offline
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getFleetSkills } from "@/lib/conductor/fleetSkills";
|
||||
|
||||
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
@@ -20,6 +22,9 @@ const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
* capabilities as an A2A agent.
|
||||
*/
|
||||
export async function GET() {
|
||||
// Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when
|
||||
// the hub is unset/offline — the card stays valid without the fleet section).
|
||||
const fleetSkills = await getFleetSkills();
|
||||
const agentCard = {
|
||||
name: "OmniRoute AI 网关",
|
||||
description:
|
||||
@@ -95,6 +100,7 @@ export async function GET() {
|
||||
tags: ["discovery", "capabilities"],
|
||||
examples: ["你能做什么?", "列出你的技能", "展示能力"],
|
||||
},
|
||||
...fleetSkills,
|
||||
],
|
||||
authentication: {
|
||||
schemes: ["api-key"],
|
||||
|
||||
106
src/lib/conductor/fleetSkills.ts
Normal file
106
src/lib/conductor/fleetSkills.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Fleet skills for the Agent Card (Conductor PRD RF2) — derives A2A skills from
|
||||
* the OmniConductor hub's runner registry (`GET /v1/runners`, OASF capabilities).
|
||||
*
|
||||
* Fail-open by design: any problem (env unset, hub offline, bad shape) yields
|
||||
* `[]` so the Agent Card stays valid, just without the fleet section. Results
|
||||
* are cached for ~60s to keep the card endpoint cheap.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export interface FleetSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** Untrusted hub response — validate only what we read. */
|
||||
const runnersSchema = z.array(
|
||||
z.object({
|
||||
online: z.boolean().optional(),
|
||||
capabilities: z.object({
|
||||
clis: z
|
||||
.array(
|
||||
z.object({
|
||||
profile: z.string(),
|
||||
models: z.array(z.object({ id: z.string() })).optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
let cache: { at: number; skills: FleetSkill[] } | null = null;
|
||||
|
||||
/** Test hook: resets the module cache. */
|
||||
export function clearFleetSkillsCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export interface FleetSkillsOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
nowMs?: () => number;
|
||||
}
|
||||
|
||||
export async function getFleetSkills(opts: FleetSkillsOptions = {}): Promise<FleetSkill[]> {
|
||||
const hubUrl = process.env.CONDUCTOR_HUB_URL?.trim();
|
||||
if (!hubUrl) return [];
|
||||
const now = opts.nowMs ?? Date.now;
|
||||
if (cache && now() - cache.at < CACHE_TTL_MS) return cache.skills;
|
||||
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
let skills: FleetSkill[] = [];
|
||||
try {
|
||||
const res = await doFetch(`${hubUrl}/v1/runners`, {
|
||||
headers: { authorization: `Bearer ${process.env.CONDUCTOR_HUB_TOKEN?.trim() ?? ""}` },
|
||||
});
|
||||
if (res.ok) skills = deriveSkills(runnersSchema.parse(await res.json()));
|
||||
} catch {
|
||||
skills = []; // hub offline / shape inválido: o card omite a frota, nunca quebra
|
||||
}
|
||||
cache = { at: now(), skills };
|
||||
return skills;
|
||||
}
|
||||
|
||||
function deriveSkills(runners: z.infer<typeof runnersSchema>): FleetSkill[] {
|
||||
const online = runners.filter((r) => r.online !== false);
|
||||
const byProfile = new Map<string, { count: number; models: Set<string> }>();
|
||||
const oasfSkills = new Set<string>();
|
||||
for (const r of online) {
|
||||
for (const cli of r.capabilities.clis ?? []) {
|
||||
const entry = byProfile.get(cli.profile) ?? { count: 0, models: new Set<string>() };
|
||||
entry.count++;
|
||||
for (const m of cli.models ?? []) entry.models.add(m.id);
|
||||
byProfile.set(cli.profile, entry);
|
||||
}
|
||||
for (const s of r.capabilities.skills ?? []) oasfSkills.add(s);
|
||||
}
|
||||
|
||||
const skills: FleetSkill[] = [];
|
||||
for (const [profile, info] of [...byProfile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const models = [...info.models].slice(0, 8);
|
||||
skills.push({
|
||||
id: `conductor-cli-${profile}`,
|
||||
name: `Conductor fleet: ${profile} CLI`,
|
||||
description:
|
||||
`Delegate coding tasks to the OmniConductor fleet's ${profile} CLI ` +
|
||||
`(${info.count} runner(s) online${models.length ? `; models: ${models.join(", ")}` : ""}). ` +
|
||||
"Tasks run in disposable git worktrees; results come back as branches with graduated manifests.",
|
||||
tags: ["conductor", "fleet", "cli", profile],
|
||||
});
|
||||
}
|
||||
for (const s of [...oasfSkills].sort()) {
|
||||
skills.push({
|
||||
id: `conductor-skill-${s}`,
|
||||
name: `Conductor fleet skill: ${s}`,
|
||||
description: `OASF skill "${s}" declared by online runners of the OmniConductor fleet.`,
|
||||
tags: ["conductor", "fleet", "skill"],
|
||||
});
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
52
tests/unit/conductor-agent-card.test.ts
Normal file
52
tests/unit/conductor-agent-card.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
import { GET } from "../../src/app/.well-known/agent.json/route.ts";
|
||||
import { clearFleetSkillsCache } from "../../src/lib/conductor/fleetSkills.ts";
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearFleetSkillsCache();
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("sem CONDUCTOR_HUB_URL o card continua válido, com as skills estáticas e zero conductor-*", async () => {
|
||||
const res = await GET();
|
||||
const card = await res.json();
|
||||
assert.equal(typeof card.name, "string");
|
||||
assert.ok(Array.isArray(card.skills) && card.skills.length >= 6, "skills estáticas presentes");
|
||||
assert.ok(card.skills.every((s: { id: string }) => !s.id.startsWith("conductor-")));
|
||||
});
|
||||
|
||||
test("com hub de pé o card anuncia as skills da frota SEM perder as estáticas", async () => {
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify([
|
||||
{ id: "r_1", online: true, capabilities: { name: "devbox", clis: [{ profile: "claude" }], skills: [] } },
|
||||
])
|
||||
);
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const addr = server.address();
|
||||
process.env.CONDUCTOR_HUB_URL = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
|
||||
const res = await GET();
|
||||
const card = await res.json();
|
||||
const ids = card.skills.map((s: { id: string }) => s.id);
|
||||
assert.ok(ids.includes("conductor-cli-claude"), `frota anunciada (ids: ${ids.join(",")})`);
|
||||
assert.ok(ids.includes("smart-routing"), "estáticas intactas");
|
||||
});
|
||||
95
tests/unit/conductor-fleet-skills.test.ts
Normal file
95
tests/unit/conductor-fleet-skills.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getFleetSkills, clearFleetSkillsCache } from "../../src/lib/conductor/fleetSkills.ts";
|
||||
|
||||
const RUNNERS = [
|
||||
{
|
||||
id: "r_1",
|
||||
online: true,
|
||||
capabilities: {
|
||||
name: "devbox",
|
||||
clis: [
|
||||
{ profile: "claude", models: [{ id: "claude-sonnet-5", cost: 3, capability: 4 }] },
|
||||
{ profile: "codex" },
|
||||
],
|
||||
skills: ["deploy"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "r_2",
|
||||
online: true,
|
||||
capabilities: { name: "vm02", clis: [{ profile: "claude" }], skills: [] },
|
||||
},
|
||||
{
|
||||
id: "r_3",
|
||||
online: false, // offline: fora do anúncio
|
||||
capabilities: { name: "morta", clis: [{ profile: "gemini" }], skills: ["secret"] },
|
||||
},
|
||||
];
|
||||
|
||||
function fakeFetch(body: unknown, status = 200) {
|
||||
const calls: string[] = [];
|
||||
const impl = (async (url: string | URL | Request) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}) as typeof fetch;
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearFleetSkillsCache();
|
||||
process.env.CONDUCTOR_HUB_URL = "http://hub.test:7910";
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test("derives one skill per unique online CLI profile + one per declared OASF skill", async () => {
|
||||
const { impl, calls } = fakeFetch(RUNNERS);
|
||||
const skills = await getFleetSkills({ fetchImpl: impl });
|
||||
const ids = skills.map((s) => s.id).sort();
|
||||
assert.deepEqual(ids, ["conductor-cli-claude", "conductor-cli-codex", "conductor-skill-deploy"]);
|
||||
assert.ok(calls[0].includes("/v1/runners"));
|
||||
const claude = skills.find((s) => s.id === "conductor-cli-claude")!;
|
||||
assert.match(claude.description, /2 runner/);
|
||||
assert.match(claude.description, /claude-sonnet-5/);
|
||||
assert.ok(claude.tags.includes("conductor"));
|
||||
// runner offline não anuncia nada (gemini/secret ausentes)
|
||||
assert.ok(!ids.some((i) => i.includes("gemini") || i.includes("secret")));
|
||||
});
|
||||
|
||||
test("caches for the TTL and refetches after it expires (injectable clock)", async () => {
|
||||
const { impl, calls } = fakeFetch(RUNNERS);
|
||||
let now = 1_000_000;
|
||||
await getFleetSkills({ fetchImpl: impl, nowMs: () => now });
|
||||
await getFleetSkills({ fetchImpl: impl, nowMs: () => now + 30_000 });
|
||||
assert.equal(calls.length, 1, "dentro do TTL: sem refetch");
|
||||
now += 61_000;
|
||||
await getFleetSkills({ fetchImpl: impl, nowMs: () => now });
|
||||
assert.equal(calls.length, 2, "TTL vencido: refetch");
|
||||
});
|
||||
|
||||
test("hub offline/erro → [] (o card omite a seção, nunca quebra)", async () => {
|
||||
const failing = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as unknown as typeof fetch;
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: failing }), []);
|
||||
const { impl } = fakeFetch({ error: "x" }, 503);
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: impl }), []);
|
||||
});
|
||||
|
||||
test("sem CONDUCTOR_HUB_URL → [] sem nem tentar fetch", async () => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
const { impl, calls } = fakeFetch(RUNNERS);
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: impl }), []);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("shape inválido do hub → [] (input não confiável)", async () => {
|
||||
const { impl } = fakeFetch({ nao: "é array" });
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: impl }), []);
|
||||
});
|
||||
Reference in New Issue
Block a user