diff --git a/examples/review-agent/agent-card.json b/examples/review-agent/agent-card.json new file mode 100644 index 00000000..86af422b --- /dev/null +++ b/examples/review-agent/agent-card.json @@ -0,0 +1,47 @@ +{ + "name": "review-agent", + "description": "Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.", + "url": "http://localhost:3000", + "version": "0.0.0", + "skills": [ + { + "id": "review-rubric", + "name": "Review Rubric", + "description": "Apply a consistent correctness and regression-risk review rubric.", + "tags": [ + "https://prpm.dev/packages/@agentworkforce/review-rubric", + "github", + "slack" + ] + }, + { + "id": "review", + "name": "Review", + "description": "Persona capability: Review", + "tags": [ + "github", + "slack" + ] + } + ], + "provider": { + "organization": "AgentWorkforce", + "persona_id": "review-agent", + "intent": "review", + "tags": [ + "review" + ] + }, + "capabilities": { + "streaming": false, + "pushNotifications": false + }, + "default_input_modes": [ + "text/plain", + "application/json" + ], + "default_output_modes": [ + "text/plain", + "application/json" + ] +} diff --git a/examples/review-agent/persona.json b/examples/review-agent/persona.json index 0612059e..2b20db70 100644 --- a/examples/review-agent/persona.json +++ b/examples/review-agent/persona.json @@ -5,6 +5,17 @@ "review" ], "description": "Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.", + "skills": [ + { + "id": "review-rubric", + "source": "https://prpm.dev/packages/@agentworkforce/review-rubric", + "description": "Apply a consistent correctness and regression-risk review rubric." + } + ], + "capabilities": { + "review": true, + "issueClaim": false + }, "cloud": true, "useSubscription": true, "integrations": { diff --git a/examples/review-agent/persona.ts b/examples/review-agent/persona.ts index ae150190..e7a699c8 100644 --- a/examples/review-agent/persona.ts +++ b/examples/review-agent/persona.ts @@ -6,6 +6,17 @@ export default definePersona({ tags: ['review'], description: 'Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.', + skills: [ + { + id: 'review-rubric', + source: 'https://prpm.dev/packages/@agentworkforce/review-rubric', + description: 'Apply a consistent correctness and regression-risk review rubric.' + } + ], + capabilities: { + review: true, + issueClaim: false + }, cloud: true, useSubscription: true, // Connection config only — which events fire this agent lives in agent.ts @@ -16,7 +27,14 @@ export default definePersona({ }, memory: { enabled: true, - scopes: ['workspace'] + scopes: ['workspace'], + trajectories: { + enabled: true, + autoCompact: true + }, + aiMemory: { + enabled: true + } }, onEvent: './agent.ts', harness: 'codex', diff --git a/package.json b/package.json index 3771cff7..d2eef977 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,10 @@ } }, "devDependencies": { + "@relaycast/a2a": "^6.2.0", "@types/node": "^22.18.0", "agent-trajectories": "^0.5.3", + "tsx": "^4.23.1", "typescript": "^5.9.2" }, "scripts": { @@ -33,7 +35,8 @@ "dev:cli": "node packages/cli/dist/cli.js", "typecheck": "pnpm -r typecheck && pnpm run typecheck:examples", "typecheck:examples": "tsc -p examples/tsconfig.json --noEmit", - "test": "node --test scripts/release-workflows.test.mjs && pnpm -r test", + "test": "node --test scripts/release-workflows.test.mjs && pnpm -r test && pnpm run test:e2e:agent-card", + "test:e2e:agent-card": "pnpm -r build && tsx scripts/e2e-agent-card.ts", "lint": "pnpm -r lint", "check": "pnpm run lint && pnpm run typecheck && pnpm run test" } diff --git a/packages/cli/src/agent-card-command.test.ts b/packages/cli/src/agent-card-command.test.ts new file mode 100644 index 00000000..61694c63 --- /dev/null +++ b/packages/cli/src/agent-card-command.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseAgentCardCommandArgs } from './agent-card-command.js'; + +test('parseAgentCardCommandArgs parses the shared card contract flags', () => { + assert.deepEqual( + parseAgentCardCommandArgs([ + 'persona.ts', + '--base-url', + 'https://agent.example.test', + '--version=2.0.0', + '--documentation-url', + 'https://docs.example.test/agent', + '--input-mode', + 'application/json', + '--input-mode=text/plain', + '--output-mode=text/markdown', + '--json' + ]), + { + personaPath: 'persona.ts', + json: true, + options: { + baseUrl: 'https://agent.example.test', + version: '2.0.0', + documentationUrl: 'https://docs.example.test/agent', + inputModes: ['application/json', 'text/plain'], + outputModes: ['text/markdown'] + } + } + ); +}); + +test('parseAgentCardCommandArgs defaults compile-time deployment values', () => { + assert.deepEqual(parseAgentCardCommandArgs(['persona.json']), { + personaPath: 'persona.json', + json: false, + options: { baseUrl: 'http://localhost:3000', version: '0.0.0' } + }); +}); + +test('parseAgentCardCommandArgs rejects missing and extra persona paths', () => { + assert.throws( + () => parseAgentCardCommandArgs([]), + /missing / + ); + assert.throws( + () => parseAgentCardCommandArgs(['one.json', 'two.json']), + /unexpected argument "two\.json"/ + ); +}); diff --git a/packages/cli/src/agent-card-command.ts b/packages/cli/src/agent-card-command.ts new file mode 100644 index 00000000..a02a68ea --- /dev/null +++ b/packages/cli/src/agent-card-command.ts @@ -0,0 +1,90 @@ +import { + deriveAgentCard, + type DeriveAgentCardOptions +} from '@agentworkforce/persona-kit'; + +import { + DEFAULT_COMPILED_AGENT_CARD_OPTIONS, + loadParsedPersonaSourceFile, + parseAgentCardOptions +} from './persona-compile.js'; + +interface Writable { + write(chunk: string): unknown; +} + +interface AgentCardCommandDeps { + stdout?: Writable; +} + +export async function runAgentCardCommand( + args: readonly string[], + deps: AgentCardCommandDeps = {} +): Promise { + const stdout = deps.stdout ?? process.stdout; + if (args[0] === '-h' || args[0] === '--help') { + stdout.write(AGENT_CARD_USAGE); + return; + } + + const { personaPath, json, options } = parseAgentCardCommandArgs(args); + const { persona } = await loadParsedPersonaSourceFile(personaPath); + const card = deriveAgentCard(persona, options); + + // MCP's JSON text content uses JSON.stringify without whitespace. Keep the + // CLI --json surface byte-identical so either presenter is interchangeable. + stdout.write(json ? JSON.stringify(card) : `${JSON.stringify(card, null, 2)}\n`); +} + +export function parseAgentCardCommandArgs(args: readonly string[]): { + personaPath: string; + json: boolean; + options: DeriveAgentCardOptions; +} { + let personaPath: string | undefined; + let json = false; + const optionArgs: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--json') { + json = true; + } else if (arg?.startsWith('--')) { + optionArgs.push(arg); + if (!arg.includes('=')) { + const value = args[++index]; + if (value !== undefined) optionArgs.push(value); + } + } else if (!personaPath && arg) { + personaPath = arg; + } else { + throw new Error(`agent-card: unexpected argument "${arg}"`); + } + } + + if (!personaPath) { + throw new Error('agent-card: missing '); + } + + return { + personaPath, + json, + options: { + ...DEFAULT_COMPILED_AGENT_CARD_OPTIONS, + ...parseAgentCardOptions(optionArgs, 'agent-card') + } + }; +} + +const AGENT_CARD_USAGE = `Usage: agentworkforce agent-card [flags] + +Derive a schema-validated A2A agent card from a persona. + +Flags: + --base-url Deployed agent origin (default: http://localhost:3000) + --version Deployment/package version (default: 0.0.0) + --documentation-url Optional persona documentation URL + --input-mode Override an input mode; repeat for multiple modes + --output-mode Override an output mode; repeat for multiple modes + --json Emit compact JSON matching get_agent_card exactly +`; diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index df49f6eb..3a7570d8 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -248,7 +248,10 @@ Commands: --json emit the resolved PersonaSpec as JSON persona compile Compile a typed persona.ts authoring file to sibling - persona.json after validating it with persona-kit. + persona.json and agent-card.json after validation. + agent-card [--base-url ] [--version ] [--json] + Derive a canonical A2A agent card. Compact --json output + is byte-identical to mcp-workforce get_agent_card. install [flags] Copy persona JSON files from an npm package or local package directory into @@ -5069,6 +5072,16 @@ export async function main(): Promise { } } + if (subcommand === 'agent-card') { + try { + const { runAgentCardCommand } = await import('./agent-card-command.js'); + await runAgentCardCommand(rest); + return; + } catch (err) { + die((err as Error)?.message ?? String(err), false); + } + } + if (subcommand === 'install') { runPersonaInstall(rest); } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 65e58458..b97e603a 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -729,11 +729,18 @@ test('main: persona compile dispatches to the typed persona compiler', async () ); assert.equal(exitCode, 0); assert.equal(stderr, ''); - assert.match(stdout, /Compiled .*persona\.ts -> .*persona\.json \(cli-compiled\)/); + assert.match( + stdout, + /Compiled .*persona\.ts -> .*persona\.json, .*agent-card\.json \(cli-compiled\)/ + ); const compiled = JSON.parse(readFileSync(join(root, 'persona.json'), 'utf8')) as { id: string; }; + const agentCard = JSON.parse( + readFileSync(join(root, 'agent-card.json'), 'utf8') + ) as { name: string }; assert.equal(compiled.id, 'cli-compiled'); + assert.equal(agentCard.name, 'cli-compiled'); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/cli/src/persona-compile.test.ts b/packages/cli/src/persona-compile.test.ts index 97cf6175..336e6435 100644 --- a/packages/cli/src/persona-compile.test.ts +++ b/packages/cli/src/persona-compile.test.ts @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, @@ -11,6 +12,8 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { A2aAgentCardSchema } from '@relaycast/a2a'; + import { compilePersonaFile } from './persona-compile.js'; test('compilePersonaFile bundles persona.ts, validates it, and writes persona.json', async () => { @@ -18,6 +21,7 @@ test('compilePersonaFile bundles persona.ts, validates it, and writes persona.js try { const inputPath = join(root, 'persona.ts'); const outputPath = join(root, 'persona.json'); + const agentCardPath = join(root, 'agent-card.json'); writeFileSync( inputPath, `import { definePersona } from '@agentworkforce/persona-kit'; @@ -37,6 +41,15 @@ export default definePersona({ jira: {}, unknown: {} }, + skills: [{ + id: 'review-rubric', + source: '@agentworkforce/review-rubric', + description: 'Apply the review rubric.' + }], + capabilities: { + review: true, + issueClaim: false + }, onEvent: './agent.ts', harnessSettings: { reasoning: 'medium', @@ -56,11 +69,21 @@ export default definePersona({ assert.equal(result.personaId, 'compiled-persona'); assert.equal(result.outputPath, outputPath); + assert.equal(result.agentCardPath, agentCardPath); assert.equal(compiled.id, 'compiled-persona'); assert.equal(compiled.inputs?.TARGET, 'repo'); // Integration connections (source/scope) are preserved; triggers live in agent.ts. assert.equal(compiled.integrations?.github.scope?.repo, 'org/repo'); assert.ok(compiled.integrations?.unknown); + + const card = A2aAgentCardSchema.parse( + JSON.parse(readFileSync(agentCardPath, 'utf8')) + ); + assert.equal(card.url, 'http://localhost:3000'); + assert.equal(card.version, '0.0.0'); + assert.ok(card.skills.some((skill) => skill.id === 'review-rubric')); + assert.ok(card.skills.some((skill) => skill.id === 'review')); + assert.ok(!card.skills.some((skill) => skill.id === 'issueClaim')); } finally { rmSync(root, { recursive: true, force: true }); } @@ -246,10 +269,8 @@ test('compilePersonaFile removes the temporary evaluated module', async () => { const root = mkdtempSync(join(tmpdir(), 'aw-persona-compile-cleanup-')); try { const inputPath = join(root, 'persona.ts'); - const tempPrefix = 'agentworkforce-persona-'; - const existingTempDirs = new Set( - readdirSync(tmpdir()).filter((name) => name.startsWith(tempPrefix)) - ); + const isolatedTemp = join(root, 'tmp'); + mkdirSync(isolatedTemp); writeFileSync( inputPath, `export default { @@ -263,9 +284,22 @@ test('compilePersonaFile removes the temporary evaluated module', async () => { 'utf8' ); - await compilePersonaFile(inputPath); - const leftovers = readdirSync(tmpdir()).filter((name) => - name.startsWith(tempPrefix) && !existingTempDirs.has(name) + const child = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { compilePersonaFile } from ${JSON.stringify(new URL('./persona-compile.js', import.meta.url).href)}; await compilePersonaFile(process.argv[1]);`, + inputPath + ], + { + encoding: 'utf8', + env: { ...process.env, TMPDIR: isolatedTemp } + } + ); + assert.equal(child.status, 0, child.stderr || child.stdout); + const leftovers = readdirSync(isolatedTemp).filter((name) => + name.startsWith('agentworkforce-persona-') ); assert.deepEqual(leftovers, []); } finally { diff --git a/packages/cli/src/persona-compile.ts b/packages/cli/src/persona-compile.ts index d326df10..da26a1d7 100644 --- a/packages/cli/src/persona-compile.ts +++ b/packages/cli/src/persona-compile.ts @@ -1,48 +1,91 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; -import { isIntent, isObject, parsePersonaSpec } from '@agentworkforce/persona-kit'; +import { + deriveAgentCard, + isIntent, + isObject, + parsePersonaSpec, + type DeriveAgentCardOptions, + type PersonaSpec +} from '@agentworkforce/persona-kit'; import { loadPersonaSourceFile } from '@agentworkforce/deploy'; +export const DEFAULT_COMPILED_AGENT_CARD_OPTIONS: DeriveAgentCardOptions = { + baseUrl: 'http://localhost:3000', + version: '0.0.0' +}; + export interface PersonaCompileResult { inputPath: string; outputPath: string; + agentCardPath: string; personaId: string; } -export async function compilePersonaFile( - inputPath: string, - outputPath?: string -): Promise { - const absInput = resolve(inputPath); - const absOutput = resolve(outputPath ?? join(dirname(absInput), 'persona.json')); +export interface LoadedPersonaSource { + inputPath: string; + source: Record; + persona: PersonaSpec; +} - const { persona: spec } = await loadPersonaSourceFile(absInput); - if (!isObject(spec)) { - throw new Error('persona compile: default export must be a persona object'); +export async function loadParsedPersonaSourceFile( + inputPath: string +): Promise { + const absInput = resolve(inputPath); + const { persona: source } = await loadPersonaSourceFile(absInput); + if (!isObject(source)) { + throw new Error('persona: default export must be a persona object'); } - if (typeof spec.intent !== 'string') { - throw new Error('persona compile: default export must include a string intent'); + if (typeof source.intent !== 'string') { + throw new Error('persona: default export must include a string intent'); } - if (!isIntent(spec.intent)) { - throw new Error(`persona compile: intent "${spec.intent}" is invalid`); + if (!isIntent(source.intent)) { + throw new Error(`persona: intent "${source.intent}" is invalid`); } - const parsed = parsePersonaSpec(spec, spec.intent); + return { + inputPath: absInput, + source, + persona: parsePersonaSpec(source, source.intent) + }; +} + +export async function compilePersonaFile( + inputPath: string, + outputPath?: string, + agentCardOptions: DeriveAgentCardOptions = DEFAULT_COMPILED_AGENT_CARD_OPTIONS +): Promise { + const loaded = await loadParsedPersonaSourceFile(inputPath); + const absOutput = resolve( + outputPath ?? join(dirname(loaded.inputPath), 'persona.json') + ); + const agentCardPath = join(dirname(absOutput), 'agent-card.json'); + const agentCard = deriveAgentCard(loaded.persona, agentCardOptions); await mkdir(dirname(absOutput), { recursive: true }); - await writeFile(absOutput, JSON.stringify(spec, null, 2) + '\n', 'utf8'); + await Promise.all([ + writeFile( + absOutput, + `${JSON.stringify(loaded.source, null, 2)}\n`, + 'utf8' + ), + writeFile(agentCardPath, `${JSON.stringify(agentCard, null, 2)}\n`, 'utf8') + ]); return { - inputPath: absInput, + inputPath: loaded.inputPath, outputPath: absOutput, - personaId: parsed.id + agentCardPath, + personaId: loaded.persona.id }; } export async function runPersonaCompileCommand(args: string[]): Promise { const [action, personaPath, ...rest] = args; if (!action || action === '-h' || action === '--help') { - process.stdout.write('Usage: agentworkforce persona compile \n'); + process.stdout.write( + 'Usage: agentworkforce persona compile [--base-url ] [--version ]\n' + ); process.exit(action ? 0 : 1); } if (action !== 'compile') { @@ -51,12 +94,80 @@ export async function runPersonaCompileCommand(args: string[]): Promise { if (!personaPath) { throw new Error('persona compile: missing '); } - if (rest.length > 0) { - throw new Error(`persona compile: unexpected argument "${rest[0]}"`); - } + const agentCardOptions = parseAgentCardOptions(rest, 'persona compile'); - const result = await compilePersonaFile(personaPath); + const result = await compilePersonaFile(personaPath, undefined, { + ...DEFAULT_COMPILED_AGENT_CARD_OPTIONS, + ...agentCardOptions + }); process.stdout.write( - `Compiled ${result.inputPath} -> ${result.outputPath} (${result.personaId})\n` + `Compiled ${result.inputPath} -> ${result.outputPath}, ${result.agentCardPath} (${result.personaId})\n` ); } + +export function parseAgentCardOptions( + args: readonly string[], + context: string +): Partial { + const options: Partial = {}; + const inputModes: string[] = []; + const outputModes: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--base-url') { + options.baseUrl = expectValue(context, arg, args[++index]); + } else if (arg?.startsWith('--base-url=')) { + options.baseUrl = expectValue( + context, + '--base-url', + arg.slice('--base-url='.length) + ); + } else if (arg === '--version') { + options.version = expectValue(context, arg, args[++index]); + } else if (arg?.startsWith('--version=')) { + options.version = expectValue( + context, + '--version', + arg.slice('--version='.length) + ); + } else if (arg === '--documentation-url') { + options.documentationUrl = expectValue(context, arg, args[++index]); + } else if (arg?.startsWith('--documentation-url=')) { + options.documentationUrl = expectValue( + context, + '--documentation-url', + arg.slice('--documentation-url='.length) + ); + } else if (arg === '--input-mode') { + inputModes.push(expectValue(context, arg, args[++index])); + } else if (arg?.startsWith('--input-mode=')) { + inputModes.push( + expectValue(context, '--input-mode', arg.slice('--input-mode='.length)) + ); + } else if (arg === '--output-mode') { + outputModes.push(expectValue(context, arg, args[++index])); + } else if (arg?.startsWith('--output-mode=')) { + outputModes.push( + expectValue(context, '--output-mode', arg.slice('--output-mode='.length)) + ); + } else { + throw new Error(`${context}: unexpected argument "${arg}"`); + } + } + + if (inputModes.length > 0) options.inputModes = inputModes; + if (outputModes.length > 0) options.outputModes = outputModes; + return options; +} + +function expectValue( + context: string, + flag: string, + value: string | undefined +): string { + if (!value || value.startsWith('-')) { + throw new Error(`${context}: ${flag} requires a value`); + } + return value; +} diff --git a/packages/mcp-workforce/src/index.ts b/packages/mcp-workforce/src/index.ts index fcf5a372..20a11501 100644 --- a/packages/mcp-workforce/src/index.ts +++ b/packages/mcp-workforce/src/index.ts @@ -27,3 +27,7 @@ export { type ListIntegrationsArgs, type ListIntegrationsDeps } from './tools/list-integrations.js'; +export { + getAgentCardTool, + type GetAgentCardArgs +} from './tools/get-agent-card.js'; diff --git a/packages/mcp-workforce/src/server.test.ts b/packages/mcp-workforce/src/server.test.ts index 58d16daa..e02707ca 100644 --- a/packages/mcp-workforce/src/server.test.ts +++ b/packages/mcp-workforce/src/server.test.ts @@ -44,6 +44,7 @@ test('createWorkforceMcpServer registers the documented tool set', () => { const tools = (server as unknown as Registry)._registeredTools ?? {}; const names = Object.keys(tools).sort(); assert.deepEqual(names, [ + 'get_agent_card', 'integration.github.comment', 'integration.github.createIssue', 'integration.github.getPr', diff --git a/packages/mcp-workforce/src/server.ts b/packages/mcp-workforce/src/server.ts index c7b60a8f..a8e5c3c1 100644 --- a/packages/mcp-workforce/src/server.ts +++ b/packages/mcp-workforce/src/server.ts @@ -10,6 +10,7 @@ import { type IntegrationToolName } from './tools/integrations.js'; import { listIntegrationsTool } from './tools/list-integrations.js'; +import { getAgentCardTool } from './tools/get-agent-card.js'; const MEMORY_SCOPE_ENUM = z.enum(['workspace', 'user', 'global']); @@ -112,6 +113,24 @@ export function createWorkforceMcpServer(config: WorkforceMcpConfig): McpServer async (args) => jsonResult(await listIntegrationsTool(args, { config })) ); + server.registerTool( + 'get_agent_card', + { + title: 'Derive an A2A agent card from a workforce persona', + description: + 'Returns the same canonical card JSON as `agentworkforce agent-card --json`.', + inputSchema: { + persona: z.record(z.string(), z.unknown()), + baseUrl: z.string().url(), + version: z.string().min(1), + documentationUrl: z.string().url().optional(), + inputModes: z.array(z.string().min(1)).optional(), + outputModes: z.array(z.string().min(1)).optional() + } + }, + async (args) => jsonResult(getAgentCardTool(args)) + ); + registerGithubTools(server, config); return server; diff --git a/packages/mcp-workforce/src/tools/get-agent-card.test.ts b/packages/mcp-workforce/src/tools/get-agent-card.test.ts new file mode 100644 index 00000000..acd9da96 --- /dev/null +++ b/packages/mcp-workforce/src/tools/get-agent-card.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { A2aAgentCardSchema } from '@relaycast/a2a'; + +import { getAgentCardTool } from './get-agent-card.js'; + +const persona = { + id: 'team-reviewer', + intent: 'review', + description: 'Reviews changes with a team.', + tags: ['review'], + skills: [ + { + id: 'review-rubric', + source: '@agentworkforce/review-rubric', + description: 'Apply the review rubric.' + } + ], + integrations: { github: {} }, + capabilities: { + review: true, + teamSolve: { enabled: true, maxMembers: 2 }, + issueClaim: false + }, + relay: { agentName: 'relay-team-reviewer' }, + harness: 'codex', + model: 'gpt-5', + systemPrompt: 'Review the change.', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 60 } +}; + +test('getAgentCardTool parses the persona and returns the canonical card', () => { + const card = getAgentCardTool({ + persona, + baseUrl: 'https://agent.example.test', + version: '2.1.0' + }); + + assert.deepEqual(A2aAgentCardSchema.parse(card), card); + assert.equal(card.name, 'relay-team-reviewer'); + assert.ok(card.skills.some((skill) => skill.id === 'review-rubric')); + assert.ok(card.skills.some((skill) => skill.id === 'review')); + assert.ok(card.skills.some((skill) => skill.id === 'teamSolve')); + assert.ok(!card.skills.some((skill) => skill.id === 'issueClaim')); + assert.ok(card.skills.every((skill) => skill.tags?.includes('github'))); +}); + +test('getAgentCardTool rejects unparsed persona input', () => { + assert.throws( + () => + getAgentCardTool({ + persona: { id: 'broken', intent: 'not-an-intent' }, + baseUrl: 'https://agent.example.test', + version: '1.0.0' + }), + /persona\.intent must be a valid persona intent/ + ); +}); diff --git a/packages/mcp-workforce/src/tools/get-agent-card.ts b/packages/mcp-workforce/src/tools/get-agent-card.ts new file mode 100644 index 00000000..60aa34da --- /dev/null +++ b/packages/mcp-workforce/src/tools/get-agent-card.ts @@ -0,0 +1,32 @@ +import { + deriveAgentCard, + isIntent, + isObject, + parsePersonaSpec, + type DeriveAgentCardOptions +} from '@agentworkforce/persona-kit/spec'; + +export interface GetAgentCardArgs extends DeriveAgentCardOptions { + persona: unknown; +} + +/** Return the same canonical card document as `agentworkforce agent-card --json`. */ +export function getAgentCardTool(args: GetAgentCardArgs) { + if (!isObject(args.persona)) { + throw new Error('get_agent_card: persona must be an object'); + } + if (typeof args.persona.intent !== 'string' || !isIntent(args.persona.intent)) { + throw new Error('get_agent_card: persona.intent must be a valid persona intent'); + } + + const persona = parsePersonaSpec(args.persona, args.persona.intent); + return deriveAgentCard(persona, { + baseUrl: args.baseUrl, + version: args.version, + ...(args.documentationUrl !== undefined + ? { documentationUrl: args.documentationUrl } + : {}), + ...(args.inputModes ? { inputModes: args.inputModes } : {}), + ...(args.outputModes ? { outputModes: args.outputModes } : {}) + }); +} diff --git a/packages/persona-kit/package.json b/packages/persona-kit/package.json index b518f329..3e9b169f 100644 --- a/packages/persona-kit/package.json +++ b/packages/persona-kit/package.json @@ -41,6 +41,7 @@ "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@relaycast/a2a": "^6.2.0", "@relayfile/adapter-core": "^0.5.1", "@relayfile/local-mount": "^0.10.23" }, diff --git a/packages/persona-kit/src/agent-card.test.ts b/packages/persona-kit/src/agent-card.test.ts new file mode 100644 index 00000000..ffeca4cc --- /dev/null +++ b/packages/persona-kit/src/agent-card.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { A2aAgentCardSchema } from '@relaycast/a2a'; + +import { deriveAgentCard } from './agent-card.js'; +import type { PersonaSpec } from './types.js'; + +const options = { + baseUrl: 'https://review-agent.example.test', + version: '1.2.3' +}; + +function persona(overrides: Partial = {}): PersonaSpec { + return { + id: 'review-agent', + intent: 'review', + tags: ['review'], + description: 'Reviews changes.', + skills: [ + { + id: 'review-rubric', + source: '@agentworkforce/review-rubric', + description: 'Apply the review rubric.' + } + ], + harness: 'codex', + model: 'gpt-5', + systemPrompt: 'Review the change.', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 60 }, + ...overrides + }; +} + +test('deriveAgentCard produces the canonical A2A shape with defaults', () => { + const card = deriveAgentCard(persona(), options); + + assert.deepEqual(A2aAgentCardSchema.parse(card), card); + assert.equal(card.name, 'review-agent'); + assert.deepEqual(card.capabilities, { + streaming: false, + pushNotifications: false + }); + assert.deepEqual(card.default_input_modes, [ + 'text/plain', + 'application/json' + ]); + assert.deepEqual(card.default_output_modes, [ + 'text/plain', + 'application/json' + ]); + assert.deepEqual(card.provider, { + organization: 'AgentWorkforce', + persona_id: 'review-agent', + intent: 'review', + tags: ['review'] + }); +}); + +test('deriveAgentCard includes enabled capabilities and omits disabled ones', () => { + const card = deriveAgentCard( + persona({ + capabilities: { + review: true, + issueClaim: false, + conflictAutofix: { enabled: false }, + httpRead: { enabled: true, allow: [] } + } + }), + options + ); + + assert.deepEqual( + card.skills.map((skill) => skill.id), + ['review-rubric', 'review', 'httpRead'] + ); +}); + +test('deriveAgentCard preserves enabled unknown capabilities', () => { + const card = deriveAgentCard( + persona({ + capabilities: { + teamSolve: { enabled: true, maxMembers: 3 }, + futureCapability: { enabled: false } + } + }), + options + ); + + assert.ok(card.skills.some((skill) => skill.id === 'teamSolve')); + assert.ok(!card.skills.some((skill) => skill.id === 'futureCapability')); +}); + +test('deriveAgentCard uses relay.agentName and otherwise falls back to persona id', () => { + assert.equal( + deriveAgentCard(persona({ relay: { agentName: 'relay-reviewer' } }), options) + .name, + 'relay-reviewer' + ); + assert.equal(deriveAgentCard(persona({ relay: true }), options).name, 'review-agent'); + assert.equal(deriveAgentCard(persona({ relay: {} }), options).name, 'review-agent'); +}); + +test('deriveAgentCard surfaces integration providers as skill tags', () => { + const card = deriveAgentCard( + persona({ + integrations: { github: {}, slack: {} }, + capabilities: { review: true } + }), + options + ); + + for (const skill of card.skills) { + assert.ok(skill.tags?.includes('github')); + assert.ok(skill.tags?.includes('slack')); + } + assert.deepEqual(card.skills[0]?.tags, [ + '@agentworkforce/review-rubric', + 'github', + 'slack' + ]); +}); + +test('deriveAgentCard canonicalizes pullRequest and derives A2A capability flags', () => { + const card = deriveAgentCard( + persona({ + capabilities: { + review: false, + pullRequest: true, + streaming: {}, + pushNotifications: { enabled: false } + } + }), + { + ...options, + documentationUrl: 'https://docs.example.test/reviewer', + inputModes: ['application/json'], + outputModes: ['text/markdown'] + } + ); + + assert.equal(card.skills.filter((skill) => skill.id === 'review').length, 1); + assert.deepEqual(card.capabilities, { + streaming: true, + pushNotifications: false + }); + assert.ok(!card.skills.some((skill) => skill.id === 'streaming')); + assert.ok(!card.skills.some((skill) => skill.id === 'pushNotifications')); + assert.deepEqual(card.default_input_modes, ['application/json']); + assert.deepEqual(card.default_output_modes, ['text/markdown']); + assert.equal(card.documentation_url, 'https://docs.example.test/reviewer'); +}); + +test('deriveAgentCard uses intent as the schema-required fallback skill', () => { + const card = deriveAgentCard(persona({ skills: [], capabilities: {} }), options); + + assert.deepEqual(card.skills, [ + { + id: 'review', + name: 'Review', + description: 'Reviews changes.', + tags: [] + } + ]); + assert.deepEqual(A2aAgentCardSchema.parse(card), card); +}); diff --git a/packages/persona-kit/src/agent-card.ts b/packages/persona-kit/src/agent-card.ts new file mode 100644 index 00000000..1710e0a2 --- /dev/null +++ b/packages/persona-kit/src/agent-card.ts @@ -0,0 +1,136 @@ +import { + A2aAgentCardSchema, + type A2aAgentCard, + type A2aSkill +} from '@relaycast/a2a'; + +import type { CapabilityValue, PersonaSpec } from './types.js'; + +const DEFAULT_MODES = ['text/plain', 'application/json'] as const; +const A2A_TRANSPORT_CAPABILITIES = new Set([ + 'streaming', + 'pushNotifications' +]); + +/** Deployment-specific values that cannot be inferred from a persona definition. */ +export interface DeriveAgentCardOptions { + /** Deployed agent origin. A2A RPC is expected at `/a2a/rpc`. */ + baseUrl: string; + /** Deployment or package version advertised by the agent. */ + version: string; + documentationUrl?: string; + inputModes?: readonly string[]; + outputModes?: readonly string[]; +} + +/** + * Map a parsed persona to Relaycast's canonical A2A agent-card contract. + * + * This module intentionally has no Node dependencies so it remains safe to + * export from persona-kit's side-effect-free `./spec` entrypoint. + */ +export function deriveAgentCard( + personaSpec: PersonaSpec, + options: DeriveAgentCardOptions +): A2aAgentCard { + const integrationTags = Object.keys(personaSpec.integrations ?? {}); + const skills = mergeSkills(personaSpec, integrationTags); + + const relayName = + typeof personaSpec.relay === 'object' && personaSpec.relay !== null + ? personaSpec.relay.agentName + : undefined; + + return A2aAgentCardSchema.parse({ + name: relayName ?? personaSpec.id, + description: personaSpec.description, + url: options.baseUrl, + version: options.version, + skills, + capabilities: { + streaming: capabilityEnabled(personaSpec.capabilities?.streaming), + pushNotifications: capabilityEnabled( + personaSpec.capabilities?.pushNotifications + ) + }, + default_input_modes: [...(options.inputModes ?? DEFAULT_MODES)], + default_output_modes: [...(options.outputModes ?? DEFAULT_MODES)], + provider: { + organization: 'AgentWorkforce', + persona_id: personaSpec.id, + intent: personaSpec.intent, + tags: [...(personaSpec.tags ?? [])] + }, + ...(options.documentationUrl !== undefined + ? { documentation_url: options.documentationUrl } + : {}) + }); +} + +function mergeSkills( + personaSpec: PersonaSpec, + integrationTags: readonly string[] +): A2aSkill[] { + const skills = personaSpec.skills.map((skill) => ({ + id: skill.id, + name: humanize(skill.id), + description: skill.description, + tags: unique([skill.source, ...integrationTags]) + })); + + for (const [declaredName, value] of Object.entries( + personaSpec.capabilities ?? {} + )) { + if (!capabilityEnabled(value)) continue; + + // These keys describe the A2A transport itself, not work the persona can + // perform. They are projected into card.capabilities above; all other + // unknown capability keys remain discoverable as skills. + if (A2A_TRANSPORT_CAPABILITIES.has(declaredName)) continue; + + const id = declaredName === 'pullRequest' ? 'review' : declaredName; + const existing = skills.find((skill) => skill.id === id); + if (existing) { + existing.tags = unique([...(existing.tags ?? []), ...integrationTags]); + continue; + } + + skills.push({ + id, + name: humanize(id), + description: `Persona capability: ${humanize(id)}`, + tags: unique(integrationTags) + }); + } + + // PersonaSpec allows an empty skills/capabilities merge, while the + // canonical A2A schema requires at least one skill. The intent is the + // narrowest truthful fallback and keeps every valid persona derivable. + if (skills.length === 0) { + skills.push({ + id: personaSpec.intent, + name: humanize(personaSpec.intent), + description: personaSpec.description, + tags: unique(integrationTags) + }); + } + + return skills; +} + +function capabilityEnabled(value: CapabilityValue | undefined): boolean { + if (value === true) return true; + if (value === false || value === undefined) return false; + return value.enabled !== false; +} + +function humanize(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/gu, '$1 $2') + .replace(/[-_:]+/gu, ' ') + .replace(/\b\w/gu, (letter) => letter.toUpperCase()); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index e35bfc06..1c7819fd 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -13,6 +13,12 @@ export { } from './constants.js'; export type { KnownPersonaTag } from './constants.js'; +// Canonical A2A agent-card derivation +export { + deriveAgentCard, + type DeriveAgentCardOptions +} from './agent-card.js'; + // Types export type { AgentSpec, diff --git a/packages/persona-kit/src/spec.test.ts b/packages/persona-kit/src/spec.test.ts index 6e390350..e4bd71d0 100644 --- a/packages/persona-kit/src/spec.test.ts +++ b/packages/persona-kit/src/spec.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test'; import { HARNESS_VALUES, + deriveAgentCard, isHarness, isIntent, parseAgentSpec, @@ -28,6 +29,7 @@ test('spec entrypoint re-exports the validation surface', () => { assert.equal(typeof parseAgentSpec, 'function'); assert.equal(typeof isIntent, 'function'); assert.equal(typeof isHarness, 'function'); + assert.equal(typeof deriveAgentCard, 'function'); assert.ok(Array.isArray(HARNESS_VALUES)); }); diff --git a/packages/persona-kit/src/spec.ts b/packages/persona-kit/src/spec.ts index c3b5916c..52d2c048 100644 --- a/packages/persona-kit/src/spec.ts +++ b/packages/persona-kit/src/spec.ts @@ -13,10 +13,10 @@ * the barrel therefore EVALUATES every one of those modules' top-level * code — `node:child_process`, `node:fs`, and the deferred * `@relayfile/local-mount` → `@parcel/watcher` edge — none of which a pure - * validation consumer needs. `./spec.js` transitively imports only - * `./parse.js`, `./constants.js`, and `./types.js`, which have ZERO - * external runtime dependencies (no `@relayfile/*`, no native bindings, - * no Node process/fs APIs at module-eval time). + * validation consumer needs. `./spec.js` transitively imports only the pure + * parser/constants/types modules plus `./agent-card.js` and Relaycast's Zod + * schema contract. None use `node:*`, native bindings, or process/fs APIs at + * module-eval time. * * Keep this entrypoint in lockstep with the validation-related exports of * `./index.js`; it is intentionally a strict subset, never a superset. @@ -37,6 +37,12 @@ export { } from './constants.js'; export type { KnownPersonaTag } from './constants.js'; +// Canonical A2A agent-card derivation +export { + deriveAgentCard, + type DeriveAgentCardOptions +} from './agent-card.js'; + // Spec types export type { AgentSpec, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a980004..72d2a39b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,12 +19,18 @@ importers: .: devDependencies: + '@relaycast/a2a': + specifier: ^6.2.0 + version: 6.2.0 '@types/node': specifier: ^22.18.0 version: 22.19.15 agent-trajectories: specifier: ^0.5.3 version: 0.5.3 + tsx: + specifier: ^4.23.1 + version: 4.23.1 typescript: specifier: ^5.9.2 version: 5.9.3 @@ -165,6 +171,9 @@ importers: packages/persona-kit: dependencies: + '@relaycast/a2a': + specifier: ^6.2.0 + version: 6.2.0 '@relayfile/adapter-core': specifier: ^0.5.1 version: 0.5.1(@relayfile/sdk@0.7.40) @@ -507,156 +516,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -1073,6 +1238,9 @@ packages: resolution: {integrity: sha512-C5eVSepGrlQY5b1yg+LfROLYhDW/w1WFVsuitRzvbXMr+qpFYFHLoMNxa9R0Q6ZiP02r6EXSazii2VNo2LZ0Vg==} engines: {node: '>=22'} + '@relaycast/a2a@6.2.0': + resolution: {integrity: sha512-gFz6yEHYFjxQ+zCcd8ELc7d4LXL2TI8bTOnJ0tkaYjt4krKn68LtOllc8kjxlUI05v2YXCARbZ022T8Y4yDc2g==} + '@relaycast/sdk@6.0.5': resolution: {integrity: sha512-LNixPCBMsqD7yhuNvt8FzcEagdmnWnXvY62HZOlZbOrxRPDP1Wxnc/EDJVjRMP2TuRmLm7aHlkTIRZKG2bxdbA==} @@ -1594,6 +1762,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1683,6 +1856,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2171,6 +2349,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -2971,81 +3154,159 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.1 @@ -3516,6 +3777,10 @@ snapshots: '@relayburn/sdk-linux-arm64-gnu': 2.5.2 '@relayburn/sdk-linux-x64-gnu': 2.5.2 + '@relaycast/a2a@6.2.0': + dependencies: + zod: 4.4.3 + '@relaycast/sdk@6.0.5': dependencies: '@relaycast/types': 6.0.5 @@ -4108,6 +4373,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -4223,6 +4517,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} @@ -4703,6 +5000,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@0.14.5: optional: true diff --git a/scripts/e2e-agent-card.ts b/scripts/e2e-agent-card.ts new file mode 100644 index 00000000..c87ab0e7 --- /dev/null +++ b/scripts/e2e-agent-card.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { + cpSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { A2aAgentCardSchema } from '@relaycast/a2a'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const fixtureDir = mkdtempSync(join(repoRoot, '.tmp-e2e-agent-card-')); +const personaPath = join(fixtureDir, 'persona.ts'); +const personaJsonPath = join(fixtureDir, 'persona.json'); +const agentCardPath = join(fixtureDir, 'agent-card.json'); +const baseUrl = 'https://review-agent.example.test'; +const version = '9.8.7-e2e'; + +try { + // Exercise the real checked-in example without mutating its generated files. + cpSync(join(repoRoot, 'examples/review-agent/persona.ts'), personaPath); + + runCli([ + 'persona', + 'compile', + personaPath, + '--base-url', + baseUrl, + '--version', + version + ]); + + const emittedJson = readFileSync(agentCardPath, 'utf8'); + const emittedCard = A2aAgentCardSchema.parse(JSON.parse(emittedJson)); + const skillIds = emittedCard.skills.map((skill) => skill.id); + + assert.equal(emittedCard.url, baseUrl, 'compiled card must advertise its origin'); + assert.equal( + emittedCard.version, + version, + 'compiled card must advertise its deployment version' + ); + assert.ok( + skillIds.includes('review-rubric'), + 'compiled card must include the example persona declared skill' + ); + assert.ok( + skillIds.includes('review'), + 'compiled card must include the enabled review capability' + ); + assert.ok( + !skillIds.includes('issueClaim'), + 'compiled card must omit the disabled issueClaim capability' + ); + + const cliJson = runCli([ + 'agent-card', + personaPath, + '--base-url', + baseUrl, + '--version', + version, + '--json' + ]); + const mcpJson = runMcpTool(); + + assert.equal( + cliJson, + mcpJson, + 'CLI --json and mcp-workforce get_agent_card JSON must be byte-identical' + ); + assert.deepEqual( + A2aAgentCardSchema.parse(JSON.parse(cliJson)), + emittedCard, + 'compile and discovery surfaces must derive the same card' + ); + + const originalSource = readFileSync(personaPath, 'utf8'); + const toggledSource = originalSource + .replace('review: true', 'review: false') + .replace('issueClaim: false', 'issueClaim: true'); + assert.notEqual( + toggledSource, + originalSource, + 'E2E fixture must contain the capability flags it toggles' + ); + writeFileSync(personaPath, toggledSource, 'utf8'); + + runCli([ + 'persona', + 'compile', + personaPath, + '--base-url', + baseUrl, + '--version', + version + ]); + const toggledCard = A2aAgentCardSchema.parse( + JSON.parse(readFileSync(agentCardPath, 'utf8')) + ); + const toggledSkillIds = toggledCard.skills.map((skill) => skill.id); + assert.ok( + !toggledSkillIds.includes('review'), + 'disabling review in the persona must remove it from the compiled card' + ); + assert.ok( + toggledSkillIds.includes('issueClaim'), + 'enabling issueClaim in the persona must add it to the compiled card' + ); + + process.stdout.write( + `agent-card E2E passed: ${emittedCard.name} (${skillIds.join(', ')} -> ${toggledSkillIds.join(', ')})\n` + ); +} finally { + rmSync(fixtureDir, { recursive: true, force: true }); +} + +function runCli(args: string[]): string { + const result = spawnSync( + process.execPath, + [join(repoRoot, 'packages/cli/dist/cli.js'), ...args], + { cwd: repoRoot, encoding: 'utf8' } + ); + if (result.status !== 0) { + throw new Error( + `CLI failed (${args.join(' ')}):\n${result.stderr || result.stdout}` + ); + } + return result.stdout; +} + +function runMcpTool(): string { + // Invoke the registered MCP handler, including its JSON text presenter, in + // plain Node. This tests the real get_agent_card registration while keeping + // tsx's CJS compatibility hook away from unrelated ESM-only server deps. + const script = ` + import { readFileSync } from 'node:fs'; + import { createWorkforceMcpServer } from './packages/mcp-workforce/dist/server.js'; + const server = createWorkforceMcpServer({ + workspaceId: 'agent-card-e2e', + cloudUrl: 'https://cloud.agentworkforce.com', + writebackTimeoutMs: 30000 + }); + const tool = server._registeredTools?.get_agent_card; + if (!tool?.handler) throw new Error('get_agent_card is not registered'); + const persona = JSON.parse(readFileSync(process.env.AGENT_CARD_PERSONA_PATH, 'utf8')); + const result = await tool.handler({ + persona, + baseUrl: process.env.AGENT_CARD_BASE_URL, + version: process.env.AGENT_CARD_VERSION + }, {}); + const text = result.content?.[0]?.text; + if (typeof text !== 'string') throw new Error('get_agent_card returned no JSON text'); + process.stdout.write(text); + `; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + AGENT_CARD_PERSONA_PATH: personaJsonPath, + AGENT_CARD_BASE_URL: baseUrl, + AGENT_CARD_VERSION: version + } + }); + if (result.status !== 0) { + throw new Error(`MCP get_agent_card failed:\n${result.stderr || result.stdout}`); + } + return result.stdout; +}