@agentuity/runtime 0.0.90 → 0.0.92

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,484 @@
1
+ import { context, SpanKind, SpanStatusCode, type Context, trace } from '@opentelemetry/api';
2
+ import { TraceState } from '@opentelemetry/core';
3
+ import type {
4
+ KeyValueStorage,
5
+ StreamStorage,
6
+ VectorStorage,
7
+ } from '@agentuity/core';
8
+ import type { AgentContext, AgentRegistry, AgentRuntimeState } from './agent';
9
+ import { AGENT_RUNTIME, AGENT_IDS } from './_config';
10
+ import type { Logger } from './logger';
11
+ import type { Thread, Session } from './session';
12
+ import { generateId } from './session';
13
+ import WaitUntilHandler from './_waituntil';
14
+ import { registerServices } from './_services';
15
+ import { getAgentAsyncLocalStorage } from './_context';
16
+ import {
17
+ getLogger,
18
+ getTracer,
19
+ getAppState,
20
+ } from './_server';
21
+ import {
22
+ getThreadProvider,
23
+ getSessionProvider,
24
+ getSessionEventProvider,
25
+ } from './_services';
26
+ import * as runtimeConfig from './_config';
27
+
28
+ /**
29
+ * Options for creating a standalone agent context.
30
+ *
31
+ * Use this when executing agents outside of HTTP requests (Discord bots, cron jobs, etc.)
32
+ */
33
+ export interface StandaloneContextOptions {
34
+ /**
35
+ * Session ID for this execution. If not provided, will be auto-generated from trace context.
36
+ */
37
+ sessionId?: string;
38
+ /**
39
+ * Thread for multi-turn conversations. If not provided, will be restored/created from thread provider.
40
+ */
41
+ thread?: Thread;
42
+ /**
43
+ * Session for this execution. If not provided, will be created.
44
+ */
45
+ session?: Session;
46
+ /**
47
+ * Parent OpenTelemetry context for distributed tracing.
48
+ */
49
+ parentContext?: Context;
50
+ /**
51
+ * Trigger type for this execution (used in telemetry and session events).
52
+ */
53
+ trigger?: import('@agentuity/core').SessionStartEvent['trigger'];
54
+ }
55
+
56
+ /**
57
+ * Options for invoke() method.
58
+ */
59
+ export interface InvokeOptions {
60
+ /**
61
+ * Span name for OpenTelemetry trace (default: 'agent-invocation')
62
+ */
63
+ spanName?: string;
64
+ }
65
+
66
+ /**
67
+ * Standalone agent context for executing agents outside of HTTP requests.
68
+ *
69
+ * This context provides the same infrastructure as HTTP request contexts:
70
+ * - OpenTelemetry tracing with proper span hierarchy
71
+ * - Session and thread management (save/restore)
72
+ * - Background task handling (waitUntil)
73
+ * - Session event tracking (start/complete)
74
+ * - Access to all services (kv, stream, vector)
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * import { createAgentContext } from '@agentuity/runtime';
79
+ * import myAgent from './agents/my-agent';
80
+ *
81
+ * // Simple usage:
82
+ * const ctx = createAgentContext();
83
+ * const result = await ctx.invoke(() => myAgent.run(input));
84
+ *
85
+ * // With custom session tracking:
86
+ * const ctx = createAgentContext({
87
+ * sessionId: discordMessage.id,
88
+ * trigger: 'discord'
89
+ * });
90
+ * const result = await ctx.invoke(() => myAgent.run(input));
91
+ *
92
+ * // Reuse context for multiple agents:
93
+ * const ctx = createAgentContext();
94
+ * const result1 = await ctx.invoke(() => agent1.run(input1));
95
+ * const result2 = await ctx.invoke(() => agent2.run(result1));
96
+ * ```
97
+ */
98
+ export class StandaloneAgentContext<
99
+ TAgentMap extends AgentRegistry = AgentRegistry,
100
+ TConfig = unknown,
101
+ TAppState = Record<string, never>,
102
+ > implements AgentContext<TAgentMap, TConfig, TAppState>
103
+ {
104
+ // Immutable context properties (safe for concurrent access)
105
+ agent: TAgentMap = {} as TAgentMap;
106
+ logger: Logger;
107
+ tracer: import('@opentelemetry/api').Tracer;
108
+ kv!: KeyValueStorage;
109
+ stream!: StreamStorage;
110
+ vector!: VectorStorage;
111
+ config: TConfig;
112
+ app: TAppState;
113
+ [AGENT_RUNTIME]: AgentRuntimeState;
114
+
115
+ // Note: The following are mutable and will be set per-invocation via AsyncLocalStorage
116
+ // They exist on the interface for compatibility but are overwritten during invoke()
117
+ sessionId: string;
118
+ state: Map<string, unknown>;
119
+ session: Session;
120
+ thread: Thread;
121
+ [AGENT_IDS]?: Set<string>;
122
+
123
+ // Immutable options stored from constructor
124
+ private readonly parentContext: Context;
125
+ private readonly trigger: import('@agentuity/core').SessionStartEvent['trigger'];
126
+ private readonly initialSessionId?: string;
127
+
128
+ constructor(options?: StandaloneContextOptions) {
129
+ const logger = getLogger();
130
+ const tracer = getTracer();
131
+ const app = getAppState();
132
+
133
+ if (!logger || !tracer || !app) {
134
+ throw new Error(
135
+ 'Global state not initialized. Make sure createServer() has been called before createAgentContext().'
136
+ );
137
+ }
138
+
139
+ this.logger = logger;
140
+ this.tracer = tracer;
141
+ this.app = app as TAppState;
142
+ this.config = {} as TConfig;
143
+ this.state = new Map();
144
+ this.parentContext = options?.parentContext ?? context.active();
145
+ this.trigger = (options?.trigger as typeof this.trigger) ?? 'manual';
146
+ this.initialSessionId = options?.sessionId;
147
+
148
+ // Session ID will be set properly in invoke() after span is created
149
+ this.sessionId = options?.sessionId ?? 'pending';
150
+
151
+ // Thread and session will be restored in invoke()
152
+ this.thread = options?.thread ?? ({
153
+ id: 'pending',
154
+ state: new Map(),
155
+ addEventListener: () => {},
156
+ removeEventListener: () => {},
157
+ destroy: async () => {},
158
+ empty: () => true,
159
+ } as Thread);
160
+
161
+ this.session = options?.session ?? ({
162
+ id: 'pending',
163
+ thread: this.thread,
164
+ state: new Map(),
165
+ addEventListener: () => {},
166
+ removeEventListener: () => {},
167
+ serializeUserData: () => undefined,
168
+ } as Session);
169
+
170
+ // Create isolated runtime state
171
+ this[AGENT_RUNTIME] = {
172
+ agents: new Map(),
173
+ agentConfigs: new Map(),
174
+ agentEventListeners: new WeakMap(),
175
+ };
176
+
177
+ // Register services (kv, stream, vector)
178
+ registerServices(this, true); // true = populate agents registry
179
+ }
180
+
181
+ waitUntil(_callback: Promise<void> | (() => void | Promise<void>)): void {
182
+ // This will be called from within invoke() where waitUntilHandler is in scope
183
+ // We need to access the per-call waitUntilHandler from the current invocation
184
+ // This is handled by updating the context during invoke() via AsyncLocalStorage
185
+ throw new Error('waitUntil must be called from within invoke() execution context');
186
+ }
187
+
188
+ /**
189
+ * Execute a function within this agent context.
190
+ *
191
+ * This method:
192
+ * 1. Creates an OpenTelemetry span for the invocation
193
+ * 2. Restores/creates session and thread
194
+ * 3. Sends session start event
195
+ * 4. Executes the function within AsyncLocalStorage context
196
+ * 5. Waits for background tasks (waitUntil)
197
+ * 6. Saves session and thread
198
+ * 7. Sends session complete event
199
+ *
200
+ * @param fn - Function to execute (typically () => agent.run(input))
201
+ * @param options - Optional configuration for the invocation
202
+ * @returns Promise that resolves to the function's return value
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * const result = await ctx.invoke(() => myAgent.run({ userId: '123' }));
207
+ * ```
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * // Multiple agents in sequence:
212
+ * const result = await ctx.invoke(async () => {
213
+ * const step1 = await agent1.run(input);
214
+ * return agent2.run(step1);
215
+ * });
216
+ * ```
217
+ */
218
+ async invoke<T>(fn: () => Promise<T>, options?: InvokeOptions): Promise<T> {
219
+ const threadProvider = getThreadProvider();
220
+ const sessionProvider = getSessionProvider();
221
+ const sessionEventProvider = getSessionEventProvider();
222
+ const storage = getAgentAsyncLocalStorage();
223
+
224
+ // Create per-invocation state (prevents race conditions on concurrent calls)
225
+ const waitUntilHandler = new WaitUntilHandler(this.tracer);
226
+ const agentIds = new Set<string>();
227
+ let invocationSessionId = this.initialSessionId ?? 'pending';
228
+ let invocationThread: Thread;
229
+ let invocationSession: Session;
230
+ const invocationState = new Map<string, unknown>();
231
+
232
+ // Create a per-call context that inherits from this but has isolated mutable state
233
+ const callContext = Object.create(this) as StandaloneAgentContext<TAgentMap, TConfig, TAppState>;
234
+ callContext.sessionId = invocationSessionId;
235
+ callContext.state = invocationState;
236
+ callContext[AGENT_IDS] = agentIds;
237
+ callContext.waitUntil = (callback: Promise<void> | (() => void | Promise<void>)) => {
238
+ waitUntilHandler.waitUntil(callback);
239
+ };
240
+
241
+ // Execute within parent context (for distributed tracing)
242
+ return await context.with(this.parentContext, async () => {
243
+ // Create a span for this invocation (similar to otelMiddleware's HTTP span)
244
+ return await trace.getTracer('standalone-agent').startActiveSpan(
245
+ options?.spanName ?? 'agent-invocation',
246
+ {
247
+ kind: SpanKind.INTERNAL, // Not HTTP, but internal invocation
248
+ attributes: {
249
+ 'trigger': this.trigger,
250
+ },
251
+ },
252
+ async (span) => {
253
+ const sctx = span.spanContext();
254
+
255
+ // Generate sessionId from traceId if not provided
256
+ invocationSessionId = this.initialSessionId ?? (sctx?.traceId ? `sess_${sctx.traceId}` : generateId('sess'));
257
+ callContext.sessionId = invocationSessionId;
258
+
259
+ // Add to tracestate (like otelMiddleware does)
260
+ // Note: SpanContext.traceState is readonly, so we update it by setting the span with a new context
261
+ let traceState = sctx.traceState ?? new TraceState();
262
+ const projectId = runtimeConfig.getProjectId();
263
+ const orgId = runtimeConfig.getOrganizationId();
264
+ const deploymentId = runtimeConfig.getDeploymentId();
265
+ const isDevMode = runtimeConfig.isDevMode();
266
+ if (projectId) {
267
+ traceState = traceState.set('pid', projectId);
268
+ }
269
+ if (orgId) {
270
+ traceState = traceState.set('oid', orgId);
271
+ }
272
+ if (isDevMode) {
273
+ traceState = traceState.set('d', '1');
274
+ }
275
+
276
+ // Update the active context with the new trace state
277
+ // We do this by setting the span in the context with updated trace state
278
+ // Note: This creates a new context but we don't need to use it directly
279
+ // as the span already has the trace state we need for propagation
280
+ trace.setSpan(
281
+ context.active(),
282
+ trace.wrapSpanContext({
283
+ ...sctx,
284
+ traceState
285
+ })
286
+ );
287
+
288
+ // Restore thread and session (like otelMiddleware does)
289
+ // For standalone contexts, we create a simple thread/session if not provided
290
+ // The threadProvider.restore expects a Hono context with cookie/header access
291
+ // For standalone contexts without HTTP, we just create a new thread
292
+ const { DefaultThread, generateId: genId } = await import('./session');
293
+ const threadId = genId('thrd');
294
+ invocationThread = new DefaultThread(threadProvider, threadId);
295
+ callContext.thread = invocationThread;
296
+
297
+ invocationSession = await sessionProvider.restore(invocationThread, invocationSessionId);
298
+ callContext.session = invocationSession;
299
+
300
+ // Send session start event (if configured)
301
+ const shouldSendSession = !!(orgId && projectId);
302
+ let canSendSessionEvents = true;
303
+
304
+ if (shouldSendSession) {
305
+ await sessionEventProvider
306
+ .start({
307
+ id: invocationSessionId,
308
+ orgId,
309
+ projectId,
310
+ threadId: invocationThread.id,
311
+ routeId: 'standalone', // No route for standalone contexts
312
+ deploymentId,
313
+ devmode: isDevMode,
314
+ environment: runtimeConfig.getEnvironment(),
315
+ method: 'STANDALONE',
316
+ url: '',
317
+ trigger: this.trigger,
318
+ })
319
+ .catch((ex) => {
320
+ canSendSessionEvents = false;
321
+ this.logger.error('error sending session start event: %s', ex);
322
+ });
323
+ }
324
+
325
+ let hasPendingWaits = false;
326
+
327
+ try {
328
+ // Execute function within AsyncLocalStorage context with per-call context
329
+ const result = await storage.run(callContext, fn);
330
+
331
+ // Wait for background tasks (like otelMiddleware does)
332
+ if (waitUntilHandler.hasPending()) {
333
+ hasPendingWaits = true;
334
+ waitUntilHandler
335
+ .waitUntilAll(this.logger, invocationSessionId)
336
+ .then(async () => {
337
+ this.logger.debug('wait until finished for session %s', invocationSessionId);
338
+ await sessionProvider.save(invocationSession);
339
+ await threadProvider.save(invocationThread);
340
+ span.setStatus({ code: SpanStatusCode.OK });
341
+ if (shouldSendSession && canSendSessionEvents) {
342
+ const userData = invocationSession.serializeUserData();
343
+ sessionEventProvider
344
+ .complete({
345
+ id: invocationSessionId,
346
+ threadId: invocationThread.empty() ? null : invocationThread.id,
347
+ statusCode: 200, // Success
348
+ agentIds: Array.from(agentIds),
349
+ userData,
350
+ })
351
+ .then(() => {})
352
+ .catch((ex) => this.logger.error(ex));
353
+ }
354
+ })
355
+ .catch((ex) => {
356
+ this.logger.error('wait until errored for session %s. %s', invocationSessionId, ex);
357
+ if (ex instanceof Error) {
358
+ span.recordException(ex);
359
+ }
360
+ const message = (ex as Error).message ?? String(ex);
361
+ span.setStatus({
362
+ code: SpanStatusCode.ERROR,
363
+ message,
364
+ });
365
+ this.logger.error(message);
366
+ if (shouldSendSession && canSendSessionEvents) {
367
+ const userData = invocationSession.serializeUserData();
368
+ sessionEventProvider
369
+ .complete({
370
+ id: invocationSessionId,
371
+ threadId: invocationThread.empty() ? null : invocationThread.id,
372
+ statusCode: 500, // Error
373
+ error: message,
374
+ agentIds: Array.from(agentIds),
375
+ userData,
376
+ })
377
+ .then(() => {})
378
+ .catch((ex) => this.logger.error(ex));
379
+ }
380
+ })
381
+ .finally(() => {
382
+ span.end();
383
+ });
384
+ } else {
385
+ span.setStatus({ code: SpanStatusCode.OK });
386
+ if (shouldSendSession && canSendSessionEvents) {
387
+ const userData = invocationSession.serializeUserData();
388
+ sessionEventProvider
389
+ .complete({
390
+ id: invocationSessionId,
391
+ threadId: invocationThread.empty() ? null : invocationThread.id,
392
+ statusCode: 200,
393
+ agentIds: Array.from(agentIds),
394
+ userData,
395
+ })
396
+ .then(() => {})
397
+ .catch((ex) => this.logger.error(ex));
398
+ }
399
+ }
400
+
401
+ return result;
402
+ } catch (ex) {
403
+ if (ex instanceof Error) {
404
+ span.recordException(ex);
405
+ }
406
+ const message = (ex as Error).message ?? String(ex);
407
+ span.setStatus({
408
+ code: SpanStatusCode.ERROR,
409
+ message,
410
+ });
411
+ this.logger.error(message);
412
+ if (shouldSendSession && canSendSessionEvents) {
413
+ const userData = invocationSession.serializeUserData();
414
+ sessionEventProvider
415
+ .complete({
416
+ id: invocationSessionId,
417
+ threadId: invocationThread.empty() ? null : invocationThread.id,
418
+ statusCode: 500,
419
+ error: message,
420
+ agentIds: Array.from(agentIds),
421
+ userData,
422
+ })
423
+ .then(() => {})
424
+ .catch((ex) => this.logger.error(ex));
425
+ }
426
+ throw ex;
427
+ } finally {
428
+ if (!hasPendingWaits) {
429
+ try {
430
+ await sessionProvider.save(invocationSession);
431
+ await threadProvider.save(invocationThread);
432
+ } finally {
433
+ span.end();
434
+ }
435
+ }
436
+ }
437
+ }
438
+ );
439
+ });
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Create a standalone agent context for executing agents outside of HTTP requests.
445
+ *
446
+ * This is useful for Discord bots, cron jobs, WebSocket callbacks, or any scenario
447
+ * where you need to run agents but don't have an HTTP request context.
448
+ *
449
+ * @param options - Optional configuration for the context
450
+ * @returns A StandaloneAgentContext instance
451
+ *
452
+ * @example
453
+ * ```typescript
454
+ * import { createAgentContext } from '@agentuity/runtime';
455
+ * import myAgent from './agents/my-agent';
456
+ *
457
+ * // Simple usage:
458
+ * const ctx = createAgentContext();
459
+ * const result = await ctx.invoke(() => myAgent.run(input));
460
+ *
461
+ * // Discord bot example:
462
+ * client.on('messageCreate', async (message) => {
463
+ * const ctx = createAgentContext({
464
+ * sessionId: message.id,
465
+ * trigger: 'discord'
466
+ * });
467
+ * const response = await ctx.invoke(() =>
468
+ * chatAgent.run({ message: message.content })
469
+ * );
470
+ * await message.reply(response.text);
471
+ * });
472
+ *
473
+ * // Cron job example:
474
+ * cron.schedule('0 * * * *', async () => {
475
+ * const ctx = createAgentContext({ trigger: 'cron' });
476
+ * await ctx.invoke(() => cleanupAgent.run());
477
+ * });
478
+ * ```
479
+ */
480
+ export function createAgentContext<TAppState = Record<string, never>>(
481
+ options?: StandaloneContextOptions
482
+ ): StandaloneAgentContext<AgentRegistry, unknown, TAppState> {
483
+ return new StandaloneAgentContext<AgentRegistry, unknown, TAppState>(options);
484
+ }
package/src/agent.ts CHANGED
@@ -13,7 +13,7 @@ import { context, SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'
13
13
  import type { Context, MiddlewareHandler } from 'hono';
14
14
  import type { Handler } from 'hono/types';
15
15
  import { validator } from 'hono/validator';
16
- import { AGENT_RUNTIME, INTERNAL_AGENT, CURRENT_AGENT } from './_config';
16
+ import { AGENT_RUNTIME, INTERNAL_AGENT, CURRENT_AGENT, AGENT_IDS } from './_config';
17
17
  import {
18
18
  getAgentContext,
19
19
  inHTTPContext,
@@ -1542,6 +1542,13 @@ export function createAgent<
1542
1542
  honoCtx.var.agentIds.add(agent.metadata.id);
1543
1543
  honoCtx.var.agentIds.add(agent.metadata.agentId);
1544
1544
  }
1545
+ } else {
1546
+ // For standalone contexts, check for AGENT_IDS symbol
1547
+ const agentIds = (agentCtx as any)[AGENT_IDS] as Set<string> | undefined;
1548
+ if (agentIds) {
1549
+ agentIds.add(agent.metadata.id);
1550
+ agentIds.add(agent.metadata.agentId);
1551
+ }
1545
1552
  }
1546
1553
 
1547
1554
  agentCtx.logger = agentCtx.logger.child(attrs);
package/src/index.ts CHANGED
@@ -91,6 +91,14 @@ export type { Logger } from './logger';
91
91
  // _server.ts exports
92
92
  export { getRouter, getAppState, AGENT_CONTEXT_PROPERTIES } from './_server';
93
93
 
94
+ // _standalone.ts exports
95
+ export {
96
+ createAgentContext,
97
+ StandaloneAgentContext,
98
+ type StandaloneContextOptions,
99
+ type InvokeOptions,
100
+ } from './_standalone';
101
+
94
102
  // io/email exports
95
103
  export { Email, parseEmail } from './io/email';
96
104