@agentforge-io/core 2.0.18 → 2.0.19

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.
@@ -20,6 +20,20 @@ export interface Message {
20
20
  content: string;
21
21
  toolCalls?: ToolCallRecord[];
22
22
  usage?: TokenUsage;
23
+ /**
24
+ * Free-form metadata the host can attach to a message. The runtime is
25
+ * agnostic about its contents — it persists and surfaces it back. Used
26
+ * today by:
27
+ *
28
+ * - `tool_approvals`: synthetic assistant message carries
29
+ * `{ kind: 'awaiting_approval', approvalId, toolName, expiresAt }`
30
+ * so the chat client can render an Approve/Deny card inline
31
+ * instead of plain text.
32
+ *
33
+ * Add new kinds without a schema migration — the field is JSONB in
34
+ * the persistence adapter.
35
+ */
36
+ metadata?: Record<string, unknown>;
23
37
  createdAt: Date;
24
38
  }
25
39
  export type NewConversation = Pick<Conversation, 'userId' | 'agentId'> & Partial<Omit<Conversation, 'id' | 'createdAt' | 'updatedAt'>>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AgentService = exports.AgentForbiddenError = void 0;
4
+ const tool_approval_gate_1 = require("./tool-approval-gate");
4
5
  class AgentForbiddenError extends Error {
5
6
  constructor(reason) {
6
7
  super(`Usage limit exceeded: ${reason}`);
@@ -240,18 +241,73 @@ class AgentService {
240
241
  const resolvedExtras = await this.resolveExtraTools(agent.connectorOwnerUserId ?? params.userId);
241
242
  const filter = params.overrides?.extraToolsFilter;
242
243
  const extraTools = filter && resolvedExtras ? filter(resolvedExtras) : resolvedExtras;
243
- for await (const chunk of this.runner.stream(agent, messages, {
244
- userId: params.userId,
245
- conversationId: params.conversationId,
246
- agentId: conv.agentId,
247
- messageId: 'streaming',
248
- agent: { timezone: agent.timezone },
249
- }, { ...(params.overrides ?? {}), extraTools })) {
250
- if (chunk.type === 'text_delta')
251
- fullContent += chunk.delta;
252
- if (chunk.type === 'usage')
253
- finalUsage = chunk.usage;
254
- yield chunk;
244
+ try {
245
+ for await (const chunk of this.runner.stream(agent, messages, {
246
+ userId: params.userId,
247
+ conversationId: params.conversationId,
248
+ agentId: conv.agentId,
249
+ messageId: 'streaming',
250
+ agent: { timezone: agent.timezone },
251
+ }, { ...(params.overrides ?? {}), extraTools })) {
252
+ if (chunk.type === 'text_delta')
253
+ fullContent += chunk.delta;
254
+ if (chunk.type === 'usage')
255
+ finalUsage = chunk.usage;
256
+ yield chunk;
257
+ }
258
+ }
259
+ catch (err) {
260
+ // Tool-approval gate decisions surface as typed exceptions. We
261
+ // intercept them HERE so the SSE consumer sees a structured
262
+ // chunk instead of a torn-down generator. Each branch also
263
+ // persists a synthetic assistant message carrying the metadata
264
+ // the chat client needs to re-render the card on reload — the
265
+ // stream may close, but the conversation history doesn't lose
266
+ // context.
267
+ if ((0, tool_approval_gate_1.isToolApprovalRequired)(err)) {
268
+ yield {
269
+ type: 'awaiting_approval',
270
+ approvalId: err.approvalId,
271
+ toolName: err.toolName,
272
+ expiresAt: err.expiresAt,
273
+ };
274
+ await this.conversations.addMessage({
275
+ conversationId: params.conversationId,
276
+ userId: params.userId,
277
+ role: 'assistant',
278
+ // Plain-text body so legacy clients (or a server reload of
279
+ // history into a non-aware client) still get a readable
280
+ // line. The card lives in `metadata`.
281
+ content: `(waiting for approval to run \`${err.toolName}\`)`,
282
+ metadata: {
283
+ kind: 'awaiting_approval',
284
+ approvalId: err.approvalId,
285
+ toolName: err.toolName,
286
+ expiresAt: err.expiresAt,
287
+ },
288
+ });
289
+ return;
290
+ }
291
+ if ((0, tool_approval_gate_1.isToolBlockedError)(err)) {
292
+ yield {
293
+ type: 'tool_blocked',
294
+ toolName: err.toolName,
295
+ reason: err.reason,
296
+ };
297
+ await this.conversations.addMessage({
298
+ conversationId: params.conversationId,
299
+ userId: params.userId,
300
+ role: 'assistant',
301
+ content: err.reason ?? `Tool "${err.toolName}" is blocked.`,
302
+ metadata: {
303
+ kind: 'tool_blocked',
304
+ toolName: err.toolName,
305
+ reason: err.reason,
306
+ },
307
+ });
308
+ return;
309
+ }
310
+ throw err;
255
311
  }
256
312
  if (fullContent) {
257
313
  await this.conversations.addMessage({
@@ -23,6 +23,9 @@ export declare class ConversationService {
23
23
  content: string;
24
24
  toolCalls?: ToolCallRecord[];
25
25
  usage?: TokenUsage;
26
+ /** Optional structured payload. See `Message.metadata` in
27
+ * `domain/conversation.ts` for usage. */
28
+ metadata?: Record<string, unknown>;
26
29
  }): Promise<Message>;
27
30
  /** Throws on not-found OR cross-user access. */
28
31
  private loadOwned;
@@ -34,6 +34,7 @@ class ConversationService {
34
34
  content: params.content,
35
35
  toolCalls: params.toolCalls,
36
36
  usage: params.usage,
37
+ metadata: params.metadata,
37
38
  });
38
39
  await this.convRepo.updateStats(params.conversationId, {
39
40
  addInputTokens: params.usage?.inputTokens,
@@ -94,6 +95,7 @@ class ConversationService {
94
95
  content: msg.content,
95
96
  toolCalls: msg.toolCalls,
96
97
  usage: msg.usage,
98
+ metadata: msg.metadata,
97
99
  createdAt: msg.createdAt,
98
100
  };
99
101
  }
@@ -173,6 +173,27 @@ export type StreamChunk = {
173
173
  } | {
174
174
  type: 'usage';
175
175
  usage: TokenUsage;
176
+ } | {
177
+ /**
178
+ * Emitted by the runtime when a tool dispatch hit
179
+ * `ToolApprovalGate.check() → { kind: 'approval' }`. The stream
180
+ * ends right after this chunk — the runner is paused until the
181
+ * approval is decided. The chat client renders an Approve/Deny
182
+ * card based on the carried fields; after Approve the client
183
+ * triggers a fresh `sendMessage("continue")` and the gate's
184
+ * fast-path consumes the pending row.
185
+ */
186
+ type: 'awaiting_approval';
187
+ approvalId: string;
188
+ toolName: string;
189
+ expiresAt: string;
190
+ } | {
191
+ /** Emitted when a tool dispatch hit `kind: 'blocked'`. Mirrors the
192
+ * shape above so the client renders a terminal-error card with
193
+ * the same component. */
194
+ type: 'tool_blocked';
195
+ toolName: string;
196
+ reason?: string;
176
197
  } | {
177
198
  type: 'done';
178
199
  messageId: string;
@@ -207,5 +228,9 @@ export interface MessageRecord {
207
228
  content: string;
208
229
  toolCalls?: ToolCallRecord[];
209
230
  usage?: TokenUsage;
231
+ /** Structured payload mirror of `Message.metadata`. Same kinds, same
232
+ * back-compat semantics. Lets the chat history endpoint pass the
233
+ * approval-card / blocked-tool markers through to the client. */
234
+ metadata?: Record<string, unknown>;
210
235
  createdAt: Date;
211
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "description": "Framework-free AI runtime SDK. Owns: agent loop (Anthropic), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",