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.
@@ -0,0 +1,420 @@
1
+ /**
2
+ * March Agent SDK - Gateway Client
3
+ * Port of Python march_agent/gateway_client.py
4
+ *
5
+ * gRPC client for communicating with the Agent Gateway.
6
+ */
7
+
8
+ import * as grpc from '@grpc/grpc-js'
9
+ import * as protoLoader from '@grpc/proto-loader'
10
+ import { fileURLToPath } from 'url'
11
+ import { dirname, join } from 'path'
12
+ import { GatewayError } from './exceptions.js'
13
+ import type { KafkaMessage, ProduceAck } from './types.js'
14
+
15
+ const __filename = fileURLToPath(import.meta.url)
16
+ const __dirname = dirname(__filename)
17
+
18
+ // Load proto file
19
+ const PROTO_PATH = join(__dirname, 'proto', 'gateway.proto')
20
+
21
+ const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
22
+ keepCase: true,
23
+ longs: String,
24
+ enums: String,
25
+ defaults: true,
26
+ oneofs: true,
27
+ })
28
+
29
+ const gatewayProto = grpc.loadPackageDefinition(packageDefinition) as unknown as {
30
+ gateway: {
31
+ AgentGateway: new (
32
+ address: string,
33
+ credentials: grpc.ChannelCredentials
34
+ ) => AgentGatewayClient
35
+ }
36
+ }
37
+
38
+ interface AgentGatewayClient {
39
+ AgentStream(): grpc.ClientDuplexStream<ClientMessage, ServerMessage>
40
+ }
41
+
42
+ interface ClientMessage {
43
+ auth?: { api_key: string; agent_names: string[] }
44
+ subscribe?: { agent_name: string }
45
+ unsubscribe?: { agent_name: string }
46
+ produce?: {
47
+ topic: string
48
+ key: string
49
+ headers: Record<string, string>
50
+ body: Buffer
51
+ correlation_id: string
52
+ }
53
+ ping?: { timestamp: string }
54
+ }
55
+
56
+ interface ServerMessage {
57
+ auth_response?: { connection_id: string; subscribed_topics: string[] }
58
+ message?: {
59
+ topic: string
60
+ partition: number
61
+ offset: string
62
+ key: string
63
+ headers: Record<string, string>
64
+ body: Buffer
65
+ timestamp: string
66
+ }
67
+ produce_ack?: {
68
+ topic: string
69
+ partition: number
70
+ offset: string
71
+ correlation_id: string
72
+ }
73
+ subscribe_ack?: { topic: string }
74
+ unsubscribe_ack?: { agent_name: string }
75
+ pong?: { client_timestamp: string; server_timestamp: string }
76
+ error?: { code: string; message: string; correlation_id: string }
77
+ }
78
+
79
+ /**
80
+ * Client for communicating with the Agent Gateway.
81
+ * Provides gRPC bidirectional streaming for Kafka consume/produce.
82
+ */
83
+ export class GatewayClient {
84
+ private readonly gatewayUrl: string
85
+ private readonly apiKey: string
86
+ private readonly secure: boolean
87
+
88
+ private client?: AgentGatewayClient
89
+ private stream?: grpc.ClientDuplexStream<ClientMessage, ServerMessage>
90
+ private _connectionId?: string
91
+ private messageQueue: KafkaMessage[] = []
92
+ private pendingProduceAcks: Map<string, (ack: ProduceAck) => void> = new Map()
93
+ private pendingSubscribeAcks: Map<string, (topic: string) => void> = new Map()
94
+ private messageHandlers: Map<string, (msg: KafkaMessage) => void> = new Map()
95
+ private correlationCounter: number = 0
96
+ private connected: boolean = false
97
+
98
+ constructor(gatewayUrl: string, apiKey: string, secure: boolean = false) {
99
+ this.gatewayUrl = gatewayUrl
100
+ this.apiKey = apiKey
101
+ this.secure = secure
102
+ }
103
+
104
+ /**
105
+ * HTTP URL for AI Inventory service via proxy.
106
+ */
107
+ get aiInventoryUrl(): string {
108
+ const protocol = this.secure ? 'https' : 'http'
109
+ return `${protocol}://${this.gatewayUrl}/s/ai-inventory`
110
+ }
111
+
112
+ /**
113
+ * HTTP URL for Conversation Store service via proxy.
114
+ */
115
+ get conversationStoreUrl(): string {
116
+ const protocol = this.secure ? 'https' : 'http'
117
+ return `${protocol}://${this.gatewayUrl}/s/conversation-store`
118
+ }
119
+
120
+ /**
121
+ * HTTP URL for AI Memory service via proxy.
122
+ */
123
+ get aiMemoryUrl(): string {
124
+ const protocol = this.secure ? 'https' : 'http'
125
+ return `${protocol}://${this.gatewayUrl}/s/ai-memory`
126
+ }
127
+
128
+ /**
129
+ * HTTP URL for Attachment service via proxy.
130
+ */
131
+ get attachmentUrl(): string {
132
+ const protocol = this.secure ? 'https' : 'http'
133
+ return `${protocol}://${this.gatewayUrl}/s/attachment`
134
+ }
135
+
136
+ /**
137
+ * Register a handler for a topic.
138
+ */
139
+ registerHandler(topic: string, handler: (msg: KafkaMessage) => void): void {
140
+ this.messageHandlers.set(topic, handler)
141
+ }
142
+
143
+ /**
144
+ * Connect to the gateway and authenticate.
145
+ */
146
+ async connect(agentNames: string[]): Promise<string[]> {
147
+ const credentials = this.secure
148
+ ? grpc.credentials.createSsl()
149
+ : grpc.credentials.createInsecure()
150
+
151
+ this.client = new gatewayProto.gateway.AgentGateway(
152
+ this.gatewayUrl,
153
+ credentials
154
+ )
155
+
156
+ this.stream = this.client.AgentStream()
157
+
158
+ // Set up message handling
159
+ this.stream.on('data', (msg: ServerMessage) => {
160
+ this.handleServerMessage(msg)
161
+ })
162
+
163
+ this.stream.on('error', (err: Error) => {
164
+ console.error('Gateway stream error:', err)
165
+ this.connected = false
166
+ })
167
+
168
+ this.stream.on('end', () => {
169
+ console.log('Gateway stream ended')
170
+ this.connected = false
171
+ })
172
+
173
+ // Send auth request
174
+ return new Promise((resolve, reject) => {
175
+ const timeout = setTimeout(() => {
176
+ reject(new GatewayError('Authentication timeout'))
177
+ }, 10000)
178
+
179
+ const authHandler = (msg: ServerMessage) => {
180
+ if (msg.auth_response) {
181
+ clearTimeout(timeout)
182
+ this._connectionId = msg.auth_response.connection_id
183
+ this.connected = true
184
+ resolve(msg.auth_response.subscribed_topics)
185
+ } else if (msg.error) {
186
+ clearTimeout(timeout)
187
+ reject(new GatewayError(`Authentication failed: ${msg.error.message}`))
188
+ }
189
+ }
190
+
191
+ // Temporarily override handler for auth response
192
+ const originalHandler = this.handleServerMessage.bind(this)
193
+ this.handleServerMessage = (msg: ServerMessage) => {
194
+ if (msg.auth_response || msg.error) {
195
+ authHandler(msg)
196
+ this.handleServerMessage = originalHandler
197
+ } else {
198
+ originalHandler(msg)
199
+ }
200
+ }
201
+
202
+ this.stream!.write({
203
+ auth: {
204
+ api_key: this.apiKey,
205
+ agent_names: agentNames,
206
+ },
207
+ })
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Handle incoming server messages.
213
+ */
214
+ private handleServerMessage(msg: ServerMessage): void {
215
+ if (msg.message) {
216
+ const kafkaMsg: KafkaMessage = {
217
+ topic: msg.message.topic,
218
+ partition: msg.message.partition,
219
+ offset: parseInt(msg.message.offset, 10),
220
+ key: msg.message.key,
221
+ headers: msg.message.headers,
222
+ body: JSON.parse(msg.message.body.toString()),
223
+ timestamp: parseInt(msg.message.timestamp, 10),
224
+ }
225
+
226
+ // Check for registered handler
227
+ const handler = this.messageHandlers.get(kafkaMsg.topic)
228
+ if (handler) {
229
+ handler(kafkaMsg)
230
+ } else {
231
+ // Queue message if no handler
232
+ this.messageQueue.push(kafkaMsg)
233
+ }
234
+ } else if (msg.produce_ack) {
235
+ const callback = this.pendingProduceAcks.get(msg.produce_ack.correlation_id)
236
+ if (callback) {
237
+ callback({
238
+ topic: msg.produce_ack.topic,
239
+ partition: msg.produce_ack.partition,
240
+ offset: parseInt(msg.produce_ack.offset, 10),
241
+ correlationId: msg.produce_ack.correlation_id,
242
+ })
243
+ this.pendingProduceAcks.delete(msg.produce_ack.correlation_id)
244
+ }
245
+ } else if (msg.subscribe_ack) {
246
+ const callback = this.pendingSubscribeAcks.get(msg.subscribe_ack.topic)
247
+ if (callback) {
248
+ callback(msg.subscribe_ack.topic)
249
+ this.pendingSubscribeAcks.delete(msg.subscribe_ack.topic)
250
+ }
251
+ } else if (msg.error) {
252
+ console.error('Gateway error:', msg.error.message)
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Subscribe to an additional agent's topic.
258
+ */
259
+ async subscribe(agentName: string): Promise<string> {
260
+ if (!this.stream || !this.connected) {
261
+ throw new GatewayError('Not connected')
262
+ }
263
+
264
+ return new Promise((resolve, reject) => {
265
+ const timeout = setTimeout(() => {
266
+ reject(new GatewayError('Subscribe timeout'))
267
+ }, 5000)
268
+
269
+ const expectedTopic = `${agentName}.inbox`
270
+ this.pendingSubscribeAcks.set(expectedTopic, (topic) => {
271
+ clearTimeout(timeout)
272
+ resolve(topic)
273
+ })
274
+
275
+ this.stream!.write({
276
+ subscribe: { agent_name: agentName },
277
+ })
278
+ })
279
+ }
280
+
281
+ /**
282
+ * Unsubscribe from an agent's topic.
283
+ */
284
+ unsubscribe(agentName: string): void {
285
+ if (!this.stream || !this.connected) {
286
+ throw new GatewayError('Not connected')
287
+ }
288
+
289
+ this.stream.write({
290
+ unsubscribe: { agent_name: agentName },
291
+ })
292
+ }
293
+
294
+ /**
295
+ * Produce a message to Kafka via the gateway.
296
+ */
297
+ produce(
298
+ topic: string,
299
+ key: string,
300
+ headers: Record<string, string>,
301
+ body: Record<string, unknown>,
302
+ correlationId?: string
303
+ ): void {
304
+ if (!this.stream || !this.connected) {
305
+ throw new GatewayError('Not connected')
306
+ }
307
+
308
+ const corrId = correlationId ?? `${++this.correlationCounter}`
309
+
310
+ this.stream.write({
311
+ produce: {
312
+ topic,
313
+ key,
314
+ headers,
315
+ body: Buffer.from(JSON.stringify(body)),
316
+ correlation_id: corrId,
317
+ },
318
+ })
319
+ }
320
+
321
+ /**
322
+ * Produce a message and wait for acknowledgment.
323
+ */
324
+ async produceAndWait(
325
+ topic: string,
326
+ key: string,
327
+ headers: Record<string, string>,
328
+ body: Record<string, unknown>
329
+ ): Promise<ProduceAck> {
330
+ if (!this.stream || !this.connected) {
331
+ throw new GatewayError('Not connected')
332
+ }
333
+
334
+ const correlationId = `${++this.correlationCounter}`
335
+
336
+ return new Promise((resolve, reject) => {
337
+ const timeout = setTimeout(() => {
338
+ this.pendingProduceAcks.delete(correlationId)
339
+ reject(new GatewayError('Produce timeout'))
340
+ }, 10000)
341
+
342
+ this.pendingProduceAcks.set(correlationId, (ack) => {
343
+ clearTimeout(timeout)
344
+ resolve(ack)
345
+ })
346
+
347
+ this.stream!.write({
348
+ produce: {
349
+ topic,
350
+ key,
351
+ headers,
352
+ body: Buffer.from(JSON.stringify(body)),
353
+ correlation_id: correlationId,
354
+ },
355
+ })
356
+ })
357
+ }
358
+
359
+ /**
360
+ * Consume a single message (polling from queue).
361
+ */
362
+ consumeOne(_timeout: number = 1000): KafkaMessage | null {
363
+ if (this.messageQueue.length > 0) {
364
+ return this.messageQueue.shift()!
365
+ }
366
+ return null
367
+ }
368
+
369
+ /**
370
+ * Send a ping to the gateway.
371
+ */
372
+ ping(): void {
373
+ if (!this.stream || !this.connected) {
374
+ throw new GatewayError('Not connected')
375
+ }
376
+
377
+ this.stream.write({
378
+ ping: { timestamp: String(Date.now()) },
379
+ })
380
+ }
381
+
382
+ /**
383
+ * Make a sync POST request (used for registration).
384
+ */
385
+ async httpPost(
386
+ service: string,
387
+ path: string,
388
+ body: unknown
389
+ ): Promise<Response> {
390
+ const protocol = this.secure ? 'https' : 'http'
391
+ const url = `${protocol}://${this.gatewayUrl}/s/${service}${path}`
392
+
393
+ return fetch(url, {
394
+ method: 'POST',
395
+ headers: {
396
+ 'Content-Type': 'application/json',
397
+ 'X-API-Key': this.apiKey,
398
+ },
399
+ body: JSON.stringify(body),
400
+ })
401
+ }
402
+
403
+ /**
404
+ * Check if connected.
405
+ */
406
+ isConnected(): boolean {
407
+ return this.connected
408
+ }
409
+
410
+ /**
411
+ * Close the gateway connection.
412
+ */
413
+ close(): void {
414
+ if (this.stream) {
415
+ this.stream.end()
416
+ this.stream = undefined
417
+ }
418
+ this.connected = false
419
+ }
420
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * March Agent SDK - Heartbeat Manager
3
+ * Port of Python march_agent/heartbeat.py
4
+ */
5
+
6
+ import type { GatewayClient } from './gateway-client.js'
7
+ import { AI_INVENTORY_PATHS, SERVICES } from './api-paths.js'
8
+
9
+ /**
10
+ * Manages periodic heartbeats to keep the agent status active.
11
+ * Sends heartbeats via HTTP to the AI Inventory service.
12
+ */
13
+ export class HeartbeatManager {
14
+ private readonly gatewayClient: GatewayClient
15
+ private readonly agentName: string
16
+ private readonly intervalMs: number
17
+ private timer?: ReturnType<typeof setInterval>
18
+ private running: boolean = false
19
+
20
+ constructor(
21
+ gatewayClient: GatewayClient,
22
+ agentName: string,
23
+ intervalSeconds: number = 60
24
+ ) {
25
+ this.gatewayClient = gatewayClient
26
+ this.agentName = agentName
27
+ this.intervalMs = intervalSeconds * 1000
28
+ }
29
+
30
+ /**
31
+ * Start sending heartbeats.
32
+ */
33
+ start(): void {
34
+ if (this.running) {
35
+ return
36
+ }
37
+
38
+ this.running = true
39
+ this.timer = setInterval(() => {
40
+ this.sendHeartbeat()
41
+ }, this.intervalMs)
42
+
43
+ // Send first heartbeat immediately
44
+ this.sendHeartbeat()
45
+ }
46
+
47
+ /**
48
+ * Stop sending heartbeats.
49
+ */
50
+ stop(): void {
51
+ this.running = false
52
+ if (this.timer) {
53
+ clearInterval(this.timer)
54
+ this.timer = undefined
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Send a single heartbeat via HTTP to AI Inventory.
60
+ */
61
+ private async sendHeartbeat(): Promise<void> {
62
+ if (!this.running) {
63
+ return
64
+ }
65
+
66
+ try {
67
+ const response = await this.gatewayClient.httpPost(
68
+ SERVICES.AI_INVENTORY,
69
+ AI_INVENTORY_PATHS.HEALTH_HEARTBEAT,
70
+ { name: this.agentName }
71
+ )
72
+
73
+ if (response.status === 404) {
74
+ console.warn(`Agent '${this.agentName}' not found. Re-registration may be needed.`)
75
+ } else if (!response.ok) {
76
+ console.warn(`Heartbeat returned status ${response.status}`)
77
+ }
78
+ } catch (error) {
79
+ console.error('Heartbeat failed:', error)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if heartbeat is running.
85
+ */
86
+ isRunning(): boolean {
87
+ return this.running
88
+ }
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * March Agent SDK - TypeScript framework for building AI agents
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ // Main classes
8
+ export { MarchAgentApp } from './app.js'
9
+ export { Agent, SenderFilter } from './agent.js'
10
+ export { Message } from './message.js'
11
+ export { Streamer } from './streamer.js'
12
+ export { Conversation } from './conversation.js'
13
+ export { Memory } from './memory.js'
14
+
15
+ // Message types
16
+ export { ConversationMessage } from './conversation-message.js'
17
+ export { type Artifact, type ArtifactType, type ArtifactInput, toArtifact } from './artifact.js'
18
+
19
+ // Clients
20
+ export { GatewayClient } from './gateway-client.js'
21
+ export { ConversationClient } from './conversation-client.js'
22
+ export { CheckpointClient, type CheckpointConfig, type CheckpointData, type CheckpointMetadata, type CheckpointTuple } from './checkpoint-client.js'
23
+ export { AgentStateClient, type AgentStateResponse } from './agent-state-client.js'
24
+ export { MemoryClient } from './memory-client.js'
25
+ export { AttachmentClient, createAttachmentInfo, type AttachmentInfo } from './attachment-client.js'
26
+
27
+ // Helpers
28
+ export { HeartbeatManager } from './heartbeat.js'
29
+
30
+ // API Paths Configuration
31
+ export {
32
+ AI_INVENTORY_PATHS,
33
+ CONVERSATION_STORE_PATHS,
34
+ MEMORY_PATHS,
35
+ SERVICES,
36
+ type ServiceName,
37
+ } from './api-paths.js'
38
+
39
+ // Types
40
+ export type {
41
+ KafkaMessage,
42
+ KafkaHeaders,
43
+ AgentRegistrationData,
44
+ RegisterOptions,
45
+ MessageHandler,
46
+ SenderFilterOptions,
47
+ StreamOptions,
48
+ StreamerOptions,
49
+ AppOptions,
50
+ ConversationData,
51
+ GetMessagesOptions,
52
+ MemoryMessage,
53
+ MemorySearchResult,
54
+ UserSummary,
55
+ ProduceAck,
56
+ } from './types.js'
57
+
58
+ // Exceptions
59
+ export {
60
+ MarchAgentError,
61
+ RegistrationError,
62
+ KafkaError,
63
+ ConfigurationError,
64
+ APIException,
65
+ HeartbeatError,
66
+ GatewayError,
67
+ } from './exceptions.js'
68
+
69
+ // Version
70
+ export const VERSION = '0.3.0'
@@ -0,0 +1,125 @@
1
+ /**
2
+ * March Agent SDK - Memory Client
3
+ * Port of Python march_agent/memory_client.py
4
+ */
5
+
6
+ import { APIException } from './exceptions.js'
7
+ import type { MemoryMessage, MemorySearchResult, UserSummary } from './types.js'
8
+
9
+ /**
10
+ * Async HTTP client for AI memory API.
11
+ * Provides long-term memory storage and semantic search.
12
+ */
13
+ export class MemoryClient {
14
+ private readonly baseUrl: string
15
+
16
+ constructor(baseUrl: string) {
17
+ this.baseUrl = baseUrl.replace(/\/$/, '')
18
+ }
19
+
20
+ /**
21
+ * Add messages to memory for a user/conversation.
22
+ */
23
+ async addMessages(
24
+ userId: string,
25
+ conversationId: string,
26
+ messages: MemoryMessage[]
27
+ ): Promise<{ added: number }> {
28
+ const url = `${this.baseUrl}/memory/${userId}/messages`
29
+ const payload = {
30
+ conversation_id: conversationId,
31
+ messages,
32
+ }
33
+
34
+ try {
35
+ const response = await fetch(url, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify(payload),
39
+ })
40
+
41
+ if (!response.ok) {
42
+ const errorText = await response.text()
43
+ throw new APIException(`Failed to add messages to memory: ${response.status} - ${errorText}`, response.status)
44
+ }
45
+
46
+ return await response.json() as { added: number }
47
+ } catch (error) {
48
+ if (error instanceof APIException) throw error
49
+ throw new APIException(`Failed to add messages to memory: ${error}`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Search memory for relevant content.
55
+ */
56
+ async search(
57
+ userId: string,
58
+ query: string,
59
+ limit: number = 10
60
+ ): Promise<MemorySearchResult[]> {
61
+ const url = new URL(`${this.baseUrl}/memory/${userId}/search`)
62
+ url.searchParams.set('query', query)
63
+ url.searchParams.set('limit', String(limit))
64
+
65
+ try {
66
+ const response = await fetch(url.toString())
67
+
68
+ if (!response.ok) {
69
+ const errorText = await response.text()
70
+ throw new APIException(`Failed to search memory: ${response.status} - ${errorText}`, response.status)
71
+ }
72
+
73
+ return await response.json() as MemorySearchResult[]
74
+ } catch (error) {
75
+ if (error instanceof APIException) throw error
76
+ throw new APIException(`Failed to search memory: ${error}`)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Get user summary.
82
+ */
83
+ async getUserSummary(userId: string): Promise<UserSummary | null> {
84
+ const url = `${this.baseUrl}/memory/${userId}/summary`
85
+
86
+ try {
87
+ const response = await fetch(url)
88
+
89
+ if (response.status === 404) {
90
+ return null
91
+ }
92
+
93
+ if (!response.ok) {
94
+ const errorText = await response.text()
95
+ throw new APIException(`Failed to get user summary: ${response.status} - ${errorText}`, response.status)
96
+ }
97
+
98
+ return await response.json() as UserSummary
99
+ } catch (error) {
100
+ if (error instanceof APIException) throw error
101
+ throw new APIException(`Failed to get user summary: ${error}`)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Clear user's memory.
107
+ */
108
+ async clearMemory(userId: string): Promise<{ deleted: number }> {
109
+ const url = `${this.baseUrl}/memory/${userId}`
110
+
111
+ try {
112
+ const response = await fetch(url, { method: 'DELETE' })
113
+
114
+ if (!response.ok) {
115
+ const errorText = await response.text()
116
+ throw new APIException(`Failed to clear memory: ${response.status} - ${errorText}`, response.status)
117
+ }
118
+
119
+ return await response.json() as { deleted: number }
120
+ } catch (error) {
121
+ if (error instanceof APIException) throw error
122
+ throw new APIException(`Failed to clear memory: ${error}`)
123
+ }
124
+ }
125
+ }