feat: Add tool to get contents of URL (#340)

This commit is contained in:
Brace Sproul 2025-07-08 11:14:55 -04:00 • committed by GitHub
parent 6f7c5e1111
commit 9082b86799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1015 additions and 13 deletions

View file

@ -24,11 +24,13 @@
"dependencies": { "dependencies": {
"@daytonaio/sdk": "^0.18.1", "@daytonaio/sdk": "^0.18.1",
"@langchain/anthropic": "^0.3.20", "@langchain/anthropic": "^0.3.20",
"@langchain/community": "^0.3.47",
"@langchain/core": "^0.3.56", "@langchain/core": "^0.3.56",
"@langchain/google-genai": "^0.2.9", "@langchain/google-genai": "^0.2.9",
"@langchain/langgraph": "^0.3.3", "@langchain/langgraph": "^0.3.3",
"@langchain/langgraph-sdk": "^0.0.85", "@langchain/langgraph-sdk": "^0.0.85",
"@langchain/openai": "^0.5.10", "@langchain/openai": "^0.5.10",
"@mendable/firecrawl-js": "^1.29.1",
"@octokit/app": "^16.0.1", "@octokit/app": "^16.0.1",
"@octokit/core": "^7.0.2", "@octokit/core": "^7.0.2",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",

View file

@ -1,5 +1,8 @@
import { loadModel, Task } from "../../../../utils/load-model.js"; import { loadModel, Task } from "../../../../utils/load-model.js";
import { createShellTool } from "../../../../tools/index.js"; import {
createGetURLContentTool,
createShellTool,
} from "../../../../tools/index.js";
import { import {
PlannerGraphState, PlannerGraphState,
PlannerGraphUpdate, PlannerGraphUpdate,
@ -51,6 +54,7 @@ export async function generateAction(
createRgTool(state), createRgTool(state),
createShellTool(state), createShellTool(state),
createPlannerNotesTool(), createPlannerNotesTool(),
createGetURLContentTool(),
]; ];
const modelWithTools = model.bindTools(tools, { const modelWithTools = model.bindTools(tools, {
tool_choice: "auto", tool_choice: "auto",

View file

@ -19,9 +19,12 @@ Your sole objective in this phase is to gather comprehensive context about the c
- You do not want to have to generate tasks such as 'Locate the XYZ file', 'Examine the structure of the codebase', or 'Do X if Y is true, otherwise to Z'. - You do not want to have to generate tasks such as 'Locate the XYZ file', 'Examine the structure of the codebase', or 'Do X if Y is true, otherwise to Z'.
- To ensure the above does not happen, you should be thorough in your context gathering. Always gather enough context to cover all edge cases, and prevent unclear instructions. - To ensure the above does not happen, you should be thorough in your context gathering. Always gather enough context to cover all edge cases, and prevent unclear instructions.
4. **Leverage efficient search tools**: Use \`rg\` (ripgrep) for all file searches because it respects .gitignore patterns and provides significantly faster results than alternatives like grep or ls -R. 4. **Leverage efficient search tools**:
- When searching for specific file types, use glob patterns: \`rg -i pattern -g **/*.tsx project-directory/\` - Use \`rg\` (ripgrep) for all file searches because it respects .gitignore patterns and provides significantly faster results than alternatives like grep or ls -R.
- This explicit pattern matching ensures accurate results across all file extensions - When searching for specific file types, use glob patterns: \`rg -i pattern -g **/*.tsx project-directory/\`
- This explicit pattern matching ensures accurate results across all file extensions
- If the user passes a URL, you should use the \`get_url_content\` tool to fetch the contents of the URL.
- You should only use this tool to fetch the contents of a URL the user has provided, or that you've discovered during your context searching, which you believe is vital to gathering context for the user's request.
5. **Format shell commands precisely**: Ensure all shell commands include proper quoting and escaping. Well-formatted commands prevent errors and provide reliable results. 5. **Format shell commands precisely**: Ensure all shell commands include proper quoting and escaping. Well-formatted commands prevent errors and provide reliable results.

View file

@ -1,5 +1,8 @@
import { isAIMessage, ToolMessage } from "@langchain/core/messages"; import { isAIMessage, ToolMessage } from "@langchain/core/messages";
import { createShellTool } from "../../../tools/index.js"; import {
createGetURLContentTool,
createShellTool,
} from "../../../tools/index.js";
import { GraphConfig } from "@open-swe/shared/open-swe/types"; import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { import {
PlannerGraphState, PlannerGraphState,
@ -34,10 +37,12 @@ export async function takeActions(
const shellTool = createShellTool(state); const shellTool = createShellTool(state);
const rgTool = createRgTool(state); const rgTool = createRgTool(state);
const plannerNotesTool = createPlannerNotesTool(); const plannerNotesTool = createPlannerNotesTool();
const getURLContentTool = createGetURLContentTool();
const toolsMap = { const toolsMap = {
[shellTool.name]: shellTool, [shellTool.name]: shellTool,
[rgTool.name]: rgTool, [rgTool.name]: rgTool,
[plannerNotesTool.name]: plannerNotesTool, [plannerNotesTool.name]: plannerNotesTool,
[getURLContentTool.name]: getURLContentTool,
}; };
const toolCalls = lastMessage.tool_calls; const toolCalls = lastMessage.tool_calls;
@ -96,9 +101,18 @@ 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 toolMessage = new ToolMessage({ const toolMessage = new ToolMessage({
tool_call_id: toolCall.id ?? "", tool_call_id: toolCall.id ?? "",
content: truncateOutput(result), content: truncatedOutput,
name: toolCall.name, name: toolCall.name,
status: toolCallStatus, status: toolCallStatus,
}); });

View file

@ -9,6 +9,7 @@ import {
createApplyPatchTool, createApplyPatchTool,
createRequestHumanHelpToolFields, createRequestHumanHelpToolFields,
createUpdatePlanToolFields, createUpdatePlanToolFields,
createGetURLContentTool,
} from "../../../../tools/index.js"; } from "../../../../tools/index.js";
import { formatPlanPrompt } from "../../../../utils/plan-prompt.js"; import { formatPlanPrompt } from "../../../../utils/plan-prompt.js";
import { stopSandbox } from "../../../../utils/sandbox.js"; import { stopSandbox } from "../../../../utils/sandbox.js";
@ -77,6 +78,7 @@ export async function generateAction(
createApplyPatchTool(state), createApplyPatchTool(state),
createRequestHumanHelpToolFields(), createRequestHumanHelpToolFields(),
createUpdatePlanToolFields(), createUpdatePlanToolFields(),
createGetURLContentTool(),
// Only provide the dependencies installed tool if they're not already installed. // Only provide the dependencies installed tool if they're not already installed.
...(state.dependenciesInstalled ...(state.dependenciesInstalled
? [] ? []

View file

@ -45,6 +45,7 @@ You are currently executing a specific task from a pre-generated plan. You have
* **Pre-commit**: Run \`pre-commit run --files ...\` if .pre-commit-config.yaml exists * **Pre-commit**: Run \`pre-commit run --files ...\` if .pre-commit-config.yaml exists
* **History**: Use \`git log\` and \`git blame\` for additional context when needed * **History**: Use \`git log\` and \`git blame\` for additional context when needed
* **Parallel Tool Calling**: You're allowed, and encouraged to call multiple tools at once, as long as they do not conflict, or depend on each other. * **Parallel Tool Calling**: You're allowed, and encouraged to call multiple tools at once, as long as they do not conflict, or depend on each other.
* **URL Content**: Use the \`get_url_content\` tool to fetch the contents of a URL. You should only use this tool to fetch the contents of a URL the user has provided, or that you've discovered during your context searching, which you believe is vital to gathering context for the user's request.
### Coding Standards ### Coding Standards

View file

@ -1,6 +1,10 @@
import { isAIMessage, ToolMessage } from "@langchain/core/messages"; import { isAIMessage, ToolMessage } from "@langchain/core/messages";
import { createLogger, LogLevel } from "../../../utils/logger.js"; import { createLogger, LogLevel } from "../../../utils/logger.js";
import { createApplyPatchTool, createShellTool } from "../../../tools/index.js"; import {
createApplyPatchTool,
createGetURLContentTool,
createShellTool,
} from "../../../tools/index.js";
import { import {
GraphState, GraphState,
GraphConfig, GraphConfig,
@ -45,11 +49,13 @@ export async function takeAction(
const shellTool = createShellTool(state); const shellTool = createShellTool(state);
const rgTool = createRgTool(state); const rgTool = createRgTool(state);
const installDependenciesTool = createInstallDependenciesTool(state); const installDependenciesTool = createInstallDependenciesTool(state);
const getURLContentTool = createGetURLContentTool();
const toolsMap = { const toolsMap = {
[applyPatchTool.name]: applyPatchTool, [applyPatchTool.name]: applyPatchTool,
[shellTool.name]: shellTool, [shellTool.name]: shellTool,
[rgTool.name]: rgTool, [rgTool.name]: rgTool,
[installDependenciesTool.name]: installDependenciesTool, [installDependenciesTool.name]: installDependenciesTool,
[getURLContentTool.name]: getURLContentTool,
}; };
const toolCalls = lastMessage.tool_calls; const toolCalls = lastMessage.tool_calls;

View file

@ -1,5 +1,6 @@
export * from "./apply-patch.js"; export * from "./apply-patch.js";
export * from "./shell.js"; export * from "./shell.js";
export * from "./url-content.js";
export { export {
createUpdatePlanToolFields, createUpdatePlanToolFields,
createSessionPlanToolFields, createSessionPlanToolFields,

View file

@ -0,0 +1,52 @@
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";
const logger = createLogger(LogLevel.INFO, "GetURLContentTool");
export function createGetURLContentTool() {
const getURLContentTool = tool(
async (input): Promise<{ result: string; status: "success" | "error" }> => {
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",
};
}
try {
const loader = new FireCrawlLoader({
url: parsedUrl.href,
mode: "scrape",
params: {
formats: ["markdown"],
},
});
const docs = await loader.load();
const text = docs.map((doc) => doc.pageContent).join("\n\n");
return {
result: text,
status: "success",
};
} catch (e) {
const errorString = e instanceof Error ? e.message : String(e);
logger.error("Failed to get URL content", { url, error: errorString });
return {
result: `Failed to get URL content: ${url}\nError:\n${errorString}`,
status: "error",
};
}
},
createGetURLContentToolFields(),
);
return getURLContentTool;
}

View file

@ -13,12 +13,14 @@ import {
FileText, FileText,
CloudDownload, CloudDownload,
Search, Search,
Globe,
} from "lucide-react"; } from "lucide-react";
import { import {
createApplyPatchToolFields, createApplyPatchToolFields,
createShellToolFields, createShellToolFields,
createInstallDependenciesToolFields, createInstallDependenciesToolFields,
createTakePlannerNotesFields, createTakePlannerNotesFields,
createGetURLContentToolFields,
formatRgCommand, formatRgCommand,
RipgrepCommand, RipgrepCommand,
} from "@open-swe/shared/open-swe/tools"; } from "@open-swe/shared/open-swe/tools";
@ -36,6 +38,8 @@ type InstallDependenciesToolArgs = z.infer<
>; >;
const plannerNotesTool = createTakePlannerNotesFields(); const plannerNotesTool = createTakePlannerNotesFields();
type PlannerNotesToolArgs = z.infer<typeof plannerNotesTool.schema>; type PlannerNotesToolArgs = z.infer<typeof plannerNotesTool.schema>;
const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
// Common props for all action types // Common props for all action types
type BaseActionProps = { type BaseActionProps = {
@ -81,13 +85,20 @@ type PlannerNotesActionProps = BaseActionProps &
actionType: "planner_notes"; actionType: "planner_notes";
}; };
type GetURLContentActionProps = BaseActionProps &
Partial<GetURLContentToolArgs> & {
actionType: "get_url_content";
output?: string;
};
export type ActionItemProps = export type ActionItemProps =
| (BaseActionProps & { status: "loading" }) | (BaseActionProps & { status: "loading" })
| ShellActionProps | ShellActionProps
| PatchActionProps | PatchActionProps
| RgActionProps | RgActionProps
| InstallDependenciesActionProps | InstallDependenciesActionProps
| PlannerNotesActionProps; | PlannerNotesActionProps
| GetURLContentActionProps;
export type ActionStepProps = { export type ActionStepProps = {
actions: ActionItemProps[]; actions: ActionItemProps[];
@ -101,6 +112,7 @@ const ACTION_GENERATING_TEXT_MAP = {
["rg"]: "Searching...", ["rg"]: "Searching...",
[installDependenciesTool.name]: "Installing dependencies...", [installDependenciesTool.name]: "Installing dependencies...",
[plannerNotesTool.name]: "Saving notes...", [plannerNotesTool.name]: "Saving notes...",
[getURLContentTool.name]: "Fetching URL content...",
}; };
function ActionItem(props: ActionItemProps) { function ActionItem(props: ActionItemProps) {
@ -143,6 +155,10 @@ function ActionItem(props: ActionItemProps) {
return props.success ? "Dependencies installed" : "Installation failed"; return props.success ? "Dependencies installed" : "Installation failed";
} else if (props.actionType === "planner_notes") { } else if (props.actionType === "planner_notes") {
return props.success ? "Notes saved" : "Failed to save notes"; return props.success ? "Notes saved" : "Failed to save notes";
} else if (props.actionType === "get_url_content") {
return props.success
? "URL content fetched"
: "Failed to fetch URL content";
} }
} }
@ -156,7 +172,8 @@ function ActionItem(props: ActionItemProps) {
if ( if (
props.actionType === "shell" || props.actionType === "shell" ||
props.actionType === "rg" || props.actionType === "rg" ||
props.actionType === "install_dependencies" props.actionType === "install_dependencies" ||
props.actionType === "get_url_content"
) { ) {
return !!props.output; return !!props.output;
} else if (props.actionType === "apply-patch") { } else if (props.actionType === "apply-patch") {
@ -183,6 +200,8 @@ function ActionItem(props: ActionItemProps) {
return <FileCode className="text-muted-foreground mr-2 size-3.5" />; return <FileCode className="text-muted-foreground mr-2 size-3.5" />;
} else if (props.actionType === "rg") { } else if (props.actionType === "rg") {
return <Search className="text-muted-foreground mr-2 size-3.5" />; return <Search className="text-muted-foreground mr-2 size-3.5" />;
} else if (props.actionType === "get_url_content") {
return <Globe className="text-muted-foreground mr-2 size-3.5" />;
} else { } else {
return <Terminal className="text-muted-foreground mr-2 size-3.5" />; return <Terminal className="text-muted-foreground mr-2 size-3.5" />;
} }
@ -208,6 +227,16 @@ function ActionItem(props: ActionItemProps) {
); );
} }
if (props.actionType === "get_url_content") {
return (
<div className="flex-1">
<code className="text-foreground/80 text-xs font-normal">
{props.url}
</code>
</div>
);
}
if ( if (
props.actionType === "shell" || props.actionType === "shell" ||
props.actionType === "install_dependencies" props.actionType === "install_dependencies"
@ -291,6 +320,14 @@ function ActionItem(props: ActionItemProps) {
)} )}
</div> </div>
); );
} else if (props.actionType === "get_url_content" && props.output) {
return (
<div className="bg-muted text-foreground/90 overflow-x-auto p-2 dark:bg-gray-900">
<pre className="text-xs font-normal whitespace-pre-wrap">
{props.output}
</pre>
</div>
);
} else if (props.actionType === "apply-patch" && props.diff) { } else if (props.actionType === "apply-patch" && props.diff) {
return ( return (
<div className="bg-muted overflow-x-auto p-2 dark:bg-gray-900"> <div className="bg-muted overflow-x-auto p-2 dark:bg-gray-900">

View file

@ -32,6 +32,7 @@ import {
createInstallDependenciesToolFields, createInstallDependenciesToolFields,
createTakePlannerNotesFields, createTakePlannerNotesFields,
createDiagnoseErrorToolFields, createDiagnoseErrorToolFields,
createGetURLContentToolFields,
} from "@open-swe/shared/open-swe/tools"; } from "@open-swe/shared/open-swe/tools";
import { z } from "zod"; import { z } from "zod";
import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages"; import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages";
@ -59,6 +60,9 @@ type PlannerNotesToolArgs = z.infer<typeof plannerNotesTool.schema>;
const diagnoseErrorTool = createDiagnoseErrorToolFields(); const diagnoseErrorTool = createDiagnoseErrorToolFields();
type DiagnoseErrorToolArgs = z.infer<typeof diagnoseErrorTool.schema>; type DiagnoseErrorToolArgs = z.infer<typeof diagnoseErrorTool.schema>;
const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
function CustomComponent({ function CustomComponent({
message, message,
thread, thread,
@ -185,6 +189,16 @@ export function mapToolMessageToActionStepProps(
notes: args.notes || [], notes: args.notes || [],
reasoningText, reasoningText,
}; };
} else if (toolCall?.name === getURLContentTool.name) {
const args = toolCall.args as GetURLContentToolArgs;
return {
actionType: "get_url_content",
status,
success,
url: args.url || "",
output: getContentString(message.content),
reasoningText,
};
} }
return { return {
status: "loading", status: "loading",
@ -249,7 +263,8 @@ export function AssistantMessage({
tc.name === applyPatchTool.name || tc.name === applyPatchTool.name ||
tc.name === rgTool.name || tc.name === rgTool.name ||
tc.name === installDependenciesTool.name || tc.name === installDependenciesTool.name ||
tc.name === plannerNotesTool.name, tc.name === plannerNotesTool.name ||
tc.name === getURLContentTool.name,
) )
: []; : [];
@ -397,6 +412,14 @@ export function AssistantMessage({
status: "generating", status: "generating",
notes: args?.notes || [], notes: args?.notes || [],
} as ActionItemProps; } as ActionItemProps;
} else if (toolCall.name === getURLContentTool.name) {
const args = toolCall.args as GetURLContentToolArgs;
return {
actionType: "get_url_content",
status: "generating",
url: args?.url || "",
output: "",
} as ActionItemProps;
} else { } else {
if (isShellTool) { if (isShellTool) {
const args = toolCall.args as ShellToolArgs; const args = toolCall.args as ShellToolArgs;

View file

@ -281,3 +281,19 @@ export function createDiagnoseErrorToolFields() {
schema: diagnoseErrorToolSchema, schema: diagnoseErrorToolSchema,
}; };
} }
export function createGetURLContentToolFields() {
const getURLContentSchema = z.object({
url: z
.string()
.describe(
"The URL to get the content of. Returns the page content in markdown format.",
),
});
return {
name: "get_url_content",
description: "Get the full page content of a given URL in markdown format.",
schema: getURLContentSchema,
};
}

847
yarn.lock

File diff suppressed because it is too large Load diff