Skip to content

Commit 4cc8820

Browse files
committed
Added Eliza chatbot to demonstrate universal authentication with the Agentic Profile
1 parent fc46580 commit 4cc8820

File tree

10 files changed

+1065
-2
lines changed

10 files changed

+1065
-2
lines changed

‎samples/js/package.json‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111
"scripts": {
1212
"a2a:cli": "npx tsx src/cli.ts",
1313
"agents:movie-agent": "npx tsx src/agents/movie-agent/index.ts",
14-
"agents:coder": "npx tsx src/agents/coder/index.ts"
14+
"agents:coder": "npx tsx src/agents/coder/index.ts",
15+
"agents:eliza": "npx tsx src/agents/eliza/eliza-app.ts",
16+
"agents:eliza:create-profile": "tsx src/agents/eliza/create-global-agentic-profile.ts",
17+
"agents:eliza:authcli": "tsx src/agents/eliza/universal-auth-cli.ts"
1518
},
1619
"dependencies": {
1720
"@a2a-js/sdk": "^0.2.4",
18-
"@genkit-ai/googleai": "^1.8.0",
21+
"@agentic-profile/eliza": "^0.1.0",
22+
"@agentic-profile/auth": "^0.6.0",
1923
"@genkit-ai/vertexai": "^1.8.0",
2024
"@types/cors": "^2.8.17",
2125
"@types/express": "^5.0.1",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Eliza Agent
2+
3+
This agent provides a demonstration of the historical [Eliza chatbot](https://en.wikipedia.org/wiki/ELIZA)
4+
5+
Eliza was one of the first chatbots (1966!) to attempt the Turing test and designed to explore communication between humans and machines. To run:
6+
7+
```bash
8+
npm run agents:eliza
9+
```
10+
11+
The agent server will start on `http://localhost:41241` and provides agent.json cards at three endpoints which are listed by the server.
12+
13+
14+
## Chat with Eliza without Universal Authentication
15+
16+
The Eliza agent provides a well-known endpoint that does not require authentication.
17+
18+
1. Make sure Eliza is running:
19+
20+
```bash
21+
npm run agents:eliza
22+
```
23+
24+
2. In a separate terminal window, use the standard command line interface to connect:
25+
26+
```bash
27+
npm run a2a:cli http://localhost:41241
28+
```
29+
30+
31+
## Chat with Eliza WITH Universal Authentication
32+
33+
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.
34+
35+
The Eliza agent provides the /agents/eliza endpoint that requires authentication.
36+
37+
1. Make sure you have created a demo agentic profile
38+
39+
```bash
40+
npm run agents:eliza:create-profile
41+
```
42+
43+
2. Make sure Eliza is running:
44+
45+
```bash
46+
npm run agents:eliza
47+
```
48+
49+
3. In a separate terminal window, use the special authenticating command line interface to connect:
50+
51+
```bash
52+
npm run agents:eliza:authcli http://localhost:41241/agents/eliza #connect
53+
```
54+
55+
Type in a message to the Eliza agent to cause an A2A RPC call to the server which triggers the authentication.
56+
57+
To read more about Universal Authentication and DIDs can be used with A2A please visit the [AgenticProfile blog](https://agenticprofile.ai)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* A2AExpressService provides endpoints for an A2A service agent card and JSON-RPC requests.
3+
* This class supports multiple A2A services on the same server, as well as support for
4+
* universal authentication and agent multi-tenancy.
5+
*
6+
* Agent multi-tenancy is the ability of one agent to represent multiple users. For example, an
7+
* (Eliza) therapist agent POST /message to the "Joseph" therapist would reply as Joseph, whereas the
8+
* same agent POST /message to the "Sarah" therapist would reply as Sarah.
9+
*
10+
* To see multi-tenancy in production, see [Matchwise](https://matchwise.ai) where the Connect agent represents
11+
* many users to provide personalized business networking advice.
12+
*
13+
* Universal authentication is the ability to authenticate any client without the need for
14+
* an authentication service like OAuth. Universal authentication uses public key cryptography
15+
* where the public keys for clients are distributed in W3C DID documents.
16+
*/
17+
18+
import { Request, Response, Router } from 'express';
19+
import { Resolver } from "did-resolver";
20+
import {
21+
b64u,
22+
ClientAgentSession,
23+
ClientAgentSessionStore,
24+
createChallenge,
25+
handleAuthorization,
26+
} from "@agentic-profile/auth";
27+
import {
28+
A2AResponse,
29+
JSONRPCErrorResponse,
30+
JSONRPCSuccessResponse,
31+
} from "@a2a-js/sdk"; // Import common types
32+
import {
33+
A2AError,
34+
A2ARequestHandler,
35+
JsonRpcTransportHandler,
36+
} from "@a2a-js/sdk/server"; // Import server components
37+
38+
export type AgentSessionResolver = (req: Request, res: Response) => Promise<ClientAgentSession | null>
39+
40+
/**
41+
* Options for configuring the A2AService.
42+
*/
43+
export interface A2AServiceOptions {
44+
/** Task storage implementation. Defaults to InMemoryTaskStore. */
45+
//taskStore?: TaskStore;
46+
47+
/** URL Path for the A2A endpoint. If not provided, the req.originalUrl is used. */
48+
agentPath?: string;
49+
50+
/** Agent session resolver. If not defined, then universal authentication is not supported. */
51+
agentSessionResolver?: AgentSessionResolver
52+
}
53+
54+
export class A2AExpressService {
55+
private requestHandler: A2ARequestHandler; // Kept for getAgentCard
56+
private jsonRpcTransportHandler: JsonRpcTransportHandler;
57+
private options: A2AServiceOptions;
58+
59+
constructor(requestHandler: A2ARequestHandler, options?: A2AServiceOptions) {
60+
this.requestHandler = requestHandler; // DefaultRequestHandler instance
61+
this.jsonRpcTransportHandler = new JsonRpcTransportHandler(requestHandler);
62+
this.options = options;
63+
}
64+
65+
public cardEndpoint = async (req: Request, res: Response) => {
66+
try {
67+
// resolve agent service endpoint
68+
let url: string;
69+
if (this.options?.agentPath) {
70+
url = `${req.protocol}://${req.get('host')}${this.options.agentPath}`;
71+
} else {
72+
/* If there's no explicit agent path, then derive one from the Express
73+
Request originalUrl by removing the trailing /agent.json if present */
74+
const baseUrl = req.originalUrl.replace(/\/agent\.json$/, '');
75+
url = `${req.protocol}://${req.get('host')}${baseUrl}`;
76+
}
77+
78+
const agentCard = await this.requestHandler.getAgentCard();
79+
res.json({ ...agentCard, url });
80+
} catch (error: any) {
81+
console.error("Error fetching agent card:", error);
82+
res.status(500).json({ error: "Failed to retrieve agent card" });
83+
}
84+
}
85+
86+
public agentEndpoint = async (req: Request, res: Response) => {
87+
try {
88+
// Handle client authentication
89+
let agentSession: ClientAgentSession | null = null;
90+
if (this.options?.agentSessionResolver) {
91+
agentSession = await this.options.agentSessionResolver(req, res);
92+
if (!agentSession)
93+
return; // 401 response with challenge already issued
94+
else console.log("Agent session resolved:", agentSession.id, agentSession.agentDid);
95+
}
96+
97+
const rpcResponseOrStream = await this.jsonRpcTransportHandler.handle(req.body);
98+
99+
// Check if it's an AsyncGenerator (stream)
100+
if (typeof (rpcResponseOrStream as any)?.[Symbol.asyncIterator] === 'function') {
101+
const stream = rpcResponseOrStream as AsyncGenerator<JSONRPCSuccessResponse, void, undefined>;
102+
103+
res.setHeader('Content-Type', 'text/event-stream');
104+
res.setHeader('Cache-Control', 'no-cache');
105+
res.setHeader('Connection', 'keep-alive');
106+
res.flushHeaders();
107+
108+
try {
109+
for await (const event of stream) {
110+
// Each event from the stream is already a JSONRPCResult
111+
res.write(`id: ${new Date().getTime()}\n`);
112+
res.write(`data: ${JSON.stringify(event)}\n\n`);
113+
}
114+
} catch (streamError: any) {
115+
console.error(`Error during SSE streaming (request ${req.body?.id}):`, streamError);
116+
// If the stream itself throws an error, send a final JSONRPCErrorResponse
117+
const a2aError = streamError instanceof A2AError ? streamError : A2AError.internalError(streamError.message || 'Streaming error.');
118+
const errorResponse: JSONRPCErrorResponse = {
119+
jsonrpc: '2.0',
120+
id: req.body?.id || null, // Use original request ID if available
121+
error: a2aError.toJSONRPCError(),
122+
};
123+
if (!res.headersSent) { // Should not happen if flushHeaders worked
124+
res.status(500).json(errorResponse); // Should be JSON, not SSE here
125+
} else {
126+
// Try to send as last SSE event if possible, though client might have disconnected
127+
res.write(`id: ${new Date().getTime()}\n`);
128+
res.write(`event: error\n`); // Custom event type for client-side handling
129+
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`);
130+
}
131+
} finally {
132+
if (!res.writableEnded) {
133+
res.end();
134+
}
135+
}
136+
} else { // Single JSON-RPC response
137+
const rpcResponse = rpcResponseOrStream as A2AResponse;
138+
res.status(200).json(rpcResponse);
139+
}
140+
} catch (error: any) { // Catch errors from jsonRpcTransportHandler.handle itself (e.g., initial parse error)
141+
console.error("Unhandled error in A2AExpressApp POST handler:", error);
142+
const a2aError = error instanceof A2AError ? error : A2AError.internalError('General processing error.');
143+
const errorResponse: JSONRPCErrorResponse = {
144+
jsonrpc: '2.0',
145+
id: req.body?.id || null,
146+
error: a2aError.toJSONRPCError(),
147+
};
148+
if (!res.headersSent) {
149+
res.status(500).json(errorResponse);
150+
} else if (!res.writableEnded) {
151+
// If headers sent (likely during a stream attempt that failed early), try to end gracefully
152+
res.end();
153+
}
154+
}
155+
}
156+
157+
/**
158+
* Adds A2A routes to an existing Express app.
159+
* @param app Optional existing Express app.
160+
* @param baseUrl The base URL for A2A endpoints (e.g., "/a2a/api").
161+
* @returns The Express app with A2A routes.
162+
*/
163+
public routes(): Router {
164+
const router = Router();
165+
166+
router.get("/agent.json", this.cardEndpoint);
167+
168+
router.post("/", this.agentEndpoint);
169+
170+
// The separate /stream endpoint is no longer needed.
171+
return router;
172+
}
173+
}
174+
175+
/**
176+
* If an authorization header is provided, then an attemot to resolve an agent session is made,
177+
* otherwise a 401 response with a new challenge in the WWW-Authenticate header.
178+
* @returns a ClientAgentSession, or null if request handled by 401/challenge
179+
* @throws {Error} if authorization header is invalid. If authorization is expired or not
180+
* found, then no error is thrown and instead a new challenge is issued.
181+
*/
182+
export async function resolveAgentSession(
183+
req: Request,
184+
res: Response,
185+
store: ClientAgentSessionStore,
186+
didResolver: Resolver
187+
): Promise<ClientAgentSession | null> {
188+
const { authorization } = req.headers;
189+
if (authorization) {
190+
const agentSession = await handleAuthorization(authorization, store, didResolver);
191+
if (agentSession)
192+
return agentSession;
193+
}
194+
195+
const challenge = await createChallenge(store);
196+
res.status(401)
197+
.set('WWW-Authenticate', `Agentic ${b64u.objectToBase64Url(challenge)}`)
198+
.end();
199+
return null;
200+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import os from "os";
2+
import { join } from "path";
3+
import {
4+
createAgenticProfile,
5+
prettyJson,
6+
webDidToUrl
7+
} from "@agentic-profile/common";
8+
import {
9+
createEdDsaJwk,
10+
postJson
11+
} from "@agentic-profile/auth";
12+
import { saveProfile } from "./universal-auth.js";
13+
14+
15+
(async ()=>{
16+
const services = [
17+
{
18+
name: "Business networking connector",
19+
type: "A2A",
20+
id: "connect",
21+
url: `httsp://example.com/agents/connect`
22+
}
23+
];
24+
const { profile, keyring, b64uPublicKey } = await createAgenticProfile({ services, createJwkSet: createEdDsaJwk });
25+
26+
try {
27+
// publish profile to web (so did:web:... will resolve)
28+
const { data } = await postJson(
29+
"https://testing.agenticprofile.ai/agentic-profile",
30+
{ profile, b64uPublicKey }
31+
);
32+
const savedProfile = data.profile;
33+
const did = savedProfile.id;
34+
console.log( `Published demo user agentic profile to:
35+
36+
${webDidToUrl(did)}
37+
38+
Or via DID at:
39+
40+
${did}
41+
`);
42+
43+
// also save locally for reference
44+
const dir = join( os.homedir(), ".agentic", "iam", "a2a-demo-user" );
45+
await saveProfile({ dir, profile: savedProfile, keyring });
46+
47+
console.log(`Saved demo user agentic profile to ${dir}
48+
49+
Shhhh! Keyring for testing... ${prettyJson( keyring )}`);
50+
} catch (error) {
51+
console.error( "Failed to create demo user profile", error );
52+
}
53+
})();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { AgentCard } from "@a2a-js/sdk";
2+
3+
export const elizaAgentCard = ( url?: string ): AgentCard => ({
4+
name: 'Eliza Agent',
5+
description: 'The classic AI chatbot from 1966',
6+
// Adjust the base URL and port as needed. /a2a is the default base in A2AExpressApp
7+
url,
8+
provider: {
9+
organization: 'A2A Samples',
10+
url: 'https://example.com/a2a-samples' // Added provider URL
11+
},
12+
version: '0.0.1', // Incremented version
13+
capabilities: {
14+
streaming: true, // The new framework supports streaming
15+
pushNotifications: false, // Assuming not implemented for this agent yet
16+
stateTransitionHistory: true, // Agent uses history
17+
},
18+
// authentication: null, // Property 'authentication' does not exist on type 'AgentCard'.
19+
securitySchemes: undefined, // Or define actual security schemes if any
20+
security: undefined,
21+
defaultInputModes: ['text'],
22+
defaultOutputModes: ['text', 'task-status'], // task-status is a common output mode
23+
skills: [
24+
{
25+
id: 'therapy',
26+
name: 'Rogerian Psychotherapy',
27+
description: 'Provides Rogerian psychotherapy',
28+
tags: ['health', 'wellness', 'therapy'],
29+
examples: [
30+
'I feel like I am not good enough',
31+
'I am not sure what to do',
32+
'I am feeling overwhelmed',
33+
'I am not sure what to do'
34+
],
35+
inputModes: ['text'], // Explicitly defining for skill
36+
outputModes: ['text', 'task-status'] // Explicitly defining for skill
37+
},
38+
],
39+
supportsAuthenticatedExtendedCard: false,
40+
});

0 commit comments

Comments
 (0)