@agentforge-io/core 2.0.19 → 2.0.21

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.
@@ -4,6 +4,7 @@ import type { SdkHooks } from '../types/hooks';
4
4
  import type { AgentRunnerService } from './agent-runner.service';
5
5
  import type { ConversationService } from './conversation.service';
6
6
  import type { ConnectorRegistryService } from './connector-registry.service';
7
+ import type { ApprovalCopywriterService } from './approval-copywriter.service';
7
8
  /**
8
9
  * Minimal record the host's agent-config layer must supply to resolve
9
10
  * agents by `(tenantId, slug)` or by id. The SDK doesn't care where this
@@ -94,6 +95,11 @@ export declare class AgentService {
94
95
  * authenticated user's connector tools (Gmail, Drive, …) on the fly
95
96
  * via `overrides.extraTools`. Optional — connectors are opt-in. */
96
97
  private readonly connectorRegistry?;
98
+ /** When wired, the AgentService asks this service to generate the
99
+ * microcopy shown in the in-chat approval bubble. Falls back to a
100
+ * meta-only render on the client when unwired or when generation
101
+ * fails. */
102
+ private readonly copywriter?;
97
103
  constructor(agents: AgentDefinition[], runner: AgentRunnerService, conversations: ConversationService,
98
104
  /** When wired, agents created via the admin UI are looked up here first;
99
105
  * the hardcoded `agents` array remains a fallback for legacy installs. */
@@ -104,7 +110,25 @@ export declare class AgentService {
104
110
  /** When wired, every streamMessage / sendMessage call attaches the
105
111
  * authenticated user's connector tools (Gmail, Drive, …) on the fly
106
112
  * via `overrides.extraTools`. Optional — connectors are opt-in. */
107
- connectorRegistry?: ConnectorRegistryService | undefined);
113
+ connectorRegistry?: ConnectorRegistryService | undefined,
114
+ /** When wired, the AgentService asks this service to generate the
115
+ * microcopy shown in the in-chat approval bubble. Falls back to a
116
+ * meta-only render on the client when unwired or when generation
117
+ * fails. */
118
+ copywriter?: ApprovalCopywriterService | undefined);
119
+ /**
120
+ * Look up the human-friendly connector name + tool description for a
121
+ * given tool slug. Powers the friendly copy in `awaiting_approval` /
122
+ * `tool_blocked` cards: the visitor sees "Granola" instead of
123
+ * `granola_list_meetings`. Returns `undefined` for built-in / MCP
124
+ * tools, or when the registry isn't wired — the chat client falls
125
+ * back to the raw `toolName` in that case.
126
+ *
127
+ * Iterates every registered connector definition. The registry is
128
+ * small (handful of entries) so this is O(connectors × tools);
129
+ * acceptable for the rare per-approval call path.
130
+ */
131
+ private describeTool;
108
132
  /**
109
133
  * Fetch the connector tools the user has authorized, swallowing failures.
110
134
  * The agent loop must keep working even if a connector's refresh token is
@@ -21,13 +21,51 @@ class AgentService {
21
21
  /** When wired, every streamMessage / sendMessage call attaches the
22
22
  * authenticated user's connector tools (Gmail, Drive, …) on the fly
23
23
  * via `overrides.extraTools`. Optional — connectors are opt-in. */
24
- connectorRegistry) {
24
+ connectorRegistry,
25
+ /** When wired, the AgentService asks this service to generate the
26
+ * microcopy shown in the in-chat approval bubble. Falls back to a
27
+ * meta-only render on the client when unwired or when generation
28
+ * fails. */
29
+ copywriter) {
25
30
  this.agents = agents;
26
31
  this.runner = runner;
27
32
  this.conversations = conversations;
28
33
  this.resolver = resolver;
29
34
  this.hooks = hooks;
30
35
  this.connectorRegistry = connectorRegistry;
36
+ this.copywriter = copywriter;
37
+ }
38
+ /**
39
+ * Look up the human-friendly connector name + tool description for a
40
+ * given tool slug. Powers the friendly copy in `awaiting_approval` /
41
+ * `tool_blocked` cards: the visitor sees "Granola" instead of
42
+ * `granola_list_meetings`. Returns `undefined` for built-in / MCP
43
+ * tools, or when the registry isn't wired — the chat client falls
44
+ * back to the raw `toolName` in that case.
45
+ *
46
+ * Iterates every registered connector definition. The registry is
47
+ * small (handful of entries) so this is O(connectors × tools);
48
+ * acceptable for the rare per-approval call path.
49
+ */
50
+ describeTool(toolName) {
51
+ if (!this.connectorRegistry)
52
+ return undefined;
53
+ try {
54
+ for (const def of this.connectorRegistry.list()) {
55
+ for (const factory of def.tools) {
56
+ if (factory.definition.name === toolName) {
57
+ return {
58
+ connectorName: def.name,
59
+ toolDescription: factory.definition.description,
60
+ };
61
+ }
62
+ }
63
+ }
64
+ }
65
+ catch {
66
+ // Registry is misconfigured — fall back to bare tool name.
67
+ }
68
+ return undefined;
31
69
  }
32
70
  /**
33
71
  * Fetch the connector tools the user has authorized, swallowing failures.
@@ -265,44 +303,80 @@ class AgentService {
265
303
  // stream may close, but the conversation history doesn't lose
266
304
  // context.
267
305
  if ((0, tool_approval_gate_1.isToolApprovalRequired)(err)) {
306
+ const ctx = this.describeTool(err.toolName);
307
+ const copy = await this.copywriter?.generate({
308
+ kind: 'approval',
309
+ toolName: err.toolName,
310
+ connectorName: ctx?.connectorName,
311
+ toolDescription: ctx?.toolDescription,
312
+ recentMessages: extractRecentForCopywriter(messages),
313
+ agentPersona: agent.systemPrompt,
314
+ });
268
315
  yield {
269
316
  type: 'awaiting_approval',
270
317
  approvalId: err.approvalId,
271
318
  toolName: err.toolName,
272
319
  expiresAt: err.expiresAt,
320
+ connectorName: ctx?.connectorName,
321
+ toolDescription: ctx?.toolDescription,
322
+ copy,
273
323
  };
274
324
  await this.conversations.addMessage({
275
325
  conversationId: params.conversationId,
276
326
  userId: params.userId,
277
327
  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}\`)`,
328
+ // Plain-text fallback for legacy clients that don't render
329
+ // `metadata.kind`. Capable widgets read the structured card
330
+ // from `metadata.copy`.
331
+ content: copy?.body ??
332
+ (ctx?.connectorName
333
+ ? `Necesito tu permiso para usar ${ctx.connectorName}.`
334
+ : `Necesito tu permiso para continuar.`),
282
335
  metadata: {
283
336
  kind: 'awaiting_approval',
284
337
  approvalId: err.approvalId,
285
338
  toolName: err.toolName,
286
339
  expiresAt: err.expiresAt,
340
+ connectorName: ctx?.connectorName,
341
+ toolDescription: ctx?.toolDescription,
342
+ copy,
287
343
  },
288
344
  });
289
345
  return;
290
346
  }
291
347
  if ((0, tool_approval_gate_1.isToolBlockedError)(err)) {
348
+ const ctx = this.describeTool(err.toolName);
349
+ const copy = await this.copywriter?.generate({
350
+ kind: 'blocked',
351
+ toolName: err.toolName,
352
+ connectorName: ctx?.connectorName,
353
+ toolDescription: ctx?.toolDescription,
354
+ recentMessages: extractRecentForCopywriter(messages),
355
+ agentPersona: agent.systemPrompt,
356
+ });
292
357
  yield {
293
358
  type: 'tool_blocked',
294
359
  toolName: err.toolName,
295
360
  reason: err.reason,
361
+ connectorName: ctx?.connectorName,
362
+ toolDescription: ctx?.toolDescription,
363
+ copy,
296
364
  };
297
365
  await this.conversations.addMessage({
298
366
  conversationId: params.conversationId,
299
367
  userId: params.userId,
300
368
  role: 'assistant',
301
- content: err.reason ?? `Tool "${err.toolName}" is blocked.`,
369
+ content: copy?.blockedBody ??
370
+ (ctx?.connectorName
371
+ ? `No puedo usar ${ctx.connectorName} en esta cuenta.`
372
+ : `No puedo usar esa herramienta en esta cuenta.`),
302
373
  metadata: {
303
374
  kind: 'tool_blocked',
304
375
  toolName: err.toolName,
305
376
  reason: err.reason,
377
+ connectorName: ctx?.connectorName,
378
+ toolDescription: ctx?.toolDescription,
379
+ copy,
306
380
  },
307
381
  });
308
382
  return;
@@ -383,6 +457,20 @@ class AgentService {
383
457
  }
384
458
  }
385
459
  exports.AgentService = AgentService;
460
+ /**
461
+ * Pull the last few text-only turns out of the Anthropic message array
462
+ * for the approval copywriter. We strip tool blocks because they are
463
+ * not useful for language/tone detection and they can be long. Returns
464
+ * the most recent 6 turns, each capped to ~600 chars.
465
+ */
466
+ function extractRecentForCopywriter(messages) {
467
+ const trimmed = messages
468
+ .map((m) => ({ role: m.role, content: m.content?.trim?.() ?? '' }))
469
+ .filter((m) => m.content.length > 0)
470
+ .slice(-6)
471
+ .map((m) => ({ role: m.role, content: m.content.slice(0, 600) }));
472
+ return trimmed;
473
+ }
386
474
  /**
387
475
  * Map a persisted `AgentRecord` to the runtime `AgentDefinition` the runner
388
476
  * expects. The `context` column (plain-text knowledge) is prepended to the
@@ -0,0 +1,57 @@
1
+ export interface ApprovalCopyBundle {
2
+ title: string;
3
+ body: string;
4
+ approveLabel: string;
5
+ approveBusyLabel: string;
6
+ denyLabel: string;
7
+ denyBusyLabel: string;
8
+ approvedPill: string;
9
+ deniedPill: string;
10
+ readOnlyHint: string;
11
+ blockedTitle: string;
12
+ blockedBody: string;
13
+ /** Formatter hint for "Expires in Xm Ys". The SDK fills the time. */
14
+ expiresPrefix: string;
15
+ }
16
+ export interface ApprovalCopywriterInput {
17
+ toolName: string;
18
+ connectorName?: string;
19
+ toolDescription?: string;
20
+ kind: 'approval' | 'blocked';
21
+ /** Last few turns of the conversation. The model uses these to pick
22
+ * language, tone and any references the user already established. */
23
+ recentMessages?: {
24
+ role: 'user' | 'assistant';
25
+ content: string;
26
+ }[];
27
+ /** Optional agent persona/instructions excerpt so the copy keeps the
28
+ * agent's voice. Trimmed to a few hundred chars by the caller. */
29
+ agentPersona?: string;
30
+ }
31
+ /**
32
+ * Generates a natural-language approval copy bundle for the bubble the
33
+ * chat widget renders when the tool-approval gate pauses a run.
34
+ *
35
+ * Why a dedicated service: the SDK used to ship a hardcoded English (then
36
+ * Spanish) string set. That doesn't scale — different tenants speak
37
+ * different languages, and the bubble should sound like the rest of the
38
+ * agent's voice, not a generic system prompt. Delegating copy to the
39
+ * model that's already shaping the conversation keeps tone, locale and
40
+ * register coherent without dictionaries to maintain.
41
+ *
42
+ * The actual model call is Haiku (cheap + ~200ms) with a tight token cap.
43
+ * If anything goes wrong — timeout, parse error, missing API key — the
44
+ * caller gets `undefined` and falls back to a meta-only render. The gate
45
+ * MUST keep working when the copywriter doesn't.
46
+ */
47
+ export declare class ApprovalCopywriterService {
48
+ private readonly client;
49
+ private readonly cache;
50
+ constructor(opts: {
51
+ apiKey?: string;
52
+ baseURL?: string;
53
+ });
54
+ generate(input: ApprovalCopywriterInput): Promise<ApprovalCopyBundle | undefined>;
55
+ private cacheKey;
56
+ private callModel;
57
+ }
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ApprovalCopywriterService = void 0;
7
+ const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
8
+ const crypto_1 = require("crypto");
9
+ const CACHE_TTL_MS = 5 * 60 * 1000;
10
+ const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
11
+ const HAIKU_MAX_TOKENS = 400;
12
+ const HAIKU_TIMEOUT_MS = 2_500;
13
+ /**
14
+ * Generates a natural-language approval copy bundle for the bubble the
15
+ * chat widget renders when the tool-approval gate pauses a run.
16
+ *
17
+ * Why a dedicated service: the SDK used to ship a hardcoded English (then
18
+ * Spanish) string set. That doesn't scale — different tenants speak
19
+ * different languages, and the bubble should sound like the rest of the
20
+ * agent's voice, not a generic system prompt. Delegating copy to the
21
+ * model that's already shaping the conversation keeps tone, locale and
22
+ * register coherent without dictionaries to maintain.
23
+ *
24
+ * The actual model call is Haiku (cheap + ~200ms) with a tight token cap.
25
+ * If anything goes wrong — timeout, parse error, missing API key — the
26
+ * caller gets `undefined` and falls back to a meta-only render. The gate
27
+ * MUST keep working when the copywriter doesn't.
28
+ */
29
+ class ApprovalCopywriterService {
30
+ constructor(opts) {
31
+ this.cache = new Map();
32
+ if (!opts.apiKey) {
33
+ this.client = undefined;
34
+ return;
35
+ }
36
+ this.client = new sdk_1.default({
37
+ apiKey: opts.apiKey,
38
+ baseURL: opts.baseURL,
39
+ });
40
+ }
41
+ async generate(input) {
42
+ if (!this.client)
43
+ return undefined;
44
+ const key = this.cacheKey(input);
45
+ const hit = this.cache.get(key);
46
+ if (hit && hit.expiresAt > Date.now())
47
+ return hit.value;
48
+ try {
49
+ const value = await this.callModel(input);
50
+ if (!value)
51
+ return undefined;
52
+ this.cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
53
+ return value;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ cacheKey(input) {
60
+ const recent = (input.recentMessages ?? [])
61
+ .slice(-3)
62
+ .map((m) => `${m.role[0]}:${m.content.slice(0, 120)}`)
63
+ .join('|');
64
+ const raw = [
65
+ input.kind,
66
+ input.toolName,
67
+ input.connectorName ?? '',
68
+ input.toolDescription ?? '',
69
+ input.agentPersona?.slice(0, 200) ?? '',
70
+ recent,
71
+ ].join('');
72
+ return (0, crypto_1.createHash)('sha1').update(raw).digest('hex');
73
+ }
74
+ async callModel(input) {
75
+ const recent = (input.recentMessages ?? [])
76
+ .slice(-6)
77
+ .map((m) => `${m.role.toUpperCase()}: ${m.content.slice(0, 600)}`)
78
+ .join('\n');
79
+ const targetLabel = input.connectorName ?? input.toolName;
80
+ const whatItDoes = input.toolDescription
81
+ ? `It does: ${input.toolDescription}`
82
+ : '';
83
+ const personaLine = input.agentPersona
84
+ ? `Agent persona: ${input.agentPersona.slice(0, 400)}`
85
+ : '';
86
+ const guidance = input.kind === 'approval'
87
+ ? `The agent paused because it wants to use the "${targetLabel}" capability and needs the user's permission. Write a short, warm bubble that matches the language, tone and register of the conversation so far. Mention "${targetLabel}" naturally. Keep it conversational — not corporate or robotic.`
88
+ : `The agent tried to use the "${targetLabel}" capability but it is blocked on this account. Write a short bubble that tells the user clearly, in the language and tone of the conversation, that this action isn't available. Do not blame them. Do not invite retrying.`;
89
+ const system = [
90
+ 'You write microcopy for an in-chat permission card. Output STRICT JSON, no prose, no code fences.',
91
+ 'Match the language of the most recent USER message. If the conversation is empty, default to English.',
92
+ 'Keep all strings under ~80 chars unless noted. Buttons under ~20 chars.',
93
+ 'No emojis. No markdown.',
94
+ ].join(' ');
95
+ const userPrompt = [
96
+ guidance,
97
+ whatItDoes,
98
+ personaLine,
99
+ '',
100
+ 'Recent conversation (most recent last):',
101
+ recent || '(none)',
102
+ '',
103
+ 'Return JSON with exactly these keys (all strings):',
104
+ '{',
105
+ ' "title": "headline of the bubble",',
106
+ ' "body": "one sentence asking permission or explaining the block",',
107
+ ' "approveLabel": "primary button when asking permission",',
108
+ ' "approveBusyLabel": "primary button while confirming",',
109
+ ' "denyLabel": "secondary button when asking permission",',
110
+ ' "denyBusyLabel": "secondary button while confirming",',
111
+ ' "approvedPill": "tiny status pill after the user approved",',
112
+ ' "deniedPill": "tiny status pill after the user denied",',
113
+ ' "readOnlyHint": "tiny line shown when the bubble is read-only (e.g. on history)",',
114
+ ' "blockedTitle": "headline when the action is blocked",',
115
+ ' "blockedBody": "one sentence explaining the block",',
116
+ ' "expiresPrefix": "short prefix shown before the countdown, e.g. \\"Expires in\\""',
117
+ '}',
118
+ ].join('\n');
119
+ const ac = new AbortController();
120
+ const timer = setTimeout(() => ac.abort(), HAIKU_TIMEOUT_MS);
121
+ try {
122
+ const res = await this.client.messages.create({
123
+ model: HAIKU_MODEL,
124
+ max_tokens: HAIKU_MAX_TOKENS,
125
+ temperature: 0.4,
126
+ system,
127
+ messages: [{ role: 'user', content: userPrompt }],
128
+ }, { signal: ac.signal });
129
+ const text = res.content
130
+ .filter((b) => b.type === 'text')
131
+ .map((b) => b.text)
132
+ .join('')
133
+ .trim();
134
+ return parseBundle(text);
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+ }
141
+ exports.ApprovalCopywriterService = ApprovalCopywriterService;
142
+ function parseBundle(text) {
143
+ const jsonStart = text.indexOf('{');
144
+ const jsonEnd = text.lastIndexOf('}');
145
+ if (jsonStart < 0 || jsonEnd <= jsonStart)
146
+ return undefined;
147
+ let obj;
148
+ try {
149
+ obj = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ const keys = [
155
+ 'title',
156
+ 'body',
157
+ 'approveLabel',
158
+ 'approveBusyLabel',
159
+ 'denyLabel',
160
+ 'denyBusyLabel',
161
+ 'approvedPill',
162
+ 'deniedPill',
163
+ 'readOnlyHint',
164
+ 'blockedTitle',
165
+ 'blockedBody',
166
+ 'expiresPrefix',
167
+ ];
168
+ const out = {};
169
+ for (const k of keys) {
170
+ const v = obj[k];
171
+ if (typeof v !== 'string' || !v.trim())
172
+ return undefined;
173
+ out[k] = v.trim();
174
+ }
175
+ return out;
176
+ }
@@ -5,6 +5,7 @@ export { PreparedStreamService, PreparedStreamError, } from './prepared-stream.s
5
5
  export { InMemoryPreparedStreamStore } from './in-memory-prepared-stream.store';
6
6
  export { ConversationService, ConversationNotFoundError, } from './conversation.service';
7
7
  export { AgentService, AgentForbiddenError, type AgentNotFoundError, } from './agent.service';
8
+ export { ApprovalCopywriterService, type ApprovalCopyBundle as ApprovalCopywriterBundle, type ApprovalCopywriterInput, } from './approval-copywriter.service';
8
9
  export { OrchestratorService, OrchestratorError, type OrchestratorServiceOptions, } from './orchestrator.service';
9
10
  export { AgentJobWorker } from './agent-job.worker';
10
11
  export { ChatTokenService, ChatTokenError, type CreateChatTokenInput, } from './chat-token.service';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.InMemoryAuthorizeStateStore = exports.ConnectorError = exports.ConnectorRegistryService = exports.OAuth2Service = exports.McpServerError = exports.McpServerService = exports.McpClientError = exports.McpClientService = exports.ChatTokenError = exports.ChatTokenService = exports.AgentJobWorker = exports.OrchestratorError = exports.OrchestratorService = exports.AgentForbiddenError = exports.AgentService = exports.ConversationNotFoundError = exports.ConversationService = exports.InMemoryPreparedStreamStore = exports.PreparedStreamError = exports.PreparedStreamService = exports.isToolBlockedError = exports.isToolApprovalRequired = exports.ToolBlockedError = exports.ToolApprovalRequired = exports.AgentRunnerService = exports.ToolRegistryService = void 0;
3
+ exports.InMemoryAuthorizeStateStore = exports.ConnectorError = exports.ConnectorRegistryService = exports.OAuth2Service = exports.McpServerError = exports.McpServerService = exports.McpClientError = exports.McpClientService = exports.ChatTokenError = exports.ChatTokenService = exports.AgentJobWorker = exports.OrchestratorError = exports.OrchestratorService = exports.ApprovalCopywriterService = exports.AgentForbiddenError = exports.AgentService = exports.ConversationNotFoundError = exports.ConversationService = exports.InMemoryPreparedStreamStore = exports.PreparedStreamError = exports.PreparedStreamService = exports.isToolBlockedError = exports.isToolApprovalRequired = exports.ToolBlockedError = exports.ToolApprovalRequired = exports.AgentRunnerService = exports.ToolRegistryService = void 0;
4
4
  // Public service surface of @agentforge-io/core. AI runtime only — auth,
5
5
  // identity, billing, infra, etc. have all moved to the host server.
6
6
  var tool_registry_service_1 = require("./tool-registry.service");
@@ -27,6 +27,8 @@ Object.defineProperty(exports, "ConversationNotFoundError", { enumerable: true,
27
27
  var agent_service_1 = require("./agent.service");
28
28
  Object.defineProperty(exports, "AgentService", { enumerable: true, get: function () { return agent_service_1.AgentService; } });
29
29
  Object.defineProperty(exports, "AgentForbiddenError", { enumerable: true, get: function () { return agent_service_1.AgentForbiddenError; } });
30
+ var approval_copywriter_service_1 = require("./approval-copywriter.service");
31
+ Object.defineProperty(exports, "ApprovalCopywriterService", { enumerable: true, get: function () { return approval_copywriter_service_1.ApprovalCopywriterService; } });
30
32
  var orchestrator_service_1 = require("./orchestrator.service");
31
33
  Object.defineProperty(exports, "OrchestratorService", { enumerable: true, get: function () { return orchestrator_service_1.OrchestratorService; } });
32
34
  Object.defineProperty(exports, "OrchestratorError", { enumerable: true, get: function () { return orchestrator_service_1.OrchestratorError; } });
@@ -151,6 +151,20 @@ export interface ToolExecutionContext {
151
151
  export type AnthropicMessage = Anthropic.MessageParam;
152
152
  export type AnthropicTool = Anthropic.Tool;
153
153
  export type AnthropicContentBlock = Anthropic.ContentBlock;
154
+ export interface ApprovalCopyBundle {
155
+ title: string;
156
+ body: string;
157
+ approveLabel: string;
158
+ approveBusyLabel: string;
159
+ denyLabel: string;
160
+ denyBusyLabel: string;
161
+ approvedPill: string;
162
+ deniedPill: string;
163
+ readOnlyHint: string;
164
+ blockedTitle: string;
165
+ blockedBody: string;
166
+ expiresPrefix: string;
167
+ }
154
168
  export type StreamChunk = {
155
169
  type: 'text_delta';
156
170
  delta: string;
@@ -187,13 +201,21 @@ export type StreamChunk = {
187
201
  approvalId: string;
188
202
  toolName: string;
189
203
  expiresAt: string;
204
+ connectorName?: string;
205
+ toolDescription?: string;
206
+ /** AI-generated microcopy for the bubble. Built server-side by
207
+ * the approval copywriter (a small Haiku call) so the language,
208
+ * tone and register match the conversation. Optional — when
209
+ * absent the client falls back to a minimal render derived from
210
+ * `connectorName` / `toolName`. */
211
+ copy?: ApprovalCopyBundle;
190
212
  } | {
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
213
  type: 'tool_blocked';
195
214
  toolName: string;
196
215
  reason?: string;
216
+ connectorName?: string;
217
+ toolDescription?: string;
218
+ copy?: ApprovalCopyBundle;
197
219
  } | {
198
220
  type: 'done';
199
221
  messageId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
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",