@primo-ai/core 0.1.3 → 0.1.4

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
@@ -40,6 +40,7 @@ export declare class Agent {
40
40
  private orchestrator;
41
41
  private sessionManager?;
42
42
  private contextBuilder;
43
+ private activeAbortController;
43
44
  constructor(config: AgentConfig, deps?: AgentDependencies);
44
45
  use(factory: Processor | PluginFactory): void;
45
46
  teardown(): Promise<void>;
@@ -54,6 +55,18 @@ export declare class Agent {
54
55
  resume(sessionId: string, signal?: globalThis.AbortSignal): Promise<AgentRunResult>;
55
56
  stream(input: string, signal?: globalThis.AbortSignal): AsyncGenerator<string>;
56
57
  streamEvents(input: string, signal?: globalThis.AbortSignal): AsyncGenerator<import('@primo-ai/sdk').StreamEvent>;
58
+ /** Abort a running agent. Idempotent — no-op if agent is not running. */
59
+ abort(): void;
60
+ /**
61
+ * Continue an existing session by restoring its context and running the pipeline
62
+ * with a new user message.
63
+ */
64
+ continue(sessionId: string, message: string, signal?: globalThis.AbortSignal): Promise<AgentRunResult>;
65
+ /**
66
+ * Continue an existing session with streaming. Yields StreamEvents for the
67
+ * continued conversation.
68
+ */
69
+ continueStream(sessionId: string, message: string, signal?: globalThis.AbortSignal): AsyncGenerator<import('@primo-ai/sdk').StreamEvent>;
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. */
package/dist/agent.js CHANGED
@@ -22,6 +22,7 @@ export class Agent {
22
22
  orchestrator;
23
23
  sessionManager;
24
24
  contextBuilder;
25
+ activeAbortController = null;
25
26
  constructor(config, deps) {
26
27
  this.config = config;
27
28
  this.modelFactory = deps?.modelFactory ?? new ModelFactory();
@@ -83,10 +84,15 @@ export class Agent {
83
84
  // agent.start hook
84
85
  await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
85
86
  const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
87
+ const controller = new AbortController();
88
+ this.activeAbortController = controller;
89
+ if (signal) {
90
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
91
+ }
86
92
  try {
87
93
  const { context: finalCtx, compatRetries } = await this.orchestrator.runLoop(context, {
88
94
  maxIterations: maxIter,
89
- signal,
95
+ signal: controller.signal,
90
96
  modelString: this.config.model,
91
97
  sessionId: context.request.sessionId,
92
98
  autoCheckpoint: this._autoCheckpoint,
@@ -103,6 +109,7 @@ export class Agent {
103
109
  throw error;
104
110
  }
105
111
  finally {
112
+ this.activeAbortController = null;
106
113
  // agent.end hook — always fires, even on error; suppress hook errors to preserve original
107
114
  try {
108
115
  await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
@@ -149,16 +156,22 @@ export class Agent {
149
156
  // agent.start hook
150
157
  await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
151
158
  const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
159
+ const controller = new AbortController();
160
+ this.activeAbortController = controller;
161
+ if (signal) {
162
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
163
+ }
152
164
  try {
153
165
  yield* this.orchestrator.streamLoop(context, {
154
166
  maxIterations: maxIter,
155
- signal,
167
+ signal: controller.signal,
156
168
  modelString: this.config.model,
157
169
  sessionId: context.request.sessionId,
158
170
  autoCheckpoint: this._autoCheckpoint,
159
171
  });
160
172
  }
161
173
  finally {
174
+ this.activeAbortController = null;
162
175
  try {
163
176
  await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
164
177
  }
@@ -172,16 +185,128 @@ export class Agent {
172
185
  const hm = this._pluginManager.hookManager;
173
186
  await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
174
187
  const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
188
+ const controller = new AbortController();
189
+ this.activeAbortController = controller;
190
+ if (signal) {
191
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
192
+ }
175
193
  try {
176
194
  yield* this.orchestrator.streamEvents(context, {
177
195
  maxIterations: maxIter,
178
- signal,
196
+ signal: controller.signal,
197
+ modelString: this.config.model,
198
+ sessionId: context.request.sessionId,
199
+ autoCheckpoint: this._autoCheckpoint,
200
+ });
201
+ }
202
+ finally {
203
+ this.activeAbortController = null;
204
+ try {
205
+ await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
206
+ }
207
+ catch { /* hook error must not mask original */ }
208
+ }
209
+ }
210
+ /** Abort a running agent. Idempotent — no-op if agent is not running. */
211
+ abort() {
212
+ if (!this.orchestrator.stateMachine.canTransition('cancelled'))
213
+ return;
214
+ this.orchestrator.stateMachine.transition('cancelled');
215
+ this.activeAbortController?.abort();
216
+ this.activeAbortController = null;
217
+ }
218
+ /**
219
+ * Continue an existing session by restoring its context and running the pipeline
220
+ * with a new user message.
221
+ */
222
+ async continue(sessionId, message, signal) {
223
+ if (signal?.aborted)
224
+ throw new DOMException('Agent continue aborted', 'AbortError');
225
+ if (!this.sessionManager)
226
+ throw new Error('Session manager is required for continue()');
227
+ const context = await this.sessionManager.restore(sessionId);
228
+ context.request.input = message;
229
+ if (context.session.messageHistory) {
230
+ context.session.messageHistory.push({ role: 'user', content: message });
231
+ }
232
+ else {
233
+ context.session.messageHistory = [{ role: 'user', content: message }];
234
+ }
235
+ context.iteration = { step: 0 };
236
+ this._pluginManager.freezeHarnessInstances();
237
+ const hm = this._pluginManager.hookManager;
238
+ await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
239
+ const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
240
+ const controller = new AbortController();
241
+ this.activeAbortController = controller;
242
+ if (signal) {
243
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
244
+ }
245
+ try {
246
+ const { context: finalCtx, compatRetries } = await this.orchestrator.runLoop(context, {
247
+ maxIterations: maxIter,
248
+ signal: controller.signal,
249
+ modelString: this.config.model,
250
+ sessionId: context.request.sessionId,
251
+ autoCheckpoint: this._autoCheckpoint,
252
+ });
253
+ return {
254
+ response: finalCtx.iteration.response ?? '',
255
+ tokenUsage: finalCtx.session.totalTokenUsage ?? { input: 0, output: 0 },
256
+ sessionId: context.request.sessionId,
257
+ compatRetries,
258
+ };
259
+ }
260
+ catch (error) {
261
+ this.autoInvalidateModel(error);
262
+ throw error;
263
+ }
264
+ finally {
265
+ this.activeAbortController = null;
266
+ try {
267
+ await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
268
+ }
269
+ catch { /* hook error must not mask original */ }
270
+ }
271
+ }
272
+ /**
273
+ * Continue an existing session with streaming. Yields StreamEvents for the
274
+ * continued conversation.
275
+ */
276
+ async *continueStream(sessionId, message, signal) {
277
+ if (signal?.aborted)
278
+ throw new DOMException('Agent continue stream aborted', 'AbortError');
279
+ if (!this.sessionManager)
280
+ throw new Error('Session manager is required for continueStream()');
281
+ const context = await this.sessionManager.restore(sessionId);
282
+ context.request.input = message;
283
+ if (context.session.messageHistory) {
284
+ context.session.messageHistory.push({ role: 'user', content: message });
285
+ }
286
+ else {
287
+ context.session.messageHistory = [{ role: 'user', content: message }];
288
+ }
289
+ context.iteration = { step: 0 };
290
+ this._pluginManager.freezeHarnessInstances();
291
+ const hm = this._pluginManager.hookManager;
292
+ await hm.invoke('agent.start', { sessionId: context.request.sessionId, request: context.request, agentConfig: this.config }, {});
293
+ const maxIter = typeof this.config.maxIterations === 'number' ? this.config.maxIterations : 10;
294
+ const controller = new AbortController();
295
+ this.activeAbortController = controller;
296
+ if (signal) {
297
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
298
+ }
299
+ try {
300
+ yield* this.orchestrator.streamEvents(context, {
301
+ maxIterations: maxIter,
302
+ signal: controller.signal,
179
303
  modelString: this.config.model,
180
304
  sessionId: context.request.sessionId,
181
305
  autoCheckpoint: this._autoCheckpoint,
182
306
  });
183
307
  }
184
308
  finally {
309
+ this.activeAbortController = null;
185
310
  try {
186
311
  await hm.invoke('agent.end', { sessionId: context.request.sessionId }, {});
187
312
  }
@@ -4,4 +4,5 @@ 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
+ once(eventType: string): Promise<unknown>;
7
8
  }
package/dist/event-bus.js CHANGED
@@ -32,4 +32,12 @@ export class EventBus {
32
32
  set.add(handler);
33
33
  return () => set.delete(handler);
34
34
  }
35
+ once(eventType) {
36
+ return new Promise((resolve) => {
37
+ const unsub = this.subscribe(eventType, (data) => {
38
+ unsub();
39
+ resolve(data);
40
+ });
41
+ });
42
+ }
35
43
  }
@@ -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
  }
package/dist/index.d.ts CHANGED
@@ -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
@@ -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.4",
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/sdk": "0.1.4",
25
+ "@primo-ai/observability": "0.1.4",
26
+ "@primo-ai/tools": "0.1.4"
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",