From 7b951761fc89bc06a6b0154105a081165967c9ea Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:35:16 -0300 Subject: [PATCH] feat(a2a): fleet skills derived from the Conductor hub for the agent card --- src/lib/conductor/fleetSkills.ts | 106 ++++++++++++++++++++++ tests/unit/conductor-fleet-skills.test.ts | 95 +++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/lib/conductor/fleetSkills.ts create mode 100644 tests/unit/conductor-fleet-skills.test.ts diff --git a/src/lib/conductor/fleetSkills.ts b/src/lib/conductor/fleetSkills.ts new file mode 100644 index 0000000000..d836be8799 --- /dev/null +++ b/src/lib/conductor/fleetSkills.ts @@ -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 { + 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): FleetSkill[] { + const online = runners.filter((r) => r.online !== false); + const byProfile = new Map }>(); + const oasfSkills = new Set(); + for (const r of online) { + for (const cli of r.capabilities.clis ?? []) { + const entry = byProfile.get(cli.profile) ?? { count: 0, models: new Set() }; + 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; +} diff --git a/tests/unit/conductor-fleet-skills.test.ts b/tests/unit/conductor-fleet-skills.test.ts new file mode 100644 index 0000000000..eb16a76d2f --- /dev/null +++ b/tests/unit/conductor-fleet-skills.test.ts @@ -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 }), []); +});