feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI (#348)
This commit was merged in pull request #348.
This commit is contained in:
@@ -23,6 +23,7 @@ import { createFileTools } from './tools/file-tools.js';
|
||||
import { createGitTools } from './tools/git-tools.js';
|
||||
import { createShellTools } from './tools/shell-tools.js';
|
||||
import { createWebTools } from './tools/web-tools.js';
|
||||
import { createSearchTools } from './tools/search-tools.js';
|
||||
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||
@@ -146,6 +147,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
...createGitTools(sandboxDir),
|
||||
...createShellTools(sandboxDir),
|
||||
...createWebTools(),
|
||||
...createSearchTools(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -190,5 +190,169 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
},
|
||||
};
|
||||
|
||||
return [readFileTool, writeFileTool, listDirectoryTool];
|
||||
const editFileTool: ToolDefinition = {
|
||||
name: 'fs_edit_file',
|
||||
label: 'Edit File',
|
||||
description:
|
||||
'Make targeted text replacements in a file. Each edit replaces an exact match of oldText with newText. ' +
|
||||
'All edits are matched against the original file content (not incrementally). ' +
|
||||
'Each oldText must be unique in the file and edits must not overlap.',
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: 'File path (relative to sandbox base or absolute within it)',
|
||||
}),
|
||||
edits: Type.Array(
|
||||
Type.Object({
|
||||
oldText: Type.String({
|
||||
description: 'Exact text to find and replace (must be unique in the file)',
|
||||
}),
|
||||
newText: Type.String({ description: 'Replacement text' }),
|
||||
}),
|
||||
{ description: 'One or more targeted replacements', minItems: 1 },
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path, edits } = params as {
|
||||
path: string;
|
||||
edits: Array<{ oldText: string; newText: string }>;
|
||||
};
|
||||
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardPath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(safePath);
|
||||
if (!info.isFile()) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
if (info.size > MAX_READ_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: file too large for editing (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(safePath, { encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate all edits before applying any
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i]!;
|
||||
const occurrences = content.split(edit.oldText).length - 1;
|
||||
if (occurrences === 0) {
|
||||
errors.push(`Edit ${i + 1}: oldText not found in file`);
|
||||
} else if (occurrences > 1) {
|
||||
errors.push(`Edit ${i + 1}: oldText matches ${occurrences} locations (must be unique)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping edits
|
||||
if (errors.length === 0) {
|
||||
const positions = edits.map((edit, i) => ({
|
||||
index: i,
|
||||
start: content.indexOf(edit.oldText),
|
||||
end: content.indexOf(edit.oldText) + edit.oldText.length,
|
||||
}));
|
||||
positions.sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
if (positions[i]!.start < positions[i - 1]!.end) {
|
||||
errors.push(
|
||||
`Edits ${positions[i - 1]!.index + 1} and ${positions[i]!.index + 1} overlap`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Edit validation failed:\n${errors.join('\n')}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply edits: process from end to start to preserve positions
|
||||
const positions = edits.map((edit) => ({
|
||||
edit,
|
||||
start: content.indexOf(edit.oldText),
|
||||
}));
|
||||
positions.sort((a, b) => b.start - a.start); // reverse order
|
||||
|
||||
let result = content;
|
||||
for (const { edit } of positions) {
|
||||
result = result.replace(edit.oldText, edit.newText);
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(result, 'utf8') > MAX_WRITE_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: resulting file too large (limit ${MAX_WRITE_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(safePath, result, { encoding: 'utf8' });
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `File edited successfully: ${path} (${edits.length} edit(s) applied)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [readFileTool, writeFileTool, listDirectoryTool, editFileTool];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export { createBrainTools } from './brain-tools.js';
|
||||
export { createCoordTools } from './coord-tools.js';
|
||||
export { createFileTools } from './file-tools.js';
|
||||
export { createGitTools } from './git-tools.js';
|
||||
export { createSearchTools } from './search-tools.js';
|
||||
export { createShellTools } from './shell-tools.js';
|
||||
export { createWebTools } from './web-tools.js';
|
||||
export { createSkillTools } from './skill-tools.js';
|
||||
|
||||
496
apps/gateway/src/agent/tools/search-tools.ts
Normal file
496
apps/gateway/src/agent/tools/search-tools.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const MAX_RESULTS = 10;
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024; // 256 KB
|
||||
|
||||
// ─── Provider helpers ────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
provider: string;
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLimited(response: Response): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return '';
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.length;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
chunks.push(value.subarray(0, MAX_RESPONSE_BYTES - (total - value.length)));
|
||||
reader.cancel();
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const combined = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(combined);
|
||||
}
|
||||
|
||||
// ─── Brave Search ────────────────────────────────────────────────────────────
|
||||
|
||||
async function searchBrave(query: string, limit: number): Promise<SearchResponse> {
|
||||
const apiKey = process.env['BRAVE_API_KEY'];
|
||||
if (!apiKey) return { provider: 'brave', query, results: [], error: 'BRAVE_API_KEY not set' };
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(Math.min(limit, 20)),
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.search.brave.com/res/v1/web/search?${params}`,
|
||||
{ headers: { 'X-Subscription-Token': apiKey, Accept: 'application/json' } },
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { provider: 'brave', query, results: [], error: `HTTP ${res.status}: ${body}` };
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
web?: { results?: Array<{ title: string; url: string; description: string }> };
|
||||
};
|
||||
const results: SearchResult[] = (data.web?.results ?? []).slice(0, limit).map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.description,
|
||||
}));
|
||||
return { provider: 'brave', query, results };
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: 'brave',
|
||||
query,
|
||||
results: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tavily Search ───────────────────────────────────────────────────────────
|
||||
|
||||
async function searchTavily(query: string, limit: number): Promise<SearchResponse> {
|
||||
const apiKey = process.env['TAVILY_API_KEY'];
|
||||
if (!apiKey) return { provider: 'tavily', query, results: [], error: 'TAVILY_API_KEY not set' };
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
'https://api.tavily.com/search',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
query,
|
||||
max_results: Math.min(limit, 10),
|
||||
include_answer: false,
|
||||
}),
|
||||
},
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { provider: 'tavily', query, results: [], error: `HTTP ${res.status}: ${body}` };
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
results?: Array<{ title: string; url: string; content: string }>;
|
||||
};
|
||||
const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.content,
|
||||
}));
|
||||
return { provider: 'tavily', query, results };
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: 'tavily',
|
||||
query,
|
||||
results: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SearXNG (self-hosted) ───────────────────────────────────────────────────
|
||||
|
||||
async function searchSearxng(query: string, limit: number): Promise<SearchResponse> {
|
||||
const baseUrl = process.env['SEARXNG_URL'];
|
||||
if (!baseUrl) return { provider: 'searxng', query, results: [], error: 'SEARXNG_URL not set' };
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
pageno: '1',
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`${baseUrl.replace(/\/$/, '')}/search?${params}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { provider: 'searxng', query, results: [], error: `HTTP ${res.status}: ${body}` };
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
results?: Array<{ title: string; url: string; content: string }>;
|
||||
};
|
||||
const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.content,
|
||||
}));
|
||||
return { provider: 'searxng', query, results };
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: 'searxng',
|
||||
query,
|
||||
results: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DuckDuckGo (lite HTML endpoint) ─────────────────────────────────────────
|
||||
|
||||
async function searchDuckDuckGo(query: string, limit: number): Promise<SearchResponse> {
|
||||
try {
|
||||
// Use the DuckDuckGo Instant Answer API (JSON, free, no key)
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
no_html: '1',
|
||||
skip_disambig: '1',
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.duckduckgo.com/?${params}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
provider: 'duckduckgo',
|
||||
query,
|
||||
results: [],
|
||||
error: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
const text = await readLimited(res);
|
||||
const data = JSON.parse(text) as {
|
||||
AbstractText?: string;
|
||||
AbstractURL?: string;
|
||||
AbstractSource?: string;
|
||||
RelatedTopics?: Array<{
|
||||
Text?: string;
|
||||
FirstURL?: string;
|
||||
Result?: string;
|
||||
Topics?: Array<{ Text?: string; FirstURL?: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Main abstract result
|
||||
if (data.AbstractText && data.AbstractURL) {
|
||||
results.push({
|
||||
title: data.AbstractSource ?? 'DuckDuckGo Abstract',
|
||||
url: data.AbstractURL,
|
||||
snippet: data.AbstractText,
|
||||
});
|
||||
}
|
||||
|
||||
// Related topics
|
||||
for (const topic of data.RelatedTopics ?? []) {
|
||||
if (results.length >= limit) break;
|
||||
if (topic.Text && topic.FirstURL) {
|
||||
results.push({
|
||||
title: topic.Text.slice(0, 120),
|
||||
url: topic.FirstURL,
|
||||
snippet: topic.Text,
|
||||
});
|
||||
}
|
||||
// Sub-topics
|
||||
for (const sub of topic.Topics ?? []) {
|
||||
if (results.length >= limit) break;
|
||||
if (sub.Text && sub.FirstURL) {
|
||||
results.push({
|
||||
title: sub.Text.slice(0, 120),
|
||||
url: sub.FirstURL,
|
||||
snippet: sub.Text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { provider: 'duckduckgo', query, results: results.slice(0, limit) };
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: 'duckduckgo',
|
||||
query,
|
||||
results: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider resolution ─────────────────────────────────────────────────────
|
||||
|
||||
type SearchProvider = 'brave' | 'tavily' | 'searxng' | 'duckduckgo' | 'auto';
|
||||
|
||||
function getAvailableProviders(): SearchProvider[] {
|
||||
const available: SearchProvider[] = [];
|
||||
if (process.env['BRAVE_API_KEY']) available.push('brave');
|
||||
if (process.env['TAVILY_API_KEY']) available.push('tavily');
|
||||
if (process.env['SEARXNG_URL']) available.push('searxng');
|
||||
// DuckDuckGo is always available (no API key needed)
|
||||
available.push('duckduckgo');
|
||||
return available;
|
||||
}
|
||||
|
||||
async function executeSearch(
|
||||
provider: SearchProvider,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<SearchResponse> {
|
||||
switch (provider) {
|
||||
case 'brave':
|
||||
return searchBrave(query, limit);
|
||||
case 'tavily':
|
||||
return searchTavily(query, limit);
|
||||
case 'searxng':
|
||||
return searchSearxng(query, limit);
|
||||
case 'duckduckgo':
|
||||
return searchDuckDuckGo(query, limit);
|
||||
case 'auto': {
|
||||
// Try providers in priority order: Brave > Tavily > SearXNG > DuckDuckGo
|
||||
const available = getAvailableProviders();
|
||||
for (const p of available) {
|
||||
const result = await executeSearch(p, query, limit);
|
||||
if (!result.error && result.results.length > 0) return result;
|
||||
}
|
||||
// Fall back to DuckDuckGo if everything failed
|
||||
return searchDuckDuckGo(query, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatSearchResults(response: SearchResponse): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Search provider: ${response.provider}`);
|
||||
lines.push(`Query: "${response.query}"`);
|
||||
|
||||
if (response.error) {
|
||||
lines.push(`Error: ${response.error}`);
|
||||
}
|
||||
|
||||
if (response.results.length === 0) {
|
||||
lines.push('No results found.');
|
||||
} else {
|
||||
lines.push(`Results (${response.results.length}):\n`);
|
||||
for (let i = 0; i < response.results.length; i++) {
|
||||
const r = response.results[i]!;
|
||||
lines.push(`${i + 1}. ${r.title}`);
|
||||
lines.push(` URL: ${r.url}`);
|
||||
lines.push(` ${r.snippet}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── Tool exports ────────────────────────────────────────────────────────────
|
||||
|
||||
export function createSearchTools(): ToolDefinition[] {
|
||||
const webSearch: ToolDefinition = {
|
||||
name: 'web_search',
|
||||
label: 'Web Search',
|
||||
description:
|
||||
'Search the web using configured search providers. ' +
|
||||
'Supports Brave, Tavily, SearXNG, and DuckDuckGo. ' +
|
||||
'Use "auto" provider to pick the best available. ' +
|
||||
'DuckDuckGo is always available as a fallback (no API key needed).',
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: 'Search query' }),
|
||||
provider: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Search provider: "auto" (default), "brave", "tavily", "searxng", or "duckduckgo"',
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ description: `Max results to return (default 5, max ${MAX_RESULTS})` }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { query, provider, limit } = params as {
|
||||
query: string;
|
||||
provider?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const effectiveProvider = (provider ?? 'auto') as SearchProvider;
|
||||
const validProviders = ['auto', 'brave', 'tavily', 'searxng', 'duckduckgo'];
|
||||
if (!validProviders.includes(effectiveProvider)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid provider "${provider}". Valid: ${validProviders.join(', ')}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS);
|
||||
|
||||
try {
|
||||
const response = await executeSearch(effectiveProvider, query, effectiveLimit);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: formatSearchResults(response) }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const webSearchNews: ToolDefinition = {
|
||||
name: 'web_search_news',
|
||||
label: 'Web Search (News)',
|
||||
description:
|
||||
'Search for recent news articles. Uses Brave News API if available, falls back to standard search with news keywords.',
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: 'News search query' }),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ description: `Max results (default 5, max ${MAX_RESULTS})` }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { query, limit } = params as { query: string; limit?: number };
|
||||
const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS);
|
||||
|
||||
// Try Brave News API first (dedicated news endpoint)
|
||||
const braveKey = process.env['BRAVE_API_KEY'];
|
||||
if (braveKey) {
|
||||
try {
|
||||
const newsParams = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(effectiveLimit),
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.search.brave.com/res/v1/news/search?${newsParams}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Subscription-Token': braveKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
results?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
age?: string;
|
||||
}>;
|
||||
};
|
||||
const results: SearchResult[] = (data.results ?? [])
|
||||
.slice(0, effectiveLimit)
|
||||
.map((r) => ({
|
||||
title: r.title + (r.age ? ` (${r.age})` : ''),
|
||||
url: r.url,
|
||||
snippet: r.description,
|
||||
}));
|
||||
const response: SearchResponse = { provider: 'brave-news', query, results };
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: formatSearchResults(response) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to generic search
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: standard search with "news" appended
|
||||
const newsQuery = `${query} news latest`;
|
||||
const response = await executeSearch('auto', newsQuery, effectiveLimit);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: formatSearchResults(response) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const searchProviders: ToolDefinition = {
|
||||
name: 'web_search_providers',
|
||||
label: 'List Search Providers',
|
||||
description: 'List the currently available and configured web search providers.',
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
const available = getAvailableProviders();
|
||||
const allProviders = [
|
||||
{ name: 'brave', configured: !!process.env['BRAVE_API_KEY'], envVar: 'BRAVE_API_KEY' },
|
||||
{ name: 'tavily', configured: !!process.env['TAVILY_API_KEY'], envVar: 'TAVILY_API_KEY' },
|
||||
{ name: 'searxng', configured: !!process.env['SEARXNG_URL'], envVar: 'SEARXNG_URL' },
|
||||
{ name: 'duckduckgo', configured: true, envVar: '(none — always available)' },
|
||||
];
|
||||
|
||||
const lines = ['Search providers:\n'];
|
||||
for (const p of allProviders) {
|
||||
const status = p.configured ? '✓ configured' : '✗ not configured';
|
||||
lines.push(` ${p.name}: ${status} (${p.envVar})`);
|
||||
}
|
||||
lines.push(`\nActive providers for "auto" mode: ${available.join(', ')}`);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: lines.join('\n') }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [webSearch, webSearchNews, searchProviders];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
SlashCommandPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
AbortPayload,
|
||||
} from '@mosaic/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
@@ -325,6 +326,38 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('abort')
|
||||
async handleAbort(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: AbortPayload,
|
||||
): Promise<void> {
|
||||
const conversationId = data.conversationId;
|
||||
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
||||
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
error: 'No active session to abort.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await session.piSession.abort();
|
||||
this.logger.log(`Agent session ${conversationId} aborted successfully`);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to abort session ${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
error: 'Failed to abort the agent operation.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('command:execute')
|
||||
async handleCommandExecute(
|
||||
@ConnectedSocket() client: Socket,
|
||||
|
||||
@@ -82,6 +82,7 @@ function buildService(): CommandExecutorService {
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { ReloadService } from '../reload/reload.service.js';
|
||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
@@ -28,6 +29,9 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
@@ -105,6 +109,8 @@ export class CommandExecutorService {
|
||||
};
|
||||
case 'tools':
|
||||
return await this.handleTools(conversationId, userId);
|
||||
case 'mcp':
|
||||
return await this.handleMcp(args ?? null, conversationId);
|
||||
case 'reload': {
|
||||
if (!this.reloadService) {
|
||||
return {
|
||||
@@ -489,4 +495,92 @@ export class CommandExecutorService {
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleMcp(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!this.mcpClient) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'MCP client service is not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||
|
||||
switch (action) {
|
||||
case 'status':
|
||||
case 'servers': {
|
||||
const statuses = this.mcpClient.getServerStatuses();
|
||||
if (statuses.length === 0) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: true,
|
||||
message:
|
||||
'No MCP servers configured. Set MCP_SERVERS env var to connect external tool servers.',
|
||||
};
|
||||
}
|
||||
const lines = ['MCP Server Status:\n'];
|
||||
for (const s of statuses) {
|
||||
const status = s.connected ? '✓ connected' : '✗ disconnected';
|
||||
lines.push(` ${s.name}: ${status}`);
|
||||
lines.push(` URL: ${s.url}`);
|
||||
lines.push(` Tools: ${s.toolCount}`);
|
||||
if (s.error) lines.push(` Error: ${s.error}`);
|
||||
lines.push('');
|
||||
}
|
||||
const tools = this.mcpClient.getToolDefinitions();
|
||||
if (tools.length > 0) {
|
||||
lines.push(`Total bridged tools: ${tools.length}`);
|
||||
lines.push(`Tool names: ${tools.map((t) => t.name).join(', ')}`);
|
||||
}
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'reconnect': {
|
||||
const serverName = args?.trim().split(/\s+/).slice(1).join(' ');
|
||||
if (!serverName) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'Usage: /mcp reconnect <server-name>',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.mcpClient.reconnectServer(serverName);
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Unknown MCP action: "${action}". Use: /mcp status, /mcp servers, /mcp reconnect <name>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,23 @@ export class CommandRegistryService implements OnModuleInit {
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mcp',
|
||||
description: 'Manage MCP server connections (status/reconnect/servers)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['status', 'reconnect', 'servers'],
|
||||
description: 'Action: status (default), reconnect <name>, servers',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
||||
|
||||
@@ -65,6 +65,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
mockBrain as never,
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
null, // mcpClient (optional)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user