mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(a2a): fleet skills derived from the Conductor hub for the agent card
This commit is contained in:
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;
|
||||
}
|
||||
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