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] <open-swe@users.noreply.github.com>
Co-authored-by: Dylan Boudro <121908331+starmorph@users.noreply.github.com>
Co-authored-by: starmorph <dylan@starmorph.com>
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
open-swe[bot] 2025-07-30 17:48:42 -07:00 • committed by GitHub
parent bfa020ef07
commit 2f561796a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 594 additions and 123 deletions

View file

@ -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 = "<open-swe-plan-message>";
const PLAN_MESSAGE_CLOSE_TAG = "</open-swe-plan-message>";
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<void> {
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[];

View file

@ -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 }),

View file

@ -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<string, any> | null => {
try {
const payload = JSON.parse(body);

View file

@ -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 = "<open-swe-plan-message>";
const PLAN_MESSAGE_CLOSE_TAG = "</open-swe-plan-message>";
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<void> {
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
}
}

View file

@ -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 "";
}
};

View file

@ -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 (

View file

@ -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<string | null>(
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<HTMLTextAreaElement>(null);
const getStatusBadge = () => {
if (isResponded) {
return (
<Badge
variant="secondary"
className="border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
>
<Check className="h-3 w-3" />
{hasEventResponse ? "Responded" : "Submitted"}
</Badge>
);
}
switch (status) {
case "loading":
return (
<Badge
variant="secondary"
className="border-orange-200 bg-orange-100 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300"
>
<Clock className="h-3 w-3" />
Preparing
</Badge>
);
case "generating":
return (
<Badge
variant="secondary"
className="border-orange-200 bg-orange-100 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300"
>
<Loader2 className="h-3 w-3 animate-spin" />
Requesting
</Badge>
);
case "done":
return (
<Badge
variant="secondary"
className="border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300"
>
<HelpCircle className="h-3 w-3" />
Awaiting Response
</Badge>
);
}
};
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 (
<div className="border-t p-4">
{helpRequest && (
<div className="mb-4">
<div className="bg-muted/30 rounded-lg p-3">
<h4 className="text-muted-foreground mb-2 text-xs font-medium">
Help Request
</h4>
<div
id="help-request-description"
className="text-foreground text-sm"
>
<BasicMarkdownText className="text-sm">
{helpRequest}
</BasicMarkdownText>
</div>
</div>
</div>
)}
{isResponded && finalResponse ? (
<div className="bg-muted/30 rounded-lg p-3">
<h4 className="text-muted-foreground mb-2 text-xs font-medium">
{hasEventResponse ? "Response" : "Your Response"}
</h4>
<div className="rounded-lg border border-emerald-200 bg-emerald-100/50 p-3 dark:border-emerald-800 dark:bg-emerald-900/30">
<div className="text-sm whitespace-pre-wrap text-emerald-700 dark:text-emerald-300">
{finalResponse}
</div>
</div>
</div>
) : (
(status === "generating" || status === "done") &&
onSubmitResponse &&
!hasEventResponse && (
<div className="space-y-3">
<div className="bg-muted/30 rounded-lg p-3">
<h4 className="text-muted-foreground mb-2 text-xs font-medium">
Your Response
</h4>
<Textarea
ref={textareaRef}
value={userResponse}
onChange={(e) => setUserResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response here... (Ctrl+Enter to submit)"
className="min-h-[80px] text-sm"
aria-label="Human help response"
aria-describedby="help-request-description"
/>
</div>
<Button
onClick={handleSubmit}
disabled={!userResponse.trim()}
size="sm"
className="w-full"
>
<Send className="mr-2 h-3 w-3" />
Submit Response
</Button>
</div>
)
)}
</div>
);
};
return (
<div
className={cn(
"group via-background to-background dark:via-background dark:to-background rounded-xl border bg-gradient-to-br from-orange-50/50 transition-shadow dark:from-orange-950/20",
!expanded ? "shadow-sm hover:shadow-md" : "",
)}
>
{/* Header */}
<div
className={cn(
"relative flex items-center bg-gradient-to-r from-orange-50 to-orange-50/50 p-4 backdrop-blur-sm dark:from-orange-950/30 dark:to-orange-950/10",
!expanded ? "rounded-xl" : "rounded-t-xl rounded-b-none",
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500 shadow-md dark:bg-orange-600">
<HelpCircle className="h-4 w-4 text-white" />
</div>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground text-sm font-semibold">
{getStatusText()}
</h3>
{getStatusBadge()}
</div>
<p className="text-muted-foreground mt-1 text-xs">
{getSubtitleText()}
</p>
</div>
<div className="flex items-center gap-2">
{shouldShowToggle() && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(!expanded)}
className="h-8 px-2"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
!expanded && "-rotate-90",
)}
/>
</Button>
)}
</div>
</div>
{/* Content */}
{renderContent()}
{/* Reasoning Footer */}
{reasoningText && status === "done" && expanded && (
<div className="border-t bg-gradient-to-r from-blue-50 to-blue-50/50 p-3 backdrop-blur-sm dark:from-blue-950/30 dark:to-blue-950/10">
<div className="flex items-start gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 dark:bg-blue-600">
<HelpCircle className="h-3 w-3 text-white" />
</div>
<p className="text-sm text-blue-700 dark:text-blue-300">
{reasoningText}
</p>
</div>
</div>
)}
</div>
);
}

View file

@ -1,14 +1,7 @@
"use client";
import { useState } from "react";
import {
CheckCircle,
Loader2,
ChevronDown,
ChevronUp,
FileText,
MinusCircle,
} from "lucide-react";
import { CheckCircle, Loader2, FileText, MinusCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { BasicMarkdownText } from "../thread/markdown-text";

View file

@ -3,6 +3,7 @@ import {
AIMessage,
Checkpoint,
Message,
StreamMode,
ToolMessage,
} from "@langchain/langgraph-sdk";
import { getContentString } from "../utils";
@ -28,6 +29,7 @@ import {
import { DiagnoseErrorAction } from "@/components/v2/diagnose-error-action";
import { WriteTechnicalNotes } from "@/components/gen-ui/write-technical-notes";
import { CodeReviewStarted } from "@/components/gen-ui/code-review-started";
import { RequestHumanHelp } from "@/components/gen-ui/request-human-help";
import { ToolCall } from "@langchain/core/messages/tool";
import {
createApplyPatchToolFields,
@ -44,6 +46,7 @@ import {
createSearchDocumentForToolFields,
createWriteTechnicalNotesToolFields,
createConversationHistorySummaryToolFields,
createRequestHumanHelpToolFields,
createReviewStartedToolFields,
createScratchpadFields,
createTextEditorToolFields,
@ -54,6 +57,9 @@ import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages";
import { useStream } from "@langchain/langgraph-sdk/react";
import { ConversationHistorySummary } from "@/components/gen-ui/conversation-summary";
import { getMessageContentString } from "@open-swe/shared/messages";
import { HumanResponse } from "@langchain/langgraph/prebuilt";
import { OPEN_SWE_STREAM_MODE } from "@open-swe/shared/constants";
import { CustomNodeEvent } from "@open-swe/shared/open-swe/custom-node-events";
// Used only for Zod type inference.
const dummyRepo = { owner: "dummy", repo: "dummy" };
@ -109,6 +115,8 @@ type ConversationHistorySummaryToolArgs = z.infer<
typeof conversationHistorySummaryTool.schema
>;
const requestHumanHelpTool = createRequestHumanHelpToolFields();
type RequestHumanHelpToolArgs = z.infer<typeof requestHumanHelpTool.schema>;
const textEditorTool = createTextEditorToolFields({
owner: "dummy",
repo: "dummy",
@ -128,6 +136,7 @@ function isMcpTool(toolName: string): boolean {
getURLContentTool.name,
openPrTool.name,
diagnoseErrorTool.name,
requestHumanHelpTool.name,
textEditorTool.name,
viewTool.name,
];
@ -338,20 +347,44 @@ export function mapToolMessageToActionStepProps(
export function AssistantMessage({
message,
isLoading,
handleRegenerate,
threadId,
assistantId,
forceRenderInterrupt = false,
thread,
threadMessages,
modifyRunId,
requestHelpEvents,
}: {
message: Message | undefined;
isLoading: boolean;
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
threadId: string;
assistantId: string;
forceRenderInterrupt?: boolean;
thread: ReturnType<typeof useStream<Record<string, unknown>>>;
threadMessages: Message[];
modifyRunId?: (runId: string) => Promise<void>;
requestHelpEvents?: CustomNodeEvent[];
}) {
const content = message?.content ?? [];
const handleHumanHelpResponse = async (response: string) => {
const humanResponse: HumanResponse[] = [
{
type: "response",
args: response,
},
];
const newRun = await thread.client.runs.create(threadId, assistantId, {
command: { resume: humanResponse },
config: {
recursion_limit: 400,
},
streamResumable: true,
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
await modifyRunId?.(newRun.run_id);
};
const contentString = getContentString(content);
const [hideToolCalls] = useQueryState(
"hideToolCalls",
@ -361,9 +394,7 @@ export function AssistantMessage({
const messages = threadMessages;
const idx = message ? messages.findIndex((m) => m.id === message.id) : -1;
const meta = message ? thread.getMessagesMetadata(message) : undefined;
const threadInterrupt = thread.interrupt;
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const anthropicStreamedToolCalls = Array.isArray(content)
? parseAnthropicStreamedToolCalls(content)
: undefined;
@ -444,6 +475,10 @@ export function AssistantMessage({
? aiToolCalls.find((tc) => tc.name === reviewStartedTool.name)
: undefined;
const requestHumanHelpToolCall = message
? aiToolCalls.find((tc) => tc.name === requestHumanHelpTool.name)
: undefined;
// Check if this is a conversation history summary message
if (conversationHistorySummaryToolCall && aiToolCalls.length === 1) {
const correspondingToolResult = toolResults.find(
@ -476,6 +511,27 @@ export function AssistantMessage({
);
}
if (requestHumanHelpToolCall) {
const correspondingToolResult = toolResults.find(
(tr) => tr && tr.tool_call_id === requestHumanHelpToolCall.id,
);
const args = requestHumanHelpToolCall.args as RequestHumanHelpToolArgs;
const reasoningText = getContentString(content);
return (
<div className="flex flex-col gap-4">
<RequestHumanHelp
status={correspondingToolResult ? "done" : "generating"}
helpRequest={args.help_request}
reasoningText={reasoningText}
onSubmitResponse={handleHumanHelpResponse}
requestHelpEvents={requestHelpEvents}
/>
</div>
);
}
// We can be sure that if either task status tool call is present, it will be the
// only tool call/result we need to render for this message.
if (markTaskCompletedToolCall || markTaskNotCompletedToolCall) {

View file

@ -7,6 +7,7 @@ import {
INITIALIZE_NODE_ID,
ACCEPTED_PLAN_NODE_ID,
mapCustomEventsToSteps,
REQUEST_HELP_NODE_ID,
} from "@open-swe/shared/open-swe/custom-node-events";
import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants";
import { Message } from "@langchain/langgraph-sdk";
@ -66,10 +67,12 @@ interface ActionsRendererProps<
StateType extends PlannerGraphState | GraphState,
> {
runId?: string;
threadId: string;
customNodeEvents: CustomNodeEvent[];
setCustomNodeEvents: Dispatch<SetStateAction<CustomNodeEvent[]>>;
stream: ReturnType<typeof useStream<StateType>>;
taskPlan?: TaskPlan;
modifyRunId?: (runId: string) => Promise<void>;
}
const getCustomNodeEventsFromMessages = (
@ -102,6 +105,8 @@ export function ActionsRenderer<
customNodeEvents,
setCustomNodeEvents,
stream,
threadId,
modifyRunId,
}: ActionsRendererProps<StateType>) {
const [streamLoading, setStreamLoading] = useState(stream.isLoading);
const [errorState, setErrorState] = useState<ErrorState | null>(null);
@ -122,6 +127,11 @@ export function ActionsRenderer<
[customNodeEvents, runId],
);
const requestHelpEvents = useMemo(
() => customNodeEvents.filter((e) => e.nodeId === REQUEST_HELP_NODE_ID),
[customNodeEvents],
);
const steps = mapCustomEventsToSteps(initializeEvents);
const allSuccess =
steps.length > 0 && steps.every((s) => s.status === "success");
@ -223,8 +233,10 @@ export function ActionsRenderer<
thread={stream as UseStream<Record<string, unknown>>}
threadMessages={stream.messages}
message={m}
isLoading={false}
handleRegenerate={() => {}}
modifyRunId={modifyRunId}
threadId={threadId}
assistantId={stream.assistantId}
requestHelpEvents={requestHelpEvents}
/>
))}
{acceptedPlanEvents.length > 0 &&

View file

@ -410,6 +410,7 @@ export function ThreadView({
setCustomPlannerNodeEvents
}
stream={plannerStream}
threadId={plannerSession.threadId}
/>
</div>
) : (
@ -455,6 +456,40 @@ export function ThreadView({
setCustomProgrammerNodeEvents
}
stream={programmerStream}
threadId={programmerSession.threadId}
modifyRunId={async (runId) => {
setProgrammerSession((prev) => {
if (!prev) {
return {
threadId: programmerSession.threadId,
runId,
};
}
return {
...prev,
runId,
};
});
if (plannerSession?.threadId) {
try {
// Attempt to update the planner session with the new run ID of the programmer.
await programmerStream.client.threads.updateState(
plannerSession?.threadId,
{
values: {
programmerSession: {
threadId:
programmerSession.threadId,
runId,
},
},
},
);
} catch {
// no-op
}
}
}}
/>
</div>
) : (

View file

@ -14,4 +14,3 @@
"app": "./apps/open-swe/src/routes/app.ts:app"
}
}

View file

@ -28,6 +28,7 @@ export function isCustomNodeEvent(event: unknown): event is CustomNodeEvent {
}
export const INITIALIZE_NODE_ID = "initialize";
export const ACCEPTED_PLAN_NODE_ID = "accepted-plan";
export const REQUEST_HELP_NODE_ID = "request-help";
export const INIT_STEPS = [
"Resuming sandbox",