@primo-ai/core 0.1.3 → 0.1.5

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/dist/agent.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentConfig, CheckpointStore, PipelineStageConfig, Processor, SessionManager, Tracer } from '@primo-ai/sdk';
1
+ import type { AgentConfig, AgentSimpleConfig, CheckpointStore, PipelineStageConfig, Processor, SessionManager, Tracer } from '@primo-ai/sdk';
2
2
  import { PipelineRunner } from './pipeline.js';
3
3
  import { serialize } from './serialize.js';
4
4
  import { ToolRegistry } from './tool-registry.js';
@@ -40,6 +40,9 @@ export declare class Agent {
40
40
  private orchestrator;
41
41
  private sessionManager?;
42
42
  private contextBuilder;
43
+ private activeAbortController;
44
+ private lastContext?;
45
+ constructor(config: AgentSimpleConfig);
43
46
  constructor(config: AgentConfig, deps?: AgentDependencies);
44
47
  use(factory: Processor | PluginFactory): void;
45
48
  teardown(): Promise<void>;
@@ -49,17 +52,33 @@ export declare class Agent {
49
52
  get eventBus(): import('./event-bus.js').EventBus;
50
53
  get eventSystem(): import('./event-system.js').EventSystem;
51
54
  get state(): import('./state-machine.js').AgentState;
55
+ /** Subscribe to an event type. Returns an unsubscribe function. */
56
+ on(eventType: string, handler: (data?: unknown) => void): () => void;
57
+ /** Subscribe to an event type for at most one emission. */
58
+ once(eventType: string, handler: (data?: unknown) => void): void;
59
+ /** Remove a specific handler for an event type. */
60
+ off(eventType: string, handler: (data?: unknown) => void): void;
52
61
  get _contextBuilder(): ContextBuilder;
53
62
  run(input: string, signal?: globalThis.AbortSignal): Promise<AgentRunResult>;
54
63
  resume(sessionId: string, signal?: globalThis.AbortSignal): Promise<AgentRunResult>;
55
64
  stream(input: string, signal?: globalThis.AbortSignal): AsyncGenerator<string>;
56
65
  streamEvents(input: string, signal?: globalThis.AbortSignal): AsyncGenerator<import('@primo-ai/sdk').StreamEvent>;
66
+ /** Abort a running agent. Idempotent — no-op if agent is not running. */
67
+ abort(): void;
68
+ /** Clear conversation state so the next run/stream starts a fresh session. */
69
+ reset(): void;
57
70
  /** Clear the cached model so the next run re-resolves from the factory. */
58
71
  invalidateModel(): void;
59
72
  /** Auto-invalidate cached model when the error indicates auth failure or model-not-found. */
60
73
  private autoInvalidateModel;
61
74
  private getLLM;
75
+ private buildContext;
62
76
  private createContext;
63
77
  private registerTools;
64
78
  private registerBuiltinProcessors;
65
79
  }
80
+ /**
81
+ * Create an Agent from a simple config. Convenience wrapper for single-agent usage.
82
+ * For advanced usage (Dynamic config, providerOptions, custom dependencies), use `new Agent(config, deps)`.
83
+ */
84
+ export declare function createAgent(config: AgentSimpleConfig): Agent;
package/dist/agent.js CHANGED
@@ -22,6 +22,8 @@ export class Agent {
22
22
  orchestrator;
23
23
  sessionManager;
24
24
  contextBuilder;
25
+ activeAbortController = null;
26
+ lastContext;
25
27
  constructor(config, deps) {
26
28
  this.config = config;
27
29
  this.modelFactory = deps?.modelFactory ?? new ModelFactory();
@@ -71,6 +73,18 @@ export class Agent {
71
73
  get state() {
72
74
  return this.orchestrator.state;
73
75
  }
76
+ /** Subscribe to an event type. Returns an unsubscribe function. */
77
+ on(eventType, handler) {
78
+ return this.eventBus.subscribe(eventType, handler);
79
+ }
80
+ /** Subscribe to an event type for at most one emission. */
81
+ once(eventType, handler) {
82
+ this.eventBus.once(eventType, handler);
83
+ }
84
+ /** Remove a specific handler for an event type. */
85
+ off(eventType, handler) {
86
+ this.eventBus.unsubscribe(eventType, handler);
87
+ }
74
88
  get _contextBuilder() {
75
89
  return this.contextBuilder;
76
90
  }
@@ -78,19 +92,25 @@ export class Agent {
78
92
  if (signal?.aborted)
79
93
  throw new DOMException('Agent run aborted', 'AbortError');
80
94
  this._pluginManager.freezeHarnessInstances();
81
- const context = await this.createContext(input);
95
+ const context = await this.buildContext(input);
82
96
  const hm = this._pluginManager.hookManager;
83
97
  // agent.start hook
84
98
  await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
85
99
  const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
100
+ const controller = new AbortController();
101
+ this.activeAbortController = controller;
102
+ if (signal) {
103
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
104
+ }
86
105
  try {
87
106
  const { context: finalCtx, compatRetries } = await this.orchestrator.runLoop(context, {
88
107
  maxIterations: maxIter,
89
- signal,
108
+ signal: controller.signal,
90
109
  modelString: this.config.model,
91
110
  sessionId: context.request.sessionId,
92
111
  autoCheckpoint: this._autoCheckpoint,
93
112
  });
113
+ this.lastContext = finalCtx;
94
114
  return {
95
115
  response: finalCtx.iteration.response ?? '',
96
116
  tokenUsage: finalCtx.session.totalTokenUsage ?? { input: 0, output: 0 },
@@ -103,6 +123,7 @@ export class Agent {
103
123
  throw error;
104
124
  }
105
125
  finally {
126
+ this.activeAbortController = null;
106
127
  // agent.end hook — always fires, even on error; suppress hook errors to preserve original
107
128
  try {
108
129
  await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
@@ -144,21 +165,36 @@ export class Agent {
144
165
  async *stream(input, signal) {
145
166
  if (signal?.aborted)
146
167
  throw new DOMException('Agent stream aborted', 'AbortError');
147
- const context = await this.createContext(input);
168
+ const context = await this.buildContext(input);
148
169
  const hm = this._pluginManager.hookManager;
149
170
  // agent.start hook
150
171
  await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
151
172
  const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
173
+ const controller = new AbortController();
174
+ this.activeAbortController = controller;
175
+ if (signal) {
176
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
177
+ }
152
178
  try {
153
- yield* this.orchestrator.streamLoop(context, {
179
+ let finalCtx;
180
+ for await (const event of this.orchestrator.streamEvents(context, {
154
181
  maxIterations: maxIter,
155
- signal,
182
+ signal: controller.signal,
156
183
  modelString: this.config.model,
157
184
  sessionId: context.request.sessionId,
158
185
  autoCheckpoint: this._autoCheckpoint,
159
- });
186
+ })) {
187
+ if (event.type === 'text_delta')
188
+ yield event.text;
189
+ if (event.type === 'suspended')
190
+ yield ` [suspended: ${event.reason}]`;
191
+ if (event.type === 'complete')
192
+ finalCtx = event.context;
193
+ }
194
+ this.lastContext = finalCtx;
160
195
  }
161
196
  finally {
197
+ this.activeAbortController = null;
162
198
  try {
163
199
  await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
164
200
  }
@@ -168,26 +204,50 @@ export class Agent {
168
204
  async *streamEvents(input, signal) {
169
205
  if (signal?.aborted)
170
206
  throw new DOMException('Agent stream aborted', 'AbortError');
171
- const context = await this.createContext(input);
207
+ const context = await this.buildContext(input);
172
208
  const hm = this._pluginManager.hookManager;
173
209
  await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
174
210
  const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
211
+ const controller = new AbortController();
212
+ this.activeAbortController = controller;
213
+ if (signal) {
214
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
215
+ }
175
216
  try {
176
- yield* this.orchestrator.streamEvents(context, {
217
+ let finalCtx;
218
+ for await (const event of this.orchestrator.streamEvents(context, {
177
219
  maxIterations: maxIter,
178
- signal,
220
+ signal: controller.signal,
179
221
  modelString: this.config.model,
180
222
  sessionId: context.request.sessionId,
181
223
  autoCheckpoint: this._autoCheckpoint,
182
- });
224
+ })) {
225
+ if (event.type === 'complete')
226
+ finalCtx = event.context;
227
+ yield event;
228
+ }
229
+ this.lastContext = finalCtx;
183
230
  }
184
231
  finally {
232
+ this.activeAbortController = null;
185
233
  try {
186
234
  await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
187
235
  }
188
236
  catch { /* hook error must not mask original */ }
189
237
  }
190
238
  }
239
+ /** Abort a running agent. Idempotent — no-op if agent is not running. */
240
+ abort() {
241
+ if (!this.orchestrator.stateMachine.canTransition('cancelled'))
242
+ return;
243
+ this.orchestrator.stateMachine.transition('cancelled');
244
+ this.activeAbortController?.abort();
245
+ this.activeAbortController = null;
246
+ }
247
+ /** Clear conversation state so the next run/stream starts a fresh session. */
248
+ reset() {
249
+ this.lastContext = undefined;
250
+ }
191
251
  /** Clear the cached model so the next run re-resolves from the factory. */
192
252
  invalidateModel() {
193
253
  this._model = null;
@@ -209,6 +269,24 @@ export class Agent {
209
269
  tracer: this._tracer,
210
270
  });
211
271
  }
272
+ async buildContext(input) {
273
+ if (this.lastContext) {
274
+ return {
275
+ request: { input, sessionId: this.lastContext.request.sessionId },
276
+ agent: { config: { ...this.config }, promptFragments: [], toolDeclarations: [] },
277
+ iteration: { step: 0 },
278
+ session: {
279
+ messageHistory: [
280
+ ...(this.lastContext.session.messageHistory ?? []),
281
+ { role: 'user', content: input },
282
+ ],
283
+ totalTokenUsage: this.lastContext.session.totalTokenUsage,
284
+ custom: { ...this.lastContext.session.custom },
285
+ },
286
+ };
287
+ }
288
+ return this.createContext(input);
289
+ }
212
290
  async createContext(input) {
213
291
  let sessionId = crypto.randomUUID();
214
292
  if (this.sessionManager) {
@@ -248,6 +326,13 @@ export class Agent {
248
326
  this.runner.register(processOutputProcessor);
249
327
  }
250
328
  }
329
+ /**
330
+ * Create an Agent from a simple config. Convenience wrapper for single-agent usage.
331
+ * For advanced usage (Dynamic config, providerOptions, custom dependencies), use `new Agent(config, deps)`.
332
+ */
333
+ export function createAgent(config) {
334
+ return new Agent(config);
335
+ }
251
336
  function isAuthOrNotFoundError(error) {
252
337
  if (error instanceof AuthError || error instanceof ModelNotFoundError)
253
338
  return true;
@@ -4,4 +4,10 @@ export declare class EventBus {
4
4
  constructor(onError?: ((error: unknown, eventType: string) => void) | undefined);
5
5
  emit(eventType: string, data?: unknown): void;
6
6
  subscribe(eventType: string, handler: (data?: unknown) => void): () => void;
7
+ /** Remove a specific handler for an event type. */
8
+ unsubscribe(eventType: string, handler: (data?: unknown) => void): void;
9
+ /** Register a handler that fires at most once, then auto-unsubscribes. */
10
+ once(eventType: string, handler: (data?: unknown) => void): void;
11
+ /** Promise-based once: returns a Promise that resolves on the next emission. */
12
+ oncePromise(eventType: string): Promise<unknown>;
7
13
  }
package/dist/event-bus.js CHANGED
@@ -32,4 +32,28 @@ export class EventBus {
32
32
  set.add(handler);
33
33
  return () => set.delete(handler);
34
34
  }
35
+ /** Remove a specific handler for an event type. */
36
+ unsubscribe(eventType, handler) {
37
+ const set = this.handlers.get(eventType);
38
+ if (set) {
39
+ set.delete(handler);
40
+ }
41
+ }
42
+ /** Register a handler that fires at most once, then auto-unsubscribes. */
43
+ once(eventType, handler) {
44
+ const wrapped = (data) => {
45
+ handler(data);
46
+ this.unsubscribe(eventType, wrapped);
47
+ };
48
+ this.subscribe(eventType, wrapped);
49
+ }
50
+ /** Promise-based once: returns a Promise that resolves on the next emission. */
51
+ oncePromise(eventType) {
52
+ return new Promise((resolve) => {
53
+ const unsub = this.subscribe(eventType, (data) => {
54
+ unsub();
55
+ resolve(data);
56
+ });
57
+ });
58
+ }
35
59
  }
@@ -5,4 +5,8 @@ export declare class GatewayChain {
5
5
  register(gateway: ModelGateway): void;
6
6
  resolve(modelString: string): Promise<LanguageModel>;
7
7
  get size(): number;
8
+ listGateways(): Array<{
9
+ name: string;
10
+ canResolve: (model: string) => boolean;
11
+ }>;
8
12
  }
@@ -14,4 +14,10 @@ export class GatewayChain {
14
14
  get size() {
15
15
  return this.gateways.length;
16
16
  }
17
+ listGateways() {
18
+ return this.gateways.map(gw => ({
19
+ name: gw.name,
20
+ canResolve: (model) => gw.canResolve(model),
21
+ }));
22
+ }
17
23
  }
@@ -8,6 +8,7 @@ const HOOK_TO_EVENT = {
8
8
  'llm.after': 'llm:after',
9
9
  'tool.before': 'tool:before',
10
10
  'tool.after': 'tool:after',
11
+ 'iteration.end': 'iteration:end',
11
12
  'error': 'error',
12
13
  };
13
14
  export class HookManager {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { PipelineRunner, type PipelineRunnerOptions } from './pipeline.js';
2
- export { Agent, type AgentDependencies, type AgentRunResult } from './agent.js';
2
+ export { Agent, createAgent, type AgentDependencies, type AgentRunResult } from './agent.js';
3
3
  export { LLMInvoker, type LLMInvokerOptions, type LLMInvokeInput, type LLMInvokeResult, type LLMStreamHandle } from './llm-invoker.js';
4
4
  export { resolveModel, registerProvider, parseModel, type ParsedModel } from './model-resolver.js';
5
5
  export { streamWithRetry, type RetryOptions } from './retry.js';
@@ -10,6 +10,7 @@ export { StorageReplayBackend } from './storage-replay-backend.js';
10
10
  export { HookManager, type HookManagerOptions } from './hook-manager.js';
11
11
  export { ToolRegistry, type AiSdkToolSchema, type ToolRegistryOptions } from './tool-registry.js';
12
12
  export { FilesystemSessionStorage } from './session-storage.js';
13
+ export { SqliteSessionStorage } from './session-storage-sqlite.js';
13
14
  export { SessionPersistence } from './session-persistence.js';
14
15
  export { SessionManagerImpl } from './session-manager.js';
15
16
  export { createSubAgentTool } from './sub-agent.js';
@@ -31,3 +32,5 @@ export { TiktokenCounter } from './token-counter.js';
31
32
  export type { StreamEvent } from '@primo-ai/sdk';
32
33
  export { OpenAICompatibleGateway } from './gateways/openai-compatible-gateway.js';
33
34
  export { AgentForgeError, RecoverableError, FatalError, AuthError, ModelNotFoundError, ToolExecutionError, type AgentErrorOptions, } from './errors.js';
35
+ export { PermissionManager } from './pending-permission.js';
36
+ export type { PendingPermission } from './pending-permission.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @primo-ai/core — Agent Loop, Processor Pipeline, Context, Tool Registry
2
2
  export { PipelineRunner } from './pipeline.js';
3
- export { Agent } from './agent.js';
3
+ export { Agent, createAgent } from './agent.js';
4
4
  export { LLMInvoker } from './llm-invoker.js';
5
5
  export { resolveModel, registerProvider, parseModel } from './model-resolver.js';
6
6
  export { streamWithRetry } from './retry.js';
@@ -11,6 +11,7 @@ export { StorageReplayBackend } from './storage-replay-backend.js';
11
11
  export { HookManager } from './hook-manager.js';
12
12
  export { ToolRegistry } from './tool-registry.js';
13
13
  export { FilesystemSessionStorage } from './session-storage.js';
14
+ export { SqliteSessionStorage } from './session-storage-sqlite.js';
14
15
  export { SessionPersistence } from './session-persistence.js';
15
16
  export { SessionManagerImpl } from './session-manager.js';
16
17
  export { createSubAgentTool } from './sub-agent.js';
@@ -31,3 +32,4 @@ export { HarnessAPIImpl } from './harness.js';
31
32
  export { TiktokenCounter } from './token-counter.js';
32
33
  export { OpenAICompatibleGateway } from './gateways/openai-compatible-gateway.js';
33
34
  export { AgentForgeError, RecoverableError, FatalError, AuthError, ModelNotFoundError, ToolExecutionError, } from './errors.js';
35
+ export { PermissionManager } from './pending-permission.js';
@@ -17,14 +17,14 @@ export interface LLMInvokeInput {
17
17
  }
18
18
  export interface LLMInvokeResult {
19
19
  response: string;
20
- tokenUsage: TokenUsage;
20
+ tokenUsage: TokenUsage | null;
21
21
  }
22
22
  export interface LLMStreamHandle {
23
23
  fullStream: AsyncIterable<unknown>;
24
- usage: Promise<TokenUsage>;
24
+ usage: Promise<TokenUsage | null>;
25
25
  reasoning: Promise<string | undefined>;
26
26
  }
27
- export declare function extractTokenUsage(usage: unknown): TokenUsage;
27
+ export declare function extractTokenUsage(usage: unknown): TokenUsage | null;
28
28
  export declare class LLMInvoker {
29
29
  private options;
30
30
  constructor(options: LLMInvokerOptions);
@@ -3,14 +3,15 @@ import { SpanType } from '@primo-ai/sdk';
3
3
  import { streamWithRetry } from './retry.js';
4
4
  export function extractTokenUsage(usage) {
5
5
  const u = usage;
6
- return {
7
- input: typeof u?.inputTokens === 'number'
8
- ? u.inputTokens
9
- : u?.inputTokens?.total ?? 0,
10
- output: typeof u?.outputTokens === 'number'
11
- ? u.outputTokens
12
- : u?.outputTokens?.total ?? 0,
13
- };
6
+ if (!u)
7
+ return null;
8
+ const input = typeof u?.inputTokens === 'number'
9
+ ? u.inputTokens
10
+ : u?.inputTokens?.total ?? 0;
11
+ const output = typeof u?.outputTokens === 'number'
12
+ ? u.outputTokens
13
+ : u?.outputTokens?.total ?? 0;
14
+ return { input, output };
14
15
  }
15
16
  export class LLMInvoker {
16
17
  options;
@@ -112,7 +113,7 @@ export class LLMInvoker {
112
113
  .catch((err) => {
113
114
  endSpan();
114
115
  this.options.eventBus?.emit('llm:usage_unavailable', { error: err instanceof Error ? err.message : String(err) });
115
- return { input: 0, output: 0 };
116
+ return null;
116
117
  }),
117
118
  reasoning: Promise.resolve(result.reasoningText).catch((err) => {
118
119
  this.options.eventBus?.emit('llm:reasoning_error', { error: err instanceof Error ? err.message : String(err) });
@@ -124,6 +124,8 @@ export class LoopOrchestrator {
124
124
  yield event;
125
125
  return;
126
126
  }
127
+ if (event.type === 'error')
128
+ throw event.error;
127
129
  if (event.type === 'complete')
128
130
  loopCtx = event.context;
129
131
  yield event;
@@ -161,6 +163,14 @@ export class LoopOrchestrator {
161
163
  yield event;
162
164
  return;
163
165
  }
166
+ if (event.type === 'error') {
167
+ if (event.recoverable) {
168
+ this.eventBus?.emit('pipeline:stage_error', { stage: event.stage, error: event.error });
169
+ loopBreak = true;
170
+ break;
171
+ }
172
+ throw event.error;
173
+ }
164
174
  if (event.type === 'complete')
165
175
  loopCtx = event.context;
166
176
  yield event;
@@ -8,5 +8,9 @@ export declare class ModelFactory {
8
8
  resolve(modelString: string): Promise<LanguageModel>;
9
9
  registerGateway(gateway: ModelGateway): void;
10
10
  registerProvider(name: string, factory: ProviderFactory): void;
11
+ listGateways(): Array<{
12
+ name: string;
13
+ canResolve: (model: string) => boolean;
14
+ }>;
11
15
  }
12
16
  export {};
@@ -36,4 +36,7 @@ export class ModelFactory {
36
36
  registerProvider(name, factory) {
37
37
  this.providerGateway.addProvider(name, factory);
38
38
  }
39
+ listGateways() {
40
+ return this.chain.listGateways();
41
+ }
39
42
  }
@@ -0,0 +1,16 @@
1
+ export interface PendingPermission {
2
+ permissionId: string;
3
+ sessionId: string;
4
+ toolName: string;
5
+ args: Record<string, unknown>;
6
+ reason: string;
7
+ createdAt: string;
8
+ }
9
+ export declare class PermissionManager {
10
+ private pending;
11
+ awaitDecision(permission: PendingPermission): Promise<boolean>;
12
+ resolve(permissionId: string, approved: boolean): void;
13
+ list(): PendingPermission[];
14
+ getBySession(sessionId: string): PendingPermission[];
15
+ get(permissionId: string): PendingPermission | undefined;
16
+ }
@@ -0,0 +1,24 @@
1
+ export class PermissionManager {
2
+ pending = new Map();
3
+ awaitDecision(permission) {
4
+ return new Promise((resolve) => {
5
+ this.pending.set(permission.permissionId, { resolve, permission });
6
+ });
7
+ }
8
+ resolve(permissionId, approved) {
9
+ const entry = this.pending.get(permissionId);
10
+ if (!entry)
11
+ throw new Error(`Permission not found: ${permissionId}`);
12
+ entry.resolve(approved);
13
+ this.pending.delete(permissionId);
14
+ }
15
+ list() {
16
+ return Array.from(this.pending.values()).map(e => e.permission);
17
+ }
18
+ getBySession(sessionId) {
19
+ return this.list().filter(p => p.sessionId === sessionId);
20
+ }
21
+ get(permissionId) {
22
+ return this.pending.get(permissionId)?.permission;
23
+ }
24
+ }
@@ -1,6 +1,6 @@
1
- import type { AbortSignal, PipelineContext, Processor, StageName, StreamEvent, SuspensionSignal, Tracer } from '@primo-ai/sdk';
1
+ import type { AbortSignal, ErrorResult, PipelineContext, Processor, StageName, StreamEvent, SuspensionSignal, Tracer } from '@primo-ai/sdk';
2
2
  import type { HookManager } from './hook-manager.js';
3
- export type RunResult = PipelineContext | AbortSignal | SuspensionSignal;
3
+ export type RunResult = PipelineContext | AbortSignal | SuspensionSignal | ErrorResult;
4
4
  export interface PipelineRunnerOptions {
5
5
  tracer?: Tracer;
6
6
  hookManager?: HookManager;
@@ -23,5 +23,6 @@ export declare class PipelineRunner {
23
23
  private executeStage;
24
24
  private isAbort;
25
25
  private isSuspend;
26
+ private isError;
26
27
  private consumeStream;
27
28
  }
package/dist/pipeline.js CHANGED
@@ -100,6 +100,17 @@ export class PipelineRunner {
100
100
  rootSpan.end();
101
101
  return stageResult;
102
102
  }
103
+ if (this.isError(stageResult)) {
104
+ stageSpan.end();
105
+ if (this.hookManager) {
106
+ try {
107
+ await this.hookManager.invoke('error', { error: stageResult.error, stage: stageResult.stage }, {});
108
+ }
109
+ catch { /* hook error must not mask original */ }
110
+ }
111
+ rootSpan.end();
112
+ return stageResult;
113
+ }
103
114
  ctx = stageResult;
104
115
  ctx = await this.consumeStream(ctx);
105
116
  // Fire llm.after after stream is consumed (response is now available)
@@ -155,6 +166,18 @@ export class PipelineRunner {
155
166
  yield { type: 'suspended', suspensionId: stageResult.suspensionId, reason: stageResult.reason, checkpoint: stageResult.checkpoint };
156
167
  return;
157
168
  }
169
+ if (this.isError(stageResult)) {
170
+ stageSpan.end();
171
+ if (this.hookManager) {
172
+ try {
173
+ await this.hookManager.invoke('error', { error: stageResult.error, stage: stageResult.stage }, {});
174
+ }
175
+ catch { /* hook error must not mask original */ }
176
+ }
177
+ rootSpan.end();
178
+ yield { type: 'error', error: stageResult.error, stage: stageResult.stage, recoverable: stageResult.recoverable };
179
+ return;
180
+ }
158
181
  ctx = stageResult;
159
182
  const fullStream = ctx.iteration.fullStream;
160
183
  if (fullStream) {
@@ -197,7 +220,7 @@ export class PipelineRunner {
197
220
  response: chunks.join('') || ctx.iteration.response || '',
198
221
  pendingToolCalls: toolCalls.length > 0 ? toolCalls : undefined,
199
222
  reasoningContent,
200
- tokenUsage: usage ?? pendingUsage,
223
+ tokenUsage: usage ?? pendingUsage ?? undefined,
201
224
  fullStream: undefined,
202
225
  usagePromise: undefined,
203
226
  reasoningPromise: undefined,
@@ -242,7 +265,7 @@ export class PipelineRunner {
242
265
  iteration: { ...currentCtx.iteration, span: stageSpan },
243
266
  });
244
267
  const result = await processor.execute(ctxWithSpan);
245
- if ('type' in result && (result.type === 'abort' || result.type === 'suspend')) {
268
+ if ('type' in result && (result.type === 'abort' || result.type === 'suspend' || result.type === 'error')) {
246
269
  return result;
247
270
  }
248
271
  currentCtx = deepFreeze({ ...result });
@@ -259,6 +282,9 @@ export class PipelineRunner {
259
282
  isSuspend(result) {
260
283
  return 'type' in result && result.type === 'suspend';
261
284
  }
285
+ isError(result) {
286
+ return 'type' in result && result.type === 'error';
287
+ }
262
288
  async consumeStream(ctx) {
263
289
  const fullStream = ctx.iteration.fullStream;
264
290
  if (!fullStream)
@@ -275,7 +301,7 @@ export class PipelineRunner {
275
301
  response: result.chunks.join('') || ctx.iteration.response,
276
302
  pendingToolCalls: result.toolCalls.length > 0 ? result.toolCalls : undefined,
277
303
  reasoningContent,
278
- tokenUsage: result.usage ?? pendingUsage,
304
+ tokenUsage: result.usage ?? pendingUsage ?? undefined,
279
305
  fullStream: undefined,
280
306
  usagePromise: undefined,
281
307
  reasoningPromise: undefined,
@@ -52,10 +52,9 @@ export class PluginManager {
52
52
  this.initializePlugin(factory);
53
53
  }
54
54
  catch (err) {
55
- this.errors.push({
56
- source: filePath,
57
- error: err instanceof Error ? err : new Error(String(err)),
58
- });
55
+ const error = err instanceof Error ? err : new Error(String(err));
56
+ this.eventBus.emit('plugin:load_error', { source: filePath, error });
57
+ this.errors.push({ source: filePath, error });
59
58
  }
60
59
  }
61
60
  initializePlugin(factory) {
@@ -97,12 +96,14 @@ export class PluginManager {
97
96
  this.resourceInstances.set(resource.id, instance);
98
97
  }
99
98
  catch (err) {
100
- this.errors.push({
101
- source: `resource:${resource.id}`,
102
- error: err instanceof Error ? err : new Error(String(err)),
103
- });
99
+ const error = err instanceof Error ? err : new Error(String(err));
100
+ this.eventBus.emit('plugin:resource_init_error', { source: `resource:${resource.id}`, error });
101
+ this.errors.push({ source: `resource:${resource.id}`, error });
104
102
  }
105
103
  }
104
+ if (this.errors.length > 0) {
105
+ throw new AggregateError(this.errors.map(e => e.error), `Plugin initialization failed for ${this.errors.length} resource(s): ${this.errors.map(e => e.source).join(', ')}`);
106
+ }
106
107
  }
107
108
  _shutdown = false;
108
109
  async shutdown() {
@@ -116,10 +117,9 @@ export class PluginManager {
116
117
  await resource.stop(instance);
117
118
  }
118
119
  catch (err) {
119
- this.errors.push({
120
- source: `resource:${resource.id}`,
121
- error: err instanceof Error ? err : new Error(String(err)),
122
- });
120
+ const error = err instanceof Error ? err : new Error(String(err));
121
+ this.eventBus.emit('plugin:shutdown_error', { source: `resource:${resource.id}`, error });
122
+ this.errors.push({ source: `resource:${resource.id}`, error });
123
123
  }
124
124
  }
125
125
  this.resourceInstances.clear();
@@ -24,10 +24,14 @@ export function createEvaluateIterationProcessor(deps) {
24
24
  stage: 'evaluateIteration',
25
25
  execute: async (ctx) => {
26
26
  const prevTotal = ctx.session.totalTokenUsage ?? { input: 0, output: 0 };
27
- const iterUsage = ctx.iteration.tokenUsage ?? { input: 0, output: 0 };
27
+ const iterUsage = ctx.iteration.tokenUsage;
28
+ if (!iterUsage) {
29
+ eventBus?.emit('token:usage_unavailable', { step: ctx.iteration.step });
30
+ }
31
+ const safeUsage = iterUsage ?? { input: 0, output: 0 };
28
32
  const totalTokenUsage = {
29
- input: prevTotal.input + iterUsage.input,
30
- output: prevTotal.output + iterUsage.output,
33
+ input: prevTotal.input + safeUsage.input,
34
+ output: prevTotal.output + safeUsage.output,
31
35
  };
32
36
  const totalTokens = totalTokenUsage.input + totalTokenUsage.output;
33
37
  const requiredTools = ctx.agent.config.requiredTools;
@@ -0,0 +1,41 @@
1
+ import type { SessionEvent, SessionRecord, SessionStorage, SessionStatus, Message } from '@primo-ai/sdk';
2
+ /**
3
+ * SqliteSessionStorage — SQLite-backed implementation of SessionStorage.
4
+ *
5
+ * Uses `better-sqlite3` as an **optional** dependency. The module is loaded
6
+ * dynamically so projects that only need FilesystemSessionStorage never pay
7
+ * the cost (or install penalty) of the native binding.
8
+ *
9
+ * Pass `:memory:` as dbPath for an in-memory database (useful for tests).
10
+ */
11
+ export declare class SqliteSessionStorage implements SessionStorage {
12
+ private db;
13
+ private stmtInsertEvent;
14
+ private stmtSelectEvents;
15
+ private stmtSelectSession;
16
+ private stmtUpsertSession;
17
+ private stmtDeleteSession;
18
+ private stmtSelectSessionsByStatus;
19
+ private stmtSelectSessionsByParent;
20
+ private stmtSelectAllSessions;
21
+ private stmtNextSeq;
22
+ constructor(dbPath: string);
23
+ private initSchema;
24
+ private prepareStatements;
25
+ append(sessionId: string, event: SessionEvent): Promise<void>;
26
+ read(sessionId: string): AsyncIterable<SessionEvent>;
27
+ list(filter?: {
28
+ parentSessionId?: string;
29
+ status?: SessionStatus;
30
+ }): Promise<SessionRecord[]>;
31
+ updateMeta(sessionId: string, meta: Partial<SessionRecord>): Promise<void>;
32
+ get(sessionId: string): Promise<SessionRecord | undefined>;
33
+ delete(sessionId: string): Promise<void>;
34
+ getMessages(sessionId: string, options?: {
35
+ limit?: number;
36
+ before?: string;
37
+ }): Promise<Message[]>;
38
+ /** Close the underlying database connection. */
39
+ close(): void;
40
+ private rowToRecord;
41
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * SqliteSessionStorage — SQLite-backed implementation of SessionStorage.
3
+ *
4
+ * Uses `better-sqlite3` as an **optional** dependency. The module is loaded
5
+ * dynamically so projects that only need FilesystemSessionStorage never pay
6
+ * the cost (or install penalty) of the native binding.
7
+ *
8
+ * Pass `:memory:` as dbPath for an in-memory database (useful for tests).
9
+ */
10
+ export class SqliteSessionStorage {
11
+ db;
12
+ // Prepared statements (lazily initialised after DB is set)
13
+ stmtInsertEvent;
14
+ stmtSelectEvents;
15
+ stmtSelectSession;
16
+ stmtUpsertSession;
17
+ stmtDeleteSession;
18
+ stmtSelectSessionsByStatus;
19
+ stmtSelectSessionsByParent;
20
+ stmtSelectAllSessions;
21
+ stmtNextSeq;
22
+ constructor(dbPath) {
23
+ // Dynamic import keeps better-sqlite3 truly optional
24
+ let Database;
25
+ try {
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ Database = require('better-sqlite3');
28
+ }
29
+ catch {
30
+ throw new Error('better-sqlite3 is not installed. Install it as an optional dependency or use FilesystemSessionStorage instead.');
31
+ }
32
+ this.db = new Database(dbPath);
33
+ this.initSchema();
34
+ this.prepareStatements();
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Schema initialisation
38
+ // ---------------------------------------------------------------------------
39
+ initSchema() {
40
+ this.db.pragma('journal_mode = WAL');
41
+ this.db.pragma('foreign_keys = ON');
42
+ this.db.exec(`
43
+ CREATE TABLE IF NOT EXISTS sessions (
44
+ session_id TEXT PRIMARY KEY,
45
+ parent_session_id TEXT,
46
+ status TEXT NOT NULL DEFAULT 'active',
47
+ model TEXT,
48
+ input_tokens INTEGER DEFAULT 0,
49
+ output_tokens INTEGER DEFAULT 0,
50
+ created_at TEXT NOT NULL,
51
+ updated_at TEXT NOT NULL
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS events (
55
+ session_id TEXT NOT NULL,
56
+ seq INTEGER NOT NULL,
57
+ timestamp TEXT NOT NULL,
58
+ type TEXT NOT NULL,
59
+ payload TEXT NOT NULL,
60
+ PRIMARY KEY (session_id, seq),
61
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
65
+ CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
66
+ CREATE INDEX IF NOT EXISTS idx_events_session_seq ON events(session_id, seq);
67
+ `);
68
+ }
69
+ prepareStatements() {
70
+ this.stmtInsertEvent = this.db.prepare(`INSERT INTO events (session_id, seq, timestamp, type, payload) VALUES (?, ?, ?, ?, ?)`);
71
+ this.stmtSelectEvents = this.db.prepare(`SELECT seq, timestamp, type, payload FROM events WHERE session_id = ? ORDER BY seq ASC`);
72
+ this.stmtSelectSession = this.db.prepare(`SELECT session_id, parent_session_id, status, model, input_tokens, output_tokens, created_at, updated_at FROM sessions WHERE session_id = ?`);
73
+ this.stmtUpsertSession = this.db.prepare(`INSERT INTO sessions (session_id, parent_session_id, status, model, input_tokens, output_tokens, created_at, updated_at)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
75
+ ON CONFLICT(session_id) DO UPDATE SET
76
+ parent_session_id = COALESCE(excluded.parent_session_id, sessions.parent_session_id),
77
+ status = COALESCE(excluded.status, sessions.status),
78
+ model = COALESCE(excluded.model, sessions.model),
79
+ input_tokens = CASE WHEN excluded.input_tokens != 0 THEN excluded.input_tokens ELSE sessions.input_tokens END,
80
+ output_tokens = CASE WHEN excluded.output_tokens != 0 THEN excluded.output_tokens ELSE sessions.output_tokens END,
81
+ updated_at = excluded.updated_at`);
82
+ this.stmtDeleteSession = this.db.prepare(`DELETE FROM sessions WHERE session_id = ?`);
83
+ this.stmtSelectSessionsByStatus = this.db.prepare(`SELECT session_id, parent_session_id, status, model, input_tokens, output_tokens, created_at, updated_at FROM sessions WHERE status = ?`);
84
+ this.stmtSelectSessionsByParent = this.db.prepare(`SELECT session_id, parent_session_id, status, model, input_tokens, output_tokens, created_at, updated_at FROM sessions WHERE parent_session_id = ?`);
85
+ this.stmtSelectAllSessions = this.db.prepare(`SELECT session_id, parent_session_id, status, model, input_tokens, output_tokens, created_at, updated_at FROM sessions`);
86
+ this.stmtNextSeq = this.db.prepare(`SELECT COALESCE(MAX(seq), 0) AS max_seq FROM events WHERE session_id = ?`);
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // SessionStorage interface
90
+ // ---------------------------------------------------------------------------
91
+ async append(sessionId, event) {
92
+ // Ensure session row exists
93
+ const now = new Date().toISOString();
94
+ this.stmtUpsertSession.run(sessionId, null, // parent_session_id
95
+ 'active', null, // model
96
+ 0, 0, now, now);
97
+ // Determine seq: if event has seq > 0 use it, otherwise auto-increment
98
+ let seq;
99
+ if (event.seq && event.seq > 0) {
100
+ seq = event.seq;
101
+ }
102
+ else {
103
+ const row = this.stmtNextSeq.get(sessionId);
104
+ seq = (row?.max_seq ?? 0) + 1;
105
+ }
106
+ this.stmtInsertEvent.run(sessionId, seq, event.timestamp ?? now, event.type, JSON.stringify(event.payload));
107
+ }
108
+ async *read(sessionId) {
109
+ const rows = this.stmtSelectEvents.all(sessionId);
110
+ for (const row of rows) {
111
+ let payload;
112
+ try {
113
+ payload = JSON.parse(row.payload);
114
+ }
115
+ catch {
116
+ payload = {};
117
+ }
118
+ yield {
119
+ seq: row.seq,
120
+ timestamp: row.timestamp,
121
+ type: row.type,
122
+ payload,
123
+ };
124
+ }
125
+ }
126
+ async list(filter) {
127
+ let rows;
128
+ if (filter?.status) {
129
+ rows = this.stmtSelectSessionsByStatus.all(filter.status);
130
+ }
131
+ else if (filter?.parentSessionId) {
132
+ rows = this.stmtSelectSessionsByParent.all(filter.parentSessionId);
133
+ }
134
+ else {
135
+ rows = this.stmtSelectAllSessions.all();
136
+ }
137
+ // If both filters are specified, we need to intersect
138
+ if (filter?.status && filter?.parentSessionId) {
139
+ // Re-fetch with both filters using a custom query
140
+ const stmt = this.db.prepare(`SELECT session_id, parent_session_id, status, model, input_tokens, output_tokens, created_at, updated_at
141
+ FROM sessions WHERE status = ? AND parent_session_id = ?`);
142
+ rows = stmt.all(filter.status, filter.parentSessionId);
143
+ }
144
+ return rows.map(row => this.rowToRecord(row));
145
+ }
146
+ async updateMeta(sessionId, meta) {
147
+ const now = new Date().toISOString();
148
+ const existing = await this.get(sessionId);
149
+ this.stmtUpsertSession.run(sessionId, meta.parentSessionId ?? existing?.parentSessionId ?? null, meta.status ?? existing?.status ?? 'active', meta.model ?? existing?.model ?? null, meta.tokenUsage?.input ?? (existing?.tokenUsage?.input ?? 0), meta.tokenUsage?.output ?? (existing?.tokenUsage?.output ?? 0), existing?.createdAt ?? now, now);
150
+ }
151
+ async get(sessionId) {
152
+ const row = this.stmtSelectSession.get(sessionId);
153
+ if (!row)
154
+ return undefined;
155
+ return this.rowToRecord(row);
156
+ }
157
+ async delete(sessionId) {
158
+ this.stmtDeleteSession.run(sessionId);
159
+ }
160
+ async getMessages(sessionId, options) {
161
+ const events = [];
162
+ for await (const event of this.read(sessionId)) {
163
+ events.push(event);
164
+ }
165
+ if (events.length === 0)
166
+ return [];
167
+ const messages = [];
168
+ let toolCallIdx = 0;
169
+ for (const event of events) {
170
+ const payload = event.payload;
171
+ if (!payload)
172
+ continue;
173
+ switch (event.type) {
174
+ case 'iteration:end':
175
+ case 'iteration.end': {
176
+ if (payload.response) {
177
+ messages.push({ role: 'assistant', content: payload.response });
178
+ }
179
+ break;
180
+ }
181
+ case 'tool:after':
182
+ case 'tool.after': {
183
+ const toolName = payload.toolName;
184
+ const content = payload.error
185
+ ? String(payload.error)
186
+ : typeof payload.result === 'string'
187
+ ? payload.result
188
+ : JSON.stringify(payload.result ?? '');
189
+ const msg = {
190
+ role: 'tool',
191
+ content,
192
+ toolCallId: `restored_${toolName}_${toolCallIdx++}`,
193
+ toolName,
194
+ };
195
+ if (payload.error)
196
+ msg.error = String(payload.error);
197
+ if (payload.result !== undefined)
198
+ msg.result = payload.result;
199
+ messages.push(msg);
200
+ break;
201
+ }
202
+ case 'error': {
203
+ messages.push({ role: 'assistant', content: `[Error] ${String(payload.error)}` });
204
+ break;
205
+ }
206
+ default:
207
+ break;
208
+ }
209
+ }
210
+ // Apply pagination
211
+ if (options?.limit && options.limit > 0) {
212
+ return messages.slice(-options.limit);
213
+ }
214
+ return messages;
215
+ }
216
+ // ---------------------------------------------------------------------------
217
+ // Lifecycle
218
+ // ---------------------------------------------------------------------------
219
+ /** Close the underlying database connection. */
220
+ close() {
221
+ this.db.close();
222
+ }
223
+ // ---------------------------------------------------------------------------
224
+ // Internal helpers
225
+ // ---------------------------------------------------------------------------
226
+ rowToRecord(row) {
227
+ const inputTokens = typeof row.input_tokens === 'number' ? row.input_tokens : 0;
228
+ const outputTokens = typeof row.output_tokens === 'number' ? row.output_tokens : 0;
229
+ return {
230
+ sessionId: row.session_id,
231
+ parentSessionId: row.parent_session_id ?? undefined,
232
+ status: row.status ?? 'active',
233
+ model: row.model ?? undefined,
234
+ tokenUsage: (inputTokens || outputTokens) ? { input: inputTokens, output: outputTokens } : undefined,
235
+ createdAt: row.created_at,
236
+ updatedAt: row.updated_at,
237
+ };
238
+ }
239
+ }
@@ -15,4 +15,10 @@ export declare class FilesystemSessionStorage implements SessionStorage {
15
15
  private metaPath;
16
16
  private readMeta;
17
17
  private defaultRecord;
18
+ get(sessionId: string): Promise<SessionRecord | undefined>;
19
+ delete(sessionId: string): Promise<void>;
20
+ getMessages(sessionId: string, options?: {
21
+ limit?: number;
22
+ before?: string;
23
+ }): Promise<import('@primo-ai/sdk').Message[]>;
18
24
  }
@@ -99,4 +99,69 @@ export class FilesystemSessionStorage {
99
99
  status: 'active',
100
100
  };
101
101
  }
102
+ async get(sessionId) {
103
+ return this.readMeta(sessionId);
104
+ }
105
+ async delete(sessionId) {
106
+ const dir = this.sessionDir(sessionId);
107
+ const { rm } = await import('node:fs/promises');
108
+ await rm(dir, { recursive: true, force: true });
109
+ }
110
+ async getMessages(sessionId, options) {
111
+ const events = [];
112
+ for await (const event of this.read(sessionId)) {
113
+ events.push(event);
114
+ }
115
+ if (events.length === 0)
116
+ return [];
117
+ // Rebuild Message[] from events — same logic as SessionManagerImpl.restore()
118
+ const messages = [];
119
+ let toolCallIdx = 0;
120
+ for (const event of events) {
121
+ const payload = event.payload;
122
+ if (!payload)
123
+ continue;
124
+ switch (event.type) {
125
+ case 'iteration:end':
126
+ case 'iteration.end': {
127
+ if (payload.response) {
128
+ messages.push({ role: 'assistant', content: payload.response });
129
+ }
130
+ break;
131
+ }
132
+ case 'tool:after':
133
+ case 'tool.after': {
134
+ const toolName = payload.toolName;
135
+ const content = payload.error
136
+ ? String(payload.error)
137
+ : typeof payload.result === 'string'
138
+ ? payload.result
139
+ : JSON.stringify(payload.result ?? '');
140
+ const msg = {
141
+ role: 'tool',
142
+ content,
143
+ toolCallId: `restored_${toolName}_${toolCallIdx++}`,
144
+ toolName,
145
+ };
146
+ if (payload.error)
147
+ msg.error = String(payload.error);
148
+ if (payload.result !== undefined)
149
+ msg.result = payload.result;
150
+ messages.push(msg);
151
+ break;
152
+ }
153
+ case 'error': {
154
+ messages.push({ role: 'assistant', content: `[Error] ${String(payload.error)}` });
155
+ break;
156
+ }
157
+ default:
158
+ break;
159
+ }
160
+ }
161
+ // Apply pagination
162
+ if (options?.limit && options.limit > 0) {
163
+ return messages.slice(-options.limit);
164
+ }
165
+ return messages;
166
+ }
102
167
  }
@@ -69,8 +69,12 @@ export class TaskManagerImpl {
69
69
  const handle = new InternalAsyncTaskHandle(state);
70
70
  this.handles.set(taskId, handle);
71
71
  // Fire-and-forget the async work
72
- this.executeTask(state, config, prompt).catch(() => {
73
- // Errors are handled inside executeTask
72
+ this.executeTask(state, config, prompt).catch((err) => {
73
+ if (state.status === 'pending' || state.status === 'running') {
74
+ state.status = 'failed';
75
+ state.error = err instanceof Error ? err : new Error(String(err));
76
+ this.eventBus?.emit('task:error', { taskId: state.taskId, error: state.error });
77
+ }
74
78
  });
75
79
  return handle;
76
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primo-ai/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -21,15 +21,19 @@
21
21
  "ai": "^6.0.177",
22
22
  "js-tiktoken": "^1.0.21",
23
23
  "zod": "^4.4.3",
24
- "@primo-ai/observability": "0.1.3",
25
- "@primo-ai/sdk": "0.1.3",
26
- "@primo-ai/tools": "0.1.3"
24
+ "@primo-ai/observability": "0.1.5",
25
+ "@primo-ai/tools": "0.1.5",
26
+ "@primo-ai/sdk": "0.1.5"
27
27
  },
28
28
  "devDependencies": {
29
+ "@types/better-sqlite3": "^7.6.13",
29
30
  "@types/node": "^22.15.0",
30
31
  "typescript": "^5.7.3",
31
32
  "vitest": "^3.0.5"
32
33
  },
34
+ "optionalDependencies": {
35
+ "better-sqlite3": "^12.9.0"
36
+ },
33
37
  "scripts": {
34
38
  "build": "tsc -p tsconfig.build.json",
35
39
  "test": "tsc --noEmit && vitest run",