get-claudia 1.32.0 → 1.34.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/gateway/src/bridge.js +276 -8
- package/gateway/src/config.js +8 -1
- package/gateway/src/tools.js +202 -0
- package/gateway/tests/bridge-tooluse.test.js +396 -0
- package/gateway/tests/tools.test.js +192 -0
- package/package.json +1 -1
- package/template-v2/.claude/skills/README.md +3 -2
- package/template-v2/.claude/skills/gateway/SKILL.md +4 -4
- package/template-v2/.claude/skills/setup-gateway.md +215 -0
- package/template-v2/.claude/skills/setup-telegram.md +4 -2
- package/template-v2/CLAUDE.md +1 -0
package/gateway/src/bridge.js
CHANGED
|
@@ -18,6 +18,7 @@ import { existsSync } from 'fs';
|
|
|
18
18
|
import { readFileSync } from 'fs';
|
|
19
19
|
import { createLogger } from './utils/logger.js';
|
|
20
20
|
import { loadPersonality } from './personality.js';
|
|
21
|
+
import { ToolManager } from './tools.js';
|
|
21
22
|
|
|
22
23
|
const log = createLogger('bridge');
|
|
23
24
|
|
|
@@ -45,6 +46,8 @@ export class Bridge {
|
|
|
45
46
|
this.extractor = null; // Set by gateway after construction
|
|
46
47
|
this._consecutiveFailures = 0;
|
|
47
48
|
this._personality = null; // Loaded Claudia personality prompt
|
|
49
|
+
this._toolManager = null; // ToolManager instance (or null if tool_use disabled)
|
|
50
|
+
this._toolUseEnabled = false;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
async start() {
|
|
@@ -97,6 +100,16 @@ export class Bridge {
|
|
|
97
100
|
|
|
98
101
|
// Try to connect to memory daemon via MCP
|
|
99
102
|
await this._connectMemory();
|
|
103
|
+
|
|
104
|
+
// Initialize tool_use if memory is available and tool_use is enabled
|
|
105
|
+
if (this.memoryAvailable && this._isToolUseEnabled()) {
|
|
106
|
+
this._toolManager = new ToolManager();
|
|
107
|
+
await this._toolManager.initialize(this.mcpClient);
|
|
108
|
+
this._toolUseEnabled = this._toolManager.isReady();
|
|
109
|
+
if (this._toolUseEnabled) {
|
|
110
|
+
log.info('Tool use enabled', { toolCount: this._toolManager.toolCount });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
async stop() {
|
|
@@ -127,9 +140,12 @@ export class Bridge {
|
|
|
127
140
|
async processMessage(message, conversationHistory = [], episodeId = null) {
|
|
128
141
|
const { text, userId, userName, channel } = message;
|
|
129
142
|
|
|
130
|
-
|
|
143
|
+
const useTools = this._toolUseEnabled && this._isToolUseEnabled(channel);
|
|
144
|
+
|
|
145
|
+
// 1. Recall relevant memories (skip if tool_use is active and preRecall is off)
|
|
131
146
|
let memoryContext = '';
|
|
132
|
-
|
|
147
|
+
const doPreRecall = this.config.preRecall !== false;
|
|
148
|
+
if (this.memoryAvailable && this._consecutiveFailures < 3 && (doPreRecall || !useTools)) {
|
|
133
149
|
try {
|
|
134
150
|
memoryContext = await this._recallContext(text, userName);
|
|
135
151
|
this._consecutiveFailures = 0;
|
|
@@ -143,7 +159,7 @@ export class Bridge {
|
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
// 2. Build the prompt
|
|
146
|
-
const systemPrompt = this._buildSystemPrompt(memoryContext, userName, channel);
|
|
162
|
+
const systemPrompt = this._buildSystemPrompt(memoryContext, userName, channel, useTools);
|
|
147
163
|
|
|
148
164
|
// 3. Build message history
|
|
149
165
|
const messages = [];
|
|
@@ -155,21 +171,30 @@ export class Bridge {
|
|
|
155
171
|
}
|
|
156
172
|
messages.push({ role: 'user', content: text });
|
|
157
173
|
|
|
158
|
-
// 4. Call LLM (Anthropic or Ollama)
|
|
174
|
+
// 4. Call LLM (Anthropic or Ollama), with or without tool loop
|
|
159
175
|
const resolvedModel = this._resolveModel(channel);
|
|
160
176
|
log.info('Calling LLM', {
|
|
161
177
|
provider: this.provider,
|
|
162
178
|
model: resolvedModel,
|
|
163
179
|
channel,
|
|
164
180
|
messageCount: messages.length,
|
|
181
|
+
toolUse: useTools,
|
|
165
182
|
});
|
|
166
183
|
|
|
167
184
|
let result;
|
|
168
185
|
try {
|
|
169
|
-
if (
|
|
170
|
-
|
|
186
|
+
if (useTools) {
|
|
187
|
+
if (this.provider === 'anthropic') {
|
|
188
|
+
result = await this._callAnthropicWithTools(systemPrompt, messages, resolvedModel, channel);
|
|
189
|
+
} else {
|
|
190
|
+
result = await this._callOllamaWithTools(systemPrompt, messages, resolvedModel, channel);
|
|
191
|
+
}
|
|
171
192
|
} else {
|
|
172
|
-
|
|
193
|
+
if (this.provider === 'anthropic') {
|
|
194
|
+
result = await this._callAnthropic(systemPrompt, messages, resolvedModel);
|
|
195
|
+
} else {
|
|
196
|
+
result = await this._callOllama(systemPrompt, messages, resolvedModel);
|
|
197
|
+
}
|
|
173
198
|
}
|
|
174
199
|
} catch (err) {
|
|
175
200
|
log.error('LLM call failed', { provider: this.provider, error: err.message });
|
|
@@ -409,7 +434,7 @@ export class Bridge {
|
|
|
409
434
|
* 2. Custom systemPromptPath file (backward compat)
|
|
410
435
|
* 3. DEFAULT_SYSTEM_PROMPT fallback
|
|
411
436
|
*/
|
|
412
|
-
_buildSystemPrompt(memoryContext, userName, channel) {
|
|
437
|
+
_buildSystemPrompt(memoryContext, userName, channel, toolUse = false) {
|
|
413
438
|
let prompt;
|
|
414
439
|
|
|
415
440
|
if (this._personality) {
|
|
@@ -435,6 +460,16 @@ export class Bridge {
|
|
|
435
460
|
prompt += `\n\n# Memory Context\n${memoryContext}`;
|
|
436
461
|
}
|
|
437
462
|
|
|
463
|
+
if (toolUse) {
|
|
464
|
+
prompt += `\n\n# Memory Tools
|
|
465
|
+
You have access to memory tools to search, store, and manage your persistent knowledge. Use them naturally during conversation:
|
|
466
|
+
- Search for more context when a name, topic, or project comes up that you don't have enough info on
|
|
467
|
+
- Store new facts when the user mentions something important worth remembering
|
|
468
|
+
- Correct or invalidate memories when the user tells you something is wrong or outdated
|
|
469
|
+
- Trace provenance when asked where you learned something
|
|
470
|
+
Don't announce tool usage -- just use the tools and incorporate results naturally into your response.`;
|
|
471
|
+
}
|
|
472
|
+
|
|
438
473
|
return prompt;
|
|
439
474
|
}
|
|
440
475
|
|
|
@@ -515,6 +550,237 @@ export class Bridge {
|
|
|
515
550
|
throw lastError;
|
|
516
551
|
}
|
|
517
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Check if tool_use is enabled for a given channel.
|
|
555
|
+
* Resolution: per-channel override → global config → auto-detect by provider.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} [channel] - Channel name
|
|
558
|
+
* @returns {boolean}
|
|
559
|
+
*/
|
|
560
|
+
_isToolUseEnabled(channel) {
|
|
561
|
+
// Per-channel override (must be explicit boolean, not undefined)
|
|
562
|
+
if (channel) {
|
|
563
|
+
const channelToolUse = this.config.channels?.[channel]?.toolUse;
|
|
564
|
+
if (typeof channelToolUse === 'boolean') return channelToolUse;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Global config override
|
|
568
|
+
if (typeof this.config.toolUse === 'boolean') return this.config.toolUse;
|
|
569
|
+
|
|
570
|
+
// Auto-detect: enabled for Anthropic, disabled for Ollama
|
|
571
|
+
return this.provider === 'anthropic';
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Call Anthropic API with tool_use loop.
|
|
576
|
+
* Runs up to toolUseMaxIterations rounds, executing tool calls and feeding
|
|
577
|
+
* results back until the model produces a final text response.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} systemPrompt
|
|
580
|
+
* @param {Object[]} messages - Conversation messages (will be mutated with tool results)
|
|
581
|
+
* @param {string} model
|
|
582
|
+
* @param {string} channel
|
|
583
|
+
* @returns {{ text: string, usage: Object }}
|
|
584
|
+
*/
|
|
585
|
+
async _callAnthropicWithTools(systemPrompt, messages, model, channel) {
|
|
586
|
+
const tools = this._toolManager.getAnthropicTools();
|
|
587
|
+
const maxIterations = this.config.toolUseMaxIterations || 5;
|
|
588
|
+
let totalUsage = { input_tokens: 0, output_tokens: 0 };
|
|
589
|
+
|
|
590
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
591
|
+
const response = await this.anthropic.messages.create({
|
|
592
|
+
model,
|
|
593
|
+
max_tokens: this.config.maxTokens || 2048,
|
|
594
|
+
system: systemPrompt,
|
|
595
|
+
messages,
|
|
596
|
+
tools,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Accumulate usage
|
|
600
|
+
if (response.usage) {
|
|
601
|
+
totalUsage.input_tokens += response.usage.input_tokens || 0;
|
|
602
|
+
totalUsage.output_tokens += response.usage.output_tokens || 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// If model didn't request tool use, extract text and return
|
|
606
|
+
if (response.stop_reason !== 'tool_use') {
|
|
607
|
+
const text = response.content
|
|
608
|
+
.filter((block) => block.type === 'text')
|
|
609
|
+
.map((block) => block.text)
|
|
610
|
+
.join('\n');
|
|
611
|
+
return { text, usage: totalUsage };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Process tool calls
|
|
615
|
+
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
616
|
+
const toolResults = [];
|
|
617
|
+
|
|
618
|
+
for (const block of toolUseBlocks) {
|
|
619
|
+
log.debug('Tool call', {
|
|
620
|
+
iteration,
|
|
621
|
+
tool: block.name,
|
|
622
|
+
id: block.id,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const result = await this._executeToolCall(block.name, block.input, channel);
|
|
626
|
+
toolResults.push({
|
|
627
|
+
type: 'tool_result',
|
|
628
|
+
tool_use_id: block.id,
|
|
629
|
+
content: result,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Append assistant response + tool results for next iteration
|
|
634
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
635
|
+
messages.push({ role: 'user', content: toolResults });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Exhausted iterations -- make one final call without tools to force a text response
|
|
639
|
+
log.warn('Tool loop exhausted max iterations', { maxIterations });
|
|
640
|
+
const finalResponse = await this.anthropic.messages.create({
|
|
641
|
+
model,
|
|
642
|
+
max_tokens: this.config.maxTokens || 2048,
|
|
643
|
+
system: systemPrompt,
|
|
644
|
+
messages,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
if (finalResponse.usage) {
|
|
648
|
+
totalUsage.input_tokens += finalResponse.usage.input_tokens || 0;
|
|
649
|
+
totalUsage.output_tokens += finalResponse.usage.output_tokens || 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const text = finalResponse.content
|
|
653
|
+
.filter((block) => block.type === 'text')
|
|
654
|
+
.map((block) => block.text)
|
|
655
|
+
.join('\n');
|
|
656
|
+
|
|
657
|
+
return { text, usage: totalUsage };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Call Ollama /api/chat with tool_use loop.
|
|
662
|
+
*
|
|
663
|
+
* @param {string} systemPrompt
|
|
664
|
+
* @param {Object[]} messages
|
|
665
|
+
* @param {string} model
|
|
666
|
+
* @param {string} channel
|
|
667
|
+
* @returns {{ text: string, usage: null }}
|
|
668
|
+
*/
|
|
669
|
+
async _callOllamaWithTools(systemPrompt, messages, model, channel) {
|
|
670
|
+
const tools = this._toolManager.getOllamaTools();
|
|
671
|
+
const host = this.config.ollama?.host || 'http://localhost:11434';
|
|
672
|
+
const maxIterations = this.config.toolUseMaxIterations || 5;
|
|
673
|
+
|
|
674
|
+
const ollamaMessages = [{ role: 'system', content: systemPrompt }];
|
|
675
|
+
for (const msg of messages) {
|
|
676
|
+
ollamaMessages.push({ role: msg.role, content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
680
|
+
const res = await fetch(`${host}/api/chat`, {
|
|
681
|
+
method: 'POST',
|
|
682
|
+
headers: { 'Content-Type': 'application/json' },
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
model,
|
|
685
|
+
messages: ollamaMessages,
|
|
686
|
+
stream: false,
|
|
687
|
+
tools,
|
|
688
|
+
options: { temperature: 0.7 },
|
|
689
|
+
}),
|
|
690
|
+
signal: AbortSignal.timeout(60000),
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
if (!res.ok) {
|
|
694
|
+
const body = await res.text().catch(() => '');
|
|
695
|
+
throw new Error(`Ollama HTTP ${res.status}: ${body}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const data = await res.json();
|
|
699
|
+
|
|
700
|
+
// If no tool calls, return the text response
|
|
701
|
+
if (!data.message?.tool_calls?.length) {
|
|
702
|
+
return { text: data.message?.content || '', usage: null };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Append assistant message with tool calls
|
|
706
|
+
ollamaMessages.push(data.message);
|
|
707
|
+
|
|
708
|
+
// Execute each tool call and add results
|
|
709
|
+
for (const tc of data.message.tool_calls) {
|
|
710
|
+
const toolName = tc.function?.name;
|
|
711
|
+
const toolInput = tc.function?.arguments || {};
|
|
712
|
+
|
|
713
|
+
log.debug('Ollama tool call', { iteration, tool: toolName });
|
|
714
|
+
|
|
715
|
+
const result = await this._executeToolCall(toolName, toolInput, channel);
|
|
716
|
+
ollamaMessages.push({
|
|
717
|
+
role: 'tool',
|
|
718
|
+
content: result,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Exhausted iterations -- final call without tools
|
|
724
|
+
log.warn('Ollama tool loop exhausted max iterations', { maxIterations });
|
|
725
|
+
const finalRes = await fetch(`${host}/api/chat`, {
|
|
726
|
+
method: 'POST',
|
|
727
|
+
headers: { 'Content-Type': 'application/json' },
|
|
728
|
+
body: JSON.stringify({
|
|
729
|
+
model,
|
|
730
|
+
messages: ollamaMessages,
|
|
731
|
+
stream: false,
|
|
732
|
+
options: { temperature: 0.7 },
|
|
733
|
+
}),
|
|
734
|
+
signal: AbortSignal.timeout(60000),
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
if (!finalRes.ok) {
|
|
738
|
+
const body = await finalRes.text().catch(() => '');
|
|
739
|
+
throw new Error(`Ollama HTTP ${finalRes.status}: ${body}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const finalData = await finalRes.json();
|
|
743
|
+
return { text: finalData.message?.content || '', usage: null };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Execute a single tool call from the LLM against the MCP daemon.
|
|
748
|
+
*
|
|
749
|
+
* Safety: rejects calls to non-exposed tools.
|
|
750
|
+
* Auto-injects source_channel for write operations.
|
|
751
|
+
*
|
|
752
|
+
* @param {string} toolName
|
|
753
|
+
* @param {Object} toolInput
|
|
754
|
+
* @param {string} channel
|
|
755
|
+
* @returns {string} JSON string result (always valid, errors wrapped)
|
|
756
|
+
*/
|
|
757
|
+
async _executeToolCall(toolName, toolInput, channel) {
|
|
758
|
+
// Safety gate: only execute exposed tools
|
|
759
|
+
if (!this._toolManager.isExposed(toolName)) {
|
|
760
|
+
log.warn('LLM attempted to call non-exposed tool', { tool: toolName });
|
|
761
|
+
return JSON.stringify({ error: `Tool "${toolName}" is not available` });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
// Auto-inject source_channel for write operations
|
|
766
|
+
const writeTools = new Set(['memory.remember', 'memory.batch', 'memory.correct']);
|
|
767
|
+
if (writeTools.has(toolName) && channel) {
|
|
768
|
+
toolInput = { ...toolInput, source_channel: channel };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const result = await this.mcpClient.callTool({
|
|
772
|
+
name: toolName,
|
|
773
|
+
arguments: toolInput,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const parsed = this._parseMcpResult(result);
|
|
777
|
+
return JSON.stringify(parsed ?? { ok: true });
|
|
778
|
+
} catch (err) {
|
|
779
|
+
log.warn('Tool execution failed', { tool: toolName, error: err.message });
|
|
780
|
+
return JSON.stringify({ error: err.message });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
518
784
|
/**
|
|
519
785
|
* Connect to the memory daemon via MCP stdio.
|
|
520
786
|
*/
|
|
@@ -575,6 +841,8 @@ export class Bridge {
|
|
|
575
841
|
personalityLoaded: !!this._personality,
|
|
576
842
|
model:
|
|
577
843
|
this.provider === 'ollama' ? this.config.ollama?.model : this.config.model,
|
|
844
|
+
toolUseEnabled: this._toolUseEnabled,
|
|
845
|
+
toolCount: this._toolManager?.toolCount || 0,
|
|
578
846
|
};
|
|
579
847
|
}
|
|
580
848
|
}
|
package/gateway/src/config.js
CHANGED
|
@@ -18,7 +18,7 @@ const PID_PATH = join(CONFIG_DIR, 'gateway.pid');
|
|
|
18
18
|
const DEFAULT_CONFIG = {
|
|
19
19
|
// Anthropic API
|
|
20
20
|
anthropicApiKey: '',
|
|
21
|
-
model: 'claude-
|
|
21
|
+
model: 'claude-haiku-4-5-20251001',
|
|
22
22
|
maxTokens: 2048,
|
|
23
23
|
|
|
24
24
|
// Ollama (local model, auto-detected from ~/.claudia/config.json)
|
|
@@ -27,6 +27,11 @@ const DEFAULT_CONFIG = {
|
|
|
27
27
|
model: '', // Auto-detected from ~/.claudia/config.json language_model field
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
+
// Tool use (LLM-driven memory tool calls)
|
|
31
|
+
toolUse: undefined, // true/false/undefined (undefined = auto: true for Anthropic, false for Ollama)
|
|
32
|
+
toolUseMaxIterations: 5, // Max tool loop rounds per message
|
|
33
|
+
preRecall: true, // Keep programmatic pre-call recall alongside tool_use
|
|
34
|
+
|
|
30
35
|
// Personality
|
|
31
36
|
personalityDir: '', // Path to Claudia template dir (contains CLAUDE.md + .claude/rules/)
|
|
32
37
|
personalityMaxChars: 15000, // Safety limit for prompt size
|
|
@@ -52,6 +57,7 @@ const DEFAULT_CONFIG = {
|
|
|
52
57
|
token: '',
|
|
53
58
|
allowedUsers: [],
|
|
54
59
|
model: '', // Per-channel model override (empty = use global)
|
|
60
|
+
toolUse: undefined, // Per-channel tool_use override (undefined = use global)
|
|
55
61
|
},
|
|
56
62
|
slack: {
|
|
57
63
|
enabled: false,
|
|
@@ -60,6 +66,7 @@ const DEFAULT_CONFIG = {
|
|
|
60
66
|
signingSecret: '',
|
|
61
67
|
allowedUsers: [],
|
|
62
68
|
model: '', // Per-channel model override (empty = use global)
|
|
69
|
+
toolUse: undefined, // Per-channel tool_use override (undefined = use global)
|
|
63
70
|
},
|
|
64
71
|
},
|
|
65
72
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Schema Manager for Claudia Gateway
|
|
3
|
+
*
|
|
4
|
+
* Fetches MCP tool schemas from the memory daemon, filters to a curated
|
|
5
|
+
* subset safe for LLM use, and converts them to Anthropic/Ollama formats.
|
|
6
|
+
*
|
|
7
|
+
* Design: rather than hardcoding 14 tool definitions in JavaScript
|
|
8
|
+
* (duplicating the Python schemas), we fetch at startup, filter to the
|
|
9
|
+
* curated set, and convert the format. When daemon schemas change, the
|
|
10
|
+
* gateway automatically picks them up.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createLogger } from './utils/logger.js';
|
|
14
|
+
|
|
15
|
+
const log = createLogger('tools');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tools exposed to the LLM for agentic use.
|
|
19
|
+
*
|
|
20
|
+
* Criteria for inclusion:
|
|
21
|
+
* - Read-only or safe writes (remember, correct, invalidate)
|
|
22
|
+
* - Useful in a conversational context
|
|
23
|
+
* - No destructive/admin operations
|
|
24
|
+
* - No session lifecycle tools (gateway manages those internally)
|
|
25
|
+
*/
|
|
26
|
+
const EXPOSED_TOOLS = new Set([
|
|
27
|
+
'memory.recall',
|
|
28
|
+
'memory.about',
|
|
29
|
+
'memory.remember',
|
|
30
|
+
'memory.relate',
|
|
31
|
+
'memory.entity',
|
|
32
|
+
'memory.search_entities',
|
|
33
|
+
'memory.batch',
|
|
34
|
+
'memory.correct',
|
|
35
|
+
'memory.invalidate',
|
|
36
|
+
'memory.trace',
|
|
37
|
+
'memory.reflections',
|
|
38
|
+
'memory.project_network',
|
|
39
|
+
'memory.find_path',
|
|
40
|
+
'memory.briefing',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export class ToolManager {
|
|
44
|
+
constructor() {
|
|
45
|
+
this._mcpTools = []; // Raw MCP tool schemas (filtered)
|
|
46
|
+
this._anthropicTools = []; // Converted to Anthropic format
|
|
47
|
+
this._ollamaTools = []; // Converted to Ollama format
|
|
48
|
+
this._initialized = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch tool schemas from the MCP daemon and prepare provider-specific formats.
|
|
53
|
+
*
|
|
54
|
+
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} mcpClient
|
|
55
|
+
*/
|
|
56
|
+
async initialize(mcpClient) {
|
|
57
|
+
try {
|
|
58
|
+
const result = await mcpClient.listTools();
|
|
59
|
+
const allTools = result?.tools || [];
|
|
60
|
+
|
|
61
|
+
// Filter to the curated exposed set
|
|
62
|
+
this._mcpTools = allTools.filter((t) => EXPOSED_TOOLS.has(t.name));
|
|
63
|
+
|
|
64
|
+
// Convert to provider formats
|
|
65
|
+
this._anthropicTools = this._mcpTools.map((t) => this._toAnthropicSchema(t));
|
|
66
|
+
this._ollamaTools = this._mcpTools.map((t) => this._toOllamaSchema(t));
|
|
67
|
+
|
|
68
|
+
this._initialized = true;
|
|
69
|
+
log.info('Tool schemas loaded', {
|
|
70
|
+
total: allTools.length,
|
|
71
|
+
exposed: this._mcpTools.length,
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
log.warn('Failed to load tool schemas', { error: err.message });
|
|
75
|
+
this._mcpTools = [];
|
|
76
|
+
this._anthropicTools = [];
|
|
77
|
+
this._ollamaTools = [];
|
|
78
|
+
this._initialized = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get tools in Anthropic API format.
|
|
84
|
+
* @returns {Object[]} Array of { name, description, input_schema }
|
|
85
|
+
*/
|
|
86
|
+
getAnthropicTools() {
|
|
87
|
+
return this._anthropicTools;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get tools in Ollama API format.
|
|
92
|
+
* @returns {Object[]} Array of { type: 'function', function: { name, description, parameters } }
|
|
93
|
+
*/
|
|
94
|
+
getOllamaTools() {
|
|
95
|
+
return this._ollamaTools;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check whether a tool name is in the exposed set.
|
|
100
|
+
* Used as a safety gate before executing tool calls from the LLM.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} toolName
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
isExposed(toolName) {
|
|
106
|
+
return EXPOSED_TOOLS.has(toolName);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @returns {boolean} Whether initialization succeeded and tools are available
|
|
111
|
+
*/
|
|
112
|
+
isReady() {
|
|
113
|
+
return this._initialized && this._anthropicTools.length > 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @returns {number} Number of exposed tools loaded
|
|
118
|
+
*/
|
|
119
|
+
get toolCount() {
|
|
120
|
+
return this._anthropicTools.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Private conversion methods ---
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convert an MCP tool schema to Anthropic format.
|
|
127
|
+
*
|
|
128
|
+
* MCP: { name, description, inputSchema: { type, properties, required } }
|
|
129
|
+
* Anthropic: { name, description, input_schema: { type, properties, required } }
|
|
130
|
+
*/
|
|
131
|
+
_toAnthropicSchema(mcpTool) {
|
|
132
|
+
const inputSchema = this._normalizeSchema(mcpTool.inputSchema || { type: 'object', properties: {} });
|
|
133
|
+
return {
|
|
134
|
+
name: mcpTool.name,
|
|
135
|
+
description: mcpTool.description || '',
|
|
136
|
+
input_schema: inputSchema,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Convert an MCP tool schema to Ollama format.
|
|
142
|
+
*
|
|
143
|
+
* Ollama: { type: 'function', function: { name, description, parameters } }
|
|
144
|
+
*/
|
|
145
|
+
_toOllamaSchema(mcpTool) {
|
|
146
|
+
const parameters = this._normalizeSchema(mcpTool.inputSchema || { type: 'object', properties: {} });
|
|
147
|
+
return {
|
|
148
|
+
type: 'function',
|
|
149
|
+
function: {
|
|
150
|
+
name: mcpTool.name,
|
|
151
|
+
description: mcpTool.description || '',
|
|
152
|
+
parameters,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Normalize JSON Schema types for provider compatibility.
|
|
159
|
+
*
|
|
160
|
+
* MCP/Python sometimes emits union types like `"type": ["array", "string"]`
|
|
161
|
+
* which aren't valid in Anthropic's tool schema. We pick the first type
|
|
162
|
+
* and note the alternative in the description.
|
|
163
|
+
*
|
|
164
|
+
* @param {Object} schema - JSON Schema object
|
|
165
|
+
* @returns {Object} Normalized schema (deep copy)
|
|
166
|
+
*/
|
|
167
|
+
_normalizeSchema(schema) {
|
|
168
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
169
|
+
|
|
170
|
+
const normalized = Array.isArray(schema) ? [...schema] : { ...schema };
|
|
171
|
+
|
|
172
|
+
// Normalize union types at this level
|
|
173
|
+
if (Array.isArray(normalized.type)) {
|
|
174
|
+
const types = normalized.type.filter((t) => t !== 'null');
|
|
175
|
+
normalized.type = types[0] || 'string';
|
|
176
|
+
if (types.length > 1) {
|
|
177
|
+
const altTypes = types.slice(1).join(', ');
|
|
178
|
+
normalized.description = normalized.description
|
|
179
|
+
? `${normalized.description} (also accepts: ${altTypes})`
|
|
180
|
+
: `Also accepts: ${altTypes}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Recursively normalize nested properties
|
|
185
|
+
if (normalized.properties && typeof normalized.properties === 'object') {
|
|
186
|
+
const normalizedProps = {};
|
|
187
|
+
for (const [key, value] of Object.entries(normalized.properties)) {
|
|
188
|
+
normalizedProps[key] = this._normalizeSchema(value);
|
|
189
|
+
}
|
|
190
|
+
normalized.properties = normalizedProps;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Normalize items schema (for arrays)
|
|
194
|
+
if (normalized.items && typeof normalized.items === 'object') {
|
|
195
|
+
normalized.items = this._normalizeSchema(normalized.items);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return normalized;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { EXPOSED_TOOLS };
|