feat: Document TOC Generation and Search Tool for Large Documentation (#500)

* feat: toc generation of page contents

* feat: added webpage query with summarization

* chore: code cleaning

* chore: code cleaning

* feat: page content caching and code formatting

* fix: uuid fix

* fix: graph update
This commit is contained in:
Aliyan Ishfaq 2025-07-23 17:55:59 -07:00 • committed by GitHub
parent 11872f9fb4
commit c0e557cdb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 696 additions and 55 deletions

View file

@ -6,6 +6,7 @@ import {
import {
createGetURLContentTool,
createShellTool,
createSearchDocumentForTool,
} from "../../../../tools/index.js";
import {
PlannerGraphState,
@ -79,7 +80,8 @@ export async function generateAction(
createSearchTool(state),
createShellTool(state),
createPlannerNotesTool(),
createGetURLContentTool(),
createGetURLContentTool(state),
createSearchDocumentForTool(state, config),
...mcpTools,
];
logger.info(

View file

@ -245,6 +245,7 @@ export async function interruptProposedPlan(
targetRepository: state.targetRepository,
githubIssueId: state.githubIssueId,
internalMessages: state.messages,
documentCache: state.documentCache,
};
if (state.autoAcceptPlan) {

View file

@ -7,6 +7,7 @@ import {
import {
createGetURLContentTool,
createShellTool,
createSearchDocumentForTool,
} from "../../../tools/index.js";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import {
@ -18,7 +19,7 @@ import {
safeSchemaToString,
safeBadArgsError,
} from "../../../utils/zod-to-string.js";
import { truncateOutput } from "../../../utils/truncate-outputs.js";
import { createSearchTool } from "../../../tools/search.js";
import {
getChangedFilesStatus,
@ -32,6 +33,7 @@ import { shouldDiagnoseError } from "../../../utils/tool-message-error.js";
import { Command } from "@langchain/langgraph";
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import { processToolCallContent } from "../../../utils/tool-output-processing.js";
const logger = createLogger(LogLevel.INFO, "TakeAction");
@ -49,14 +51,22 @@ export async function takeActions(
const shellTool = createShellTool(state);
const searchTool = createSearchTool(state);
const plannerNotesTool = createPlannerNotesTool();
const getURLContentTool = createGetURLContentTool();
const getURLContentTool = createGetURLContentTool(state);
const searchDocumentForTool = createSearchDocumentForTool(state, config);
const mcpTools = await getMcpTools(config);
const higherContextLimitToolNames = [
...mcpTools.map((t) => t.name),
getURLContentTool.name,
searchDocumentForTool.name,
];
const allTools = [
shellTool,
searchTool,
plannerNotesTool,
getURLContentTool,
searchDocumentForTool,
...mcpTools,
];
const toolsMap = Object.fromEntries(
@ -88,7 +98,7 @@ export async function takeActions(
status: "error",
});
return toolMessage;
return { toolMessage, stateUpdates: undefined };
}
logger.info("Executing planner tool action", {
@ -142,26 +152,46 @@ export async function takeActions(
}
}
const truncatedOutput =
toolCall.name === getURLContentTool.name
? // Allow for more context to be included from URL contents.
truncateOutput(result, {
numStartCharacters: 10000,
numEndCharacters: 10000,
})
: truncateOutput(result);
const { content, stateUpdates } = await processToolCallContent(
toolCall,
result,
{
higherContextLimitToolNames,
state,
config,
},
);
const toolMessage = new ToolMessage({
id: uuidv4(),
tool_call_id: toolCall.id ?? "",
content: truncatedOutput,
content,
name: toolCall.name,
status: toolCallStatus,
});
return toolMessage;
return { toolMessage, stateUpdates };
});
let toolCallResults = await Promise.all(toolCallResultsPromise);
const toolCallResultsWithUpdates = await Promise.all(toolCallResultsPromise);
let toolCallResults = toolCallResultsWithUpdates.map(
(item) => item.toolMessage,
);
// merging document cache updates from tool calls
const allStateUpdates = toolCallResultsWithUpdates
.map((item) => item.stateUpdates)
.filter(Boolean)
.reduce(
(acc: { documentCache: Record<string, string> }, update) => {
if (update?.documentCache) {
acc.documentCache = { ...acc.documentCache, ...update.documentCache };
}
return acc;
},
{ documentCache: {} } as { documentCache: Record<string, string> },
);
const repoPath = getRepoAbsolutePath(state.targetRepository);
const changedFiles = await getChangedFilesStatus(repoPath, sandbox);
if (changedFiles?.length > 0) {
@ -201,6 +231,7 @@ ${tc.content}`,
sandboxSessionId: sandbox.id,
...(codebaseTree && { codebaseTree }),
...(dependenciesInstalled !== null && { dependenciesInstalled }),
...allStateUpdates,
};
const maxContextActions = config.configurable?.maxContextActions ?? 75;

View file

@ -15,6 +15,7 @@ import {
createRequestHumanHelpToolFields,
createUpdatePlanToolFields,
createGetURLContentTool,
createSearchDocumentForTool,
} from "../../../../tools/index.js";
import { formatPlanPrompt } from "../../../../utils/plan-prompt.js";
import { stopSandbox } from "../../../../utils/sandbox.js";
@ -153,9 +154,10 @@ export async function generateAction(
createApplyPatchTool(state),
createRequestHumanHelpToolFields(),
createUpdatePlanToolFields(),
createGetURLContentTool(),
createGetURLContentTool(state),
createInstallDependenciesTool(state),
markTaskCompletedTool,
createSearchDocumentForTool(state, config),
...mcpTools,
];
logger.info(

View file

@ -5,6 +5,7 @@ import {
createApplyPatchTool,
createGetURLContentTool,
createShellTool,
createSearchDocumentForTool,
} from "../../../tools/index.js";
import {
GraphState,
@ -20,7 +21,7 @@ import {
safeBadArgsError,
} from "../../../utils/zod-to-string.js";
import { Command } from "@langchain/langgraph";
import { truncateOutput } from "../../../utils/truncate-outputs.js";
import { getSandboxWithErrorHandling } from "../../../utils/sandbox.js";
import {
FAILED_TO_GENERATE_TREE_MESSAGE,
@ -32,6 +33,7 @@ import { createSearchTool } from "../../../tools/search.js";
import { getMcpTools } from "../../../utils/mcp-client.js";
import { shouldDiagnoseError } from "../../../utils/tool-message-error.js";
import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js";
import { processToolCallContent } from "../../../utils/tool-output-processing.js";
const logger = createLogger(LogLevel.INFO, "TakeAction");
@ -49,16 +51,23 @@ export async function takeAction(
const shellTool = createShellTool(state);
const searchTool = createSearchTool(state);
const installDependenciesTool = createInstallDependenciesTool(state);
const getURLContentTool = createGetURLContentTool();
const getURLContentTool = createGetURLContentTool(state);
const searchDocumentForTool = createSearchDocumentForTool(state, config);
const mcpTools = await getMcpTools(config);
const higherContextLimitToolNames = [
...mcpTools.map((t) => t.name),
getURLContentTool.name,
searchDocumentForTool.name,
];
const allTools = [
shellTool,
searchTool,
installDependenciesTool,
applyPatchTool,
getURLContentTool,
searchDocumentForTool,
...mcpTools,
];
const toolsMap = Object.fromEntries(
@ -89,7 +98,7 @@ export async function takeAction(
name: toolCall.name,
status: "error",
});
return toolMessage;
return { toolMessage, stateUpdates: undefined };
}
let result = "";
@ -136,18 +145,45 @@ export async function takeAction(
}
}
const { content, stateUpdates } = await processToolCallContent(
toolCall,
result,
{
higherContextLimitToolNames,
state,
config,
},
);
const toolMessage = new ToolMessage({
id: uuidv4(),
tool_call_id: toolCall.id ?? "",
content: truncateOutput(result),
content,
name: toolCall.name,
status: toolCallStatus,
});
return toolMessage;
return { toolMessage, stateUpdates };
});
const toolCallResults = await Promise.all(toolCallResultsPromise);
const toolCallResultsWithUpdates = await Promise.all(toolCallResultsPromise);
const toolCallResults = toolCallResultsWithUpdates.map(
(item) => item.toolMessage,
);
// merging document cache updates from tool calls
const allStateUpdates = toolCallResultsWithUpdates
.map((item) => item.stateUpdates)
.filter(Boolean)
.reduce(
(acc: { documentCache: Record<string, string> }, update) => {
if (update?.documentCache) {
acc.documentCache = { ...acc.documentCache, ...update.documentCache };
}
return acc;
},
{ documentCache: {} } as { documentCache: Record<string, string> },
);
let wereDependenciesInstalled: boolean | null = null;
toolCallResults.forEach((toolCallResult) => {
@ -209,6 +245,7 @@ export async function takeAction(
...(dependenciesInstalledUpdate !== null && {
dependenciesInstalled: dependenciesInstalledUpdate,
}),
...allStateUpdates,
};
return new Command({
goto: shouldRouteDiagnoseNode ? "diagnose-error" : "generate-action",

View file

@ -1,6 +1,7 @@
export * from "./apply-patch.js";
export * from "./shell.js";
export * from "./url-content.js";
export * from "./search-documents-for/index.js";
export {
createUpdatePlanToolFields,
createSessionPlanToolFields,

View file

@ -0,0 +1,124 @@
import { tool } from "@langchain/core/tools";
import { createLogger, LogLevel } from "../../utils/logger.js";
import { createSearchDocumentForToolFields } from "@open-swe/shared/open-swe/tools";
import { FireCrawlLoader } from "@langchain/community/document_loaders/web/firecrawl";
import { loadModel, Task } from "../../utils/load-model.js";
import { GraphConfig, GraphState } from "@open-swe/shared/open-swe/types";
import { getMessageContentString } from "@open-swe/shared/messages";
import { DOCUMENT_SEARCH_PROMPT } from "./prompt.js";
import { parseUrl } from "../../utils/url-parser.js";
import { z } from "zod";
const logger = createLogger(LogLevel.INFO, "SearchDocumentForTool");
type SearchDocumentForInput = z.infer<
ReturnType<typeof createSearchDocumentForToolFields>["schema"]
>;
export function createSearchDocumentForTool(
state: Pick<GraphState, "documentCache">,
config: GraphConfig,
) {
const searchDocumentForTool = tool(
async (
input: SearchDocumentForInput,
): Promise<{
result: string;
status: "success" | "error";
stateUpdates?: Partial<Pick<GraphState, "documentCache">>;
}> => {
const { url, query } = input;
const urlParseResult = parseUrl(url);
if (!urlParseResult.success) {
return { result: urlParseResult.errorMessage, status: "error" };
}
const parsedUrl = urlParseResult.url?.href;
try {
let documentContent = state.documentCache[parsedUrl];
if (!documentContent) {
logger.info("Document not cached, fetching via FireCrawl", {
url: parsedUrl,
});
const loader = new FireCrawlLoader({
url: parsedUrl,
mode: "scrape",
params: {
formats: ["markdown"],
},
});
const docs = await loader.load();
documentContent = docs.map((doc) => doc.pageContent).join("\n\n");
if (state.documentCache) {
const stateUpdates = {
documentCache: {
...state.documentCache,
[parsedUrl]: documentContent,
},
};
return { result: documentContent, status: "success", stateUpdates };
}
} else {
logger.info("Using cached document content", {
url: parsedUrl,
contentLength: documentContent.length,
});
}
if (!documentContent.trim()) {
return {
result: `No content found at URL: ${url}`,
status: "error",
};
}
const model = await loadModel(config, Task.SUMMARIZER);
const searchPrompt = DOCUMENT_SEARCH_PROMPT.replace(
"{DOCUMENT_PAGE_CONTENT}",
documentContent,
).replace("{NATURAL_LANGUAGE_QUERY}", query);
const response = await model
.withConfig({ tags: ["nostream"], runName: "document-search" })
.invoke([
{
role: "user",
content: searchPrompt,
},
]);
const searchResult = getMessageContentString(response.content);
logger.info("Document search completed", {
url,
query,
resultLength: searchResult.length,
});
return {
result: searchResult,
status: "success",
};
} catch (e) {
const errorString = e instanceof Error ? e.message : String(e);
logger.error("Failed to search document", {
url: parsedUrl,
query,
error: errorString,
});
return {
result: `Failed to search document at ${parsedUrl}\nError:\n${errorString}`,
status: "error",
};
}
},
createSearchDocumentForToolFields(),
);
return searchDocumentForTool;
}

View file

@ -0,0 +1,59 @@
export const DOCUMENT_SEARCH_PROMPT = `<identity>
You are a specialized document information extraction agent. Your sole purpose is to find and extract relevant information from web documents and documentation based on natural language queries. You are precise, thorough, and never add information not present in the source.
</identity>
<role>
Document Search Agent - Information Extraction Phase
</role>
<primary_objective>
Extract ALL information from the provided document that relates to the natural language query. Preserve code snippets, URLs, file paths, and references exactly as they appear in the source document.
</primary_objective>
<instructions>
<core_behavior>
- **Extract Only What Exists**: Only extract information that is explicitly present in the document. NEVER add, infer, assume, or generate any information not directly found in the source material.
- **Comprehensive Coverage**: Scan the entire document for any content related to the query, including direct mentions and relevant examples or context.
- **Exact Preservation**: Copy all code snippets, file paths, URLs, and technical content exactly as written. Maintain original formatting, indentation, and structure.
- **No Hallucination**: Do not create, modify, or infer any information. If something is not in the document, do not include it.
- **Context Inclusion**: When extracting text, include enough surrounding context to make the information meaningful.
</core_behavior>
<output_format>
Your response must use this exact structure:
<extracted_document_info>
<relevant_information>
[All prose, explanations, and descriptions from the document that relate to the query. Preserve original wording and include sufficient context.]
</relevant_information>
<code_snippets>
[All code blocks and technical examples related to the query. Use markdown code blocks with language tags. Preserve exact formatting.]
</code_snippets>
<links_and_paths>
[All URLs, file paths, import statements, and references found. Format as:
- URLs: "Display Text: [URL]" or "[URL]"
- Paths: "Path: [path/to/file]"
- Imports: "Import: [statement]"
- Packages: "Package: [name]"]
</links_and_paths>
</extracted_document_info>
</output_format>
<critical_rules>
- Only extract content that actually exists in the provided document
- Never add explanations, interpretations, or additional context not present in the source
- If no relevant information is found, leave sections empty but still include them
- Preserve all technical details exactly as written
</critical_rules>
</instructions>
<natural_language_query>
{NATURAL_LANGUAGE_QUERY}
</natural_language_query>
<document_page_content>
{DOCUMENT_PAGE_CONTENT}
</document_page_content>
`;

View file

@ -2,46 +2,83 @@ import { tool } from "@langchain/core/tools";
import { createLogger, LogLevel } from "../utils/logger.js";
import { createGetURLContentToolFields } from "@open-swe/shared/open-swe/tools";
import { FireCrawlLoader } from "@langchain/community/document_loaders/web/firecrawl";
import { GraphState } from "@open-swe/shared/open-swe/types";
import { parseUrl } from "../utils/url-parser.js";
const logger = createLogger(LogLevel.INFO, "GetURLContentTool");
export function createGetURLContentTool() {
export function createGetURLContentTool(
state: Pick<GraphState, "documentCache">,
) {
const getURLContentTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
async (
input,
): Promise<{
result: string;
status: "success" | "error";
stateUpdates?: Partial<Pick<GraphState, "documentCache">>;
}> => {
const { url } = input;
let parsedUrl: URL | null = null;
try {
parsedUrl = new URL(url);
} catch (e) {
const errorString = e instanceof Error ? e.message : String(e);
logger.error("Failed to parse URL", { url, error: errorString });
return {
result: `Failed to parse URL: ${url}\nError:\n${errorString}\nPlease ensure the URL provided is properly formatted.`,
status: "error",
};
const urlParseResult = parseUrl(url);
if (!urlParseResult.success) {
return { result: urlParseResult.errorMessage, status: "error" };
}
const parsedUrl = urlParseResult.url?.href;
try {
const loader = new FireCrawlLoader({
url: parsedUrl.href,
mode: "scrape",
params: {
formats: ["markdown"],
},
});
let documentContent = state.documentCache[parsedUrl];
const docs = await loader.load();
const text = docs.map((doc) => doc.pageContent).join("\n\n");
if (!documentContent) {
logger.info("Document not cached, fetching via FireCrawl", {
url: parsedUrl,
});
const loader = new FireCrawlLoader({
url: parsedUrl,
mode: "scrape",
params: {
formats: ["markdown"],
},
});
const docs = await loader.load();
documentContent = docs.map((doc) => doc.pageContent).join("\n\n");
if (state.documentCache) {
const stateUpdates = {
documentCache: {
...state.documentCache,
[parsedUrl]: documentContent,
},
};
return { result: documentContent, status: "success", stateUpdates };
}
} else {
logger.info("Using cached document content", {
url: parsedUrl,
contentLength: documentContent.length,
});
}
if (!documentContent.trim()) {
return {
result: `No content found at URL: ${url}`,
status: "error",
};
}
return {
result: text,
result: documentContent,
status: "success",
};
} catch (e) {
const errorString = e instanceof Error ? e.message : String(e);
logger.error("Failed to get URL content", { url, error: errorString });
logger.error("Failed to get URL content", {
url: parsedUrl,
error: errorString,
});
return {
result: `Failed to get URL content: ${url}\nError:\n${errorString}`,
result: `Failed to get URL content: ${parsedUrl}\nError:\n${errorString}`,
status: "error",
};
}

View file

@ -31,7 +31,7 @@ const TASK_TO_CONFIG_DEFAULTS_MAP = {
temperature: 0,
},
[Task.SUMMARIZER]: {
modelName: "anthropic:claude-sonnet-4-0",
modelName: "google-genai:gemini-2.5-pro",
temperature: 0,
},
};

View file

@ -0,0 +1,96 @@
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { loadModel, Task } from "../load-model.js";
import { createLogger, LogLevel } from "../logger.js";
import { DOCUMENT_TOC_GENERATION_PROMPT } from "./prompt.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { truncateOutput } from "../truncate-outputs.js";
const logger = createLogger(LogLevel.INFO, "McpOutputHandler");
export async function handleMcpDocumentationOutput(
output: string,
config: GraphConfig,
options?: {
maxLength?: number;
url?: string;
},
): Promise<string> {
const { maxLength = 40000, url = "" } = options ?? {};
// If output is within limits, return as-is
if (output.length <= maxLength) {
return output;
}
logger.info("MCP output exceeds max length, generating table of contents", {
outputLength: output.length,
maxLength,
url,
});
try {
const model = await loadModel(config, Task.SUMMARIZER);
const systemPrompt = DOCUMENT_TOC_GENERATION_PROMPT.replace(
"{DOCUMENT_PAGE_CONTENT}",
output,
);
const response = await model
.withConfig({ tags: ["nostream"], runName: "mcp-doc-toc-generation" })
.invoke([
{
role: "user",
content: systemPrompt,
},
]);
const tableOfContents = getMessageContentString(response.content);
const explanatoryMessage = createExplanatoryMessage(url, tableOfContents);
return explanatoryMessage;
} catch (error) {
logger.error("Failed to generate MCP documentation summary", {
...(error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: { error }),
});
return truncateOutput(output, {
numStartCharacters: 20000,
numEndCharacters: 20000,
});
}
}
function createExplanatoryMessage(
url: string,
tableOfContents: string,
): string {
const urlInfo = url ? ` from ${url}` : "";
const searchInstruction = url
? `To get specific information from this document, use: search_document_for("${url}", "your natural language query")`
: "To get specific information from this document, use the search_document_for tool with the url of the page and the natural language query";
return `The following output was truncated due to its length exceeding the maximum allowed characters. Content${urlInfo} received ${tableOfContents ? "exceeded" : "exceeds"} 40,000 characters.
The following provides a table of contents of the document content:
${tableOfContents || "Table of contents generation failed"}
${searchInstruction}`;
}
// Keep the original simple function for backwards compatibility
export function handleMcpOutput(
output: string,
options?: {
maxLength?: number;
},
) {
const { maxLength = 40000 } = options ?? {};
if (output.length > maxLength) {
return "swoosh!!";
}
return output;
}

View file

@ -0,0 +1,44 @@
export const DOCUMENT_TOC_GENERATION_PROMPT = `You are a terminal-based agentic coding assistant built by LangChain. You excel at analyzing and structuring technical documentation with maximum comprehensiveness.
<task>
Generate a comprehensive table of contents with brief summaries for the provided documentation. Your goal is to capture EVERY concept, method, function, API, configuration option, and idea present in the document with maximum breadth coverage.
</task>
<requirements>
1. **Maximum Breadth**: Include ALL headings, subheadings, and any mentioned concepts, methods, functions, APIs, or configuration options - leave nothing out
2. **Comprehensive Coverage**: Every idea, technique, or approach mentioned should appear in your table of contents, even if briefly discussed
3. **Minimum Viable Details**: For each item, provide the essential information needed to understand what it is and its purpose (1-2 sentences)
4. **Exact Hierarchy**: Preserve the document's structure while ensuring no content is overlooked
5. **Concept Extraction**: Beyond just headings, identify and list any significant concepts, methods, or functions discussed within sections
6. **No Omissions**: If a function, API endpoint, configuration parameter, or concept is mentioned anywhere, it should be represented
7. **CRITICAL - Preserve URLs/Paths**: NEVER remove or modify URLs, relative paths, file paths, or any link references. Include them exactly as they appear in the original document to maintain navigability and reference accuracy
</requirements>
<coverage_strategy>
- Scan for ALL functions, methods, classes, and APIs mentioned
- Include configuration options, parameters, and settings discussed
- Capture examples, use cases, and implementation approaches
- Note any troubleshooting, limitations, or best practices mentioned
- List any related tools, libraries, or dependencies referenced
- **PRESERVE ALL URLs, file paths, and relative paths exactly as written** - do not modify, shorten, or remove any links or path references
</coverage_strategy>
<output_format>
Wrap your response in XML tags and use Markdown list syntax with maximum detail coverage:
\`\`\`
<detailed_table_of_contents>
- Main Section: Brief description covering the primary concepts and methods.
- Subsection: Specific functionality, APIs, or methods discussed here.
- Function/Method Name: What this specific function does and key parameters.
- Configuration Option: Purpose and usage of this setting.
- Concept/Approach: Brief explanation of this technique or idea.
- Another Subsection: Additional methods, concepts, or approaches.
- Related Functions: Any additional functions or utilities mentioned.
</detailed_table_of_contents>
\`\`\`
</output_format>
<document_content>
{DOCUMENT_PAGE_CONTENT}
</document_content>`;

View file

@ -254,7 +254,7 @@ export class ModelManager {
[Task.SUMMARIZER]: {
modelName:
config.configurable?.[`${task}ModelName`] ??
"anthropic:claude-sonnet-4-0",
"google-genai:gemini-2.5-pro",
temperature: config.configurable?.[`${task}Temperature`] ?? 0,
},
};

View file

@ -0,0 +1,67 @@
import { GraphConfig, GraphState } from "@open-swe/shared/open-swe/types";
import { truncateOutput } from "./truncate-outputs.js";
import { handleMcpDocumentationOutput } from "./mcp-output/index.js";
import { parseUrl } from "./url-parser.js";
interface ToolCall {
name: string;
args?: Record<string, any>;
}
/**
* Processes tool call results with appropriate content handling based on tool type.
* Handles search_document_for, MCP tools, and regular tools with different truncation strategies.
* Returns a new state object with the updated document cache if the tool is a higher context limit tool.
*/
export async function processToolCallContent(
toolCall: ToolCall,
result: string,
options: {
higherContextLimitToolNames: string[];
state: Pick<GraphState, "documentCache">;
config: GraphConfig;
},
): Promise<{
content: string;
stateUpdates?: Partial<Pick<GraphState, "documentCache">>;
}> {
const { higherContextLimitToolNames, state, config } = options;
if (toolCall.name === "search_document_for") {
return {
content: truncateOutput(result, {
numStartCharacters: 20000,
numEndCharacters: 20000,
}),
};
} else if (higherContextLimitToolNames.includes(toolCall.name)) {
const url = toolCall.args?.url || toolCall.args?.uri || toolCall.args?.path;
const parsedResult = typeof url === "string" ? parseUrl(url) : null;
const parsedUrl = parsedResult?.success ? parsedResult.url.href : undefined;
const processedContent = await handleMcpDocumentationOutput(
result,
config,
{
url: parsedUrl,
},
);
const stateUpdates = parsedUrl
? {
documentCache: {
...state.documentCache,
[parsedUrl]: result,
},
}
: undefined;
return {
content: processedContent,
stateUpdates,
};
} else {
return {
content: truncateOutput(result),
};
}
}

View file

@ -0,0 +1,36 @@
import { createLogger, LogLevel } from "./logger.js";
const logger = createLogger(LogLevel.INFO, "URLParser");
interface URLParseResult {
success: true;
url: URL;
}
interface URLParseError {
success: false;
errorMessage: string;
}
type URLParseResponse = URLParseResult | URLParseError;
/**
* Safely parses a URL string and returns a structured result.
*/
export function parseUrl(urlString: string): URLParseResponse {
try {
const parsedUrl = new URL(urlString);
return {
success: true,
url: parsedUrl,
};
} catch (e) {
const errorString = e instanceof Error ? e.message : String(e);
logger.error("Failed to parse URL", { url: urlString, error: errorString });
return {
success: false,
errorMessage: `Failed to parse URL: ${urlString}\nError:\n${errorString}.`,
};
}
}

View file

@ -24,6 +24,7 @@ import {
createTakePlannerNotesFields,
createGetURLContentToolFields,
createSearchToolFields,
createSearchDocumentForToolFields,
} from "@open-swe/shared/open-swe/tools";
import { z } from "zod";
import {
@ -51,6 +52,8 @@ const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
const searchTool = createSearchToolFields(dummyRepo);
type SearchToolArgs = z.infer<typeof searchTool.schema>;
const searchDocumentForTool = createSearchDocumentForToolFields();
type SearchDocumentForToolArgs = z.infer<typeof searchDocumentForTool.schema>;
// Common props for all action types
type BaseActionProps = {
@ -102,6 +105,12 @@ type SearchActionProps = BaseActionProps &
errorCode?: number;
};
type SearchDocumentForActionProps = BaseActionProps &
Partial<SearchDocumentForToolArgs> & {
actionType: "search_document_for";
output?: string;
};
type McpActionProps = BaseActionProps & {
actionType: "mcp";
toolName: string;
@ -117,7 +126,8 @@ export type ActionItemProps =
| PlannerNotesActionProps
| GetURLContentActionProps
| McpActionProps
| SearchActionProps;
| SearchActionProps
| SearchDocumentForActionProps;
export type ActionStepProps = {
actions: ActionItemProps[];
@ -131,6 +141,7 @@ const ACTION_GENERATING_TEXT_MAP = {
[installDependenciesTool.name]: "Installing dependencies...",
[plannerNotesTool.name]: "Saving notes...",
[getURLContentTool.name]: "Fetching URL content...",
[searchDocumentForTool.name]: "Searching document...",
[searchTool.name]: "Searching...",
};
@ -207,6 +218,10 @@ function ActionItem(props: ActionItemProps) {
return props.success
? "URL content fetched"
: "Failed to fetch URL content";
} else if (props.actionType === "search_document_for") {
return props.success
? "Document search completed"
: "Document search failed";
} else if (props.actionType === "search") {
return props.success ? "Search completed" : "Search failed";
} else if (props.actionType === "mcp") {
@ -227,6 +242,7 @@ function ActionItem(props: ActionItemProps) {
props.actionType === "shell" ||
props.actionType === "install_dependencies" ||
props.actionType === "get_url_content" ||
props.actionType === "search_document_for" ||
props.actionType === "search"
) {
return !!props.output;
@ -284,6 +300,13 @@ function ActionItem(props: ActionItemProps) {
icon={<Globe className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "search_document_for") {
return (
<ToolIconWithTooltip
toolNamePretty="Search Document"
icon={<FileText className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "mcp") {
return (
<ToolIconWithTooltip
@ -414,6 +437,17 @@ function ActionItem(props: ActionItemProps) {
</code>
</div>
);
} else if (props.actionType === "search_document_for") {
return (
<div className="flex flex-col">
<code className="text-foreground/80 text-xs font-normal">
{props.query}
</code>
<div className="text-muted-foreground mt-1 text-xs font-normal">
{props.url}
</div>
</div>
);
} else if (props.actionType === "mcp") {
return (
<div className="flex items-center">
@ -440,6 +474,7 @@ function ActionItem(props: ActionItemProps) {
if (
(props.actionType === "shell" ||
props.actionType === "search" ||
props.actionType === "search_document_for" ||
props.actionType === "install_dependencies") &&
props.output
) {
@ -448,11 +483,14 @@ function ActionItem(props: ActionItemProps) {
<pre className="text-xs font-normal whitespace-pre-wrap">
{props.output}
</pre>
{props.errorCode !== undefined && !props.success && (
<div className="mt-1 text-xs text-red-500 dark:text-red-400">
Exit code: {props.errorCode}
</div>
)}
{(props.actionType === "shell" || props.actionType === "search") &&
"errorCode" in props &&
props.errorCode !== undefined &&
!props.success && (
<div className="mt-1 text-xs text-red-500 dark:text-red-400">
Exit code: {props.errorCode}
</div>
)}
</div>
);
} else if (props.actionType === "mcp") {

View file

@ -42,6 +42,7 @@ import {
createCodeReviewMarkTaskNotCompleteFields,
createDiagnoseErrorToolFields,
createGetURLContentToolFields,
createSearchDocumentForToolFields,
createWriteTechnicalNotesToolFields,
createConversationHistorySummaryToolFields,
createReviewStartedToolFields,
@ -92,6 +93,8 @@ type DiagnoseErrorToolArgs = z.infer<typeof diagnoseErrorTool.schema>;
const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
const searchDocumentForTool = createSearchDocumentForToolFields();
type SearchDocumentForToolArgs = z.infer<typeof searchDocumentForTool.schema>;
const writeTechnicalNotesTool = createWriteTechnicalNotesToolFields();
type WriteTechnicalNotesToolArgs = z.infer<
@ -261,6 +264,17 @@ export function mapToolMessageToActionStepProps(
output: getContentString(message.content),
reasoningText,
};
} else if (toolCall?.name === searchDocumentForTool.name) {
const args = toolCall.args as SearchDocumentForToolArgs;
return {
actionType: "search_document_for",
status,
success,
url: args.url || "",
query: args.query || "",
output: getContentString(message.content),
reasoningText,
};
} else if (toolCall && isMcpTool(toolCall.name)) {
return {
actionType: "mcp",
@ -637,6 +651,15 @@ export function AssistantMessage({
url: args?.url || "",
output: "",
} as ActionItemProps;
} else if (toolCall.name === searchDocumentForTool.name) {
const args = toolCall.args as SearchDocumentForToolArgs;
return {
actionType: "search_document_for",
status: "generating",
url: args?.url || "",
query: args?.query || "",
output: "",
} as ActionItemProps;
} else {
if (isMcpTool(toolCall.name)) {
return {

View file

@ -36,6 +36,16 @@ export const PlannerGraphStateObj = MessagesZodState.extend({
fn: (_state, update) => update,
},
}),
/**
* Cache of fetched document content keyed by URLs.
*/
documentCache: withLangGraph(z.custom<Record<string, string>>(), {
reducer: {
schema: z.custom<Record<string, string>>(),
fn: (state, update) => ({ ...state, ...update }),
},
default: () => ({}),
}),
taskPlan: withLangGraph(z.custom<TaskPlan>(), {
reducer: {
schema: z.custom<TaskPlan>(),

View file

@ -391,6 +391,29 @@ export function createGetURLContentToolFields() {
};
}
export function createSearchDocumentForToolFields() {
const searchDocumentForSchema = z.object({
url: z
.string()
.describe(
"The URL of the document to search within. This should be a URL that was previously fetched and processed.",
),
query: z
.string()
.describe(
"The natural language query to search for within the document content. This query will be passed to an LLM which will use it to extract relevant content from the document. Be specific about what information you're looking for.",
),
});
return {
name: "search_document_for",
description:
"Search for specific information within a previously fetched document using natural language queries. This tool is particularly useful when working with large documents that have been summarized with a table of contents. " +
"This tool should only be called after a documentation or a web page has been read and summarized as a table of contents and you need to search for specific information within the document.",
schema: searchDocumentForSchema,
};
}
export function createWriteTechnicalNotesToolFields() {
const writeTechnicalNotesSchema = z.object({
notes: z

View file

@ -221,6 +221,16 @@ export const GraphAnnotation = MessagesZodState.extend({
fn: (_state, update) => update,
},
}),
/**
* Cache of fetched document content keyed by URLs.
*/
documentCache: withLangGraph(z.custom<Record<string, string>>(), {
reducer: {
schema: z.custom<Record<string, string>>(),
fn: (state, update) => ({ ...state, ...update }),
},
default: () => ({}),
}),
/**
* The ID of the Github issue this thread is associated with
*/