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": {
"@daytonaio/sdk": "^0.18.1",
"@langchain/anthropic": "^0.3.20",
"@langchain/community": "^0.3.47",
"@langchain/core": "^0.3.56",
"@langchain/google-genai": "^0.2.9",
"@langchain/langgraph": "^0.3.3",
"@langchain/langgraph-sdk": "^0.0.85",
"@langchain/openai": "^0.5.10",
"@mendable/firecrawl-js": "^1.29.1",
"@octokit/app": "^16.0.1",
"@octokit/core": "^7.0.2",
"@octokit/rest": "^22.0.0",

View file

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

View file

@ -1,5 +1,8 @@
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 {
PlannerGraphState,
@ -34,10 +37,12 @@ export async function takeActions(
const shellTool = createShellTool(state);
const rgTool = createRgTool(state);
const plannerNotesTool = createPlannerNotesTool();
const getURLContentTool = createGetURLContentTool();
const toolsMap = {
[shellTool.name]: shellTool,
[rgTool.name]: rgTool,
[plannerNotesTool.name]: plannerNotesTool,
[getURLContentTool.name]: getURLContentTool,
};
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({
tool_call_id: toolCall.id ?? "",
content: truncateOutput(result),
content: truncatedOutput,
name: toolCall.name,
status: toolCallStatus,
});

View file

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

View file

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

View file

@ -1,5 +1,6 @@
export * from "./apply-patch.js";
export * from "./shell.js";
export * from "./url-content.js";
export {
createUpdatePlanToolFields,
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,
CloudDownload,
Search,
Globe,
} from "lucide-react";
import {
createApplyPatchToolFields,
createShellToolFields,
createInstallDependenciesToolFields,
createTakePlannerNotesFields,
createGetURLContentToolFields,
formatRgCommand,
RipgrepCommand,
} from "@open-swe/shared/open-swe/tools";
@ -36,6 +38,8 @@ type InstallDependenciesToolArgs = z.infer<
>;
const plannerNotesTool = createTakePlannerNotesFields();
type PlannerNotesToolArgs = z.infer<typeof plannerNotesTool.schema>;
const getURLContentTool = createGetURLContentToolFields();
type GetURLContentToolArgs = z.infer<typeof getURLContentTool.schema>;
// Common props for all action types
type BaseActionProps = {
@ -81,13 +85,20 @@ type PlannerNotesActionProps = BaseActionProps &
actionType: "planner_notes";
};
type GetURLContentActionProps = BaseActionProps &
Partial<GetURLContentToolArgs> & {
actionType: "get_url_content";
output?: string;
};
export type ActionItemProps =
| (BaseActionProps & { status: "loading" })
| ShellActionProps
| PatchActionProps
| RgActionProps
| InstallDependenciesActionProps
| PlannerNotesActionProps;
| PlannerNotesActionProps
| GetURLContentActionProps;
export type ActionStepProps = {
actions: ActionItemProps[];
@ -101,6 +112,7 @@ const ACTION_GENERATING_TEXT_MAP = {
["rg"]: "Searching...",
[installDependenciesTool.name]: "Installing dependencies...",
[plannerNotesTool.name]: "Saving notes...",
[getURLContentTool.name]: "Fetching URL content...",
};
function ActionItem(props: ActionItemProps) {
@ -143,6 +155,10 @@ function ActionItem(props: ActionItemProps) {
return props.success ? "Dependencies installed" : "Installation failed";
} else if (props.actionType === "planner_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 (
props.actionType === "shell" ||
props.actionType === "rg" ||
props.actionType === "install_dependencies"
props.actionType === "install_dependencies" ||
props.actionType === "get_url_content"
) {
return !!props.output;
} 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" />;
} else if (props.actionType === "rg") {
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 {
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 (
props.actionType === "shell" ||
props.actionType === "install_dependencies"
@ -291,6 +320,14 @@ function ActionItem(props: ActionItemProps) {
)}
</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) {
return (
<div className="bg-muted overflow-x-auto p-2 dark:bg-gray-900">

View file

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

View file

@ -281,3 +281,19 @@ export function createDiagnoseErrorToolFields() {
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