mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 17:06:23 +02:00
Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev
This commit is contained in:
commit
96e2625c6e
15 changed files with 648 additions and 124 deletions
|
|
@ -265,6 +265,9 @@ export class StreamStepMessageBuilder {
|
|||
case "finish-step":
|
||||
this.providerOptions = event.providerOptions;
|
||||
break;
|
||||
case "error":
|
||||
this.flushBuffers();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +281,30 @@ export class StreamStepMessageBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
function formatLlmStreamError(rawError: unknown): string {
|
||||
let name: string | undefined;
|
||||
let responseBody: string | undefined;
|
||||
if (rawError && typeof rawError === "object") {
|
||||
const err = rawError as Record<string, unknown>;
|
||||
const nested = (err.error && typeof err.error === "object") ? err.error as Record<string, unknown> : null;
|
||||
const nameValue = err.name ?? nested?.name;
|
||||
const responseBodyValue = err.responseBody ?? nested?.responseBody;
|
||||
if (nameValue !== undefined) {
|
||||
name = String(nameValue);
|
||||
}
|
||||
if (responseBodyValue !== undefined) {
|
||||
responseBody = String(responseBodyValue);
|
||||
}
|
||||
} else if (typeof rawError === "string") {
|
||||
responseBody = rawError;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (name) lines.push(`name: ${name}`);
|
||||
if (responseBody) lines.push(`responseBody: ${responseBody}`);
|
||||
return lines.length ? lines.join("\n") : "Model stream error";
|
||||
}
|
||||
|
||||
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||
if (id === "copilot" || id === "rowboatx") {
|
||||
return CopilotAgent;
|
||||
|
|
@ -401,6 +428,13 @@ async function buildTools(agent: z.infer<typeof Agent>): Promise<ToolSet> {
|
|||
const tools: ToolSet = {};
|
||||
for (const [name, tool] of Object.entries(agent.tools ?? {})) {
|
||||
try {
|
||||
// Skip builtin tools that declare themselves unavailable
|
||||
if (tool.type === 'builtin') {
|
||||
const builtin = BuiltinTools[tool.name];
|
||||
if (builtin?.isAvailable && !(await builtin.isAvailable())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tools[name] = await mapAgentTool(tool);
|
||||
} catch (error) {
|
||||
console.error(`Error mapping tool ${name}:`, error);
|
||||
|
|
@ -785,6 +819,7 @@ export async function* streamAgent({
|
|||
timeZoneName: 'short'
|
||||
});
|
||||
const instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
model,
|
||||
state.messages,
|
||||
|
|
@ -803,6 +838,16 @@ export async function* streamAgent({
|
|||
event: event,
|
||||
subflow: [],
|
||||
});
|
||||
if (event.type === "error") {
|
||||
streamError = event.error;
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "error",
|
||||
error: streamError,
|
||||
subflow: [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// build and emit final message from agent response
|
||||
|
|
@ -815,6 +860,10 @@ export async function* streamAgent({
|
|||
subflow: [],
|
||||
});
|
||||
|
||||
if (streamError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if there were any ask-human calls, emit those events
|
||||
if (message.content instanceof Array) {
|
||||
for (const part of message.content) {
|
||||
|
|
@ -888,6 +937,12 @@ async function* streamLlm(
|
|||
signal?.throwIfAborted();
|
||||
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
|
||||
switch (event.type) {
|
||||
case "error":
|
||||
yield {
|
||||
type: "error",
|
||||
error: formatLlmStreamError((event as { error?: unknown }).error ?? event),
|
||||
};
|
||||
return;
|
||||
case "reasoning-start":
|
||||
yield {
|
||||
type: "reasoning-start",
|
||||
|
|
@ -938,7 +993,7 @@ async function* streamLlm(
|
|||
};
|
||||
break;
|
||||
default:
|
||||
// console.warn("Unknown event type", event);
|
||||
console.log('unknown stream event:', JSON.stringify(event));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ When a user asks for ANY task that might require external capabilities (web sear
|
|||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
|
||||
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
|
||||
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import organizeFilesSkill from "./organize-files/skill.js";
|
|||
import slackSkill from "./slack/skill.js";
|
||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
import webSearchSkill from "./web-search/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
|
@ -82,6 +83,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
|
||||
content: mcpIntegrationSkill,
|
||||
},
|
||||
{
|
||||
id: "web-search",
|
||||
title: "Web Search",
|
||||
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
|
||||
content: webSearchSkill,
|
||||
},
|
||||
{
|
||||
id: "deletion-guardrails",
|
||||
title: "Deletion Guardrails",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
export const skill = String.raw`
|
||||
# Web Search Skill
|
||||
|
||||
You have access to two search tools for finding information on the internet. Choose the right one based on the user's intent.
|
||||
|
||||
## Tools
|
||||
|
||||
### web-search (Brave Search)
|
||||
Quick, general-purpose web search. Returns titles, URLs, and short descriptions.
|
||||
|
||||
**Best for:**
|
||||
- Quick lookups for things that change ("current price of Bitcoin", "weather in SF")
|
||||
- Current events and breaking news
|
||||
- Finding a specific website or page
|
||||
- Simple questions with direct answers
|
||||
- Checking a fact or date
|
||||
|
||||
### research-search (Exa Search)
|
||||
Deep, research-oriented search. Returns full article text, highlights, and metadata (author, published date).
|
||||
|
||||
**Best for:**
|
||||
- Exploring a topic in depth ("what are the latest advances in CRISPR")
|
||||
- Finding articles, blog posts, papers, and quality sources
|
||||
- Discovering companies, people, or organizations
|
||||
- Research where you need rich context, not just links
|
||||
- When the user says "research", "find articles about", "look into", "deep dive"
|
||||
|
||||
**Category filter:** Use the category parameter when the user's intent clearly maps to one: company, research paper, news, tweet, personal site, financial report, people.
|
||||
|
||||
## How Many Searches to Do
|
||||
|
||||
**CRITICAL: Always start with exactly ONE search call.** Pick the single best tool (\`web-search\` or \`research-search\`) and make one request. Wait for the result before deciding if more searches are needed.
|
||||
|
||||
**NEVER call multiple search tools simultaneously.** No parallel web-search + research-search. No firing off two web-searches at once. Always sequential: one search at a time.
|
||||
|
||||
Only make a follow-up search if:
|
||||
- The first search returned truly uninformative or irrelevant results
|
||||
- The query has clearly distinct sub-topics that the first search couldn't cover (e.g., "compare X and Y" after getting results for X only)
|
||||
- The user explicitly asks you to dig deeper
|
||||
|
||||
One good search is almost always enough. Default to one and stop.
|
||||
|
||||
## Choosing Between the Two
|
||||
|
||||
If both tools are attached, prefer:
|
||||
- \`web-search\` when the user wants a quick answer or specific link
|
||||
- \`research-search\` when the user wants to learn, explore, or gather sources
|
||||
|
||||
If only one is attached, use whichever is available.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -33,6 +33,7 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
|
|||
input: z.any(), // (input, ctx?) => Promise<any>
|
||||
output: z.promise(z.any()),
|
||||
}),
|
||||
isAvailable: z.custom<() => Promise<boolean>>().optional(),
|
||||
}));
|
||||
|
||||
type SlackToolHint = {
|
||||
|
|
@ -1265,4 +1266,215 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Web Search (Brave Search API)
|
||||
// ============================================================================
|
||||
|
||||
'web-search': {
|
||||
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
try {
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
|
||||
try {
|
||||
// Read API key from config
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
// Build query params
|
||||
const resultCount = Math.min(Math.max(count || 5, 1), 20);
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(resultCount),
|
||||
});
|
||||
if (freshness) {
|
||||
params.set('freshness', freshness);
|
||||
}
|
||||
|
||||
const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Subscription-Token': apiKey,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `Brave Search API error (${response.status}): ${body}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
|
||||
};
|
||||
|
||||
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
description: r.description || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
results,
|
||||
count: results.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Research Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'research-search': {
|
||||
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
try {
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
|
||||
const raw = await fs.readFile(exaConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ query, numResults, category }: { query: string; numResults?: number; category?: string }) => {
|
||||
try {
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(exaConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
const resultCount = Math.min(Math.max(numResults || 5, 1), 20);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
query,
|
||||
numResults: resultCount,
|
||||
type: 'auto',
|
||||
contents: {
|
||||
text: { maxCharacters: 1000 },
|
||||
highlights: true,
|
||||
},
|
||||
};
|
||||
if (category) {
|
||||
body.category = category;
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.exa.ai/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `Exa Search API error (${response.status}): ${text}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
publishedDate?: string;
|
||||
author?: string;
|
||||
highlights?: string[];
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const results = (data.results || []).map((r) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
publishedDate: r.publishedDate || '',
|
||||
author: r.author || '',
|
||||
highlights: r.highlights || [],
|
||||
text: r.text || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
results,
|
||||
count: results.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue