@agentforge-io/core 2.0.18 → 2.0.20

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'>>;
@@ -105,6 +105,19 @@ export declare class AgentService {
105
105
  * authenticated user's connector tools (Gmail, Drive, …) on the fly
106
106
  * via `overrides.extraTools`. Optional — connectors are opt-in. */
107
107
  connectorRegistry?: ConnectorRegistryService | undefined);
108
+ /**
109
+ * Look up the human-friendly connector name + tool description for a
110
+ * given tool slug. Powers the friendly copy in `awaiting_approval` /
111
+ * `tool_blocked` cards: the visitor sees "Granola" instead of
112
+ * `granola_list_meetings`. Returns `undefined` for built-in / MCP
113
+ * tools, or when the registry isn't wired — the chat client falls
114
+ * back to the raw `toolName` in that case.
115
+ *
116
+ * Iterates every registered connector definition. The registry is
117
+ * small (handful of entries) so this is O(connectors × tools);
118
+ * acceptable for the rare per-approval call path.
119
+ */
120
+ private describeTool;
108
121
  /**
109
122
  * Fetch the connector tools the user has authorized, swallowing failures.
110
123
  * The agent loop must keep working even if a connector's refresh token is
@@ -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}`);
@@ -28,6 +29,38 @@ class AgentService {
28
29
  this.hooks = hooks;
29
30
  this.connectorRegistry = connectorRegistry;
30
31
  }
32
+ /**
33
+ * Look up the human-friendly connector name + tool description for a
34
+ * given tool slug. Powers the friendly copy in `awaiting_approval` /
35
+ * `tool_blocked` cards: the visitor sees "Granola" instead of
36
+ * `granola_list_meetings`. Returns `undefined` for built-in / MCP
37
+ * tools, or when the registry isn't wired — the chat client falls
38
+ * back to the raw `toolName` in that case.
39
+ *
40
+ * Iterates every registered connector definition. The registry is
41
+ * small (handful of entries) so this is O(connectors × tools);
42
+ * acceptable for the rare per-approval call path.
43
+ */
44
+ describeTool(toolName) {
45
+ if (!this.connectorRegistry)
46
+ return undefined;
47
+ try {
48
+ for (const def of this.connectorRegistry.list()) {
49
+ for (const factory of def.tools) {
50
+ if (factory.definition.name === toolName) {
51
+ return {
52
+ connectorName: def.name,
53
+ toolDescription: factory.definition.description,
54
+ };
55
+ }
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ // Registry is misconfigured — fall back to bare tool name.
61
+ }
62
+ return undefined;
63
+ }
31
64
  /**
32
65
  * Fetch the connector tools the user has authorized, swallowing failures.
33
66
  * The agent loop must keep working even if a connector's refresh token is
@@ -240,18 +273,87 @@ class AgentService {
240
273
  const resolvedExtras = await this.resolveExtraTools(agent.connectorOwnerUserId ?? params.userId);
241
274
  const filter = params.overrides?.extraToolsFilter;
242
275
  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;
276
+ try {
277
+ for await (const chunk of this.runner.stream(agent, messages, {
278
+ userId: params.userId,
279
+ conversationId: params.conversationId,
280
+ agentId: conv.agentId,
281
+ messageId: 'streaming',
282
+ agent: { timezone: agent.timezone },
283
+ }, { ...(params.overrides ?? {}), extraTools })) {
284
+ if (chunk.type === 'text_delta')
285
+ fullContent += chunk.delta;
286
+ if (chunk.type === 'usage')
287
+ finalUsage = chunk.usage;
288
+ yield chunk;
289
+ }
290
+ }
291
+ catch (err) {
292
+ // Tool-approval gate decisions surface as typed exceptions. We
293
+ // intercept them HERE so the SSE consumer sees a structured
294
+ // chunk instead of a torn-down generator. Each branch also
295
+ // persists a synthetic assistant message carrying the metadata
296
+ // the chat client needs to re-render the card on reload — the
297
+ // stream may close, but the conversation history doesn't lose
298
+ // context.
299
+ if ((0, tool_approval_gate_1.isToolApprovalRequired)(err)) {
300
+ const ctx = this.describeTool(err.toolName);
301
+ yield {
302
+ type: 'awaiting_approval',
303
+ approvalId: err.approvalId,
304
+ toolName: err.toolName,
305
+ expiresAt: err.expiresAt,
306
+ connectorName: ctx?.connectorName,
307
+ toolDescription: ctx?.toolDescription,
308
+ };
309
+ await this.conversations.addMessage({
310
+ conversationId: params.conversationId,
311
+ userId: params.userId,
312
+ role: 'assistant',
313
+ // Plain-text fallback. Friendly enough for legacy clients
314
+ // that don't render `metadata.kind`. The structured card
315
+ // lives in `metadata` for capable widgets.
316
+ content: ctx?.connectorName
317
+ ? `Necesito tu permiso para usar ${ctx.connectorName}.`
318
+ : `Necesito tu permiso para continuar.`,
319
+ metadata: {
320
+ kind: 'awaiting_approval',
321
+ approvalId: err.approvalId,
322
+ toolName: err.toolName,
323
+ expiresAt: err.expiresAt,
324
+ connectorName: ctx?.connectorName,
325
+ toolDescription: ctx?.toolDescription,
326
+ },
327
+ });
328
+ return;
329
+ }
330
+ if ((0, tool_approval_gate_1.isToolBlockedError)(err)) {
331
+ const ctx = this.describeTool(err.toolName);
332
+ yield {
333
+ type: 'tool_blocked',
334
+ toolName: err.toolName,
335
+ reason: err.reason,
336
+ connectorName: ctx?.connectorName,
337
+ toolDescription: ctx?.toolDescription,
338
+ };
339
+ await this.conversations.addMessage({
340
+ conversationId: params.conversationId,
341
+ userId: params.userId,
342
+ role: 'assistant',
343
+ content: ctx?.connectorName
344
+ ? `No puedo usar ${ctx.connectorName} en esta cuenta.`
345
+ : `No puedo usar esa herramienta en esta cuenta.`,
346
+ metadata: {
347
+ kind: 'tool_blocked',
348
+ toolName: err.toolName,
349
+ reason: err.reason,
350
+ connectorName: ctx?.connectorName,
351
+ toolDescription: ctx?.toolDescription,
352
+ },
353
+ });
354
+ return;
355
+ }
356
+ throw err;
255
357
  }
256
358
  if (fullContent) {
257
359
  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,39 @@ 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
+ /** Human-friendly connector name (`'Granola'`, `'Notion'`). The
191
+ * runner enriches it from the registry so the chat widget can
192
+ * render a sentence like "I need permission to use Granola"
193
+ * instead of the raw tool slug. Optional — clients fall back
194
+ * to `toolName` when the runtime doesn't supply this. */
195
+ connectorName?: string;
196
+ /** Human-friendly first sentence of the tool's description.
197
+ * Same story: enables the widget to say what the tool DOES in
198
+ * natural language. Optional. */
199
+ toolDescription?: string;
200
+ } | {
201
+ /** Emitted when a tool dispatch hit `kind: 'blocked'`. Mirrors the
202
+ * shape above so the client renders a terminal-error card with
203
+ * the same component. */
204
+ type: 'tool_blocked';
205
+ toolName: string;
206
+ reason?: string;
207
+ connectorName?: string;
208
+ toolDescription?: string;
176
209
  } | {
177
210
  type: 'done';
178
211
  messageId: string;
@@ -207,5 +240,9 @@ export interface MessageRecord {
207
240
  content: string;
208
241
  toolCalls?: ToolCallRecord[];
209
242
  usage?: TokenUsage;
243
+ /** Structured payload mirror of `Message.metadata`. Same kinds, same
244
+ * back-compat semantics. Lets the chat history endpoint pass the
245
+ * approval-card / blocked-tool markers through to the client. */
246
+ metadata?: Record<string, unknown>;
210
247
  createdAt: Date;
211
248
  }
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.20",
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",