src / agents / tools / web.ts
src / agents / tools / web.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../../core/errors";
import { boundedPreview, errorResult, okResult } from "../../core/result";
import type { SearchProvider } from "../../research/web";
import type { AgentToolContext } from "./context";
/** `web_search` — a persisted web query. */
export function createWebSearchTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "web_search",
description:
"Search the public web and persist the query/results in the run's research project. Use multiple focused queries and prefer primary sources.",
parameters: {
query: z.string(),
provider: z.enum(["auto", "duckduckgo", "wikipedia", "searxng"]).optional(),
max_results: z.number().int().min(1).max(30).optional(),
},
implementation: async (input: {
query: string;
provider?: SearchProvider;
max_results?: number;
}) => {
await beforeTool("web_search");
if (!state.allowWeb) {
return errorResult(
"research.search",
new AgenticError("PROCESS_DISABLED", "Web research is disabled for this run."),
);
}
try {
const project = await ctx.ensureResearchProject(state);
const result = await ctx.web.search(input.query, {
provider: input.provider ?? ctx.options.defaultSearchProvider,
maxResults: input.max_results ?? 10,
});
await ctx.research.recordSearch(project.id, result);
return okResult({
operation: "research.search",
summary: `Found ${result.results.length} result(s) for '${result.query}'.`,
data: {
researchProjectId: project.id,
query: result.query,
provider: result.provider,
results: result.results,
},
importance: "high",
facts: [
`research project ${project.id}`,
`web query: ${result.query}`,
...result.results.slice(0, 10).map((item) => `${item.title}: ${item.url}`),
],
omit: ["data.results"],
});
} catch (error) {
return errorResult("research.search", error, "high");
}
},
});
}
/** `fetch_source` — a fetched page persisted as a numbered source. */
export function createFetchSourceTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "fetch_source",
description:
"Fetch a public web page, extract readable text, and persist it as a numbered source. Cite the returned source id in findings and final evidence.",
parameters: {
url: z.string(),
max_text_chars: z.number().int().min(1000).max(200000).optional(),
},
implementation: async (input: { url: string; max_text_chars?: number }) => {
await beforeTool("fetch_source");
if (!state.allowWeb) {
return errorResult(
"research.fetch",
new AgenticError("PROCESS_DISABLED", "Web research is disabled for this run."),
);
}
try {
const project = await ctx.ensureResearchProject(state);
const page = await ctx.web.fetchPage(input.url, {
maxTextChars: input.max_text_chars,
});
const recorded = await ctx.research.recordSource(project.id, page);
state.sources = [
...state.sources.filter((item) => item.id !== recorded.source.id),
{
id: recorded.source.id,
url: recorded.source.url,
...(recorded.source.title ? { title: recorded.source.title } : {}),
contentPath: recorded.source.contentPath,
sha256: recorded.source.sha256,
},
].slice(-300);
await ctx.store.save(state);
const preview = boundedPreview(page.text, ctx.options.maxResultChars);
return okResult({
operation: "research.fetch",
summary: `${recorded.source.id}: ${recorded.source.title || recorded.source.url}`,
data: {
researchProjectId: project.id,
sourceId: recorded.source.id,
url: recorded.source.url,
title: recorded.source.title,
fetchedAt: recorded.source.fetchedAt,
sha256: recorded.source.sha256,
bytes: recorded.source.bytes,
truncated: recorded.source.truncated,
contentPath: recorded.source.contentPath,
text: preview.preview,
links: page.links.slice(0, 50),
},
artifacts: [
{
kind: "web_content",
path: recorded.source.contentPath,
sha256: recorded.source.sha256,
bytes: recorded.source.bytes,
description: `Fetched source ${recorded.source.id}: ${recorded.source.url}`,
},
],
importance: "critical",
facts: [
`source ${recorded.source.id}: ${recorded.source.url}`,
`source path ${recorded.source.contentPath}`,
`source sha256 ${recorded.source.sha256}`,
],
omit: ["data.text", "data.links"],
});
} catch (error) {
return errorResult("research.fetch", error, "high");
}
},
});
}
/** `record_research_note` — a sourced finding in the linked project. */
export function createRecordResearchNoteTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "record_research_note",
description:
"Persist a sourced finding, claim, unresolved question, warning, or method note in the linked research project.",
parameters: {
kind: z.enum(["finding", "claim", "question", "warning", "method"]).optional(),
text: z.string(),
source_ids: z.array(z.string()).optional(),
},
implementation: async (input: {
kind?: "finding" | "claim" | "question" | "warning" | "method";
text: string;
source_ids?: string[];
}) => {
await beforeTool("record_research_note");
try {
const project = await ctx.ensureResearchProject(state);
await ctx.research.addNote(project.id, {
kind: input.kind,
text: input.text,
sourceIds: input.source_ids,
});
return okResult({
operation: "research.note",
summary: `Research note recorded in ${project.id}.`,
data: { researchProjectId: project.id, sourceIds: input.source_ids ?? [] },
importance: "high",
facts: [
`research project ${project.id}`,
`${input.kind ?? "finding"}: ${input.text.slice(0, 1500)}`,
...(input.source_ids ?? []).map((id) => `supports: ${id}`),
],
});
} catch (error) {
return errorResult("research.note", error, "high");
}
},
});
}
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../../core/errors";
import { boundedPreview, errorResult, okResult } from "../../core/result";
import type { SearchProvider } from "../../research/web";
import type { AgentToolContext } from "./context";
/** `web_search` — a persisted web query. */
export function createWebSearchTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "web_search",
description:
"Search the public web and persist the query/results in the run's research project. Use multiple focused queries and prefer primary sources.",
parameters: {
query: z.string(),
provider: z.enum(["auto", "duckduckgo", "wikipedia", "searxng"]).optional(),
max_results: z.number().int().min(1).max(30).optional(),
},
implementation: async (input: {
query: string;
provider?: SearchProvider;
max_results?: number;
}) => {
await beforeTool("web_search");
if (!state.allowWeb) {
return errorResult(
"research.search",
new AgenticError("PROCESS_DISABLED", "Web research is disabled for this run."),
);
}
try {
const project = await ctx.ensureResearchProject(state);
const result = await ctx.web.search(input.query, {
provider: input.provider ?? ctx.options.defaultSearchProvider,
maxResults: input.max_results ?? 10,
});
await ctx.research.recordSearch(project.id, result);
return okResult({
operation: "research.search",
summary: `Found ${result.results.length} result(s) for '${result.query}'.`,
data: {
researchProjectId: project.id,
query: result.query,
provider: result.provider,
results: result.results,
},
importance: "high",
facts: [
`research project ${project.id}`,
`web query: ${result.query}`,
...result.results.slice(0, 10).map((item) => `${item.title}: ${item.url}`),
],
omit: ["data.results"],
});
} catch (error) {
return errorResult("research.search", error, "high");
}
},
});
}
/** `fetch_source` — a fetched page persisted as a numbered source. */
export function createFetchSourceTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "fetch_source",
description:
"Fetch a public web page, extract readable text, and persist it as a numbered source. Cite the returned source id in findings and final evidence.",
parameters: {
url: z.string(),
max_text_chars: z.number().int().min(1000).max(200000).optional(),
},
implementation: async (input: { url: string; max_text_chars?: number }) => {
await beforeTool("fetch_source");
if (!state.allowWeb) {
return errorResult(
"research.fetch",
new AgenticError("PROCESS_DISABLED", "Web research is disabled for this run."),
);
}
try {
const project = await ctx.ensureResearchProject(state);
const page = await ctx.web.fetchPage(input.url, {
maxTextChars: input.max_text_chars,
});
const recorded = await ctx.research.recordSource(project.id, page);
state.sources = [
...state.sources.filter((item) => item.id !== recorded.source.id),
{
id: recorded.source.id,
url: recorded.source.url,
...(recorded.source.title ? { title: recorded.source.title } : {}),
contentPath: recorded.source.contentPath,
sha256: recorded.source.sha256,
},
].slice(-300);
await ctx.store.save(state);
const preview = boundedPreview(page.text, ctx.options.maxResultChars);
return okResult({
operation: "research.fetch",
summary: `${recorded.source.id}: ${recorded.source.title || recorded.source.url}`,
data: {
researchProjectId: project.id,
sourceId: recorded.source.id,
url: recorded.source.url,
title: recorded.source.title,
fetchedAt: recorded.source.fetchedAt,
sha256: recorded.source.sha256,
bytes: recorded.source.bytes,
truncated: recorded.source.truncated,
contentPath: recorded.source.contentPath,
text: preview.preview,
links: page.links.slice(0, 50),
},
artifacts: [
{
kind: "web_content",
path: recorded.source.contentPath,
sha256: recorded.source.sha256,
bytes: recorded.source.bytes,
description: `Fetched source ${recorded.source.id}: ${recorded.source.url}`,
},
],
importance: "critical",
facts: [
`source ${recorded.source.id}: ${recorded.source.url}`,
`source path ${recorded.source.contentPath}`,
`source sha256 ${recorded.source.sha256}`,
],
omit: ["data.text", "data.links"],
});
} catch (error) {
return errorResult("research.fetch", error, "high");
}
},
});
}
/** `record_research_note` — a sourced finding in the linked project. */
export function createRecordResearchNoteTool(ctx: AgentToolContext): Tool {
const { state, beforeTool } = ctx;
return tool({
name: "record_research_note",
description:
"Persist a sourced finding, claim, unresolved question, warning, or method note in the linked research project.",
parameters: {
kind: z.enum(["finding", "claim", "question", "warning", "method"]).optional(),
text: z.string(),
source_ids: z.array(z.string()).optional(),
},
implementation: async (input: {
kind?: "finding" | "claim" | "question" | "warning" | "method";
text: string;
source_ids?: string[];
}) => {
await beforeTool("record_research_note");
try {
const project = await ctx.ensureResearchProject(state);
await ctx.research.addNote(project.id, {
kind: input.kind,
text: input.text,
sourceIds: input.source_ids,
});
return okResult({
operation: "research.note",
summary: `Research note recorded in ${project.id}.`,
data: { researchProjectId: project.id, sourceIds: input.source_ids ?? [] },
importance: "high",
facts: [
`research project ${project.id}`,
`${input.kind ?? "finding"}: ${input.text.slice(0, 1500)}`,
...(input.source_ids ?? []).map((id) => `supports: ${id}`),
],
});
} catch (error) {
return errorResult("research.note", error, "high");
}
},
});
}