mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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:
parent
bfa020ef07
commit
2f561796a5
13 changed files with 594 additions and 123 deletions
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
93
apps/open-swe/src/utils/github/plan.ts
Normal file
93
apps/open-swe/src/utils/github/plan.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
apps/open-swe/src/utils/url-helpers.ts
Normal file
12
apps/open-swe/src/utils/url-helpers.ts
Normal 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 "";
|
||||
}
|
||||
};
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
292
apps/web/src/components/gen-ui/request-human-help.tsx
Normal file
292
apps/web/src/components/gen-ui/request-human-help.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -14,4 +14,3 @@
|
|||
"app": "./apps/open-swe/src/routes/app.ts:app"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue