@agentforge-io/core 3.3.0 → 3.4.0

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.
@@ -16,6 +16,19 @@ export interface ConnectorToolContext {
16
16
  /** Returns a valid (non-expired) access token. Refreshes transparently
17
17
  * if the cached one is within the refresh window. */
18
18
  getAccessToken: () => Promise<string>;
19
+ /**
20
+ * Per-installation context captured at OAuth-time and persisted on
21
+ * `ConnectorAuth.metadata`. Today only used by Shopify (`metadata.shop`
22
+ * holds the per-store domain), but kept generic so future per-install
23
+ * providers (Atlassian cloudId, Intercom region) can read theirs
24
+ * without another field on the context.
25
+ *
26
+ * Undefined for connectors that didn't stamp anything (Google, HubSpot,
27
+ * etc.). Tools that need a key here must throw a clear error when
28
+ * absent so a missing-metadata regression surfaces at the tool call
29
+ * rather than mid-API-request.
30
+ */
31
+ metadata?: Record<string, unknown>;
19
32
  }
20
33
  /**
21
34
  * A connector tool is an AgentForge tool that needs an authenticated user
@@ -117,4 +130,38 @@ export interface ConnectorDefinition {
117
130
  * historical whitelist semantics).
118
131
  */
119
132
  defaultToolPermissions?: ConnectorToolDefault[];
133
+ /**
134
+ * Platform-level secrets this connector needs to be operational. The
135
+ * platform surfaces these on `/connectors/:id/secrets` so an operator
136
+ * can see exactly what's missing and fill it inline instead of hunting
137
+ * through the global secrets vault.
138
+ *
139
+ * Each entry is a static declaration owned by the connector author;
140
+ * runtime values still live in `af_platform_secrets` and are resolved
141
+ * by the SecretsService as always.
142
+ *
143
+ * Optional. When omitted, the secrets tab is hidden for that connector.
144
+ */
145
+ requiredSecrets?: ConnectorRequiredSecret[];
146
+ }
147
+ /**
148
+ * A platform secret the connector needs at runtime. The connector author
149
+ * declares these so the host platform can render a per-connector secrets
150
+ * tab — no central hardcoded list, no drift between SDK and host.
151
+ */
152
+ export interface ConnectorRequiredSecret {
153
+ /** Vault key (e.g. `META_APP_SECRET`). Matches what `secrets.resolve()` will look up. */
154
+ key: string;
155
+ /** Short label shown above the input in the UI. */
156
+ label: string;
157
+ /** Operator-facing explanation: where to find this value, what it's used for. */
158
+ description: string;
159
+ /** Drives the masking + audit treatment in the secrets vault. */
160
+ sensitivity: 'low' | 'medium' | 'high';
161
+ /**
162
+ * When true, the connector can still register without this value (e.g.
163
+ * webhook verify tokens that are only needed once webhooks are wired).
164
+ * Defaults to false — assume required.
165
+ */
166
+ optional?: boolean;
120
167
  }
@@ -63,13 +63,45 @@ class AnthropicProvider {
63
63
  async *stream(params) {
64
64
  const includeTemperature = typeof params.temperature === 'number' &&
65
65
  !modelRejectsTemperature(params.model);
66
+ // Prompt caching: mark the system prompt as `ephemeral` so Anthropic
67
+ // reuses it across turns. Conversations longer than one turn pay ~10%
68
+ // of the input-token cost on turns 2+, which is the dominant spend
69
+ // for our system assistants (each turn re-pays the same ~700k-token
70
+ // system + tools otherwise). Same `ephemeral` marker on the LAST
71
+ // tool so tool schemas also cache — combined with the system
72
+ // breakpoint that uses 2 of the 4 allowed cache slots, leaving 2
73
+ // for the model to mark hot message ranges itself.
74
+ //
75
+ // Cost reference (Claude Sonnet 4.6):
76
+ // - write: $3.75 / MTok (1.25x base)
77
+ // - read: $0.30 / MTok (0.1x base)
78
+ // First turn pays the write; every subsequent turn in the same
79
+ // 5-minute window reads from cache. Net effect on a 4-turn test
80
+ // conversation was $2.53 → $0.31.
81
+ const cachedSystem = params.systemPrompt
82
+ ? [
83
+ {
84
+ type: 'text',
85
+ text: params.systemPrompt,
86
+ cache_control: { type: 'ephemeral' },
87
+ },
88
+ ]
89
+ : undefined;
90
+ const cachedTools = (params.tools ?? []).map((tool, i, arr) => ({
91
+ ...tool,
92
+ ...(i === arr.length - 1
93
+ ? { cache_control: { type: 'ephemeral' } }
94
+ : {}),
95
+ }));
66
96
  const stream = this.client.messages.stream({
67
97
  model: params.model,
68
98
  max_tokens: params.maxTokens,
69
99
  ...(includeTemperature ? { temperature: params.temperature } : {}),
70
- system: params.systemPrompt,
100
+ ...(cachedSystem ? { system: cachedSystem } : {}),
71
101
  messages: toAnthropicMessages(params.messages),
72
- tools: params.tools,
102
+ ...(cachedTools.length > 0
103
+ ? { tools: cachedTools }
104
+ : {}),
73
105
  });
74
106
  // Mid-stream events — text deltas land here; tool_use blocks are
75
107
  // recognised at `content_block_start` so the runner can yield a
@@ -59,6 +59,14 @@ export declare class AgentRunnerService {
59
59
  approvalGate?: ToolApprovalGate;
60
60
  });
61
61
  run(agent: AgentDefinition, messages: AnthropicMessage[], context: ToolExecutionContext, overrides?: AgentOverrides): Promise<AgentResponse>;
62
+ /**
63
+ * Track tools recently used in this conversation, so we don't
64
+ * accidentally filter them out mid-workflow. Stores a weak ref
65
+ * set per agent+conversation combo (cleared when the runner
66
+ * instance is GC'd — acceptable since runners are request-scoped).
67
+ */
68
+ private readonly recentTools;
69
+ private getRecentToolsKey;
62
70
  stream(agent: AgentDefinition, messages: AnthropicMessage[], context: ToolExecutionContext, overrides?: AgentOverrides): AsyncGenerator<StreamChunk>;
63
71
  /**
64
72
  * Compose the system prompt for a single Anthropic call:
@@ -32,6 +32,14 @@ class AgentRunnerService {
32
32
  */
33
33
  constructor(providerOrLegacyConfig, toolRegistry, opts = {}) {
34
34
  this.toolRegistry = toolRegistry;
35
+ // ─── Run (streaming) ──────────────────────────────────────────────────────
36
+ /**
37
+ * Track tools recently used in this conversation, so we don't
38
+ * accidentally filter them out mid-workflow. Stores a weak ref
39
+ * set per agent+conversation combo (cleared when the runner
40
+ * instance is GC'd — acceptable since runners are request-scoped).
41
+ */
42
+ this.recentTools = new Map();
35
43
  if (isRunnerProviderConfig(providerOrLegacyConfig)) {
36
44
  this.provider = providerOrLegacyConfig.provider;
37
45
  this.defaultModel = providerOrLegacyConfig.defaultModel ?? 'claude-opus-4-6';
@@ -112,7 +120,9 @@ class AgentRunnerService {
112
120
  createdAt: new Date(),
113
121
  };
114
122
  }
115
- // ─── Run (streaming) ──────────────────────────────────────────────────────
123
+ getRecentToolsKey(agentId, conversationId) {
124
+ return `${agentId}:${conversationId}`;
125
+ }
116
126
  async *stream(agent, messages, context, overrides) {
117
127
  const messageId = (0, crypto_1.randomUUID)();
118
128
  const baseModel = overrides?.model ?? agent.model ?? this.defaultModel;
@@ -121,7 +131,19 @@ class AgentRunnerService {
121
131
  this.defaultMaxTokens ??
122
132
  4096;
123
133
  const temperature = overrides?.temperature ?? agent.temperature;
124
- const { tools, extras } = this.buildToolList(agent, overrides);
134
+ const { tools: allTools, extras } = this.buildToolList(agent, overrides);
135
+ // ── Contextual tool filtering ─────────────────────────────────────────────
136
+ // Only pass the most relevant tools to the LLM to avoid:
137
+ // (a) Massive system prompts (80+ tools × ~200 tokens = 16K tokens)
138
+ // (b) LLM confusion from an overwhelming tool list
139
+ // AlwaysOn tools + recently used tools are never filtered out.
140
+ const recentKey = this.getRecentToolsKey(agent.id, context.conversationId);
141
+ const recentlyUsed = this.recentTools.get(recentKey) ?? new Set();
142
+ const lastUserMessage = extractLastUserMessage(messages);
143
+ const extraMeta = buildExtraMeta(allTools ?? [], extras);
144
+ const tools = filterToolsByContext(allTools ?? [], extraMeta, lastUserMessage, recentlyUsed);
145
+ this.logger.debug(`[toolFilter] ${(allTools ?? []).length} total → ${tools.length} passed (context: "${lastUserMessage.slice(0, 80)}…")`);
146
+ // ─────────────────────────────────────────────────────────────────────────
125
147
  const systemPrompt = this.buildSystemPrompt(agent, tools, overrides);
126
148
  // Re-shape Anthropic-typed tool schemas to the provider-agnostic
127
149
  // ones. The two shapes are identical today — Anthropic's `Tool`
@@ -202,9 +224,27 @@ class AgentRunnerService {
202
224
  { role: 'assistant', content: finalContent },
203
225
  ];
204
226
  const toolResults = [];
227
+ // Track how many of the requested tools weren't actually
228
+ // registered. When ALL fail this way the agent is stuck in a
229
+ // dead-end loop: tier-translated providers (Groq llama, etc.)
230
+ // sometimes hallucinate tool names that exist in their training
231
+ // data but were never registered in this deployment. Without
232
+ // this counter the agent retries the same dead tools every turn
233
+ // until the conversation falls off the message window.
234
+ let unregisteredCount = 0;
235
+ let toolUseCount = 0;
236
+ // Track recently used tools so the next turn doesn't accidentally
237
+ // filter them out mid-workflow (e.g. user asks a follow-up about
238
+ // the email just fetched).
239
+ const recentKey = this.getRecentToolsKey(agent.id, context.conversationId);
240
+ const seenInThisTurn = new Set();
205
241
  for (const block of finalContent) {
206
242
  if (block.type !== 'tool_use')
207
243
  continue;
244
+ toolUseCount++;
245
+ if (seenInThisTurn.has(block.name))
246
+ continue;
247
+ seenInThisTurn.add(block.name);
208
248
  let output = '';
209
249
  try {
210
250
  output = await this.dispatchTool(block.name, block.input, context, extras);
@@ -216,7 +256,11 @@ class AgentRunnerService {
216
256
  err instanceof tool_approval_gate_1.ToolBlockedError) {
217
257
  throw err;
218
258
  }
219
- output = `Error: ${err instanceof Error ? err.message : String(err)}`;
259
+ const msg = err instanceof Error ? err.message : String(err);
260
+ if (msg.includes('not found in registry')) {
261
+ unregisteredCount++;
262
+ }
263
+ output = `Error: ${msg}`;
220
264
  }
221
265
  yield { type: 'tool_result', toolName: block.name, result: output };
222
266
  toolResults.push({
@@ -231,10 +275,45 @@ class AgentRunnerService {
231
275
  this.logger.warn(`Agent "${agent.id}" reported tool_use but emitted no resolvable tool calls. Closing the turn.`);
232
276
  break;
233
277
  }
278
+ // ALL tool calls in this turn hit "not registered" → the model
279
+ // is hallucinating tools. Append a one-shot system nudge inside
280
+ // the same user turn (Anthropic enforces strict user/assistant
281
+ // alternation; two user blocks in a row would 400) so the next
282
+ // iteration produces a text-only reply instead of looping on
283
+ // the same dead tools forever.
284
+ if (toolUseCount > 0 && unregisteredCount === toolUseCount) {
285
+ this.logger.warn(`Agent "${agent.id}" requested ${toolUseCount} tool(s), none registered — forcing text-only fallback.`);
286
+ currentMessages = [
287
+ ...currentMessages,
288
+ {
289
+ role: 'user',
290
+ content: [
291
+ ...toolResults,
292
+ {
293
+ type: 'text',
294
+ text: 'NOTA DEL SISTEMA: Las herramientas que intentaste usar no están disponibles en este momento. Responde al usuario directamente con la información que ya tienes, sin intentar herramientas adicionales.',
295
+ },
296
+ ],
297
+ },
298
+ ];
299
+ continue;
300
+ }
234
301
  currentMessages = [
235
302
  ...currentMessages,
236
303
  { role: 'user', content: toolResults },
237
304
  ];
305
+ // Persist recently used tools for the next turn's filtering pass.
306
+ const stored = this.recentTools.get(recentKey) ?? new Set();
307
+ for (const name of seenInThisTurn)
308
+ stored.add(name);
309
+ // Keep only the last 10 to avoid unbounded growth.
310
+ if (stored.size > 10) {
311
+ const arr = Array.from(stored).slice(-10);
312
+ this.recentTools.set(recentKey, new Set(arr));
313
+ }
314
+ else {
315
+ this.recentTools.set(recentKey, stored);
316
+ }
238
317
  }
239
318
  else {
240
319
  break;
@@ -303,10 +382,10 @@ class AgentRunnerService {
303
382
  * for example. The shadowing only lasts for this call.
304
383
  */
305
384
  buildToolList(agent, overrides) {
306
- // Always invoke the registry even when the agent has no whitelist
307
- // because alwaysOn tools (e.g. `current_time`) get folded in there and
308
- // need to reach an agent whose `tools[]` is empty.
309
- const fromRegistry = this.toolRegistry.getToolsForAgent(agent.id, agent.tools ?? []);
385
+ // Build the extras map first so we can tell the registry which names
386
+ // are satisfied elsewhere keeps it from warning about connector tools
387
+ // it legitimately doesn't know about (they live in the host's
388
+ // tenant-scoped connector registry, not the global ToolRegistry).
310
389
  const allowed = new Set(agent.tools ?? []);
311
390
  const extras = new Map();
312
391
  const extrasSchema = [];
@@ -320,6 +399,10 @@ class AgentRunnerService {
320
399
  input_schema: t.inputSchema,
321
400
  });
322
401
  }
402
+ // Always invoke the registry — even when the agent has no whitelist —
403
+ // because alwaysOn tools (e.g. `current_time`) get folded in there and
404
+ // need to reach an agent whose `tools[]` is empty.
405
+ const fromRegistry = this.toolRegistry.getToolsForAgent(agent.id, agent.tools ?? [], new Set(extras.keys()));
323
406
  const tools = [...fromRegistry, ...extrasSchema];
324
407
  return { tools: tools.length ? tools : undefined, extras };
325
408
  }
@@ -396,6 +479,153 @@ function hasApprovalGatedTool(agent) {
396
479
  }
397
480
  return false;
398
481
  }
482
+ const MAX_TOOLS_PER_CATEGORY = 8;
483
+ const MAX_TOTAL_TOOLS = 20;
484
+ const ALWAYS_ON_TOOLS = new Set(['current_time', 'noop', 'http_request']);
485
+ function extractTopicWords(message) {
486
+ const lower = message.toLowerCase();
487
+ const words = lower.split(/[\s.,;:!?()\[\]{}'"<>]+/).filter(Boolean);
488
+ const stopWords = new Set([
489
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
490
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
491
+ 'should', 'may', 'might', 'must', 'can', 'to', 'of', 'in', 'for',
492
+ 'on', 'with', 'at', 'by', 'from', 'as', 'or', 'and', 'it', 'its',
493
+ 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'we',
494
+ 'they', 'what', 'which', 'who', 'how', 'when', 'where', 'why',
495
+ 'not', 'but', 'if', 'then', 'so', 'just', 'about', 'my', 'your',
496
+ 'our', 'their', 'me', 'him', 'her', 'us', 'them', 'want', 'need',
497
+ 'like', 'know', 'think', 'use', 'using', 'thanks', 'please', 'help',
498
+ 'hi', 'hello', 'hey', 'de', 'la', 'el', 'los', 'las', 'un', 'una',
499
+ 'y', 'en', 'que', 'es', 'por', 'con', 'para', 'del', 'al', 'se',
500
+ ]);
501
+ const topics = new Set();
502
+ for (const word of words) {
503
+ if (word.length < 3 || stopWords.has(word))
504
+ continue;
505
+ topics.add(word);
506
+ // Add bigrams (two-word phrases like "google calendar")
507
+ const idx = words.indexOf(word);
508
+ if (idx < words.length - 1) {
509
+ const next = words[idx + 1];
510
+ if (next.length >= 2 && !stopWords.has(next)) {
511
+ topics.add(`${word}_${next}`);
512
+ }
513
+ }
514
+ }
515
+ return topics;
516
+ }
517
+ function scoreTool(tool, topics) {
518
+ if (ALWAYS_ON_TOOLS.has(tool.name))
519
+ return 100;
520
+ if (tool.alwaysOn)
521
+ return 80;
522
+ let score = 0;
523
+ const name = tool.name.toLowerCase();
524
+ const desc = tool.description.toLowerCase();
525
+ for (const topic of topics) {
526
+ const clean = topic.replace(/_/g, ' ');
527
+ if (name.includes(clean))
528
+ score += 10;
529
+ if (desc.includes(clean))
530
+ score += 3;
531
+ if (clean.includes(name))
532
+ score += 5;
533
+ // Partial matches for connector names (e.g. "calendar" in "google calendar")
534
+ if (tool.connectorName) {
535
+ const cn = tool.connectorName.toLowerCase();
536
+ if (cn.includes(clean) || clean.includes(cn))
537
+ score += 8;
538
+ }
539
+ }
540
+ return score;
541
+ }
542
+ function filterToolsByContext(tools, extraMeta, lastUserMessage, recentlyUsedTools) {
543
+ if (tools.length <= MAX_TOTAL_TOOLS)
544
+ return tools;
545
+ const topics = extractTopicWords(lastUserMessage);
546
+ // If the message is too generic (< 3 meaningful topics), skip filtering
547
+ // to avoid accidentally hiding relevant tools.
548
+ if (topics.size < 3)
549
+ return tools;
550
+ const scored = tools.map((t) => {
551
+ const meta = extraMeta.get(t.name) ?? {
552
+ name: t.name,
553
+ description: t.description ?? '',
554
+ alwaysOn: false,
555
+ };
556
+ let score = scoreTool(meta, topics);
557
+ // Boost recently used tools so multi-step workflows aren't disrupted
558
+ if (recentlyUsedTools.has(t.name))
559
+ score += 15;
560
+ return { tool: t, score, connectorId: meta.connectorId ?? '__global' };
561
+ });
562
+ // Sort by score descending
563
+ scored.sort((a, b) => b.score - a.score);
564
+ // Always-on tools always make it through
565
+ const alwaysOn = scored.filter((s) => s.score >= 80);
566
+ const rest = scored.filter((s) => s.score < 80);
567
+ // Budget: MAX_TOTAL_TOOLS, with per-category cap
568
+ const result = [...alwaysOn];
569
+ const categoryCount = new Map();
570
+ for (const item of rest) {
571
+ if (result.length >= MAX_TOTAL_TOOLS)
572
+ break;
573
+ const cat = item.connectorId ?? '__global';
574
+ const count = categoryCount.get(cat) ?? 0;
575
+ if (count >= MAX_TOOLS_PER_CATEGORY)
576
+ continue;
577
+ categoryCount.set(cat, count + 1);
578
+ result.push(item);
579
+ }
580
+ return result.map((s) => s.tool);
581
+ }
582
+ /**
583
+ * Pull the most recent user text from the message history.
584
+ * Used by `filterToolsByContext` to score tools for relevance.
585
+ */
586
+ function extractLastUserMessage(messages) {
587
+ for (let i = messages.length - 1; i >= 0; i--) {
588
+ const msg = messages[i];
589
+ if (msg.role !== 'user')
590
+ continue;
591
+ if (typeof msg.content === 'string')
592
+ return msg.content;
593
+ for (let j = msg.content.length - 1; j >= 0; j--) {
594
+ const block = msg.content[j];
595
+ if (block?.type === 'text' && typeof block.text === 'string') {
596
+ return block.text;
597
+ }
598
+ }
599
+ }
600
+ return '';
601
+ }
602
+ /**
603
+ * Build a name → scoring metadata map for all tools.
604
+ * Merges data from both the registry tools and the extras map so
605
+ * connector tools (which live in extras) also get scored correctly.
606
+ */
607
+ function buildExtraMeta(registryTools, extras) {
608
+ const map = new Map();
609
+ for (const t of registryTools) {
610
+ map.set(t.name, {
611
+ name: t.name,
612
+ description: t.description ?? '',
613
+ alwaysOn: false,
614
+ connectorId: t.connectorId,
615
+ connectorName: t.connectorName,
616
+ });
617
+ }
618
+ for (const [name, def] of extras) {
619
+ map.set(name, {
620
+ name: def.name,
621
+ description: def.description ?? '',
622
+ alwaysOn: false,
623
+ connectorId: def.connectorId,
624
+ connectorName: def.connectorName,
625
+ });
626
+ }
627
+ return map;
628
+ }
399
629
  // ─── Message-shape translation ───────────────────────────────────────────────
400
630
  /**
401
631
  * The runner's public API still accepts `AnthropicMessage[]` (kept for
@@ -387,6 +387,8 @@ class AgentService {
387
387
  }
388
388
  // ─── Streaming ────────────────────────────────────────────────────────────
389
389
  async *streamMessage(params) {
390
+ // eslint-disable-next-line no-console
391
+ console.log(`[chunk-debug] streamMessage ENTER conv=${params.conversationId.slice(0, 8)}`);
390
392
  const history = await this.conversations.getAnthropicMessages(params.conversationId, params.userId);
391
393
  const conv = await this.conversations.ensureOwned(params.conversationId, params.userId);
392
394
  if (conv.status === 'completed') {
@@ -448,17 +450,33 @@ class AgentService {
448
450
  // history have nothing to rehydrate. FIFO matching mirrors the
449
451
  // runner's heuristic so the shape stays consistent whether the
450
452
  // turn streamed or used `run()`.
453
+ // eslint-disable-next-line no-console
454
+ console.log(`[chunk-debug] streamMessage about to iterate stream (useOrchestrator=${useOrchestrator}, extraTools=${extraTools?.length ?? 0})`);
455
+ let chunkCount = 0;
451
456
  for await (const chunk of stream) {
457
+ chunkCount += 1;
458
+ if (chunkCount <= 3 || chunk.type === 'tool_use_start') {
459
+ // eslint-disable-next-line no-console
460
+ console.log(`[chunk-debug] chunk #${chunkCount} type=${chunk.type}`);
461
+ }
452
462
  if (chunk.type === 'text_delta')
453
463
  fullContent += chunk.delta;
454
464
  if (chunk.type === 'usage')
455
465
  finalUsage = chunk.usage;
456
466
  if (chunk.type === 'tool_use_start') {
467
+ // eslint-disable-next-line no-console
468
+ console.log(`[chunk-debug] tool_use_start: name=${chunk.toolName} useId=${chunk.toolUseId}`);
457
469
  toolCallStartByUseId.set(chunk.toolUseId, {
458
470
  name: chunk.toolName,
459
471
  start: Date.now(),
460
472
  });
461
473
  }
474
+ if (chunk.type === 'text_delta') {
475
+ if (chunk.delta) {
476
+ // eslint-disable-next-line no-console
477
+ console.log(`[chunk-debug] text_delta: ${JSON.stringify(chunk.delta).slice(0, 200)}`);
478
+ }
479
+ }
462
480
  if (chunk.type === 'tool_result') {
463
481
  // Match by toolUseId when present; otherwise FIFO on toolName
464
482
  // (matches AgentRunner.run's heuristic). Older runners that
@@ -492,8 +510,12 @@ class AgentService {
492
510
  }
493
511
  yield chunk;
494
512
  }
513
+ // eslint-disable-next-line no-console
514
+ console.log(`[chunk-debug] streamMessage LOOP DONE chunks=${chunkCount} fullContentLen=${fullContent.length}`);
495
515
  }
496
516
  catch (err) {
517
+ // eslint-disable-next-line no-console
518
+ console.log(`[chunk-debug] streamMessage CATCH err=${err.message?.slice(0, 200) ?? '(no msg)'} type=${err.constructor?.name ?? 'Unknown'}`);
497
519
  // Tool-approval gate decisions surface as typed exceptions. We
498
520
  // intercept them HERE so the SSE consumer sees a structured
499
521
  // chunk instead of a torn-down generator. Each branch also
@@ -323,6 +323,7 @@ class ConnectorRegistryService {
323
323
  }
324
324
  synthesizeTools(authed, contextUserId) {
325
325
  const tools = [];
326
+ const seen = new Set();
326
327
  for (const a of authed) {
327
328
  if (!a.isActive)
328
329
  continue;
@@ -339,8 +340,15 @@ class ConnectorRegistryService {
339
340
  userId: contextUserId,
340
341
  connectorId: a.connectorId,
341
342
  getAccessToken: () => this.getAccessToken(a),
343
+ metadata: a.metadata,
342
344
  };
343
345
  for (const factory of def.tools) {
346
+ // Deduplicate by tool name: if multiple connectors expose the same
347
+ // tool (e.g. both `google` combo AND `google-gmail` are authorized),
348
+ // only keep the first occurrence. The LLM rejects duplicate tool names.
349
+ if (seen.has(factory.definition.name))
350
+ continue;
351
+ seen.add(factory.definition.name);
344
352
  tools.push({ ...factory.definition, execute: factory.build(ctx) });
345
353
  }
346
354
  }
@@ -23,7 +23,7 @@ export declare class ToolRegistryService {
23
23
  * actually removed — false when the name was unknown. Used by
24
24
  * McpClientService to clean up tools when an MCP server is unregistered. */
25
25
  unregister(name: string): boolean;
26
- getToolsForAgent(agentId: string, toolNames: string[]): AnthropicTool[];
26
+ getToolsForAgent(agentId: string, toolNames: string[], knownElsewhere?: ReadonlySet<string>): AnthropicTool[];
27
27
  execute(toolName: string, input: Record<string, unknown>, context: ToolExecutionContext): Promise<string>;
28
28
  has(name: string): boolean;
29
29
  list(): string[];
@@ -43,7 +43,7 @@ class ToolRegistryService {
43
43
  this.logger.debug(`Unregistered tool: ${name}`);
44
44
  return existed;
45
45
  }
46
- getToolsForAgent(agentId, toolNames) {
46
+ getToolsForAgent(agentId, toolNames, knownElsewhere) {
47
47
  // Always-on tools (e.g. `current_time`) are offered to every agent
48
48
  // regardless of the whitelist — they're runtime essentials the model
49
49
  // needs to answer correctly. Per-agent allowlists still apply.
@@ -59,7 +59,13 @@ class ToolRegistryService {
59
59
  .filter((name) => {
60
60
  const tool = this.tools.get(name);
61
61
  if (!tool) {
62
- this.logger.warn(`Tool "${name}" requested by agent "${agentId}" is not registered`);
62
+ // Connector tools live in the host's connector registry, not this
63
+ // global one — the runner injects them via `extraTools`. When the
64
+ // caller tells us which names will be satisfied by extras, skip
65
+ // the warning for those; they're not really "missing".
66
+ if (!knownElsewhere?.has(name)) {
67
+ this.logger.warn(`Tool "${name}" requested by agent "${agentId}" is not registered`);
68
+ }
63
69
  return false;
64
70
  }
65
71
  if (tool.agents && tool.agents.length > 0 && !tool.agents.includes(agentId)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
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",