march-ai-sdk 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * March Agent SDK - Agent
3
+ * Port of Python march_agent/agent.py
4
+ */
5
+
6
+ import { Message } from './message.js'
7
+ import { Streamer } from './streamer.js'
8
+ import { HeartbeatManager } from './heartbeat.js'
9
+ import { ConfigurationError } from './exceptions.js'
10
+ import type { GatewayClient } from './gateway-client.js'
11
+ import type { ConversationClient } from './conversation-client.js'
12
+ import type { MemoryClient } from './memory-client.js'
13
+ import type { AttachmentClient } from './attachment-client.js'
14
+ import type {
15
+ AgentRegistrationData,
16
+ MessageHandler,
17
+ SenderFilterOptions,
18
+ StreamerOptions,
19
+ KafkaMessage,
20
+ } from './types.js'
21
+
22
+ /**
23
+ * Filter for matching message senders.
24
+ */
25
+ export class SenderFilter {
26
+ private readonly _include: Set<string> = new Set()
27
+ private readonly _exclude: Set<string> = new Set()
28
+ readonly matchAll: boolean
29
+
30
+ constructor(senders?: string[]) {
31
+ if (!senders || senders.length === 0) {
32
+ this.matchAll = true
33
+ } else {
34
+ this.matchAll = false
35
+ for (const sender of senders) {
36
+ if (sender.startsWith('~')) {
37
+ this._exclude.add(sender.slice(1))
38
+ } else {
39
+ this._include.add(sender)
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get included senders as an array (for compatibility with Python tests).
47
+ */
48
+ get include(): string[] {
49
+ return Array.from(this._include)
50
+ }
51
+
52
+ /**
53
+ * Get excluded senders as an array (for compatibility with Python tests).
54
+ */
55
+ get exclude(): string[] {
56
+ return Array.from(this._exclude)
57
+ }
58
+
59
+ /**
60
+ * Check if sender matches this filter.
61
+ */
62
+ matches(sender: string): boolean {
63
+ // If excluded, reject
64
+ if (this._exclude.has(sender)) {
65
+ return false
66
+ }
67
+ // If match all or explicitly included
68
+ if (this.matchAll || this._include.size === 0) {
69
+ return true
70
+ }
71
+ return this._include.has(sender)
72
+ }
73
+ }
74
+
75
+ // Message handlers stored as tuples: [SenderFilter, MessageHandler]
76
+ type RegisteredHandler = [SenderFilter, MessageHandler]
77
+
78
+ /**
79
+ * Core agent class that handles messaging via the Agent Gateway.
80
+ */
81
+ export class Agent {
82
+ readonly name: string
83
+ readonly agentData: AgentRegistrationData
84
+ sendErrorResponses: boolean = true
85
+ errorMessageTemplate: string
86
+
87
+ private readonly gatewayClient: GatewayClient
88
+ private readonly conversationClient?: ConversationClient
89
+ private readonly memoryClient?: MemoryClient
90
+ private readonly attachmentClient?: AttachmentClient
91
+ private readonly heartbeatInterval: number
92
+
93
+ private messageHandlers: RegisteredHandler[] = []
94
+ private heartbeatManager?: HeartbeatManager
95
+ private initialized: boolean = false
96
+ private running: boolean = false
97
+
98
+ constructor(options: {
99
+ name: string
100
+ gatewayClient: GatewayClient
101
+ agentData: AgentRegistrationData
102
+ heartbeatInterval?: number
103
+ conversationClient?: ConversationClient
104
+ memoryClient?: MemoryClient
105
+ attachmentClient?: AttachmentClient
106
+ errorMessageTemplate?: string
107
+ }) {
108
+ this.name = options.name
109
+ this.gatewayClient = options.gatewayClient
110
+ this.agentData = options.agentData
111
+ this.heartbeatInterval = options.heartbeatInterval ?? 60
112
+ this.conversationClient = options.conversationClient
113
+ this.memoryClient = options.memoryClient
114
+ this.attachmentClient = options.attachmentClient
115
+ this.errorMessageTemplate = options.errorMessageTemplate ??
116
+ 'I encountered an error while processing your message. Please try again or contact support if the issue persists.'
117
+ }
118
+
119
+ /**
120
+ * Register a message handler.
121
+ *
122
+ * Usage:
123
+ * agent.onMessage(async (message, sender) => { ... })
124
+ * agent.onMessage(handler, { senders: ['user'] })
125
+ */
126
+ onMessage(handler: MessageHandler): void
127
+ onMessage(handler: MessageHandler, options: SenderFilterOptions): void
128
+ onMessage(
129
+ handler: MessageHandler,
130
+ options?: SenderFilterOptions
131
+ ): void {
132
+ const filter = new SenderFilter(options?.senders)
133
+ this.messageHandlers.push([filter, handler])
134
+ }
135
+
136
+ /**
137
+ * Initialize agent after gateway connection is established.
138
+ */
139
+ initializeWithGateway(): void {
140
+ if (this.initialized) {
141
+ return
142
+ }
143
+
144
+ // Register message handler with gateway
145
+ const topic = `${this.name}.inbox`
146
+ this.gatewayClient.registerHandler(topic, (msg) => {
147
+ this.handleKafkaMessage(msg)
148
+ })
149
+
150
+ // Start heartbeat
151
+ this.heartbeatManager = new HeartbeatManager(
152
+ this.gatewayClient,
153
+ this.name,
154
+ this.heartbeatInterval
155
+ )
156
+ this.heartbeatManager.start()
157
+
158
+ this.initialized = true
159
+ }
160
+
161
+ /**
162
+ * Get sender from message headers.
163
+ */
164
+ private getSender(headers: Record<string, string>): string {
165
+ return headers.from_ ?? headers.from ?? 'user'
166
+ }
167
+
168
+ /**
169
+ * Find first handler that matches the sender.
170
+ */
171
+ private findMatchingHandler(sender: string): MessageHandler | undefined {
172
+ for (const [filter, handler] of this.messageHandlers) {
173
+ if (filter.matches(sender)) {
174
+ return handler
175
+ }
176
+ }
177
+ return undefined
178
+ }
179
+
180
+ /**
181
+ * Handle incoming Kafka message.
182
+ */
183
+ private handleKafkaMessage(kafkaMsg: KafkaMessage): void {
184
+ // Run handler asynchronously
185
+ this.handleMessageAsync(kafkaMsg).catch((error) => {
186
+ console.error('Error in message handler:', error)
187
+ })
188
+ }
189
+
190
+ /**
191
+ * Async message handling with error recovery.
192
+ */
193
+ private async handleMessageAsync(kafkaMsg: KafkaMessage): Promise<void> {
194
+ let message: Message | undefined
195
+
196
+ try {
197
+ // Create message from Kafka data
198
+ message = Message.fromKafkaMessage(
199
+ kafkaMsg.body,
200
+ kafkaMsg.headers,
201
+ {
202
+ conversationClient: this.conversationClient,
203
+ memoryClient: this.memoryClient,
204
+ attachmentClient: this.attachmentClient,
205
+ agentName: this.name,
206
+ }
207
+ )
208
+
209
+ // Find matching handler
210
+ const sender = this.getSender(kafkaMsg.headers)
211
+ const handler = this.findMatchingHandler(sender)
212
+
213
+ if (!handler) {
214
+ console.warn(`No handler matched for sender: ${sender}`)
215
+ return
216
+ }
217
+
218
+ // Call handler
219
+ await handler(message, sender)
220
+
221
+ } catch (error) {
222
+ console.error('Error handling message:', error)
223
+
224
+ // Send error response if we have a message
225
+ if (message && this.sendErrorResponses) {
226
+ await this.sendErrorResponse(message, error as Error)
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Send error response to user when handler fails.
233
+ */
234
+ private async sendErrorResponse(message: Message, _error: Error): Promise<void> {
235
+ try {
236
+ const streamer = this.streamer(message, { sendTo: 'user' })
237
+ streamer.stream(this.errorMessageTemplate)
238
+ await streamer.finish()
239
+ } catch (err) {
240
+ console.error('Failed to send error response:', err)
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Create a new Streamer for streaming responses.
246
+ */
247
+ streamer(message: Message, options: StreamerOptions = {}): Streamer {
248
+ return new Streamer({
249
+ agentName: this.name,
250
+ originalMessage: message,
251
+ gatewayClient: this.gatewayClient,
252
+ conversationClient: this.conversationClient,
253
+ awaiting: options.awaiting ?? false,
254
+ sendTo: options.sendTo ?? 'user',
255
+ })
256
+ }
257
+
258
+ /**
259
+ * Mark agent as ready to consume messages.
260
+ */
261
+ startConsuming(): void {
262
+ if (this.messageHandlers.length === 0) {
263
+ throw new ConfigurationError('No message handlers registered')
264
+ }
265
+
266
+ if (!this.initialized) {
267
+ throw new ConfigurationError('Agent not initialized with gateway')
268
+ }
269
+
270
+ this.running = true
271
+ console.log(`Agent ${this.name} is now consuming messages`)
272
+ }
273
+
274
+ /**
275
+ * Shutdown agent gracefully.
276
+ */
277
+ shutdown(): void {
278
+ this.running = false
279
+
280
+ if (this.heartbeatManager) {
281
+ this.heartbeatManager.stop()
282
+ }
283
+
284
+ console.log(`Agent ${this.name} shutdown`)
285
+ }
286
+
287
+ /**
288
+ * Check if agent is running.
289
+ */
290
+ isRunning(): boolean {
291
+ return this.running
292
+ }
293
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * March Agent SDK - API Paths Configuration
3
+ *
4
+ * Centralized configuration for all API endpoint paths.
5
+ * This allows easy configuration if API versions or paths change.
6
+ */
7
+
8
+ /**
9
+ * API paths for AI Inventory service.
10
+ */
11
+ export const AI_INVENTORY_PATHS = {
12
+ /** Register a new agent */
13
+ AGENT_REGISTER: '/api/v1/agents/register',
14
+ /** Send heartbeat */
15
+ HEALTH_HEARTBEAT: '/api/v1/health/heartbeat',
16
+ } as const
17
+
18
+ /**
19
+ * API paths for Conversation Store service.
20
+ * Note: These paths don't have /api/v1/ prefix.
21
+ */
22
+ export const CONVERSATION_STORE_PATHS = {
23
+ /** Get/update conversation by ID */
24
+ CONVERSATION: (conversationId: string) => `/conversations/${conversationId}`,
25
+ /** Get messages for a conversation */
26
+ CONVERSATION_MESSAGES: (conversationId: string) => `/conversations/${conversationId}/messages`,
27
+ /** Checkpoints base path */
28
+ CHECKPOINTS: '/checkpoints/',
29
+ /** Checkpoint by thread ID */
30
+ CHECKPOINT_THREAD: (threadId: string) => `/checkpoints/${threadId}`,
31
+ /** Agent state by conversation ID */
32
+ AGENT_STATE: (conversationId: string) => `/agent-state/${conversationId}`,
33
+ } as const
34
+
35
+ /**
36
+ * API paths for AI Memory service.
37
+ * Note: These paths don't have /api/v1/ prefix.
38
+ */
39
+ export const MEMORY_PATHS = {
40
+ /** User memory base */
41
+ USER_MEMORY: (userId: string) => `/memory/${userId}`,
42
+ /** Add messages to memory */
43
+ USER_MESSAGES: (userId: string) => `/memory/${userId}/messages`,
44
+ /** Search user memory */
45
+ USER_SEARCH: (userId: string) => `/memory/${userId}/search`,
46
+ /** Get user summary */
47
+ USER_SUMMARY: (userId: string) => `/memory/${userId}/summary`,
48
+ } as const
49
+
50
+ /**
51
+ * Service names for gateway proxy routing.
52
+ */
53
+ export const SERVICES = {
54
+ AI_INVENTORY: 'ai-inventory',
55
+ CONVERSATION_STORE: 'conversation-store',
56
+ AI_MEMORY: 'ai-memory',
57
+ ATTACHMENT: 'attachment',
58
+ } as const
59
+
60
+ export type ServiceName = typeof SERVICES[keyof typeof SERVICES]
package/src/app.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * March Agent SDK - Main Application
3
+ * Port of Python march_agent/app.py
4
+ */
5
+
6
+ import { Agent } from './agent.js'
7
+ import { GatewayClient } from './gateway-client.js'
8
+ import { ConversationClient } from './conversation-client.js'
9
+ import { MemoryClient } from './memory-client.js'
10
+ import { AttachmentClient } from './attachment-client.js'
11
+ import { RegistrationError, ConfigurationError } from './exceptions.js'
12
+ import { AI_INVENTORY_PATHS, SERVICES } from './api-paths.js'
13
+ import type { AppOptions, RegisterOptions, AgentRegistrationData } from './types.js'
14
+
15
+ /**
16
+ * Main application class for March AI Agent framework.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import { MarchAgentApp } from 'march-ai-sdk'
21
+ *
22
+ * const app = new MarchAgentApp({
23
+ * gatewayUrl: 'agent-gateway:8080',
24
+ * apiKey: 'your-api-key',
25
+ * })
26
+ *
27
+ * const agent = app.registerMe({
28
+ * name: 'my-agent',
29
+ * about: 'A helpful assistant',
30
+ * document: 'Detailed description...',
31
+ * })
32
+ *
33
+ * agent.onMessage(async (message, sender) => {
34
+ * const streamer = agent.streamer(message)
35
+ * streamer.stream('Hello!')
36
+ * await streamer.finish()
37
+ * })
38
+ *
39
+ * app.run()
40
+ * ```
41
+ */
42
+ export class MarchAgentApp {
43
+ readonly gatewayClient: GatewayClient
44
+ readonly conversationClient: ConversationClient
45
+ readonly memoryClient: MemoryClient
46
+ readonly attachmentClient: AttachmentClient
47
+
48
+ private readonly heartbeatInterval: number
49
+ private readonly _maxConcurrentTasks: number
50
+ private readonly errorMessageTemplate: string
51
+
52
+ private agents: Agent[] = []
53
+ private running: boolean = false
54
+ private shutdownRequested: boolean = false
55
+
56
+ constructor(options: AppOptions) {
57
+ this.heartbeatInterval = options.heartbeatInterval ?? 60
58
+ this._maxConcurrentTasks = options.maxConcurrentTasks ?? 100
59
+ this.errorMessageTemplate = options.errorMessageTemplate ??
60
+ 'I encountered an error while processing your message. Please try again or contact support if the issue persists.'
61
+
62
+ // Create gateway client
63
+ this.gatewayClient = new GatewayClient(
64
+ options.gatewayUrl,
65
+ options.apiKey,
66
+ options.secure ?? false
67
+ )
68
+
69
+ // Create HTTP clients using gateway proxy URLs
70
+ this.conversationClient = new ConversationClient(
71
+ this.gatewayClient.conversationStoreUrl
72
+ )
73
+
74
+ this.memoryClient = new MemoryClient(
75
+ this.gatewayClient.aiMemoryUrl
76
+ )
77
+
78
+ this.attachmentClient = new AttachmentClient(
79
+ this.gatewayClient.attachmentUrl
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Register an agent with the backend.
85
+ */
86
+ async registerMe(options: RegisterOptions): Promise<Agent> {
87
+ // Register with AI Inventory
88
+ const agentData = await this.registerWithInventory(options)
89
+
90
+ // Create agent instance
91
+ const agent = new Agent({
92
+ name: options.name,
93
+ gatewayClient: this.gatewayClient,
94
+ agentData,
95
+ heartbeatInterval: this.heartbeatInterval,
96
+ conversationClient: this.conversationClient,
97
+ memoryClient: this.memoryClient,
98
+ attachmentClient: this.attachmentClient,
99
+ errorMessageTemplate: this.errorMessageTemplate,
100
+ })
101
+
102
+ this.agents.push(agent)
103
+ console.log(`Registered agent: ${options.name}`)
104
+
105
+ return agent
106
+ }
107
+
108
+ /**
109
+ * Register agent with AI Inventory service.
110
+ */
111
+ private async registerWithInventory(options: RegisterOptions): Promise<AgentRegistrationData> {
112
+ // Build registration payload (API expects camelCase)
113
+ const payload: Record<string, unknown> = {
114
+ name: options.name,
115
+ about: options.about,
116
+ document: options.document,
117
+ representationName: options.representationName || options.name,
118
+ }
119
+
120
+ if (options.baseUrl) {
121
+ payload.baseUrl = options.baseUrl
122
+ }
123
+
124
+ if (options.metadata) {
125
+ payload.metadata = options.metadata
126
+ }
127
+
128
+ if (options.relatedPages) {
129
+ payload.relatedPages = options.relatedPages
130
+ }
131
+
132
+ // Register via gateway HTTP proxy
133
+ try {
134
+ const response = await this.gatewayClient.httpPost(
135
+ SERVICES.AI_INVENTORY,
136
+ AI_INVENTORY_PATHS.AGENT_REGISTER,
137
+ payload
138
+ )
139
+
140
+ if (!response.ok) {
141
+ const errorText = await response.text()
142
+ console.error('Registration failed:', response.status, errorText)
143
+ throw new RegistrationError(
144
+ `Failed to register agent ${options.name}: ${response.status}`
145
+ )
146
+ }
147
+
148
+ const data = await response.json() as Record<string, unknown>
149
+
150
+ return {
151
+ id: data.id as string,
152
+ name: data.name as string,
153
+ about: data.about as string,
154
+ document: data.document as string,
155
+ representationName: data.representationName as string | undefined,
156
+ baseUrl: data.baseUrl as string | undefined,
157
+ metadata: data.metadata as Record<string, unknown> | undefined,
158
+ relatedPages: data.relatedPages as { name: string; endpoint: string }[] | undefined,
159
+ }
160
+ } catch (error) {
161
+ if (error instanceof RegistrationError) throw error
162
+ throw new RegistrationError(`Failed to register agent ${options.name}: ${error}`)
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Start all registered agents and block until shutdown.
168
+ */
169
+ async run(): Promise<void> {
170
+ if (this.agents.length === 0) {
171
+ throw new ConfigurationError('No agents registered')
172
+ }
173
+
174
+ // Connect to gateway
175
+ const agentNames = this.agents.map((a) => a.name)
176
+ console.log(`Connecting to gateway with agents: ${agentNames.join(', ')}`)
177
+
178
+ try {
179
+ const topics = await this.gatewayClient.connect(agentNames)
180
+ console.log(`Connected. Subscribed to topics: ${topics.join(', ')}`)
181
+ } catch (error) {
182
+ throw new ConfigurationError(`Failed to connect to gateway: ${error}`)
183
+ }
184
+
185
+ // Initialize all agents
186
+ for (const agent of this.agents) {
187
+ agent.initializeWithGateway()
188
+ agent.startConsuming()
189
+ }
190
+
191
+ this.running = true
192
+ console.log('Agent app is running. Press Ctrl+C to stop.')
193
+
194
+ // Set up shutdown handlers
195
+ process.on('SIGINT', () => this.shutdown())
196
+ process.on('SIGTERM', () => this.shutdown())
197
+
198
+ // Keep the process alive
199
+ await this.consumeLoop()
200
+ }
201
+
202
+ /**
203
+ * Main consume loop.
204
+ */
205
+ private async consumeLoop(): Promise<void> {
206
+ while (this.running && !this.shutdownRequested) {
207
+ // The gateway client handles message dispatch via callbacks
208
+ // We just need to keep the event loop alive
209
+ await new Promise((resolve) => setTimeout(resolve, 100))
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Shutdown all agents gracefully.
215
+ */
216
+ shutdown(): void {
217
+ if (this.shutdownRequested) {
218
+ return
219
+ }
220
+
221
+ console.log('\nShutting down...')
222
+ this.shutdownRequested = true
223
+ this.running = false
224
+
225
+ // Shutdown all agents
226
+ for (const agent of this.agents) {
227
+ agent.shutdown()
228
+ }
229
+
230
+ // Close gateway connection
231
+ this.gatewayClient.close()
232
+
233
+ console.log('Shutdown complete')
234
+ }
235
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * March Agent SDK - Artifact Types
3
+ * Port of Python march_agent/artifact.py
4
+ */
5
+
6
+ import { z } from 'zod'
7
+
8
+ /**
9
+ * Valid artifact types for message attachments
10
+ */
11
+ export const ArtifactTypeSchema = z.enum([
12
+ 'document',
13
+ 'image',
14
+ 'iframe',
15
+ 'video',
16
+ 'audio',
17
+ 'code',
18
+ 'link',
19
+ 'file',
20
+ ])
21
+
22
+ export type ArtifactType = z.infer<typeof ArtifactTypeSchema>
23
+
24
+ /**
25
+ * Schema for artifact validation
26
+ */
27
+ export const ArtifactSchema = z.object({
28
+ url: z.string(),
29
+ type: ArtifactTypeSchema,
30
+ title: z.string().optional(),
31
+ description: z.string().optional(),
32
+ metadata: z.record(z.string(), z.unknown()).optional(),
33
+ })
34
+
35
+ export type Artifact = z.infer<typeof ArtifactSchema>
36
+
37
+ /**
38
+ * Input type for adding artifacts (without strict validation)
39
+ */
40
+ export interface ArtifactInput {
41
+ url: string
42
+ type: ArtifactType | string
43
+ title?: string
44
+ description?: string
45
+ metadata?: Record<string, unknown>
46
+ }
47
+
48
+ /**
49
+ * Convert artifact input to validated artifact
50
+ */
51
+ export function toArtifact(input: ArtifactInput): Artifact {
52
+ return ArtifactSchema.parse({
53
+ url: input.url,
54
+ type: input.type,
55
+ title: input.title,
56
+ description: input.description,
57
+ metadata: input.metadata,
58
+ })
59
+ }