@exulu/backend 1.54.0 → 1.56.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/index.cjs +2275 -1330
- package/dist/index.d.cts +8 -30
- package/dist/index.d.ts +8 -30
- package/dist/index.js +2256 -1306
- package/ee/agentic-retrieval/v3/agent-loop.ts +49 -3
- package/ee/agentic-retrieval/v3/classifier.ts +61 -42
- package/ee/agentic-retrieval/v3/context-sampler.ts +10 -1
- package/ee/agentic-retrieval/v3/index.ts +211 -35
- package/ee/agentic-retrieval/v3/session-tools-registry.ts +20 -0
- package/ee/agentic-retrieval/v3/strategies.ts +28 -24
- package/ee/agentic-retrieval/v3/tools.ts +236 -113
- package/ee/agentic-retrieval/v3/trajectory.ts +227 -14
- package/ee/agentic-retrieval/v4/agent-loop.ts +142 -55
- package/ee/agentic-retrieval/v4/context-sampler.ts +79 -0
- package/ee/agentic-retrieval/v4/index.ts +673 -164
- package/ee/agentic-retrieval/v4/types.ts +33 -4
- package/ee/invoke-skills/create-sandbox.ts +119 -0
- package/ee/python/documents/processing/doc_processor.ts +106 -14
- package/package.json +4 -2
- package/ee/agentic-retrieval/ANALYSIS.md +0 -658
- package/ee/agentic-retrieval/index.ts +0 -1109
- package/ee/agentic-retrieval/logs/README.md +0 -198
- package/ee/agentic-retrieval/v2.ts +0 -1628
- package/ee/agentic-retrieval/v4/embed-preprocessor.ts +0 -76
- package/ee/agentic-retrieval/v4/system-prompt.ts +0 -248
- package/ee/agentic-retrieval/v4/tools.ts +0 -241
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import type { ExuluContext } from "@SRC/exulu/context";
|
|
2
|
-
import type { User } from "@EXULU_TYPES/models/user";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Finds embed('text') or embed('text', 'contextId') calls in a SQL string,
|
|
6
|
-
* generates the embedding vectors using the appropriate context's embedder,
|
|
7
|
-
* and substitutes them with ARRAY[...]::vector literals so db.raw() can execute it.
|
|
8
|
-
*
|
|
9
|
-
* Examples:
|
|
10
|
-
* embed('machine learning') → uses first context that has an embedder
|
|
11
|
-
* embed('machine learning', 'ctx1') → uses the embedder from context 'ctx1'
|
|
12
|
-
*/
|
|
13
|
-
export async function preprocessEmbedCalls(
|
|
14
|
-
sql: string,
|
|
15
|
-
contexts: ExuluContext[],
|
|
16
|
-
user?: User,
|
|
17
|
-
role?: string,
|
|
18
|
-
): Promise<string> {
|
|
19
|
-
// Match embed('...') or embed('...', 'contextId')
|
|
20
|
-
// We use a global regex but process matches manually so we can await async calls
|
|
21
|
-
const EMBED_RE = /embed\('((?:[^'\\]|\\.)*)'\s*(?:,\s*'((?:[^'\\]|\\.)*)')?\)/gi;
|
|
22
|
-
|
|
23
|
-
const matches: { fullMatch: string; text: string; contextId?: string; index: number }[] = [];
|
|
24
|
-
|
|
25
|
-
let m: RegExpExecArray | null;
|
|
26
|
-
while ((m = EMBED_RE.exec(sql)) !== null) {
|
|
27
|
-
matches.push({
|
|
28
|
-
fullMatch: m[0],
|
|
29
|
-
text: m[1],
|
|
30
|
-
contextId: m[2] || undefined,
|
|
31
|
-
index: m.index,
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (matches.length === 0) return sql;
|
|
36
|
-
|
|
37
|
-
// Generate all embeddings in parallel
|
|
38
|
-
const substitutions = await Promise.all(
|
|
39
|
-
matches.map(async ({ text, contextId }) => {
|
|
40
|
-
const context = contextId
|
|
41
|
-
? contexts.find((c) => c.id === contextId)
|
|
42
|
-
: contexts.find((c) => c.embedder != null);
|
|
43
|
-
|
|
44
|
-
if (!context?.embedder) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`No embedder available${contextId ? ` for context "${contextId}"` : ""}. ` +
|
|
47
|
-
`Available contexts with embedders: [${contexts.filter((c) => c.embedder).map((c) => c.id).join(", ")}]`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const result = await context.embedder.generateFromQuery(
|
|
52
|
-
context.id,
|
|
53
|
-
text,
|
|
54
|
-
undefined,
|
|
55
|
-
(user as any)?.id,
|
|
56
|
-
role,
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
const vector = result?.chunks?.[0]?.vector;
|
|
60
|
-
if (!vector?.length) {
|
|
61
|
-
throw new Error(`Embedder returned no vector for text: "${text}"`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return `ARRAY[${vector.join(",")}]::vector`;
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
// Replace in reverse order so indices stay valid
|
|
69
|
-
let result = sql;
|
|
70
|
-
for (let i = matches.length - 1; i >= 0; i--) {
|
|
71
|
-
const { fullMatch, index } = matches[i];
|
|
72
|
-
result = result.slice(0, index) + substitutions[i] + result.slice(index + fullMatch.length);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return result;
|
|
76
|
-
}
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
import { getTableName, getChunksTableName, type ExuluContext } from "@SRC/exulu/context";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Builds the system prompt for the V4 observe-infer-act retrieval agent.
|
|
5
|
-
*
|
|
6
|
-
* The prompt includes:
|
|
7
|
-
* 1. The observe-infer-act loop philosophy
|
|
8
|
-
* 2. The full database schema for every available context
|
|
9
|
-
* 3. Common SQL query patterns (keyword, semantic, hybrid, aggregation)
|
|
10
|
-
* 4. Instructions on when/how to use grep for large result sets
|
|
11
|
-
* 5. The standard column alias convention the agent should follow
|
|
12
|
-
*/
|
|
13
|
-
export function buildSystemPrompt(
|
|
14
|
-
contexts: ExuluContext[],
|
|
15
|
-
customInstructions?: string,
|
|
16
|
-
): string {
|
|
17
|
-
const schemaBlock = buildSchemaBlock(contexts);
|
|
18
|
-
const hasEmbedder = contexts.some((c) => c.embedder != null);
|
|
19
|
-
|
|
20
|
-
return `\
|
|
21
|
-
You are a knowledge base retrieval agent. Your job is to find all information relevant to the user's query.
|
|
22
|
-
|
|
23
|
-
## Approach: Observe → Infer → Act
|
|
24
|
-
|
|
25
|
-
Work iteratively:
|
|
26
|
-
1. **Observe** — examine what data you have and what the query asks for
|
|
27
|
-
2. **Infer** — decide what SQL query will best surface relevant information
|
|
28
|
-
3. **Act** — execute the query and study the results
|
|
29
|
-
4. Repeat until you have found sufficient information, then write your final answer.
|
|
30
|
-
|
|
31
|
-
Do NOT guess or hallucinate. If results are empty, try alternative queries (different keywords,
|
|
32
|
-
broader filters, semantic search). Exhaust the available search strategies before concluding
|
|
33
|
-
that no relevant data exists.
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## Database Schema
|
|
38
|
-
|
|
39
|
-
${schemaBlock}
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## Query Patterns
|
|
44
|
-
|
|
45
|
-
### Keyword / Full-Text Search
|
|
46
|
-
\`\`\`sql
|
|
47
|
-
SELECT
|
|
48
|
-
c.id AS chunk_id,
|
|
49
|
-
c.chunk_index,
|
|
50
|
-
c.content AS chunk_content,
|
|
51
|
-
c.metadata,
|
|
52
|
-
c.source AS item_id,
|
|
53
|
-
i.name AS item_name,
|
|
54
|
-
'<context_id>' AS context
|
|
55
|
-
FROM <context_id>_chunks c
|
|
56
|
-
JOIN <context_id>_items i ON c.source = i.id
|
|
57
|
-
WHERE c.fts @@ plainto_tsquery('english', 'your search terms')
|
|
58
|
-
AND (i.archived IS FALSE OR i.archived IS NULL)
|
|
59
|
-
ORDER BY ts_rank(c.fts, plainto_tsquery('english', 'your search terms')) DESC
|
|
60
|
-
LIMIT 20;
|
|
61
|
-
\`\`\`
|
|
62
|
-
|
|
63
|
-
For German text use \`'german'\` instead of \`'english'\`.
|
|
64
|
-
For multi-language, use \`websearch_to_tsquery\` or UNION both languages.
|
|
65
|
-
${
|
|
66
|
-
hasEmbedder
|
|
67
|
-
? `
|
|
68
|
-
### Semantic Search (use embed() helper)
|
|
69
|
-
\`\`\`sql
|
|
70
|
-
SELECT
|
|
71
|
-
c.id AS chunk_id,
|
|
72
|
-
c.chunk_index,
|
|
73
|
-
c.content AS chunk_content,
|
|
74
|
-
c.metadata,
|
|
75
|
-
c.source AS item_id,
|
|
76
|
-
i.name AS item_name,
|
|
77
|
-
'<context_id>' AS context,
|
|
78
|
-
c.embedding <=> embed('your concept here') AS distance
|
|
79
|
-
FROM <context_id>_chunks c
|
|
80
|
-
JOIN <context_id>_items i ON c.source = i.id
|
|
81
|
-
WHERE (i.archived IS FALSE OR i.archived IS NULL)
|
|
82
|
-
ORDER BY distance ASC
|
|
83
|
-
LIMIT 20;
|
|
84
|
-
\`\`\`
|
|
85
|
-
|
|
86
|
-
### Hybrid Search (keyword + semantic combined via RRF)
|
|
87
|
-
\`\`\`sql
|
|
88
|
-
WITH fts AS (
|
|
89
|
-
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank(fts, q) DESC) AS rank
|
|
90
|
-
FROM <context_id>_chunks, plainto_tsquery('english', 'your query') q
|
|
91
|
-
WHERE fts @@ q
|
|
92
|
-
LIMIT 500
|
|
93
|
-
),
|
|
94
|
-
sem AS (
|
|
95
|
-
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> embed('your query') ASC) AS rank
|
|
96
|
-
FROM <context_id>_chunks
|
|
97
|
-
LIMIT 500
|
|
98
|
-
),
|
|
99
|
-
rrf AS (
|
|
100
|
-
SELECT
|
|
101
|
-
COALESCE(fts.id, sem.id) AS id,
|
|
102
|
-
(COALESCE(1.0 / (50 + fts.rank), 0) * 2 + COALESCE(1.0 / (50 + sem.rank), 0)) AS score
|
|
103
|
-
FROM fts FULL OUTER JOIN sem ON fts.id = sem.id
|
|
104
|
-
)
|
|
105
|
-
SELECT
|
|
106
|
-
c.id AS chunk_id,
|
|
107
|
-
c.chunk_index,
|
|
108
|
-
c.content AS chunk_content,
|
|
109
|
-
c.metadata,
|
|
110
|
-
c.source AS item_id,
|
|
111
|
-
i.name AS item_name,
|
|
112
|
-
'<context_id>' AS context,
|
|
113
|
-
rrf.score
|
|
114
|
-
FROM rrf
|
|
115
|
-
JOIN <context_id>_chunks c ON c.id = rrf.id
|
|
116
|
-
JOIN <context_id>_items i ON c.source = i.id
|
|
117
|
-
WHERE (i.archived IS FALSE OR i.archived IS NULL)
|
|
118
|
-
ORDER BY rrf.score DESC
|
|
119
|
-
LIMIT 20;
|
|
120
|
-
\`\`\`
|
|
121
|
-
`
|
|
122
|
-
: `
|
|
123
|
-
Note: No embedder is configured for these contexts. Use keyword/full-text search only.
|
|
124
|
-
`
|
|
125
|
-
}
|
|
126
|
-
### Browse all chunks of a specific document (in order)
|
|
127
|
-
\`\`\`sql
|
|
128
|
-
SELECT
|
|
129
|
-
c.id AS chunk_id,
|
|
130
|
-
c.chunk_index,
|
|
131
|
-
c.content AS chunk_content,
|
|
132
|
-
c.metadata,
|
|
133
|
-
c.source AS item_id,
|
|
134
|
-
i.name AS item_name,
|
|
135
|
-
'<context_id>' AS context
|
|
136
|
-
FROM <context_id>_chunks c
|
|
137
|
-
JOIN <context_id>_items i ON c.source = i.id
|
|
138
|
-
WHERE c.source = '<item_id>'
|
|
139
|
-
ORDER BY c.chunk_index;
|
|
140
|
-
\`\`\`
|
|
141
|
-
|
|
142
|
-
### Count / aggregate
|
|
143
|
-
\`\`\`sql
|
|
144
|
-
SELECT COUNT(*) FROM <context_id>_items WHERE archived IS FALSE;
|
|
145
|
-
SELECT COUNT(*) FROM <context_id>_chunks;
|
|
146
|
-
\`\`\`
|
|
147
|
-
|
|
148
|
-
### Explore item names (when query is about a specific document)
|
|
149
|
-
\`\`\`sql
|
|
150
|
-
SELECT id, name, external_id, "createdAt"
|
|
151
|
-
FROM <context_id>_items
|
|
152
|
-
WHERE (archived IS FALSE OR archived IS NULL)
|
|
153
|
-
AND LOWER(name) LIKE '%keyword%'
|
|
154
|
-
LIMIT 50;
|
|
155
|
-
\`\`\`
|
|
156
|
-
|
|
157
|
-
### Filter by custom metadata on chunks
|
|
158
|
-
\`\`\`sql
|
|
159
|
-
SELECT chunk_id, chunk_content, item_name, context
|
|
160
|
-
FROM ...
|
|
161
|
-
WHERE c.metadata->>'page' = '5'
|
|
162
|
-
OR c.metadata @> '{"category": "finance"}'
|
|
163
|
-
\`\`\`
|
|
164
|
-
|
|
165
|
-
---
|
|
166
|
-
|
|
167
|
-
## Column Alias Convention
|
|
168
|
-
|
|
169
|
-
**Always use these aliases** in queries that return chunks so results are collected correctly:
|
|
170
|
-
|
|
171
|
-
| Alias | Source column |
|
|
172
|
-
|----------------|-------------------------|
|
|
173
|
-
| \`chunk_id\` | \`c.id\` |
|
|
174
|
-
| \`chunk_index\` | \`c.chunk_index\` |
|
|
175
|
-
| \`chunk_content\`| \`c.content\` |
|
|
176
|
-
| \`item_id\` | \`c.source\` |
|
|
177
|
-
| \`item_name\` | \`i.name\` |
|
|
178
|
-
| \`context\` | literal context id string |
|
|
179
|
-
| \`metadata\` | \`c.metadata\` |
|
|
180
|
-
|
|
181
|
-
---
|
|
182
|
-
|
|
183
|
-
## Handling Large Results
|
|
184
|
-
|
|
185
|
-
When execute_query returns a file path (results > 20k chars):
|
|
186
|
-
1. Use \`grep\` with a specific pattern to find relevant sections
|
|
187
|
-
2. Multiple grep calls are fine — narrow down iteratively
|
|
188
|
-
3. Once you know specific \`item_id\` or \`chunk_id\` values, run a targeted SELECT to get full content
|
|
189
|
-
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## Search Strategy
|
|
193
|
-
|
|
194
|
-
- **Start broad**: use keyword or hybrid search with your main terms, LIMIT 30–50
|
|
195
|
-
- **Go deeper**: if results are sparse, try alternative phrasings, synonyms, or semantic search
|
|
196
|
-
- **Drill into documents**: once you find a relevant item, fetch its chunks in order to get full context
|
|
197
|
-
- **Cross-context**: search multiple contexts when the query could span knowledge bases
|
|
198
|
-
- **Aggregate last**: use COUNT queries only for "how many" questions
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
${customInstructions ? `## Additional Instructions\n\n${customInstructions}\n\n---\n` : ""}
|
|
202
|
-
When you have gathered sufficient information, write a clear answer. Do not call any more tools once you have what you need.`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function buildSchemaBlock(contexts: ExuluContext[]): string {
|
|
206
|
-
return contexts
|
|
207
|
-
.map((ctx) => {
|
|
208
|
-
const itemsTable = getTableName(ctx.id);
|
|
209
|
-
const chunksTable = getChunksTableName(ctx.id);
|
|
210
|
-
|
|
211
|
-
const customFields =
|
|
212
|
-
ctx.fields.length > 0
|
|
213
|
-
? ctx.fields.map((f) => ` ${f.name} (${f.type})`).join("\n")
|
|
214
|
-
: " (no custom fields)";
|
|
215
|
-
|
|
216
|
-
const embedderNote = ctx.embedder
|
|
217
|
-
? `Embedder: ${ctx.embedder.name} — semantic search and embed() are available`
|
|
218
|
-
: "No embedder — use keyword search only";
|
|
219
|
-
|
|
220
|
-
return `### Context: "${ctx.name}" (id: \`${ctx.id}\`)
|
|
221
|
-
${ctx.description || ""}
|
|
222
|
-
${embedderNote}
|
|
223
|
-
|
|
224
|
-
**${itemsTable}** — documents / items
|
|
225
|
-
id (uuid, primary key)
|
|
226
|
-
name (text)
|
|
227
|
-
external_id (text, nullable)
|
|
228
|
-
archived (boolean, nullable)
|
|
229
|
-
created_by (integer, nullable)
|
|
230
|
-
rights_mode (text, nullable)
|
|
231
|
-
"createdAt" (timestamp)
|
|
232
|
-
"updatedAt" (timestamp)
|
|
233
|
-
-- Custom fields:
|
|
234
|
-
${customFields}
|
|
235
|
-
|
|
236
|
-
**${chunksTable}** — text chunks (source FK → ${itemsTable}.id)
|
|
237
|
-
id (uuid, primary key)
|
|
238
|
-
source (uuid, FK → ${itemsTable}.id)
|
|
239
|
-
content (text)
|
|
240
|
-
chunk_index (integer)
|
|
241
|
-
fts (tsvector — full-text search index)
|
|
242
|
-
embedding (vector — pgvector, nullable)
|
|
243
|
-
metadata (jsonb, nullable)
|
|
244
|
-
"createdAt" (timestamp)
|
|
245
|
-
"updatedAt" (timestamp)`;
|
|
246
|
-
})
|
|
247
|
-
.join("\n\n");
|
|
248
|
-
}
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs/promises";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import { exec } from "child_process";
|
|
4
|
-
import { promisify } from "util";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { tool } from "ai";
|
|
7
|
-
import { postgresClient } from "@SRC/postgres/client";
|
|
8
|
-
import type { ExuluContext } from "@SRC/exulu/context";
|
|
9
|
-
import type { User } from "@EXULU_TYPES/models/user";
|
|
10
|
-
import { preprocessEmbedCalls } from "./embed-preprocessor";
|
|
11
|
-
import type { ChunkResult } from "./types";
|
|
12
|
-
|
|
13
|
-
const execAsync = promisify(exec);
|
|
14
|
-
|
|
15
|
-
const MAX_INLINE_CHARS = 20_000;
|
|
16
|
-
const MAX_GREP_OUTPUT_CHARS = 5_000;
|
|
17
|
-
|
|
18
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
19
|
-
// SQL safety: only allow read-only statements
|
|
20
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
const WRITE_PATTERN =
|
|
23
|
-
/^\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|VACUUM|ANALYZE|EXPLAIN\s+ANALYZE)\b/i;
|
|
24
|
-
|
|
25
|
-
function assertReadOnly(sql: string): void {
|
|
26
|
-
if (WRITE_PATTERN.test(sql)) {
|
|
27
|
-
throw new Error(
|
|
28
|
-
"Only SELECT queries are allowed. Write operations (INSERT, UPDATE, DELETE, DROP, etc.) are not permitted.",
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
34
|
-
// Chunk harvesting: extract ChunkResult objects from raw SQL result rows
|
|
35
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Tries to interpret a raw DB row as a ChunkResult.
|
|
39
|
-
* The system prompt instructs the agent to use standard aliases, so we look for
|
|
40
|
-
* those first and fall back to common alternative column names.
|
|
41
|
-
*/
|
|
42
|
-
export function rowToChunkResult(row: Record<string, any>): ChunkResult | null {
|
|
43
|
-
const chunkId = row.chunk_id ?? row.id;
|
|
44
|
-
const chunkContent = row.chunk_content ?? row.content;
|
|
45
|
-
const itemId = row.item_id ?? row.source;
|
|
46
|
-
const context = row.context ?? row.context_id;
|
|
47
|
-
const itemName = row.item_name ?? row.name;
|
|
48
|
-
|
|
49
|
-
// Require at minimum a chunk identifier and either content or an item reference
|
|
50
|
-
if (!chunkId || (!chunkContent && !itemId)) return null;
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
item_name: itemName ?? "",
|
|
54
|
-
item_id: itemId ?? "",
|
|
55
|
-
context: context ?? "",
|
|
56
|
-
chunk_id: chunkId,
|
|
57
|
-
chunk_index: row.chunk_index ?? undefined,
|
|
58
|
-
chunk_content: chunkContent ?? undefined,
|
|
59
|
-
metadata: row.metadata ?? row.chunk_metadata ?? undefined,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
-
// Tool factory
|
|
65
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
export type ToolFactoryParams = {
|
|
68
|
-
contexts: ExuluContext[];
|
|
69
|
-
user?: User;
|
|
70
|
-
role?: string;
|
|
71
|
-
sessionDir: string;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
75
|
-
export function createTools(params: ToolFactoryParams) {
|
|
76
|
-
const { contexts, user, role, sessionDir } = params;
|
|
77
|
-
let queryCount = 0;
|
|
78
|
-
|
|
79
|
-
// ── execute_query ────────────────────────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
const execute_query = tool({
|
|
82
|
-
description: `Execute a read-only PostgreSQL SELECT query against the knowledge base.
|
|
83
|
-
|
|
84
|
-
Use this to search, filter, aggregate, and explore content. The database contains items
|
|
85
|
-
and chunks tables for each knowledge base (see schema in the system prompt).
|
|
86
|
-
|
|
87
|
-
Use embed('your text') anywhere in the query to generate a semantic search vector:
|
|
88
|
-
embedding <=> embed('machine learning') AS distance
|
|
89
|
-
|
|
90
|
-
If the result exceeds ${(MAX_INLINE_CHARS / 1000).toFixed(0)}k characters it is saved to a file.
|
|
91
|
-
Use the grep tool to iteratively search the file for relevant information.`,
|
|
92
|
-
inputSchema: z.object({
|
|
93
|
-
sql: z.string().describe("A read-only SELECT (or WITH ... SELECT) PostgreSQL query"),
|
|
94
|
-
}),
|
|
95
|
-
execute: async ({ sql }) => {
|
|
96
|
-
assertReadOnly(sql);
|
|
97
|
-
|
|
98
|
-
let processedSql: string;
|
|
99
|
-
try {
|
|
100
|
-
processedSql = await preprocessEmbedCalls(sql, contexts, user, role);
|
|
101
|
-
} catch (err: any) {
|
|
102
|
-
return JSON.stringify({ error: `embed() preprocessing failed: ${err.message}` });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
let rows: any[];
|
|
106
|
-
try {
|
|
107
|
-
const { db } = await postgresClient();
|
|
108
|
-
const result = await db.raw(processedSql);
|
|
109
|
-
rows = result.rows ?? [];
|
|
110
|
-
} catch (err: any) {
|
|
111
|
-
return JSON.stringify({ error: `Query failed: ${err.message}` });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const json = JSON.stringify(rows, null, 2);
|
|
115
|
-
|
|
116
|
-
if (json.length <= MAX_INLINE_CHARS) {
|
|
117
|
-
return json;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Results are large — store to session dir and tell the agent to grep
|
|
121
|
-
await fs.mkdir(sessionDir, { recursive: true });
|
|
122
|
-
const filename = `query_${++queryCount}.json`;
|
|
123
|
-
const filePath = path.join(sessionDir, filename);
|
|
124
|
-
await fs.writeFile(filePath, json, "utf-8");
|
|
125
|
-
|
|
126
|
-
return JSON.stringify({
|
|
127
|
-
stored: true,
|
|
128
|
-
file: filePath,
|
|
129
|
-
row_count: rows.length,
|
|
130
|
-
message: `Results too large to display (${rows.length} rows, ${(json.length / 1000).toFixed(1)}k chars). Stored at ${filePath}. Use the grep tool to search for relevant information.`,
|
|
131
|
-
grep_hint: `grep -i "keyword" ${filePath}`,
|
|
132
|
-
});
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// ── grep ─────────────────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
const grep = tool({
|
|
139
|
-
description: `Search a stored query result file using grep.
|
|
140
|
-
|
|
141
|
-
Use this after execute_query returns a file path because results were too large.
|
|
142
|
-
Iteratively narrow down the results with multiple grep calls.`,
|
|
143
|
-
inputSchema: z.object({
|
|
144
|
-
pattern: z.string().describe("Regular expression or literal string to search for"),
|
|
145
|
-
file: z.string().describe("Absolute path to the file returned by execute_query"),
|
|
146
|
-
context_lines: z
|
|
147
|
-
.number()
|
|
148
|
-
.int()
|
|
149
|
-
.min(0)
|
|
150
|
-
.max(10)
|
|
151
|
-
.default(2)
|
|
152
|
-
.describe("Number of lines of context to show around each match (default 2)"),
|
|
153
|
-
case_insensitive: z
|
|
154
|
-
.boolean()
|
|
155
|
-
.default(true)
|
|
156
|
-
.describe("Case-insensitive matching (default true)"),
|
|
157
|
-
}),
|
|
158
|
-
execute: async ({ pattern, file, context_lines, case_insensitive }) => {
|
|
159
|
-
// Security: only allow reading from our session directory
|
|
160
|
-
const resolvedFile = path.resolve(file);
|
|
161
|
-
const resolvedSession = path.resolve(sessionDir);
|
|
162
|
-
if (!resolvedFile.startsWith(resolvedSession)) {
|
|
163
|
-
return JSON.stringify({
|
|
164
|
-
error: `Access denied. Only files within the session directory (${sessionDir}) can be searched.`,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Verify file exists
|
|
169
|
-
try {
|
|
170
|
-
await fs.access(resolvedFile);
|
|
171
|
-
} catch {
|
|
172
|
-
return JSON.stringify({ error: `File not found: ${file}` });
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const flags = [
|
|
176
|
-
"-n",
|
|
177
|
-
context_lines > 0 ? `-C${context_lines}` : "",
|
|
178
|
-
case_insensitive ? "-i" : "",
|
|
179
|
-
]
|
|
180
|
-
.filter(Boolean)
|
|
181
|
-
.join(" ");
|
|
182
|
-
|
|
183
|
-
// Escape pattern for shell to prevent injection
|
|
184
|
-
const escapedPattern = pattern.replace(/'/g, `'\\''`);
|
|
185
|
-
const cmd = `grep ${flags} '${escapedPattern}' '${resolvedFile}'`;
|
|
186
|
-
|
|
187
|
-
let output: string;
|
|
188
|
-
try {
|
|
189
|
-
const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 });
|
|
190
|
-
output = stdout;
|
|
191
|
-
} catch (err: any) {
|
|
192
|
-
// grep exits with code 1 when no matches — that's not an error
|
|
193
|
-
if (err.code === 1) {
|
|
194
|
-
return JSON.stringify({ matches: 0, output: "No matches found." });
|
|
195
|
-
}
|
|
196
|
-
return JSON.stringify({ error: `grep failed: ${err.message}` });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (output.length > MAX_GREP_OUTPUT_CHARS) {
|
|
200
|
-
output =
|
|
201
|
-
output.slice(0, MAX_GREP_OUTPUT_CHARS) +
|
|
202
|
-
`\n... (output truncated at ${MAX_GREP_OUTPUT_CHARS} chars — refine your pattern to narrow results)`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const lineCount = output.split("\n").filter(Boolean).length;
|
|
206
|
-
return JSON.stringify({ matches: lineCount, output });
|
|
207
|
-
},
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
return { execute_query, grep };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Harvests ChunkResult objects from all tool results in a step.
|
|
215
|
-
* Called after each agent step to collect any chunk-shaped rows the agent retrieved.
|
|
216
|
-
*/
|
|
217
|
-
export function harvestChunks(toolResults: any[]): ChunkResult[] {
|
|
218
|
-
const chunks: ChunkResult[] = [];
|
|
219
|
-
|
|
220
|
-
for (const result of toolResults ?? []) {
|
|
221
|
-
const rawOutput = result.output ?? result.result;
|
|
222
|
-
let parsed: any;
|
|
223
|
-
try {
|
|
224
|
-
parsed = typeof rawOutput === "string" ? JSON.parse(rawOutput) : rawOutput;
|
|
225
|
-
} catch {
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Array of rows (direct SELECT result)
|
|
230
|
-
if (Array.isArray(parsed)) {
|
|
231
|
-
for (const row of parsed) {
|
|
232
|
-
if (row && typeof row === "object") {
|
|
233
|
-
const chunk = rowToChunkResult(row);
|
|
234
|
-
if (chunk) chunks.push(chunk);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return chunks;
|
|
241
|
-
}
|