Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions samples/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
45 changes: 45 additions & 0 deletions samples/js/src/agents/eliza/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:41241>


## 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)
200 changes: 200 additions & 0 deletions samples/js/src/agents/eliza/a2a_express_service.ts
Original file line number Diff line number Diff line change
@@ -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<ClientAgentSession | null>

/**
* 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<JSONRPCSuccessResponse, void, undefined>;

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<ClientAgentSession | null> {
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;
}
53 changes: 53 additions & 0 deletions samples/js/src/agents/eliza/create-global-agentic-profile.ts
Original file line number Diff line number Diff line change
@@ -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 );
}
})();
40 changes: 40 additions & 0 deletions samples/js/src/agents/eliza/eliza-agent-card.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Loading