feat(a2a): fleet skills derived from the Conductor hub for the agent card

This commit is contained in:
diegosouzapw
2026-07-22 08:35:16 -03:00
parent b97318d73b
commit 7b951761fc
2 changed files with 201 additions and 0 deletions

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