Skip to content

Commit c03592d

Browse files
Alexey Panfilovclaude
andcommitted
feat: vector similarity tool filtering via Gemini embeddings
At startup, embeddings are computed for all MCP tools using gemini-embedding-001 and cached in memory. Each request embeds the user query and selects top-K most relevant tools via cosine similarity, reducing LLM context size without keyword maintenance. Falls back to all tools if embeddings unavailable or top_k=0. Configured via models.embedding and tool_filter.top_k in config.yaml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cc369f8 commit c03592d

8 files changed

Lines changed: 223 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ flowchart LR
5252
- Connects on startup (`initialize``tools/list`). Supports JSON and SSE responses.
5353
- Per-server tool filtering: `allowTools` (allowlist) checked after `denyTools` (blocklist)
5454
- Auth via generic `headers` map (Claude Desktop format)
55+
- **Vector tool filtering**: `EnableEmbeddings(apiKey, model, topK)` + `EmbedTools(ctx)` at startup computes Gemini embeddings for all tools; `LLMToolsForQuery(ctx, query)` returns top-K most relevant tools per request via cosine similarity. Falls back to all tools if embeddings unavailable or query is empty.
5556

5657
**`internal/agent`** — agentic loop.
5758
- `Process(ctx, chatID, llm.Message, onToolCall)` — prepends `"Current date and time: ..."` to the system prompt on every call using `time.Now()` (respects `TZ` env var set in Docker)
@@ -68,7 +69,7 @@ flowchart LR
6869
| File | Purpose |
6970
|---|---|
7071
| `.env` | Secrets: `TELEGRAM_BOT_TOKEN`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `TELEGRAM_OWNER_CHAT_ID`, `TZ` (default `Europe/Belgrade`) — auto-loaded by Docker Compose from project root |
71-
| `config/config.yaml` | Models (all require `base_url`), routing, Telegram IDs — `${ENV_VAR}` substitution |
72+
| `config/config.yaml` | Models (all require `base_url`; `embedding` model is exception — no `base_url`/`max_tokens`), routing, tool_filter, Telegram IDs — `${ENV_VAR}` substitution |
7273
| `config/mcp.json` | MCP servers in Claude Desktop format — `allowTools`, `denyTools` per server |
7374
| `config/system_prompt.md` | System prompt injected on every LLM request |
7475

@@ -81,6 +82,15 @@ Paths are hardcoded in `main.go` as `config/config.yaml`, `config/system_prompt.
8182
3. **Primary** (DeepSeek Chat) — default
8283
4. **Fallback** (Gemini 3.1 Flash Lite) — primary returns 5xx/429/network error
8384

85+
### Tool filtering (vector similarity)
86+
87+
Configured via `tool_filter.top_k` in `config.yaml` and `models.embedding` (Gemini `gemini-embedding-001`).
88+
89+
- At startup: embeddings computed for all tools (`name + ": " + description`) and cached in memory
90+
- Per request: user message embedded → cosine similarity → top-K tools sent to LLM
91+
- Fallback to all tools if: embeddings not ready, `top_k=0`, `top_k >= total tools`, or embed API error
92+
- `top_k: 0` disables filtering entirely
93+
8494
### SQLite schema notes
8595

8696
Flag columns: `is_reset`, `is_compacted`, `is_summary`, `parts` (JSON). Queries always filter by `id > lastResetID`. `GetHistory` returns last 30 non-compacted messages. `ALTER TABLE ADD COLUMN parts` runs at startup for migration of existing DBs.

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A lightweight Telegram bot that acts as a personal AI assistant. Written in Go
88
- **Image support** — send a photo (with or without caption) and it's routed automatically to the vision model
99
- **Forwarded messages** — forward any message (text, photo, link) to the bot, then ask your question; messages arriving within 2 s are batched automatically
1010
- **Link extraction** — hidden hyperlinks (`text_link` entities) in forwarded messages are surfaced as plain URLs for the LLM
11-
- **MCP tool support** — connects to any MCP-compatible server (HTTP/SSE), same `mcp.json` format as Claude Desktop; per-server `allowTools`/`denyTools` filtering
11+
- **MCP tool support** — connects to any MCP-compatible server (HTTP/SSE), same `mcp.json` format as Claude Desktop; per-server `allowTools`/`denyTools` filtering; vector similarity filtering selects only the most relevant tools per request
1212
- **Persistent memory** — SQLite-backed conversation history with automatic session management
1313
- **Context compaction** — auto-summarises old history to stay within token limits
1414
- **Rich formatting** — Markdown converted to Telegram HTML; responses ≥ 4096 chars sent as `response.md`
@@ -20,7 +20,7 @@ A lightweight Telegram bot that acts as a personal AI assistant. Written in Go
2020
- Go 1.24+ (or Docker)
2121
- [Telegram Bot Token](https://t.me/BotFather)
2222
- [DeepSeek API key](https://platform.deepseek.com)
23-
- Gemini API key (optional — for fallback and image support)
23+
- Gemini API key (optional — for fallback, image support, and tool filtering)
2424

2525
## Quick start (NAS / Pi / server)
2626

@@ -131,6 +131,18 @@ models:
131131
api_key: ${GEMINI_API_KEY}
132132
max_tokens: 4096
133133
base_url: https://generativelanguage.googleapis.com/v1beta/openai/
134+
embedding:
135+
provider: gemini
136+
model: gemini-embedding-001
137+
api_key: ${GEMINI_API_KEY}
138+
139+
routing:
140+
default: default
141+
fallback: flash_lite
142+
compaction_model: default
143+
144+
tool_filter:
145+
top_k: 20 # top-K tools selected per request via vector similarity; 0 = disabled
134146
```
135147
136148
### `config/mcp.json`
@@ -197,6 +209,7 @@ flowchart TD
197209
Router["LLM Router"]
198210
MCP["MCP Client"]
199211
Store[("SQLite")]
212+
Emb["Gemini Embedding\ngemini-embedding-001"]
200213
201214
subgraph LLMs ["LLM Providers"]
202215
DS["DeepSeek Chat"]
@@ -215,7 +228,9 @@ flowchart TD
215228
Handler --> Agent
216229
Agent <--> Store
217230
Agent --> Router
218-
Agent <-->|"tools/call"| MCP
231+
Agent -->|"query embed\ntop-K filter"| MCP
232+
MCP <-->|"embed tools\nat startup"| Emb
233+
MCP <-->|"tools/call"| Servers
219234
Router --> DS
220235
Router --> DSR
221236
Router --> GL

cmd/agent/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ func main() {
9898
initCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
9999
mcpClient.Initialize(initCtx)
100100
cancel()
101+
102+
if cfg.ToolFilter.TopK > 0 && cfg.Models.Embedding.APIKey != "" {
103+
mcpClient.EnableEmbeddings(cfg.Models.Embedding.APIKey, cfg.Models.Embedding.Model, cfg.ToolFilter.TopK)
104+
embedCtx, embedCancel := context.WithTimeout(context.Background(), 60*time.Second)
105+
mcpClient.EmbedTools(embedCtx)
106+
embedCancel()
107+
}
101108
}
102109

103110
ag := agent.New(router, s, mcpClient, compacter, string(sysPromptBytes), logger)

config/config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,15 @@ models:
2929
api_key: ${GEMINI_API_KEY}
3030
max_tokens: 4096
3131
base_url: https://generativelanguage.googleapis.com/v1beta/openai/
32+
embedding:
33+
provider: gemini
34+
model: gemini-embedding-001
35+
api_key: ${GEMINI_API_KEY}
3236

3337
routing:
3438
default: default
3539
fallback: flash_lite
3640
compaction_model: default
41+
42+
tool_filter:
43+
top_k: 20

internal/agent/agent.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"strings"
78
"time"
89

910
"telegram-agent/internal/llm"
@@ -47,7 +48,7 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
4748

4849
var tools []llm.Tool
4950
if a.mcp != nil {
50-
tools = a.mcp.LLMTools()
51+
tools = a.mcp.LLMToolsForQuery(ctx, messageText(userMsg))
5152
}
5253

5354
for i := 0; i < maxToolIterations; i++ {
@@ -131,3 +132,17 @@ func (a *Agent) ListTools() []ToolInfo {
131132
}
132133
return result
133134
}
135+
136+
// messageText extracts plain text from a message (handles both Content and Parts).
137+
func messageText(msg llm.Message) string {
138+
if msg.Content != "" {
139+
return msg.Content
140+
}
141+
var sb strings.Builder
142+
for _, p := range msg.Parts {
143+
if p.Type == "text" {
144+
sb.WriteString(p.Text)
145+
}
146+
}
147+
return sb.String()
148+
}

internal/config/config.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ import (
99
)
1010

1111
type Config struct {
12-
Telegram TelegramConfig `yaml:"telegram"`
13-
Models ModelsConfig `yaml:"models"`
14-
Routing RoutingConfig `yaml:"routing"`
12+
Telegram TelegramConfig `yaml:"telegram"`
13+
Models ModelsConfig `yaml:"models"`
14+
Routing RoutingConfig `yaml:"routing"`
15+
ToolFilter ToolFilterConfig `yaml:"tool_filter"`
16+
}
17+
18+
type ToolFilterConfig struct {
19+
TopK int `yaml:"top_k"` // 0 = disabled
1520
}
1621

1722
type MCPServerConfig struct {
@@ -40,6 +45,7 @@ type ModelsConfig struct {
4045
Reasoner ModelConfig `yaml:"reasoner"`
4146
FlashLite ModelConfig `yaml:"flash_lite"`
4247
Multimodal ModelConfig `yaml:"multimodal"`
48+
Embedding ModelConfig `yaml:"embedding"`
4349
}
4450

4551
type RoutingConfig struct {

internal/mcp/client.go

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"log/slog"
1111
"net/http"
12+
"sort"
1213
"strings"
1314
"sync/atomic"
1415
"time"
@@ -19,10 +20,14 @@ import (
1920

2021
// Client manages connections to multiple MCP servers.
2122
type Client struct {
22-
servers map[string]*server
23-
tools []Tool
24-
toolServers map[string]string // tool name → server name
25-
logger *slog.Logger
23+
servers map[string]*server
24+
tools []Tool
25+
toolServers map[string]string // tool name → server name
26+
logger *slog.Logger
27+
embeddingAPIKey string
28+
embeddingModel string
29+
topK int
30+
embeddingsReady bool
2631
}
2732

2833
type server struct {
@@ -42,6 +47,7 @@ type Tool struct {
4247
Description string
4348
InputSchema json.RawMessage
4449
ServerName string
50+
Embedding []float32
4551
}
4652

4753
func NewClient(configs map[string]config.MCPServerConfig, logger *slog.Logger) *Client {
@@ -102,6 +108,77 @@ func (c *Client) Initialize(ctx context.Context) {
102108
}
103109
}
104110

111+
// EnableEmbeddings configures vector-based tool filtering.
112+
// Must be called before EmbedTools.
113+
func (c *Client) EnableEmbeddings(apiKey, model string, topK int) {
114+
c.embeddingAPIKey = apiKey
115+
c.embeddingModel = model
116+
c.topK = topK
117+
}
118+
119+
// EmbedTools computes and caches embeddings for all tools.
120+
// Should be called once after Initialize.
121+
func (c *Client) EmbedTools(ctx context.Context) {
122+
if c.embeddingAPIKey == "" {
123+
return
124+
}
125+
ok := 0
126+
for i := range c.tools {
127+
text := c.tools[i].Name + ": " + c.tools[i].Description
128+
emb, err := embed(ctx, c.embeddingAPIKey, c.embeddingModel, text)
129+
if err != nil {
130+
c.logger.Warn("failed to embed tool", "tool", c.tools[i].Name, "err", err)
131+
continue
132+
}
133+
c.tools[i].Embedding = emb
134+
ok++
135+
}
136+
if ok == len(c.tools) {
137+
c.embeddingsReady = true
138+
c.logger.Info("tool embeddings ready", "tools", ok)
139+
} else {
140+
c.logger.Warn("some tool embeddings failed, disabling filtering", "ok", ok, "total", len(c.tools))
141+
}
142+
}
143+
144+
// LLMToolsForQuery returns the top-K most relevant tools for the given query.
145+
// Falls back to all tools if embeddings are not ready or topK >= total tools.
146+
func (c *Client) LLMToolsForQuery(ctx context.Context, query string) []llm.Tool {
147+
if !c.embeddingsReady || c.topK <= 0 || c.topK >= len(c.tools) || query == "" {
148+
return c.LLMTools()
149+
}
150+
151+
queryEmb, err := embed(ctx, c.embeddingAPIKey, c.embeddingModel, query)
152+
if err != nil {
153+
c.logger.Warn("query embedding failed, using all tools", "err", err)
154+
return c.LLMTools()
155+
}
156+
157+
type scored struct {
158+
tool Tool
159+
score float64
160+
}
161+
candidates := make([]scored, len(c.tools))
162+
for i, t := range c.tools {
163+
candidates[i] = scored{t, cosineSimilarity(queryEmb, t.Embedding)}
164+
}
165+
sort.Slice(candidates, func(i, j int) bool {
166+
return candidates[i].score > candidates[j].score
167+
})
168+
169+
result := make([]llm.Tool, 0, c.topK)
170+
for i := 0; i < c.topK; i++ {
171+
t := candidates[i].tool
172+
result = append(result, llm.Tool{
173+
Name: t.Name,
174+
Description: t.Description,
175+
InputSchema: t.InputSchema,
176+
})
177+
}
178+
c.logger.Debug("tool filter applied", "query_len", len(query), "selected", len(result), "total", len(c.tools))
179+
return result
180+
}
181+
105182
// LLMTools returns tools in the format expected by the LLM provider.
106183
func (c *Client) LLMTools() []llm.Tool {
107184
result := make([]llm.Tool, 0, len(c.tools))

internal/mcp/embeddings.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package mcp
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"math"
10+
"net/http"
11+
"time"
12+
)
13+
14+
const geminiEmbeddingBaseURL = "https://generativelanguage.googleapis.com/v1beta/models/%s:embedContent"
15+
16+
func embed(ctx context.Context, apiKey, model, text string) ([]float32, error) {
17+
reqBody := map[string]any{
18+
"model": "models/" + model,
19+
"content": map[string]any{
20+
"parts": []map[string]any{{"text": text}},
21+
},
22+
}
23+
body, err := json.Marshal(reqBody)
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
url := fmt.Sprintf(geminiEmbeddingBaseURL, model)
29+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
30+
if err != nil {
31+
return nil, err
32+
}
33+
req.Header.Set("Content-Type", "application/json")
34+
req.Header.Set("x-goog-api-key", apiKey)
35+
36+
client := &http.Client{Timeout: 10 * time.Second}
37+
resp, err := client.Do(req)
38+
if err != nil {
39+
return nil, err
40+
}
41+
defer resp.Body.Close()
42+
43+
if resp.StatusCode != 200 {
44+
bodyBytes, _ := io.ReadAll(resp.Body)
45+
return nil, fmt.Errorf("embedding API returned %d: %s", resp.StatusCode, string(bodyBytes))
46+
}
47+
48+
var result struct {
49+
Embedding struct {
50+
Values []float32 `json:"values"`
51+
} `json:"embedding"`
52+
}
53+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
54+
return nil, err
55+
}
56+
return result.Embedding.Values, nil
57+
}
58+
59+
func cosineSimilarity(a, b []float32) float64 {
60+
if len(a) != len(b) || len(a) == 0 {
61+
return 0
62+
}
63+
var dot, normA, normB float64
64+
for i := range a {
65+
dot += float64(a[i]) * float64(b[i])
66+
normA += float64(a[i]) * float64(a[i])
67+
normB += float64(b[i]) * float64(b[i])
68+
}
69+
denom := math.Sqrt(normA) * math.Sqrt(normB)
70+
if denom == 0 {
71+
return 0
72+
}
73+
return dot / denom
74+
}

0 commit comments

Comments
 (0)