@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.
- package/dist/domain/connector.d.ts +47 -0
- package/dist/providers/anthropic-provider.js +34 -2
- package/dist/services/agent-runner.service.d.ts +8 -0
- package/dist/services/agent-runner.service.js +237 -7
- package/dist/services/agent.service.js +22 -0
- package/dist/services/connector-registry.service.js +8 -0
- package/dist/services/tool-registry.service.d.ts +1 -1
- package/dist/services/tool-registry.service.js +8 -2
- package/package.json +1 -1
|
@@ -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:
|
|
100
|
+
...(cachedSystem ? { system: cachedSystem } : {}),
|
|
71
101
|
messages: toAnthropicMessages(params.messages),
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|