Compare commits

...

9 Commits

5 changed files with 260 additions and 0 deletions

View 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

View File

@@ -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"],

View 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;
}

View 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");
});

View 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 }), []);
});