Skip to content

Commit 745df38

Browse files
author
razvan
committed
feat: SSE tools search improvements, engine updates, skill refinements and functional tests
1 parent aff3c78 commit 745df38

File tree

6 files changed

+916
-179
lines changed

6 files changed

+916
-179
lines changed

internal/service/engine/engine.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log"
77
"path/filepath"
8+
"sort"
89
"strings"
910
"sync"
1011
"sync/atomic"
@@ -359,6 +360,13 @@ func (e *Engine) SearchCode(ctx context.Context, filePath, queryText string, lim
359360

360361
all := append(primaryResults, otherResults...)
361362

363+
// Global sort by score descending across all language results, then cap to limit.
364+
// Without this, primary-language results always win regardless of score.
365+
sort.Slice(all, func(i, j int) bool { return all[i].Score > all[j].Score })
366+
if limit > 0 && len(all) > limit {
367+
all = all[:limit]
368+
}
369+
362370
// If nothing was found and there were errors, surface the error
363371
if len(all) == 0 && firstErr != nil {
364372
return nil, fmt.Errorf("search failed: %w", firstErr)

internal/service/tools/search_local_index.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"sort"
89
"strings"
910
"sync"
1011
"time"
@@ -249,18 +250,14 @@ func (t *SearchLocalIndexTool) Execute(ctx context.Context, params map[string]in
249250
wg.Add(1)
250251
go func(name string) {
251252
defer wg.Done()
252-
// ExactSearch first — zero embedding, deterministic
253+
// ExactSearch only — zero embedding, deterministic.
254+
// No fallback to embedding search: relation names are often stdlib/external
255+
// symbols not in the local index, and each embedding call costs ~N seconds.
253256
res, err := t.engine.SearchByName(ctx, wsID, name, 2)
254257
if err != nil || len(res) == 0 {
255-
// Fallback: embedding search (e.g. external/stdlib symbols not in index)
256-
subRes, sErr := t.engine.SearchCode(ctx, filePath, name, 2, false)
257-
if sErr == nil && subRes != nil {
258-
res = subRes.Results
259-
}
260-
}
261-
if len(res) > 0 {
262-
subChan <- subResult{targetName: name, results: res}
258+
return
263259
}
260+
subChan <- subResult{targetName: name, results: res}
264261
}(targetName)
265262
}
266263

@@ -294,6 +291,14 @@ func (t *SearchLocalIndexTool) Execute(ctx context.Context, params map[string]in
294291
}
295292
}
296293

294+
// Sort all descriptors (primary + graph expansions) by score descending
295+
// so the highest-relevance results are always surfaced first in the response.
296+
sort.Slice(descriptors, func(i, j int) bool {
297+
si, _ := descriptors[i]["score"].(float32)
298+
sj, _ := descriptors[j]["score"].(float32)
299+
return si > sj
300+
})
301+
297302
response.Context.Telemetry = telemetry.CalculateSavings(baselineBytes, actualBytes)
298303
response.Data = descriptors
299304
return response.JSON()

internal/service/tools/tests/search_test.go

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -159,24 +159,18 @@ var _ = Describe("RagSearchCodeTool", func() {
159159
"SearchCodeOnly with expansion limit should not be called when ExactSearch succeeds")
160160
})
161161

162-
It("should fall back to embedding when ExactSearch finds nothing for a dependency", func() {
163-
expansionFallbackCalls := 0
162+
It("should NOT fall back to embedding when ExactSearch finds nothing for a dependency", func() {
163+
// Regression test: graph expansion must NOT trigger embedding calls for
164+
// relations not found in the local index (stdlib/external symbols).
165+
// Each fallback embedding call costs ~N seconds serialized through Ollama.
166+
embeddingCallsForExpansion := 0
164167

165168
mockStore.SearchCodeOnlyFunc = func(ctx context.Context, col string, q storage.SearchQuery) ([]storage.SearchResult, error) {
166169
if q.Limit == 2 {
167-
expansionFallbackCalls++
168-
// Fallback expansion search — return the dependency
169-
return []storage.SearchResult{
170-
{
171-
Score: 0.7,
172-
Point: storage.Point{
173-
ID: "ext-helper-id",
174-
Payload: map[string]interface{}{"name": "ExternalHelper"},
175-
},
176-
},
177-
}, nil
170+
// This would be a fallback expansion embedding — must NOT be called.
171+
embeddingCallsForExpansion++
178172
}
179-
// Main query
173+
// Main query result — has a relation to an external/stdlib symbol
180174
return []storage.SearchResult{
181175
{
182176
Score: 1.0,
@@ -193,7 +187,7 @@ var _ = Describe("RagSearchCodeTool", func() {
193187
}, nil
194188
}
195189

196-
// ExactSearch finds nothing — triggers fallback
190+
// ExactSearch finds nothing — external symbol not in index
197191
mockStore.ExactSearchFunc = func(ctx context.Context, col string, filters map[string]interface{}, limit int) ([]storage.SearchResult, error) {
198192
return []storage.SearchResult{}, nil
199193
}
@@ -204,14 +198,14 @@ var _ = Describe("RagSearchCodeTool", func() {
204198
var resp tools.ToolResponse
205199
json.Unmarshal([]byte(resJSON), &resp)
206200
Expect(resp.Status).To(Equal("success"), "Error: "+resp.Error)
207-
Expect(resp.Message).To(ContainSubstring("Auto-fetched 1 related dependencies"))
208201

202+
// Only root result — dependency was skipped (not in index, no fallback)
209203
data := resp.Data.([]interface{})
210-
Expect(data).To(HaveLen(2))
204+
Expect(data).To(HaveLen(1))
211205

212-
// Fallback was triggered: SearchCodeOnly called with limit=2 at least once
213-
Expect(expansionFallbackCalls).To(BeNumerically(">=", 1),
214-
"Fallback embedding search should be called when ExactSearch returns no results")
206+
// No embedding fallback — critical for performance
207+
Expect(embeddingCallsForExpansion).To(Equal(0),
208+
"Embedding fallback must NOT be called for graph expansion: external/stdlib symbols not in index would cause N×26s latency")
215209
})
216210

217211
It("should deduplicate expansion targets when same dependency appears multiple times in relations", func() {

internal/skills/embedded/ragcode-sse/SKILL.md

Lines changed: 112 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,69 @@ This skill shows any agent how to call RagCode MCP directly via HTTP + Server‑
1212
## 🔌 Endpoints
1313

1414
```
15-
GET /sse # open the event stream (keep-alive)
16-
POST /messages # send JSON-RPC requests
15+
GET /sse # Opens the persistent stream
16+
POST /sse?sessionid=ID # Send JSON-RPC (exact URL from the endpoint event)
1717
```
1818

19-
Set the port with the `-http-port` flag (default `3000`). Example URLs:
19+
### 🔑 Session Handshake
20+
1. Connect to `GET /sse` and keep the connection open.
21+
2. The first SSE event is `event: endpoint`.
22+
3. Its data is the full POST URL, e.g.: `data: /sse?sessionid=H3QCVDP32TP3RBZQ5WLMEJSRF`
23+
4. **Required**: send `initialize` + `notifications/initialized` before any `tools/call`.
24+
5. All responses are delivered back on the open SSE stream.
2025

21-
```
22-
SSE stream : http://localhost:3000/sse
23-
Send message: http://localhost:3000/messages
26+
### 🧾 Bash Script (Tested and working)
27+
```bash
28+
#!/bin/bash
29+
SSE_FILE=$(mktemp)
30+
curl -s -N -H 'Accept: text/event-stream' http://localhost:3000/sse >> "$SSE_FILE" &
31+
SSE_PID=$!
32+
sleep 1
33+
34+
# Extract the POST URL from the endpoint event
35+
ENDPOINT=$(grep 'data:' "$SSE_FILE" | head -1 | sed 's/^data: //' | tr -d '[:space:]')
36+
POST_URL="http://localhost:3000${ENDPOINT}"
37+
38+
# 1. MCP handshake - initialize
39+
curl -s -X POST "$POST_URL" -H 'Content-Type: application/json' -d '{
40+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
41+
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
42+
"clientInfo": {"name": "my-agent", "version": "1.0"}}
43+
}' && sleep 1
44+
45+
# 2. MCP handshake - initialized notification
46+
curl -s -X POST "$POST_URL" -H 'Content-Type: application/json' -d '{
47+
"jsonrpc": "2.0", "method": "notifications/initialized"
48+
}' && sleep 1
49+
50+
# 3. Tool call
51+
curl -s -X POST "$POST_URL" -H 'Content-Type: application/json' -d '{
52+
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
53+
"params": {
54+
"name": "rag_search_code",
55+
"arguments": {
56+
"query": "automatic update implementation",
57+
"file_path": "/your/project/file.go"
58+
}
59+
}
60+
}'
61+
62+
# 4. Read the response from the SSE stream
63+
sleep 12
64+
kill $SSE_PID 2>/dev/null
65+
cat "$SSE_FILE"
66+
rm "$SSE_FILE"
2467
```
2568

2669
---
2770

2871
## 🧠 Protocol Basics
2972

30-
1. Keep a persistent SSE connection to `/sse` (subscribe to events).
31-
2. Send JSON-RPC payloads to `/messages`.
32-
3. Responses arrive asynchronously on the SSE stream (matching `id`).
73+
1. Keep a persistent SSE connection via `GET /sse`.
74+
2. Read the first `event: endpoint` to get the POST URL (`/sse?sessionid=ID`).
75+
3. Send `initialize` + `notifications/initialized` (MCP handshake) before any tool call.
76+
4. Send JSON-RPC payloads via `POST /sse?sessionid=ID`.
77+
5. Responses arrive asynchronously on the SSE stream (match by `id`).
3378

3479
### JSON-RPC Template
3580

@@ -55,16 +100,32 @@ Use other MCP methods such as `tools/list`, `ping`, etc.
55100

56101
## 🧾 curl Quick Start
57102

58-
Open a stream (terminal tab A):
103+
Tab A — open the stream and note the POST URL:
59104

60105
```bash
61-
curl -N http://localhost:3000/sse
106+
curl -N -H 'Accept: text/event-stream' http://localhost:3000/sse
107+
# First output: data: /sse?sessionid=XXXXXX <-- this is your POST_URL
62108
```
63109

64-
Send a message (terminal tab B):
110+
Tab B — full sequence (replace `XXXXXX` with the real sessionid):
65111

66112
```bash
67-
curl -X POST http://localhost:3000/messages \
113+
POST_URL="http://localhost:3000/sse?sessionid=XXXXXX"
114+
115+
# Step 1: initialize
116+
curl -X POST "$POST_URL" -H 'Content-Type: application/json' -d '{
117+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
118+
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
119+
"clientInfo": {"name": "curl-client", "version": "1.0"}}
120+
}' && sleep 1
121+
122+
# Step 2: notifications/initialized
123+
curl -X POST "$POST_URL" -H 'Content-Type: application/json' -d '{
124+
"jsonrpc": "2.0", "method": "notifications/initialized"
125+
}' && sleep 1
126+
127+
# Step 3: tools/call
128+
curl -X POST "$POST_URL" \
68129
-H 'Content-Type: application/json' \
69130
-d '{
70131
"jsonrpc": "2.0",
@@ -87,25 +148,42 @@ Watch the SSE tab for the response.
87148
## 🐍 Python Example
88149

89150
```python
90-
import json, requests, sseclient
151+
import json, threading, requests, sseclient
91152

92-
SSE_URL = "http://localhost:3000/sse"
93-
MSG_URL = "http://localhost:3000/messages"
153+
BASE_URL = "http://localhost:3000"
94154

95-
payload = {
96-
"jsonrpc": "2.0",
97-
"id": "list-tools",
98-
"method": "tools/list",
99-
"params": {}
100-
}
155+
# 1. Open SSE stream and extract POST URL
156+
resp = requests.get(f"{BASE_URL}/sse", stream=True, headers={"Accept": "text/event-stream"})
157+
client = sseclient.SSEClient(resp)
101158

102-
requests.post(MSG_URL, json=payload, timeout=10)
103-
client = sseclient.SSEClient(SSE_URL)
159+
post_url = None
160+
for event in client.events():
161+
if event.event == "endpoint":
162+
post_url = BASE_URL + event.data.strip()
163+
break
164+
165+
def post(payload):
166+
requests.post(post_url, json=payload, timeout=10)
167+
168+
# 2. MCP Handshake
169+
post({"jsonrpc": "2.0", "id": 1, "method": "initialize",
170+
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
171+
"clientInfo": {"name": "py-agent", "version": "1.0"}}})
172+
post({"jsonrpc": "2.0", "method": "notifications/initialized"})
173+
174+
# 3. Tool call
175+
post({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
176+
"params": {"name": "rag_search_code",
177+
"arguments": {"query": "workspace registry",
178+
"file_path": "/your/project/main.py"}}})
179+
180+
# 4. Read responses
104181
for event in client.events():
105-
print(event.data)
182+
if event.data:
183+
print(json.loads(event.data))
106184
```
107185

108-
Any SSE client works; just keep reading events and match IDs.
186+
Any SSE client works; keep the stream open and match responses by `id`.
109187

110188
---
111189

@@ -128,17 +206,19 @@ Any SSE client works; just keep reading events and match IDs.
128206

129207
| Symptom | Fix |
130208
| --- | --- |
131-
| No response | Ensure SSE connection is open; use unique `id`s per request. |
132-
| 4xx on `/messages` | Check JSON validity and `Content-Type: application/json`. |
133-
| Workspace errors | Always pass `arguments.file_path` for context detection. |
209+
| No response | Ensure SSE is open and MCP handshake was done; use unique `id` per request. |
210+
| 4xx on POST | Check JSON and `Content-Type: application/json`; POST goes to `/sse?sessionid=ID`, not `/messages`. |
211+
| `sessionid must be provided` | Extract sessionid using `sed 's/^data: //'` on the `data:` line from the SSE stream. |
212+
| Workspace errors | Always pass an absolute path in `arguments.file_path` for workspace detection. |
134213

135214
---
136215

137216
## ✅ Summary
138217

139-
- **Endpoints**: `GET /sse`, `POST /messages`.
218+
- **Endpoints**: `GET /sse` (stream), `POST /sse?sessionid=ID` (messages).
219+
- **Required handshake**: `initialize``notifications/initialized``tools/call`.
140220
- **Protocol**: JSON-RPC 2.0 (MCP spec).
141-
- **Examples**: Provided for curl and Python.
142-
- **Tool discovery**: `list_tools` response.
221+
- **Examples**: Provided for curl (bash) and Python.
222+
- **Tool discovery**: call `tools/list` after handshake.
143223

144224
Install this skill to teach any agent how to drive RagCode MCP over SSE immediately.

0 commit comments

Comments
 (0)