From 2f561796a5ff07ef0d388ed618bb1144173903b5 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" <215916821+open-swe[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:48:42 -0700 Subject: [PATCH] feat: Implement Request Human Help Tool for Agent-Human Collaboration (#505) * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * fix open-swe syntax errors * fix render text input for human_request_help * improve UI/UX post response (close input render human msg) * remove comments * move request_human_help to its own component file * format * update github comment and abstract getopensweurl util * add username tag in request help comment, rm comments * cr * refactor * cr --------- Co-authored-by: open-swe[bot] Co-authored-by: Dylan Boudro <121908331+starmorph@users.noreply.github.com> Co-authored-by: starmorph Co-authored-by: Brace Sproul --- .../src/graphs/planner/nodes/proposed-plan.ts | 91 +----- .../graphs/programmer/nodes/request-help.ts | 80 ++++- .../src/routes/github/issue-webhook.ts | 14 +- apps/open-swe/src/utils/github/plan.ts | 93 ++++++ apps/open-swe/src/utils/url-helpers.ts | 12 + .../web/src/components/gen-ui/action-step.tsx | 5 +- .../components/gen-ui/request-human-help.tsx | 292 ++++++++++++++++++ .../src/components/gen-ui/task-summary.tsx | 9 +- .../web/src/components/thread/messages/ai.tsx | 68 +++- .../src/components/v2/actions-renderer.tsx | 16 +- apps/web/src/components/v2/thread-view.tsx | 35 +++ langgraph.json | 1 - .../shared/src/open-swe/custom-node-events.ts | 1 + 13 files changed, 594 insertions(+), 123 deletions(-) create mode 100644 apps/open-swe/src/utils/github/plan.ts create mode 100644 apps/open-swe/src/utils/url-helpers.ts create mode 100644 apps/web/src/components/gen-ui/request-human-help.tsx diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index 49d0deef..a230b180 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -39,98 +39,13 @@ import { } from "@open-swe/shared/open-swe/custom-node-events"; import { getDefaultHeaders } from "../../../utils/default-headers.js"; import { getCustomConfigurableFields } from "../../../utils/config.js"; -import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; import { - createIssueComment, - getIssueComments, - updateIssueComment, -} from "../../../utils/github/api.js"; + postGitHubIssueComment, + cleanTaskItems, +} from "../../../utils/github/plan.js"; const logger = createLogger(LogLevel.INFO, "ProposedPlan"); -const PLAN_MESSAGE_OPEN_TAG = ""; -const PLAN_MESSAGE_CLOSE_TAG = ""; - -function formatBodyWithPlanMessage(body: string, message: string): string { - if ( - body.includes(PLAN_MESSAGE_OPEN_TAG) && - body.includes(PLAN_MESSAGE_CLOSE_TAG) - ) { - const bodyBeforeTag = body.split(PLAN_MESSAGE_OPEN_TAG)[0]; - const bodyAfterTag = body.split(PLAN_MESSAGE_CLOSE_TAG)[1]; - const newInnerContents = `\n${PLAN_MESSAGE_OPEN_TAG}\n\n${message}\n\n${PLAN_MESSAGE_CLOSE_TAG}\n`; - return `${bodyBeforeTag}${newInnerContents}${bodyAfterTag}`; - } - - return `${body}\n${PLAN_MESSAGE_OPEN_TAG}\n\n${message}\n\n${PLAN_MESSAGE_CLOSE_TAG}`; -} - -function cleanTaskItems(taskItem: string): string { - return "```\n" + taskItem.replace("```", "\\```") + "\n```"; -} - -/** - * Posts a comment to a GitHub issue using the installation token - */ -async function postGitHubIssueComment(input: { - githubIssueId: number; - targetRepository: { owner: string; repo: string }; - commentBody: string; - config: GraphConfig; -}): Promise { - const { githubIssueId, targetRepository, commentBody, config } = input; - const githubAppName = process.env.GITHUB_APP_NAME; - if (!githubAppName) { - throw new Error("GITHUB_APP_NAME not set"); - } - - try { - const { githubInstallationToken } = getGitHubTokensFromConfig(config); - const existingComments = await getIssueComments({ - owner: targetRepository.owner, - repo: targetRepository.repo, - issueNumber: githubIssueId, - githubInstallationToken, - filterBotComments: false, - }); - - const existingOpenSWEComment = existingComments?.findLast((c) => - c.user?.login?.toLowerCase()?.startsWith(githubAppName.toLowerCase()), - ); - - if (!existingOpenSWEComment) { - await createIssueComment({ - owner: targetRepository.owner, - repo: targetRepository.repo, - issueNumber: githubIssueId, - body: commentBody, - githubToken: githubInstallationToken, - }); - - logger.info(`Posted comment to GitHub issue #${githubIssueId}`); - return; - } - - // Update the comment - const newCommentBody = formatBodyWithPlanMessage( - existingOpenSWEComment.body ?? "", - commentBody, - ); - await updateIssueComment({ - owner: targetRepository.owner, - repo: targetRepository.repo, - commentId: existingOpenSWEComment.id, - body: newCommentBody, - githubInstallationToken, - }); - - logger.info(`Updated comment to GitHub issue #${githubIssueId}`); - } catch (error) { - logger.error("Failed to post GitHub comment:", error); - // Don't throw - we don't want to fail the entire process if comment posting fails - } -} - function createAcceptedPlanMessage(input: { planTitle: string; planItems: PlanItem[]; diff --git a/apps/open-swe/src/graphs/programmer/nodes/request-help.ts b/apps/open-swe/src/graphs/programmer/nodes/request-help.ts index a254e39b..c27e8bb5 100644 --- a/apps/open-swe/src/graphs/programmer/nodes/request-help.ts +++ b/apps/open-swe/src/graphs/programmer/nodes/request-help.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from "uuid"; -import { isAIMessage, ToolMessage } from "@langchain/core/messages"; +import { AIMessage, isAIMessage, ToolMessage } from "@langchain/core/messages"; import { GraphConfig, GraphState, @@ -7,10 +7,20 @@ import { } from "@open-swe/shared/open-swe/types"; import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt"; import { END, interrupt, Command } from "@langchain/langgraph"; +import { + DO_NOT_RENDER_ID_PREFIX, + GITHUB_USER_LOGIN_HEADER, +} from "@open-swe/shared/constants"; import { getSandboxWithErrorHandling, stopSandbox, } from "../../../utils/sandbox.js"; +import { getOpenSweAppUrl } from "../../../utils/url-helpers.js"; +import { + CustomNodeEvent, + REQUEST_HELP_NODE_ID, +} from "@open-swe/shared/open-swe/custom-node-events"; +import { postGitHubIssueComment } from "../../../utils/github/plan.js"; const constructDescription = (helpRequest: string): string => { return `The agent has requested help. Here is the help request: @@ -20,6 +30,16 @@ ${helpRequest} \`\`\``; }; +const createEventsMessage = (events: CustomNodeEvent[]) => + new AIMessage({ + id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`, + content: "Request help response", + additional_kwargs: { + hidden: true, + customNodeEvents: events, + }, + }); + export async function requestHelp( state: GraphState, config: GraphConfig, @@ -35,6 +55,42 @@ export async function requestHelp( const toolCall = lastMessage.tool_calls[0]; + const threadId = config.configurable?.thread_id; + if (!threadId) { + throw new Error("Thread ID not found in config"); + } + + const userLogin = config.configurable?.[GITHUB_USER_LOGIN_HEADER]; + const userTag = userLogin ? `@${userLogin} ` : ""; + + const runUrl = getOpenSweAppUrl(threadId); + const commentBody = runUrl + ? `### 🤖 Open SWE Needs Help + +${userTag}I've encountered a situation where I need human assistance to continue. + +**Help Request:** +${toolCall.args.help_request} + +You can view and respond to this request in the [Open SWE interface](${runUrl}). + +Please provide guidance so I can continue working on this issue.` + : `### 🤖 Open SWE Needs Help + +${userTag}I've encountered a situation where I need human assistance to continue. + +**Help Request:** +${toolCall.args.help_request} + +Please check the Open SWE interface to respond to this request.`; + + await postGitHubIssueComment({ + githubIssueId: state.githubIssueId, + targetRepository: state.targetRepository, + commentBody, + config, + }); + const interruptInput: HumanInterrupt = { action_request: { action: "Help Requested", @@ -78,8 +134,28 @@ export async function requestHelp( status: "success", }); + const customEvent = [ + { + nodeId: REQUEST_HELP_NODE_ID, + actionId: uuidv4(), + action: "Help request response", + createdAt: new Date().toISOString(), + data: { + status: "success" as const, + response: interruptRes.args, + runId: config.configurable?.run_id ?? "", + }, + }, + ]; + try { + config?.writer?.(customEvent); + } catch { + // no-op + } + + const humanResponseCustomEventMsg = createEventsMessage(customEvent); const commandUpdate: GraphUpdate = { - messages: [toolMessage], + messages: [toolMessage, humanResponseCustomEventMsg], internalMessages: [toolMessage], sandboxSessionId: sandbox.id, ...(codebaseTree && { codebaseTree }), diff --git a/apps/open-swe/src/routes/github/issue-webhook.ts b/apps/open-swe/src/routes/github/issue-webhook.ts index 3e2e0f41..098ff857 100644 --- a/apps/open-swe/src/routes/github/issue-webhook.ts +++ b/apps/open-swe/src/routes/github/issue-webhook.ts @@ -25,6 +25,7 @@ import { import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; import { RequestSource } from "../../constants.js"; import { isAllowedUser } from "@open-swe/shared/github/allowed-users"; +import { getOpenSweAppUrl } from "../../utils/url-helpers.js"; import { StreamMode } from "@langchain/langgraph-sdk"; const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook"); @@ -37,19 +38,6 @@ const webhooks = new Webhooks({ secret: GITHUB_WEBHOOK_SECRET, }); -const getOpenSweAppUrl = (threadId: string) => { - if (!process.env.OPEN_SWE_APP_URL) { - return ""; - } - try { - const baseUrl = new URL(process.env.OPEN_SWE_APP_URL); - baseUrl.pathname = `/chat/${threadId}`; - return baseUrl.toString(); - } catch { - return ""; - } -}; - const getPayload = (body: string): Record | null => { try { const payload = JSON.parse(body); diff --git a/apps/open-swe/src/utils/github/plan.ts b/apps/open-swe/src/utils/github/plan.ts new file mode 100644 index 00000000..747f3849 --- /dev/null +++ b/apps/open-swe/src/utils/github/plan.ts @@ -0,0 +1,93 @@ +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { getGitHubTokensFromConfig } from "../github-tokens.js"; +import { + createIssueComment, + getIssueComments, + updateIssueComment, +} from "./api.js"; +import { createLogger, LogLevel } from "../logger.js"; + +const logger = createLogger(LogLevel.INFO, "GitHubPlan"); + +const PLAN_MESSAGE_OPEN_TAG = ""; +const PLAN_MESSAGE_CLOSE_TAG = ""; + +function formatBodyWithPlanMessage(body: string, message: string): string { + if ( + body.includes(PLAN_MESSAGE_OPEN_TAG) && + body.includes(PLAN_MESSAGE_CLOSE_TAG) + ) { + const bodyBeforeTag = body.split(PLAN_MESSAGE_OPEN_TAG)[0]; + const bodyAfterTag = body.split(PLAN_MESSAGE_CLOSE_TAG)[1]; + const newInnerContents = `\n${PLAN_MESSAGE_OPEN_TAG}\n\n${message}\n\n${PLAN_MESSAGE_CLOSE_TAG}\n`; + return `${bodyBeforeTag}${newInnerContents}${bodyAfterTag}`; + } + + return `${body}\n${PLAN_MESSAGE_OPEN_TAG}\n\n${message}\n\n${PLAN_MESSAGE_CLOSE_TAG}`; +} + +export function cleanTaskItems(taskItem: string): string { + return "```\n" + taskItem.replace("```", "\\```") + "\n```"; +} + +/** + * Posts a comment to a GitHub issue using the installation token + */ +export async function postGitHubIssueComment(input: { + githubIssueId: number; + targetRepository: { owner: string; repo: string }; + commentBody: string; + config: GraphConfig; +}): Promise { + const { githubIssueId, targetRepository, commentBody, config } = input; + const githubAppName = process.env.GITHUB_APP_NAME; + if (!githubAppName) { + throw new Error("GITHUB_APP_NAME not set"); + } + + try { + const { githubInstallationToken } = getGitHubTokensFromConfig(config); + const existingComments = await getIssueComments({ + owner: targetRepository.owner, + repo: targetRepository.repo, + issueNumber: githubIssueId, + githubInstallationToken, + filterBotComments: false, + }); + + const existingOpenSWEComment = existingComments?.findLast((c) => + c.user?.login?.toLowerCase()?.startsWith(githubAppName.toLowerCase()), + ); + + if (!existingOpenSWEComment) { + await createIssueComment({ + owner: targetRepository.owner, + repo: targetRepository.repo, + issueNumber: githubIssueId, + body: commentBody, + githubToken: githubInstallationToken, + }); + + logger.info(`Posted comment to GitHub issue #${githubIssueId}`); + return; + } + + // Update the comment + const newCommentBody = formatBodyWithPlanMessage( + existingOpenSWEComment.body ?? "", + commentBody, + ); + await updateIssueComment({ + owner: targetRepository.owner, + repo: targetRepository.repo, + commentId: existingOpenSWEComment.id, + body: newCommentBody, + githubInstallationToken, + }); + + logger.info(`Updated comment to GitHub issue #${githubIssueId}`); + } catch (error) { + logger.error("Failed to post GitHub comment:", error); + // Don't throw - we don't want to fail the entire process if comment posting fails + } +} diff --git a/apps/open-swe/src/utils/url-helpers.ts b/apps/open-swe/src/utils/url-helpers.ts new file mode 100644 index 00000000..8972eb8c --- /dev/null +++ b/apps/open-swe/src/utils/url-helpers.ts @@ -0,0 +1,12 @@ +export const getOpenSweAppUrl = (threadId: string): string => { + if (!process.env.OPEN_SWE_APP_URL) { + return ""; + } + try { + const baseUrl = new URL(process.env.OPEN_SWE_APP_URL); + baseUrl.pathname = `/chat/${threadId}`; + return baseUrl.toString(); + } catch { + return ""; + } +}; diff --git a/apps/web/src/components/gen-ui/action-step.tsx b/apps/web/src/components/gen-ui/action-step.tsx index ef64e09e..57c45b5e 100644 --- a/apps/web/src/components/gen-ui/action-step.tsx +++ b/apps/web/src/components/gen-ui/action-step.tsx @@ -264,7 +264,6 @@ function ActionItem(props: ActionItemProps) { return ""; }; - // Determine if we should show the content toggle button const shouldShowToggle = () => { if (props.status !== "done") return false; @@ -542,8 +541,8 @@ function ActionItem(props: ActionItemProps) { // Render the content based on action type const renderContent = () => { - if (props.status !== "done" || !("actionType" in props)) return null; - + if (!("actionType" in props)) return null; + if (props.status !== "done") return null; if (!expanded) return null; if ( diff --git a/apps/web/src/components/gen-ui/request-human-help.tsx b/apps/web/src/components/gen-ui/request-human-help.tsx new file mode 100644 index 00000000..4d138214 --- /dev/null +++ b/apps/web/src/components/gen-ui/request-human-help.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { + HelpCircle, + Loader2, + CheckCircle, + ChevronDown, + Send, + Clock, + Check, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { BasicMarkdownText } from "../thread/markdown-text"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { Textarea } from "../ui/textarea"; +import { CustomNodeEvent } from "@open-swe/shared/open-swe/custom-node-events"; + +type RequestHumanHelpProps = { + status: "loading" | "generating" | "done"; + helpRequest?: string; + reasoningText?: string; + onSubmitResponse?: (response: string) => void; + requestHelpEvents?: CustomNodeEvent[]; +}; + +export function RequestHumanHelp({ + status, + helpRequest, + reasoningText, + onSubmitResponse, + requestHelpEvents, +}: RequestHumanHelpProps) { + const [expanded, setExpanded] = useState(true); + const [userResponse, setUserResponse] = useState(""); + const [submittedResponse, setSubmittedResponse] = useState( + null, + ); + const [hasSubmitted, setHasSubmitted] = useState(false); + + // Extract response from requestHelpEvents if available + const eventResponse = requestHelpEvents?.find( + (event) => + event.data && typeof event.data === "object" && "response" in event.data, + )?.data?.response as string | undefined; + + const hasEventResponse = !!eventResponse; + const finalResponse = eventResponse || submittedResponse; + const isResponded = hasEventResponse || hasSubmitted; + const textareaRef = useRef(null); + + const getStatusBadge = () => { + if (isResponded) { + return ( + + + {hasEventResponse ? "Responded" : "Submitted"} + + ); + } + switch (status) { + case "loading": + return ( + + + Preparing + + ); + case "generating": + return ( + + + Requesting + + ); + case "done": + return ( + + + Awaiting Response + + ); + } + }; + + const getStatusText = () => { + if (isResponded) { + return "Human Help Response"; + } + return "Human Help Requested"; + }; + + const getSubtitleText = () => { + if (hasEventResponse) { + return "Response received"; + } + if (hasSubmitted) { + return "Response submitted successfully"; + } + switch (status) { + case "loading": + return "Preparing help request..."; + case "generating": + return "Requesting human assistance..."; + case "done": + return "Awaiting your response"; + } + }; + + const shouldShowToggle = () => { + return ( + !!helpRequest && + (status === "generating" || status === "done" || isResponded) + ); + }; + + const handleSubmit = useCallback(() => { + if (userResponse.trim() && onSubmitResponse) { + const response = userResponse.trim(); + setSubmittedResponse(response); + setHasSubmitted(true); + onSubmitResponse(response); + setUserResponse(""); + } + }, [userResponse, onSubmitResponse]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit], + ); + + const renderContent = () => { + if (!expanded) return null; + + const shouldShowContent = + status === "done" || status === "generating" || isResponded; + + if (!shouldShowContent) return null; + + return ( +
+ {helpRequest && ( +
+
+

+ Help Request +

+
+ + {helpRequest} + +
+
+
+ )} + + {isResponded && finalResponse ? ( +
+

+ {hasEventResponse ? "Response" : "Your Response"} +

+
+
+ {finalResponse} +
+
+
+ ) : ( + (status === "generating" || status === "done") && + onSubmitResponse && + !hasEventResponse && ( +
+
+

+ Your Response +

+