/** * pi-a2a Protocol Types * * Implements the Synadia Agent Protocol for NATS v0.3 (§ core-protocol.md) * subjects and wire shapes, plus A2A-specific extensions. * * Subject topology (§2): * agents.a2a.{agent}.{owner}.{name} — A2A mailbox agent→agent * agents.prompt.{agent}.{owner}.{name} — H2A prompt human→agent (§5) * agents.hb.{agent}.{owner}.{name} — Heartbeat (§8.1, protocol-fixed) * agents.status.{agent}.{owner}.{name} — Status endpoint (§8.7) * * Chunk protocol (§6): * {type:"status",data:"ack"} — First chunk on every stream (§6.4) * {type:"response",data:"..."} — Response text (§6.3) * {type:"query",data:{...}} — Mid-stream query (§7) * — Terminator (§6.5) * * Message types follow the pi-agent-dashboard convention of discriminated * unions with a `type` field (not `$schema`). * * Agent identifiers (§2.2, Appendix C): * agent: "pi" (subject abbreviation: "pi") */ // ── Fabric identity ──────────────────────────────────────────── // // A "fabric" is a NATS server mesh. Entries in the roster and offline // queue are scoped by fabric so that switching NATS servers cleanly // shows different agent graphs without cross-contamination. // // The default local NATS (127.0.0.1:14222) gets the stable identifier // "local" for backward compatibility — no file migration needed. const DEFAULT_LOCAL_NATS = "nats://127.0.0.1:14222"; /** * Compute a stable human-readable fabric identifier from a NATS URL. * * - Default local: `"local"` (stable, backward-compatible) * - Others: `hostname:port` (e.g. `":7422"`) * - Override via `PI_A2A_FABRIC` env var for manual partitioning * * Credentials are stripped before computing the ID so token rotation * doesn't change the fabric identity. */ export function computeFabricId(natsUrl: string): string { // Env override wins const envOverride = process.env.PI_A2A_FABRIC; if (envOverride && envOverride.trim().length > 0) return envOverride.trim(); if (!natsUrl || natsUrl === DEFAULT_LOCAL_NATS) return "local"; try { const u = new URL(natsUrl); const host = u.hostname; const port = u.port || "4222"; return `${host}:${port}`; } catch { // Non-URL string (e.g. bare hostname) — sanitize as identifier return natsUrl.replace(/[^a-zA-Z0-9.:_-]/g, "_").slice(0, 64) || "unknown"; } } /** * Compute a filesystem-safe suffix for a fabric, used to build * per-fabric file paths (roster.{suffix}.json, pending.{suffix}/). */ export function computeFabricFileId(fabricId: string): string { if (fabricId === "local") return "local"; return fabricId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80); } // ── Fabric / Tenant identity ──────────────────────────────────── /** * TenantPrefix scopes all V2 subjects to a tenant namespace. * Default is "tenant.default". Override for multi-tenant deployments. */ export const TenantPrefix = "tenant.default"; // ── Reserved verbs (§2) ──────────────────────────────────────── export const SUBJECT_ROOT = "agents"; export const VERB_PROMPT = "prompt"; export const VERB_HEARTBEAT = "hb"; export const VERB_STATUS = "status"; export const VERB_ATTACHMENTS = "attachments"; export const VERB_A2A = "a2a"; // custom extension — not reserved export const AGENT_ID = "pi"; export const PROTOCOL_VERSION = "0.3"; // ── Agent Status Values ────────────────────────────────────────── export type AgentStatus = "online" | "busy" | "idle" | "away" | "offline"; // ── Safety limits ────────────────────────────────────────────── /** Maximum TTL in seconds for an A2A request (5 min). */ export const TTL_MAX_S = 300; /** Maximum collect timeout in ms (5 min). */ export const COLLECT_TIMEOUT_MAX_MS = 300_000; // ── Subject builders (§2.3) ──────────────────────────────────── export function a2aSubject(agent: string, owner: string, name: string): string { return `${SUBJECT_ROOT}.${VERB_A2A}.${agent}.${owner}.${name}`; } export const A2A_WILDCARD = `${SUBJECT_ROOT}.${VERB_A2A}.>`; export function hbSubject(agent: string, owner: string, name: string): string { return `${SUBJECT_ROOT}.${VERB_HEARTBEAT}.${agent}.${owner}.${name}`; } export function statusSubject(agent: string, owner: string, name: string): string { return `${SUBJECT_ROOT}.${VERB_STATUS}.${agent}.${owner}.${name}`; } export function promptSubject(agent: string, owner: string, name: string): string { return `${SUBJECT_ROOT}.${VERB_PROMPT}.${agent}.${owner}.${name}`; } export function groupSubject(groupId: string): string { return `${SUBJECT_ROOT}.a2a.group.${groupId.replace(/[^a-zA-Z0-9_-]/g, "_")}`; } // ── V2 Subject Builders (tenant-scoped, NKey-based) ──────────── // // These replace V1's email/PII-based subjects with NKey-based identifiers. // V1 functions are deprecated but kept for migration window compatibility. // See docs/FABRIC_upgrade.md for the dual-issue migration strategy. /** * A2A subject V2: tenant.default.a2a.{targetNkey}[.{delegatorNkey}] * * The optional delegatorNkey enables task-control hijacking prevention: * the message is addressed to targetNkey but was initiated by delegatorNkey. * The auth callout grants the target agent permission to pub to * tenant.default.a2a.{targetNkey} and sub to tenant.default.a2a.{targetNkey}.>. */ export function a2aSubjectV2(targetNkey: string, delegatorNkey?: string): string { const base = `${TenantPrefix}.a2a.${targetNkey}`; return delegatorNkey ? `${base}.${delegatorNkey}` : base; } /** * Wildcard for all A2A messages to a target NKey. */ export function a2aSubjectV2Wildcard(targetNkey: string): string { return `${TenantPrefix}.a2a.${targetNkey}.>`; } /** * Heartbeat subject V2: tenant.default.user.{ownerNkey}.agent.{agentNkey}.hb * * Uses NKeys (not email/SID) so heartbeats are PII-free and stable across * session restarts. */ export function hbSubjectV2(ownerNkey: string, agentNkey: string): string { return `${TenantPrefix}.user.${ownerNkey}.agent.${agentNkey}.hb`; } /** * Status subject V2: tenant.default.user.{ownerNkey}.agent.{agentNkey}.status */ export function statusSubjectV2(ownerNkey: string, agentNkey: string): string { return `${TenantPrefix}.user.${ownerNkey}.agent.${agentNkey}.status`; } /** * Prompt subject V2: tenant.default.user.{ownerNkey}.agent.{agentNkey}.prompt * * The agent subscribes to tenant.default.user.{ownerNkey}.agent.{agentNkey}.prompt.> * and the human/vgate publishes to tenant.default.user.{ownerNkey}.agent.{agentNkey}.prompt.{session}. */ export function promptSubjectV2(ownerNkey: string, agentNkey: string): string { return `${TenantPrefix}.user.${ownerNkey}.agent.${agentNkey}.prompt`; } /** * Prompt wildcard V2 — agent subscribes to this to receive prompts from any session. */ export function promptSubjectV2Wildcard(ownerNkey: string, agentNkey: string): string { return `${TenantPrefix}.user.${ownerNkey}.agent.${agentNkey}.prompt.>`; } /** * Group subject V2: {segmentPrefix}.a2a.group.{name} * * The segmentPrefix is typically the TenantPrefix ("tenant.default") * but can be any NATS-compatible segment for cross-tenant group messaging. */ export function groupSubjectV2(segmentPrefix: string, name: string): string { const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, "_"); return `${segmentPrefix}.a2a.group.${sanitized}`; } /** * Pending subject V2: tenant.default.pending.{agentNkey} * Published by vgate when an unclaimed agent introduces itself. */ export function pendingSubjectV2(agentNkey: string): string { return `${TenantPrefix}.pending.${agentNkey}`; } /** * Claim subject V2: tenant.default.claim.{agentNkey} * Published by vgate when a human claims an agent. */ export function claimSubjectV2(agentNkey: string): string { return `${TenantPrefix}.claim.${agentNkey}`; } // ── Discriminated Union Protocol ─────────────────────────────── export interface A2aRequest { type: "a2a_request"; id: string; prompt: string; from: { agent: string; owner: string; name: string }; attachments?: Attachment[]; a2a?: A2aRouting; ts: string; /** * Time-to-live in seconds. Capped at TTL_MAX_S (300) on the receiving end. * If absent, the receiver uses its own default timeout. */ ttlS?: number; } export interface A2aRouting { intent: "question" | "request" | "handoff" | "reply" | "sync" | "friend_request" | "friend_accept" | "friend_reject"; fromType?: string; fromAgent?: string; fromOwner?: string; fromName?: string; conversationId?: string; inReplyTo?: string; /** * "silent" suppresses ctx.ui.notify() logging for sensitive prompts. * Defaults to "normal" if absent. */ sensitivity?: "normal" | "silent"; } // ── Sync Extension ────────────────────────────────────────────── // // The sync pattern exchanges queued messages bidirectionally in one // round trip. No AI processing — just mailbox swap. // // Request envelope adds a2a_sync: // { prompt: "sync", a2a: { intent: "sync", ... }, a2a_sync: { outgoing: [...] } } // // Response: // { type: "response", data: { incoming: [...] } } export interface A2aSyncMessage { id: string; text: string; from: string; // sender session name ts: string; } export interface A2aSyncEnvelope { outgoing: A2aSyncMessage[]; } export interface A2aSyncResponse { incoming: A2aSyncMessage[]; } export interface A2aResponse { type: "a2a_response"; id: string; from: { agent: string; owner: string; name: string }; text: string; error?: boolean; errorCode?: number; ts: string; } export interface A2aEventForward { type: "a2a_event"; from: { agent: string; owner: string; name: string }; eventType: string; data: Record; ts: string; } export interface A2aToolInvite { type: "a2a_tool_invite"; id: string; to: { agent: string; owner: string; name: string }; toolName: string; params: Record; reply_subject: string; } export type A2aMessage = A2aRequest | A2aResponse | A2aEventForward | A2aToolInvite; // ── §5.1 Request Envelope (Synadia wire format) ───────────────── export interface RequestEnvelope { prompt: string; attachments?: Attachment[]; [key: string]: unknown; } export interface Attachment { filename: string; content: string; } // ── §6 Response chunks ───────────────────────────────────────── export interface Chunk { type: "response" | "status" | "query"; data: string | QueryData; } export interface QueryData { id: string; reply_subject: string; prompt: string; attachments?: Attachment[]; } // ── §8.3 Heartbeat ───────────────────────────────────────────── export type HeartbeatStatus = "online" | "busy" | "idle" | "away" | "offline"; export interface HeartbeatPayload { agent: string; owner: string; name?: string; instance_id: string; status?: HeartbeatStatus; model?: string; capabilities?: string[]; session?: string; ts: string; interval_s: number; } // ── Encoding Helpers ─────────────────────────────────────────── const encoder = new TextEncoder(); const decoder = new TextDecoder(); export function encodeJson(obj: T): Uint8Array { return encoder.encode(JSON.stringify(obj)); } export function decodeJson(data: Uint8Array): T { return JSON.parse(decoder.decode(data)) as T; } export function isJsonPayload(data: Uint8Array): boolean { let i = 0; while (i < data.length && (data[i] === 0x09 || data[i] === 0x0a || data[i] === 0x0d || data[i] === 0x20)) i++; return i < data.length && data[i] === 0x7b; } export function encodeRequest(prompt: string, extras?: { attachments?: Attachment[]; a2a?: A2aRouting }): Uint8Array { if (!extras || (!extras.attachments?.length && !extras.a2a)) return encoder.encode(prompt); const out: Record = { prompt }; if (extras.attachments?.length) out.attachments = extras.attachments; if (extras.a2a) out.a2a = extras.a2a; return encoder.encode(JSON.stringify(out)); } /** * Encode a sync request with an outgoing message batch. * Used by the a2a-sync pattern for bidirectional mailbox exchange. */ export function encodeSyncRequest( fromIdentity: { agent: string; owner: string; name: string }, outgoing: A2aSyncMessage[], ): Uint8Array { return encoder.encode(JSON.stringify({ prompt: "sync", a2a: { intent: "sync", fromAgent: fromIdentity.agent, fromOwner: fromIdentity.owner, fromName: fromIdentity.name, fromType: AGENT_ID, }, a2a_sync: { outgoing }, })); } export function decodeRequest(data: Uint8Array): RequestEnvelope { if (isJsonPayload(data)) { const parsed = JSON.parse(decoder.decode(data)) as RequestEnvelope; if (!parsed.prompt || typeof parsed.prompt !== "string") throw new Error("400: JSON envelope missing 'prompt'"); return parsed; } const text = decoder.decode(data).trim(); if (text.length === 0) throw new Error("400: empty request payload"); return { prompt: text }; } export function encodeChunk(chunk: Chunk): Uint8Array { return encoder.encode(JSON.stringify(chunk)); } export function encodeHeartbeat(hb: HeartbeatPayload): Uint8Array { return encoder.encode(JSON.stringify(hb)); } export const TERMINATOR: Uint8Array = new Uint8Array(0); export function nowISO(): string { return new Date().toISOString(); } export function shortId(): string { return crypto.randomUUID().slice(0, 8); }