diff --git a/samples/js/package.json b/samples/js/package.json index 01959a45..fabd7456 100644 --- a/samples/js/package.json +++ b/samples/js/package.json @@ -11,16 +11,22 @@ "scripts": { "a2a:cli": "npx tsx src/cli.ts", "agents:movie-agent": "npx tsx src/agents/movie-agent/index.ts", - "agents:coder": "npx tsx src/agents/coder/index.ts" + "agents:coder": "npx tsx src/agents/coder/index.ts", + "agents:eliza": "npx tsx src/agents/eliza/eliza-app.ts", + "agents:eliza:create-profile": "tsx src/agents/eliza/create-global-agentic-profile.ts", + "agents:eliza:authcli": "tsx src/agents/eliza/universal-auth-cli.ts" }, "dependencies": { "@a2a-js/sdk": "^0.2.4", - "@genkit-ai/googleai": "^1.8.0", + "@agentic-profile/auth": "^0.6.0", + "@agentic-profile/common": "^0.6.0", + "@agentic-profile/eliza": "^0.1.0", "@genkit-ai/vertexai": "^1.8.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "body-parser": "^2.2.0", "cors": "^2.8.5", + "did-resolver": "^4.1.0", "express": "^4.21.2", "genkit": "^1.8.0" }, diff --git a/samples/js/src/agents/eliza/README.md b/samples/js/src/agents/eliza/README.md new file mode 100644 index 00000000..335d4ca8 --- /dev/null +++ b/samples/js/src/agents/eliza/README.md @@ -0,0 +1,45 @@ +# Eliza Agent + +This agent provides a demonstration of the historical [Eliza chatbot](https://en.wikipedia.org/wiki/ELIZA) + +Eliza was one of the first chatbots (1966!) to attempt the Turing test and designed to explore communication between humans and machines. To run: + + npm run agents:eliza + +The agent server will start on [`http://localhost:41241`](http://localhost:41241) and provides agent.json cards at three endpoints which are listed by the server. + + +## Chat with Eliza without Universal Authentication + +The Eliza agent provides a well-known endpoint that does not require authentication. + +1. Make sure Eliza is running: + + npm run agents:eliza + +2. In a separate terminal window, use the standard command line interface to connect: + + npm run a2a:cli + + +## Chat with Eliza WITH Universal Authentication + +Universal Authentication uses W3C Decentralized IDs (DIDs) and DID documents to scope agents to people, businesses, and governments. Each DID document contains the cryptographic public keys which allow agents to authenticate without centralized authentication servers. + +The Eliza agent provides the /agents/eliza endpoint that requires authentication. + +1. Make sure you have created a demo agentic profile + + npm run agents:eliza:create-profile + +2. Make sure Eliza is running: + + npm run agents:eliza + +3. In a separate terminal window, use the special authenticating command line interface to connect: + + npm run agents:eliza:authcli [`http://localhost:41241/agents/eliza`](http://localhost:41241/agents/eliza) #connect + + Type in a message to the Eliza agent to cause an A2A RPC call to the server which triggers the authentication. + +To read more about Universal Authentication and DIDs can be used with A2A please visit the [AgenticProfile blog](https://agenticprofile.ai) diff --git a/samples/js/src/agents/eliza/a2a_express_service.ts b/samples/js/src/agents/eliza/a2a_express_service.ts new file mode 100644 index 00000000..3a1870e0 --- /dev/null +++ b/samples/js/src/agents/eliza/a2a_express_service.ts @@ -0,0 +1,200 @@ +/** + * A2AExpressService provides endpoints for an A2A service agent card and JSON-RPC requests. + * This class supports multiple A2A services on the same server, as well as support for + * universal authentication and agent multi-tenancy. + * + * Agent multi-tenancy is the ability of one agent to represent multiple users. For example, an + * (Eliza) therapist agent POST /message to the "Joseph" therapist would reply as Joseph, whereas the + * same agent POST /message to the "Sarah" therapist would reply as Sarah. + * + * To see multi-tenancy in production, see [Matchwise](https://matchwise.ai) where the Connect agent represents + * many users to provide personalized business networking advice. + * + * Universal authentication is the ability to authenticate any client without the need for + * an authentication service like OAuth. Universal authentication uses public key cryptography + * where the public keys for clients are distributed in W3C DID documents. + */ + +import { Request, Response, Router } from 'express'; +import { Resolver } from "did-resolver"; +import { + b64u, + ClientAgentSession, + ClientAgentSessionStore, + createChallenge, + handleAuthorization, +} from "@agentic-profile/auth"; +import { + A2AResponse, + JSONRPCErrorResponse, + JSONRPCSuccessResponse, +} from "@a2a-js/sdk"; // Import common types +import { + A2AError, + A2ARequestHandler, + JsonRpcTransportHandler, +} from "@a2a-js/sdk/server"; // Import server components + +export type AgentSessionResolver = (req: Request, res: Response) => Promise + +/** + * Options for configuring the A2AService. + */ +export interface A2AServiceOptions { + /** Task storage implementation. Defaults to InMemoryTaskStore. */ + //taskStore?: TaskStore; + + /** URL Path for the A2A endpoint. If not provided, the req.originalUrl is used. */ + agentPath?: string; + + /** Agent session resolver. If not defined, then universal authentication is not supported. */ + agentSessionResolver?: AgentSessionResolver +} + +export class A2AExpressService { + private requestHandler: A2ARequestHandler; // Kept for getAgentCard + private jsonRpcTransportHandler: JsonRpcTransportHandler; + private options: A2AServiceOptions; + + constructor(requestHandler: A2ARequestHandler, options?: A2AServiceOptions) { + this.requestHandler = requestHandler; // DefaultRequestHandler instance + this.jsonRpcTransportHandler = new JsonRpcTransportHandler(requestHandler); + this.options = options; + } + + public cardEndpoint = async (req: Request, res: Response) => { + try { + // resolve agent service endpoint + let url: string; + if (this.options?.agentPath) { + url = `${req.protocol}://${req.get('host')}${this.options.agentPath}`; + } else { + /* If there's no explicit agent path, then derive one from the Express + Request originalUrl by removing the trailing /agent.json if present */ + const baseUrl = req.originalUrl.replace(/\/agent\.json$/, ''); + url = `${req.protocol}://${req.get('host')}${baseUrl}`; + } + + const agentCard = await this.requestHandler.getAgentCard(); + res.json({ ...agentCard, url }); + } catch (error: unknown) { + console.error("Error fetching agent card:", error); + res.status(500).json({ error: "Failed to retrieve agent card" }); + } + } + + public agentEndpoint = async (req: Request, res: Response) => { + try { + // Handle client authentication + let agentSession: ClientAgentSession | null = null; + if (this.options?.agentSessionResolver) { + agentSession = await this.options.agentSessionResolver(req, res); + if (!agentSession) + return; // 401 response with challenge already issued + else console.log("Agent session resolved:", agentSession.id, agentSession.agentDid); + } + + const rpcResponseOrStream = await this.jsonRpcTransportHandler.handle(req.body); + + // Check if it's an AsyncGenerator (stream) + if (typeof (rpcResponseOrStream as unknown)?.[Symbol.asyncIterator] === 'function') { + const stream = rpcResponseOrStream as AsyncGenerator; + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + try { + for await (const event of stream) { + // Each event from the stream is already a JSONRPCResult + res.write(`id: ${new Date().getTime()}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + } catch (streamError: unknown) { + console.error(`Error during SSE streaming (request ${req.body?.id}):`, streamError); + // If the stream itself throws an error, send a final JSONRPCErrorResponse + const a2aError = streamError instanceof A2AError ? streamError : A2AError.internalError((streamError as Error).message || 'Streaming error.'); + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: req.body?.id || null, // Use original request ID if available + error: a2aError.toJSONRPCError(), + }; + if (!res.headersSent) { // Should not happen if flushHeaders worked + res.status(500).json(errorResponse); // Should be JSON, not SSE here + } else { + // Try to send as last SSE event if possible, though client might have disconnected + res.write(`id: ${new Date().getTime()}\n`); + res.write(`event: error\n`); // Custom event type for client-side handling + res.write(`data: ${JSON.stringify(errorResponse)}\n\n`); + } + } finally { + if (!res.writableEnded) { + res.end(); + } + } + } else { // Single JSON-RPC response + const rpcResponse = rpcResponseOrStream as A2AResponse; + res.status(200).json(rpcResponse); + } + } catch (error: unknown) { // Catch errors from jsonRpcTransportHandler.handle itself (e.g., initial parse error) + console.error("Unhandled error in A2AExpressApp POST handler:", error); + const a2aError = error instanceof A2AError ? error : A2AError.internalError('General processing error.'); + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: req.body?.id || null, + error: a2aError.toJSONRPCError(), + }; + if (!res.headersSent) { + res.status(500).json(errorResponse); + } else if (!res.writableEnded) { + // If headers sent (likely during a stream attempt that failed early), try to end gracefully + res.end(); + } + } + } + + /** + * Adds A2A routes to an existing Express app. + * @param app Optional existing Express app. + * @param baseUrl The base URL for A2A endpoints (e.g., "/a2a/api"). + * @returns The Express app with A2A routes. + */ + public routes(): Router { + const router = Router(); + + router.get("/agent.json", this.cardEndpoint); + + router.post("/", this.agentEndpoint); + + // The separate /stream endpoint is no longer needed. + return router; + } +} + +/** + * If an authorization header is provided, then an attemot to resolve an agent session is made, + * otherwise a 401 response with a new challenge in the WWW-Authenticate header. + * @returns a ClientAgentSession, or null if request handled by 401/challenge + * @throws {Error} if authorization header is invalid. If authorization is expired or not + * found, then no error is thrown and instead a new challenge is issued. + */ +export async function resolveAgentSession( + req: Request, + res: Response, + store: ClientAgentSessionStore, + didResolver: Resolver +): Promise { + const { authorization } = req.headers; + if (authorization) { + const agentSession = await handleAuthorization(authorization, store, didResolver); + if (agentSession) + return agentSession; + } + + const challenge = await createChallenge(store); + res.status(401) + .set('WWW-Authenticate', `Agentic ${b64u.objectToBase64Url(challenge)}`) + .end(); + return null; +} diff --git a/samples/js/src/agents/eliza/create-global-agentic-profile.ts b/samples/js/src/agents/eliza/create-global-agentic-profile.ts new file mode 100644 index 00000000..d0c5d918 --- /dev/null +++ b/samples/js/src/agents/eliza/create-global-agentic-profile.ts @@ -0,0 +1,53 @@ +import os from "os"; +import { join } from "path"; +import { + createAgenticProfile, + prettyJson, + webDidToUrl +} from "@agentic-profile/common"; +import { + createEdDsaJwk, + postJson +} from "@agentic-profile/auth"; +import { saveProfile } from "./universal-auth.js"; + + +(async ()=>{ + const services = [ + { + name: "Business networking connector", + type: "A2A", + id: "connect", + url: `httsp://example.com/agents/connect` + } + ]; + const { profile, keyring, b64uPublicKey } = await createAgenticProfile({ services, createJwkSet: createEdDsaJwk }); + + try { + // publish profile to web (so did:web:... will resolve) + const { data } = await postJson( + "https://testing.agenticprofile.ai/agentic-profile", + { profile, b64uPublicKey } + ); + const savedProfile = data.profile; + const did = savedProfile.id; + console.log( `Published demo user agentic profile to: + + ${webDidToUrl(did)} + +Or via DID at: + + ${did} +`); + + // also save locally for reference + const dir = join( os.homedir(), ".agentic", "iam", "a2a-demo-user" ); + await saveProfile({ dir, profile: savedProfile, keyring }); + + console.log(`Saved demo user agentic profile to ${dir} + +Shhhh! Keyring for testing... ${prettyJson( keyring )}`); + } catch (error) { + console.error( "Failed to create demo user profile", error ); + } +})(); \ No newline at end of file diff --git a/samples/js/src/agents/eliza/eliza-agent-card.ts b/samples/js/src/agents/eliza/eliza-agent-card.ts new file mode 100644 index 00000000..97b54a26 --- /dev/null +++ b/samples/js/src/agents/eliza/eliza-agent-card.ts @@ -0,0 +1,40 @@ +import { AgentCard } from "@a2a-js/sdk"; + +export const elizaAgentCard = ( url?: string ): AgentCard => ({ + name: 'Eliza Agent', + description: 'The classic AI chatbot from 1966', + // Adjust the base URL and port as needed. /a2a is the default base in A2AExpressApp + url, + provider: { + organization: 'A2A Samples', + url: 'https://example.com/a2a-samples' // Added provider URL + }, + version: '0.0.1', // Incremented version + capabilities: { + streaming: true, // The new framework supports streaming + pushNotifications: false, // Assuming not implemented for this agent yet + stateTransitionHistory: true, // Agent uses history + }, + // authentication: null, // Property 'authentication' does not exist on type 'AgentCard'. + securitySchemes: undefined, // Or define actual security schemes if any + security: undefined, + defaultInputModes: ['text'], + defaultOutputModes: ['text', 'task-status'], // task-status is a common output mode + skills: [ + { + id: 'therapy', + name: 'Rogerian Psychotherapy', + description: 'Provides Rogerian psychotherapy', + tags: ['health', 'wellness', 'therapy'], + examples: [ + 'I feel like I am not good enough', + 'I am not sure what to do', + 'I am feeling overwhelmed', + 'I am not sure what to do' + ], + inputModes: ['text'], // Explicitly defining for skill + outputModes: ['text', 'task-status'] // Explicitly defining for skill + }, + ], + supportsAuthenticatedExtendedCard: false, + }); \ No newline at end of file diff --git a/samples/js/src/agents/eliza/eliza-agent.ts b/samples/js/src/agents/eliza/eliza-agent.ts new file mode 100644 index 00000000..261a0655 --- /dev/null +++ b/samples/js/src/agents/eliza/eliza-agent.ts @@ -0,0 +1,118 @@ +import { ElizaBot } from "@agentic-profile/eliza"; +import { ClientAgentSession } from '@agentic-profile/auth'; + +import { + AgentExecutor, + RequestContext, + ExecutionEventBus, + TaskStatusUpdateEvent, + TextPart, + Message +} from "@a2a-js/sdk"; +import { SessionContextStore } from "./store.js"; + +/** + * Generate a UUID v4 compatible with Node.js versions >=16.0.0 + */ +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +/** + * ElizaAgentExecutor implements the Eliza DOCTOR agent. + */ +export class ElizaAgentExecutor implements AgentExecutor { + private sessionContextStore: SessionContextStore; + + constructor( sessionContextStore: SessionContextStore ) { + this.sessionContextStore = sessionContextStore; + } + + public cancelTask = async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _taskId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _eventBus: ExecutionEventBus, + ): Promise => { + console.log( `[ElizaAgentExecutor] Does not support Cancelling tasks` ); + }; + + async execute( + requestContext: RequestContext, + eventBus: ExecutionEventBus + ): Promise { + const userMessage = requestContext.userMessage; + const taskId = requestContext.taskId; + const contextId = requestContext.contextId; + const sessionId = getSessionId( requestContext ); + console.log( + `[ElizaAgentExecutor] Processing message ${userMessage.messageId} for task ${taskId} context ${contextId} in session ${sessionId ?? "- none -"}` + ); + + try { + const userText = userMessage.parts + .filter((p) => p.kind === 'text' && !!p.text) + .map(p => (p as TextPart).text) + .join('. '); + + const eliza = new ElizaBot(false); + const elizaState = await this.sessionContextStore.loadContext( sessionId, contextId ); + if( elizaState ) + eliza.setState( elizaState ); + + const agentReplyText = userText ? eliza.transform( userText )!: eliza.getInitial()!; + + await this.sessionContextStore.saveContext( sessionId, contextId, eliza.getState() ); + + // 5. Publish final task status update + const agentMessage: Message = { + kind: 'message', + role: 'agent', + messageId: generateUUID(), + parts: [{ kind: 'text', text: agentReplyText }], // Ensure some text + taskId: undefined, // Don't pass taskId to client, otherwise it will be passed back to the server in the next request + contextId: contextId, + }; + eventBus.publish(agentMessage); + + console.log( + `[ElizaAgentExecutor] Context ${contextId} finished` + ); + + } catch (error: unknown) { + console.error( + `[ElizaAgentExecutor] Error processing task ${taskId}:`, + error + ); + const errorMessage = error instanceof Error ? error.message : String(error); + const errorUpdate: TaskStatusUpdateEvent = { + kind: 'status-update', + taskId: taskId, + contextId: contextId, + status: { + state: 'failed', + message: { + kind: 'message', + role: 'agent', + messageId: generateUUID(), + parts: [{ kind: 'text', text: `Agent error: ${errorMessage}` }], + taskId: taskId, + contextId: contextId, + }, + timestamp: new Date().toISOString(), + }, + final: true, + }; + eventBus.publish(errorUpdate); + } + } +} + +function getSessionId( requestContext: RequestContext ): string { + const session = (requestContext as { session?: ClientAgentSession })?.session; + return session?.agentDid ?? ""; +} diff --git a/samples/js/src/agents/eliza/eliza-app.ts b/samples/js/src/agents/eliza/eliza-app.ts new file mode 100644 index 00000000..7b72e9cd --- /dev/null +++ b/samples/js/src/agents/eliza/eliza-app.ts @@ -0,0 +1,68 @@ +import { createDidResolver } from "@agentic-profile/common"; +import { Request, Response } from "express"; +import { + TaskStore, +} from "@a2a-js/sdk"; +import { + InMemoryTaskStore, + AgentExecutor, + DefaultRequestHandler, +} from "@a2a-js/sdk/server"; + +import { A2AExpressService, resolveAgentSession } from "./a2a_express_service.js"; +import express from "express"; +import { elizaAgentCard } from "./eliza-agent-card.js"; +import { ElizaAgentExecutor } from "./eliza-agent.js"; + +import { InMemoryUnifiedStore } from "./store.js"; +const unifiedStore = new InMemoryUnifiedStore(); + +async function main() { + const PORT = process.env.PORT || 41241; + + // 1. Prepare TaskStore and AgentExecutor + const taskStore: TaskStore = new InMemoryTaskStore(); + const agentExecutor: AgentExecutor = new ElizaAgentExecutor(unifiedStore); + + // 2. Create DefaultRequestHandler. This can be used by both the well-known and authenticated agents. + const agentCard = elizaAgentCard(); + const requestHandler = new DefaultRequestHandler( + agentCard, + taskStore, + agentExecutor + ); + + // 3. Create and setup A2A app + const app = express(); + app.use(express.json()); + + // 4. Create a "well-known" Eliza with no authentication + const openAgentPath = "/"; + const wellKnownService = new A2AExpressService(requestHandler, { agentPath: openAgentPath }); + app.use(openAgentPath, wellKnownService.routes()); + const wellKnownCardPath = "/.well-known/agent.json"; + app.get(wellKnownCardPath, wellKnownService.cardEndpoint); + + // 5. Prepare to resolve DIDs and agent sessions for universal authentication + const didResolver = createDidResolver(); + const agentSessionResolver = async (req: Request, res: Response) => { + return resolveAgentSession(req, res, unifiedStore, didResolver); + } + + // 6. Create an Eliza at "/agents/eliza" that requires authentication + const a2aServiceWithAuth = new A2AExpressService(requestHandler, { agentSessionResolver }); + const secureAgentPath = "/agents/eliza"; + app.use(secureAgentPath, a2aServiceWithAuth.routes()); + + // 7. Start the server + const baseUrl = `http://localhost:${PORT}`; + app.listen(PORT, () => { + console.log(`[ElizaAgent] Server using new framework started on ${baseUrl}`); + console.log(`[ElizaAgent] Open Agent Card: ${baseUrl}${wellKnownCardPath}`); + console.log(`[ElizaAgent] Open Agent Card: ${baseUrl}${openAgentPath}agent.json`); + console.log(`[ElizaAgent] Secure Agent Card: ${baseUrl}${secureAgentPath}/agent.json`); + console.log('[ElizaAgent] Press Ctrl+C to stop the server'); + }); +} + +main().catch(console.error); diff --git a/samples/js/src/agents/eliza/store.ts b/samples/js/src/agents/eliza/store.ts new file mode 100644 index 00000000..57526b9f --- /dev/null +++ b/samples/js/src/agents/eliza/store.ts @@ -0,0 +1,44 @@ +import { + ClientAgentSession, + ClientAgentSessionStore, + ClientAgentSessionUpdates +} from "@agentic-profile/auth"; + +export interface SessionContextStore { + saveContext( sessionId: string, contextId: string, context: unknown ): Promise; + loadContext( sessionId: string, contextId: string ): Promise; +} + +export class InMemoryUnifiedStore implements ClientAgentSessionStore, SessionContextStore { + private nextSessionId = 1; + private clientSessions = new Map(); + private contextStore = new Map(); + + // ClientAgentSessionStore methods + async createClientAgentSession( challenge: string ) { + const id = this.nextSessionId++; + this.clientSessions.set( id, { id, challenge, created: new Date() } as ClientAgentSession ); + return id; + } + + async fetchClientAgentSession( id:number ) { + return this.clientSessions.get( id ); + } + + async updateClientAgentSession( id:number, updates:ClientAgentSessionUpdates ) { + const session = this.clientSessions.get( id ); + if( !session ) + throw new Error("Failed to find client session by id: " + id ); + else + this.clientSessions.set( id, { ...session, ...updates } ); + } + + // SessionContextStore methods + async saveContext( sessionId: string, contextId: string, context: unknown ): Promise { + this.contextStore.set( `${sessionId}:${contextId}`, context ); + } + + async loadContext( sessionId: string, contextId: string ): Promise { + return this.contextStore.get( `${sessionId}:${contextId}` ); + } +} \ No newline at end of file diff --git a/samples/js/src/agents/eliza/universal-auth-cli.ts b/samples/js/src/agents/eliza/universal-auth-cli.ts new file mode 100644 index 00000000..34895dfa --- /dev/null +++ b/samples/js/src/agents/eliza/universal-auth-cli.ts @@ -0,0 +1,366 @@ +#!/usr/bin/env node + +import readline from "node:readline"; +import crypto from "node:crypto"; + +import { + // Specific Params/Payload types used by the CLI + MessageSendParams, // Changed from TaskSendParams + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, + Message, + Task, // Added for direct Task events + // Other types needed for message/part handling + FilePart, + DataPart, + // Type for the agent card + AgentCard, + Part, // Added for explicit Part typing + A2AClient, +} from "@a2a-js/sdk"; +import { createAuthHandler } from "./universal-auth.js"; + +// --- ANSI Colors --- +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + gray: "\x1b[90m", +}; + +// --- Helper Functions --- +function colorize(color: keyof typeof colors, text: string): string { + return `${colors[color]}${text}${colors.reset}`; +} + +function generateId(): string { // Renamed for more general use + return crypto.randomUUID(); +} + +// --- State --- +let currentTaskId: string | undefined = undefined; // Initialize as undefined +let currentContextId: string | undefined = undefined; // Initialize as undefined +const serverUrl = process.argv[2] || "http://localhost:41241"; // Agent's base URL + +// Use universal authentication? +const userAgentDid = process.argv[3]; +const authProfile = process.argv[4] ?? "a2a-demo-user"; + +let client: A2AClient; // Declare client variable +let agentName = "Agent"; // Default, try to get from agent card later + +// --- Readline Setup --- +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: colorize("cyan", "You: "), +}); + +// --- Response Handling --- +// Function now accepts the unwrapped event payload directly +function printAgentEvent( + event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent +) { + const timestamp = new Date().toLocaleTimeString(); + const prefix = colorize("magenta", `\n${agentName} [${timestamp}]:`); + + // Check if it's a TaskStatusUpdateEvent + if (event.kind === "status-update") { + const update = event as TaskStatusUpdateEvent; // Cast for type safety + const state = update.status.state; + let stateEmoji = "❓"; + let stateColor: keyof typeof colors = "yellow"; + + switch (state) { + case "working": + stateEmoji = "âŗ"; + stateColor = "blue"; + break; + case "input-required": + stateEmoji = "🤔"; + stateColor = "yellow"; + break; + case "completed": + stateEmoji = "✅"; + stateColor = "green"; + break; + case "canceled": + stateEmoji = "âšī¸"; + stateColor = "gray"; + break; + case "failed": + stateEmoji = "❌"; + stateColor = "red"; + break; + default: + stateEmoji = "â„šī¸"; // For other states like submitted, rejected etc. + stateColor = "dim"; + break; + } + + console.log( + `${prefix} ${stateEmoji} Status: ${colorize(stateColor, state)} (Task: ${update.taskId}, Context: ${update.contextId}) ${update.final ? colorize("bright", "[FINAL]") : ""}` + ); + + if (update.status.message) { + printMessageContent(update.status.message); + } + } + // Check if it's a TaskArtifactUpdateEvent + else if (event.kind === "artifact-update") { + const update = event as TaskArtifactUpdateEvent; // Cast for type safety + console.log( + `${prefix} 📄 Artifact Received: ${update.artifact.name || "(unnamed)" + } (ID: ${update.artifact.artifactId}, Task: ${update.taskId}, Context: ${update.contextId})` + ); + // Create a temporary message-like structure to reuse printMessageContent + printMessageContent({ + messageId: generateId(), // Dummy messageId + kind: "message", // Dummy kind + role: "agent", // Assuming artifact parts are from agent + parts: update.artifact.parts, + taskId: update.taskId, + contextId: update.contextId, + }); + } else { + // This case should ideally not be reached if called correctly + console.log( + prefix, + colorize("yellow", "Received unknown event type in printAgentEvent:"), + event + ); + } +} + +function printMessageContent(message: Message) { + message.parts.forEach((part: Part, index: number) => { // Added explicit Part type + const partPrefix = colorize("red", ` Part ${index + 1}:`); + if (part.kind === "text") { // Check kind property + console.log(`${partPrefix} ${colorize("green", "📝 Text:")}`, part.text); + } else if (part.kind === "file") { // Check kind property + const filePart = part as FilePart; + console.log( + `${partPrefix} ${colorize("blue", "📄 File:")} Name: ${filePart.file.name || "N/A" + }, Type: ${filePart.file.mimeType || "N/A"}, Source: ${("bytes" in filePart.file) ? "Inline (bytes)" : filePart.file.uri + }` + ); + } else if (part.kind === "data") { // Check kind property + const dataPart = part as DataPart; + console.log( + `${partPrefix} ${colorize("yellow", "📊 Data:")}`, + JSON.stringify(dataPart.data, null, 2) + ); + } else { + console.log(`${partPrefix} ${colorize("yellow", "Unsupported part kind:")}`, part); + } + }); +} + +// --- Agent Card Fetching --- +async function fetchAndDisplayAgentCard() { + // Use the client's getAgentCard method. + // The client was initialized with serverUrl, which is the agent's base URL. + console.log( + colorize("dim", `Attempting to fetch agent card from agent at: ${serverUrl}`) + ); + try { + // client.getAgentCard() uses the agentBaseUrl provided during client construction + const card: AgentCard = await client.getAgentCard(); + agentName = card.name || "Agent"; // Update global agent name + console.log(colorize("green", `✓ Agent Card Found:`)); + console.log(` Name: ${colorize("bright", agentName)}`); + if (card.description) { + console.log(` Description: ${card.description}`); + } + console.log(` Version: ${card.version || "N/A"}`); + if (card.capabilities?.streaming) { + console.log(` Streaming: ${colorize("green", "Supported")}`); + } else { + console.log(` Streaming: ${colorize("yellow", "Not Supported (or not specified)")}`); + } + // Update prompt prefix to use the fetched name + // The prompt is set dynamically before each rl.prompt() call in the main loop + // to reflect the current agentName if it changes (though unlikely after initial fetch). + } catch (error: unknown) { + console.log( + colorize("yellow", `âš ī¸ Error fetching or parsing agent card`) + ); + throw error; + } +} + +// --- Main Loop --- +async function main() { + console.log(colorize("bright", `A2A Terminal Client`)); + console.log(colorize("dim", `Agent Base URL: ${serverUrl}`)); + + // Initialize the client with proper authentication + const authHandler = userAgentDid ? await createAuthHandler(authProfile, userAgentDid) : undefined; + client = new A2AClient(serverUrl, { authHandler }); + + await fetchAndDisplayAgentCard(); // Fetch the card before starting the loop + + console.log(colorize("dim", `No active task or context initially. Use '/new' to start a fresh session or send a message.`)); + console.log( + colorize("green", `Enter messages, or use '/new' to start a new session. '/exit' to quit.`) + ); + + rl.setPrompt(colorize("cyan", `${agentName} > You: `)); // Set initial prompt + rl.prompt(); + + rl.on("line", async (line) => { + const input = line.trim(); + rl.setPrompt(colorize("cyan", `${agentName} > You: `)); // Ensure prompt reflects current agentName + + if (!input) { + rl.prompt(); + return; + } + + if (input.toLowerCase() === "/new") { + currentTaskId = undefined; + currentContextId = undefined; // Reset contextId on /new + console.log( + colorize("bright", `✨ Starting new session. Task and Context IDs are cleared.`) + ); + rl.prompt(); + return; + } + + if (input.toLowerCase() === "/exit") { + rl.close(); + return; + } + + // Construct params for sendMessageStream + const messageId = generateId(); // Generate a unique message ID + + const messagePayload: Message = { + messageId: messageId, + kind: "message", // Required by Message interface + role: "user", + parts: [ + { + kind: "text", // Required by TextPart interface + text: input, + }, + ], + }; + + // Conditionally add taskId to the message payload + if (currentTaskId) { + messagePayload.taskId = currentTaskId; + } + // Conditionally add contextId to the message payload + if (currentContextId) { + messagePayload.contextId = currentContextId; + } + + + const params: MessageSendParams = { + message: messagePayload, + // Optional: configuration for streaming, blocking, etc. + // configuration: { + // acceptedOutputModes: ['text/plain', 'application/json'], // Example + // blocking: false // Default for streaming is usually non-blocking + // } + }; + + try { + console.log(colorize("red", "Sending message...")); + // Use sendMessageStream + const stream = client.sendMessageStream(params); + + // Iterate over the events from the stream + for await (const event of stream) { + const timestamp = new Date().toLocaleTimeString(); // Get fresh timestamp for each event + const prefix = colorize("magenta", `\n${agentName} [${timestamp}]:`); + + if (event.kind === "status-update" || event.kind === "artifact-update") { + const typedEvent = event as TaskStatusUpdateEvent | TaskArtifactUpdateEvent; + printAgentEvent(typedEvent); + + // If the event is a TaskStatusUpdateEvent and it's final, reset currentTaskId + if (typedEvent.kind === "status-update" && (typedEvent as TaskStatusUpdateEvent).final && (typedEvent as TaskStatusUpdateEvent).status.state !== "input-required") { + console.log(colorize("yellow", ` Task ${typedEvent.taskId} is final. Clearing current task ID.`)); + currentTaskId = undefined; + // Optionally, you might want to clear currentContextId as well if a task ending implies context ending. + // currentContextId = undefined; + // console.log(colorize("dim", ` Context ID also cleared as task is final.`)); + } + + } else if (event.kind === "message") { + const msg = event as Message; + console.log(`${prefix} ${colorize("green", "âœ‰ī¸ Message Stream Event:")}`); + printMessageContent(msg); + if (msg.taskId && msg.taskId !== currentTaskId) { + console.log(colorize("dim", ` Task ID context updated to ${msg.taskId} based on message event.`)); + currentTaskId = msg.taskId; + } + if (msg.contextId && msg.contextId !== currentContextId) { + console.log(colorize("dim", ` Context ID updated to ${msg.contextId} based on message event.`)); + currentContextId = msg.contextId; + } + } else if (event.kind === "task") { + const task = event as Task; + console.log(`${prefix} ${colorize("blue", "â„šī¸ Task Stream Event:")} ID: ${task.id}, Context: ${task.contextId}, Status: ${task.status.state}`); + if (task.id !== currentTaskId) { + console.log(colorize("dim", ` Task ID updated from ${currentTaskId || 'N/A'} to ${task.id}`)); + currentTaskId = task.id; + } + if (task.contextId && task.contextId !== currentContextId) { + console.log(colorize("dim", ` Context ID updated from ${currentContextId || 'N/A'} to ${task.contextId}`)); + currentContextId = task.contextId; + } + if (task.status.message) { + console.log(colorize("gray", " Task includes message:")); + printMessageContent(task.status.message); + } + if (task.artifacts && task.artifacts.length > 0) { + console.log(colorize("gray", ` Task includes ${task.artifacts.length} artifact(s).`)); + } + } else { + console.log(prefix, colorize("yellow", "Received unknown event structure from stream:"), event); + } + } + console.log(colorize("dim", `--- End of response stream for this input ---`)); + } catch (error: unknown) { + const timestamp = new Date().toLocaleTimeString(); + const prefix = colorize("red", `\n${agentName} [${timestamp}] ERROR:`); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + prefix, + `Error communicating with agent:`, + errorMessage + ); + if (error && typeof error === 'object' && 'code' in error) { + console.error(colorize("gray", ` Code: ${(error as { code: unknown }).code}`)); + } + if (error && typeof error === 'object' && 'data' in error) { + console.error( + colorize("gray", ` Data: ${JSON.stringify((error as { data: unknown }).data)}`) + ); + } + if (!(error && typeof error === 'object' && ('code' in error || 'data' in error)) && error instanceof Error && error.stack) { + console.error(colorize("gray", error.stack.split('\n').slice(1, 3).join('\n'))); + } + } finally { + rl.prompt(); + } + }).on("close", () => { + console.log(colorize("yellow", "\nExiting A2A Terminal Client. Goodbye!")); + process.exit(0); + }); +} + +// --- Start --- +main().catch(err => { + console.error(colorize("red", "Unhandled error in main:"), err); + process.exit(1); +}); diff --git a/samples/js/src/agents/eliza/universal-auth.ts b/samples/js/src/agents/eliza/universal-auth.ts new file mode 100644 index 00000000..b2e951c9 --- /dev/null +++ b/samples/js/src/agents/eliza/universal-auth.ts @@ -0,0 +1,127 @@ +import { join } from "path"; +import os from "os"; +import { HttpHeaders } from "../client/auth-handler.js"; +import { generateAuthToken, parseChallengeFromWwwAuthenticate } from "@agentic-profile/auth"; +import { pruneFragmentId } from "@agentic-profile/common"; + +import { + AgenticProfile, + JWKSet, +} from "@agentic-profile/common/schema"; +import { prettyJson } from "@agentic-profile/common"; + +import { + access, + mkdir, + readFile, + writeFile +} from "fs/promises"; + + +export async function createAuthHandler( iamProfile: string = "my-a2a-client", userAgentDid: string ) { + const myProfileAndKeyring = await loadProfileAndKeyring( join( os.homedir(), ".agentic", "iam", iamProfile ) ); + let headers = {} as HttpHeaders; + + const { documentId, fragmentId } = pruneFragmentId( userAgentDid ); + const agentDid = documentId ? userAgentDid : myProfileAndKeyring.profile.id + fragmentId; + + const authHandler = { + headers: () => headers, + shouldRetryWithHeaders: async (req:RequestInit, fetchResponse: { status: number; headers: { get: (name: string) => string | null }; url: string }) => { + // can/should I handle this? + if( fetchResponse.status !== 401 ) + return undefined; + + const wwwAuthenticate = fetchResponse.headers.get( "WWW-Authenticate" ); + const agenticChallenge = parseChallengeFromWwwAuthenticate( wwwAuthenticate, fetchResponse.url ); + + const authToken = await generateAuthToken({ + agentDid, + agenticChallenge, + profileResolver: async (did:string) => { + const { documentId } = pruneFragmentId( did ); + if( documentId !== myProfileAndKeyring.profile.id ) + throw new Error(`Failed to resolve agentic profile for ${did}`); + return myProfileAndKeyring; + } + }); + return { Authorization: `Agentic ${authToken}` }; + }, + onSuccess: async (updatedHeaders:HttpHeaders) => { + headers = updatedHeaders; + } + }; + + return authHandler; +} + +/** + * Local file system utilities for loading and saving agentic profiles and keyrings. + */ + +type SaveProfileParams = { + dir: string, + profile?: AgenticProfile, + keyring?: JWKSet[] +} + +export async function saveProfile({ dir, profile, keyring }: SaveProfileParams) { + await mkdir(dir, { recursive: true }); + + const profilePath = join(dir, "did.json"); + if( profile ) { + await writeFile( + profilePath, + prettyJson( profile ), + "utf8" + ); + } + + const keyringPath = join(dir, "keyring.json"); + if( keyring ) { + await writeFile( + keyringPath, + prettyJson( keyring ), + "utf8" + ); + } + + return { profilePath, keyringPath } +} + +export async function loadProfileAndKeyring( dir: string ) { + const profile = await loadProfile( dir ); + const keyring = await loadKeyring( dir ); + return { profile, keyring }; +} + +export async function loadProfile( dir: string ) { + return loadJson( dir, "did.json" ); +} + +export async function loadKeyring( dir: string ) { + return loadJson( dir, "keyring.json" ); +} + +export async function loadJson( dir: string, filename: string ): Promise { + const path = join( dir, filename ); + if( await fileExists( path ) !== true ) + throw new Error(`Failed to load ${path} - file not found`); + + const buffer = await readFile( path, "utf-8" ); + return JSON.parse( buffer ) as T; +} + + +// +// General util +// + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} \ No newline at end of file