fix: Improve plan interrupt UI (#167)

* fix: Improve plan interrupt UI

* format

* remove old interrupt code

* cr

* bump dep

* bump deps

* fix deps

* cr

* cr

* cr
This commit is contained in:
Brace Sproul 2025-06-13 17:08:26 -07:00 • committed by GitHub
parent b25a29f126
commit 3314821e19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 375 additions and 182 deletions

View file

@ -26,7 +26,7 @@
"@langchain/anthropic": "^0.3.20", "@langchain/anthropic": "^0.3.20",
"@langchain/core": "^0.3.56", "@langchain/core": "^0.3.56",
"@langchain/google-genai": "^0.2.9", "@langchain/google-genai": "^0.2.9",
"@langchain/langgraph": "^0.3.1", "@langchain/langgraph": "^0.3.3",
"@langchain/langgraph-sdk": "^0.0.83", "@langchain/langgraph-sdk": "^0.0.83",
"@langchain/openai": "^0.5.10", "@langchain/openai": "^0.5.10",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
@ -35,7 +35,7 @@
"langchain": "^0.3.26", "langchain": "^0.3.26",
"langsmith": "^0.3.29", "langsmith": "^0.3.29",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"zod": "^3.23.8" "zod": "^3.25.32"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",

View file

@ -6,19 +6,16 @@ import { GraphAnnotation } from "@open-swe/shared/open-swe/types";
import { withLangGraph } from "@langchain/langgraph/zod"; import { withLangGraph } from "@langchain/langgraph/zod";
export const PlannerGraphStateObj = GraphAnnotation.extend({ export const PlannerGraphStateObj = GraphAnnotation.extend({
plannerMessages: withLangGraph<BaseMessage[], Messages>( plannerMessages: withLangGraph(z.custom<BaseMessage[]>(), {
z.custom<BaseMessage[]>(), reducer: {
{ schema: z.custom<Messages>(),
reducer: { fn: messagesStateReducer,
schema: z.custom<Messages>(),
fn: messagesStateReducer,
},
jsonSchemaExtra: {
langgraph_type: "messages",
},
default: () => [],
}, },
), jsonSchemaExtra: {
langgraph_type: "messages",
},
default: () => [],
}),
}); });
export type PlannerGraphState = z.infer<typeof PlannerGraphStateObj>; export type PlannerGraphState = z.infer<typeof PlannerGraphStateObj>;

View file

@ -20,7 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@langchain/core": "^0.3.57", "@langchain/core": "^0.3.57",
"@langchain/langgraph": "^0.3.1", "@langchain/langgraph": "^0.3.3",
"@langchain/langgraph-sdk": "^0.0.83", "@langchain/langgraph-sdk": "^0.0.83",
"@open-swe/shared": "*", "@open-swe/shared": "*",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
@ -65,7 +65,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.0.46", "use-stick-to-bottom": "^1.0.46",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zod": "^3.24.2", "zod": "^3.25.32",
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,18 +1,26 @@
import { Check, Clock, Play } from "lucide-react"; import { Check, Clock, Pencil, Play, Trash } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlanItem } from "@open-swe/shared/open-swe/types"; import { PlanItem } from "@open-swe/shared/open-swe/types";
import { TooltipIconButton } from "../ui/tooltip-icon-button";
import { Dispatch, SetStateAction, useState } from "react";
import { Textarea } from "../ui/textarea";
import { Button } from "../ui/button";
interface PlanViewerProps { interface PlanViewerProps {
planItems: PlanItem[]; planItems: PlanItem[];
className?: string; className?: string;
isProposedPlan?: boolean; isProposedPlan?: boolean;
setPlanItems?: Dispatch<SetStateAction<PlanItem[]>>;
} }
export function PlanViewer({ export function PlanViewer({
planItems, planItems,
className, className,
isProposedPlan, isProposedPlan,
setPlanItems,
}: PlanViewerProps) { }: PlanViewerProps) {
const [isEditing, setIsEditing] = useState<Record<number, boolean>>({});
const [newPlanItem, setNewPlanItem] = useState("");
const currentTaskIndex = planItems const currentTaskIndex = planItems
.filter((item) => !item.completed) .filter((item) => !item.completed)
.reduce( .reduce(
@ -77,37 +85,96 @@ export function PlanViewer({
{/* Task Content */} {/* Task Content */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex items-center justify-between text-gray-500">
<span className="text-xs font-medium text-gray-500"> <div className="flex items-center gap-2">
Step {item.index + 1} <span className="text-xs font-medium">
</span> Step {item.index + 1}
{status === "current" && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
In Progress
</span>
)}
{status === "completed" && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
Done
</span>
)}
{status === "proposed" && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
Proposed
</span> </span>
{status === "current" && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
In Progress
</span>
)}
{status === "completed" && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
Done
</span>
)}
{status === "proposed" && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
Proposed
</span>
)}
</div>
{isProposedPlan && (
<div className="flex items-center gap-2">
<TooltipIconButton
onClick={() =>
setIsEditing((p) => ({
...p,
[item.index]: !p[item.index],
}))
}
tooltip={
isEditing[item.index] ? `Save Plan` : `Edit Plan`
}
>
{isEditing[item.index] ? (
<Check className="size-4" />
) : (
<Pencil className="size-4" />
)}
</TooltipIconButton>
<TooltipIconButton
onClick={() =>
setPlanItems?.((prev) => {
const newArr = prev.filter(
(i) => i.index !== item.index,
);
return newArr.map((i, index) => ({
...i,
index,
}));
})
}
tooltip="Delete Plan"
className="transition-colors hover:text-red-500"
>
<Trash className="size-4" />
</TooltipIconButton>
</div>
)} )}
</div> </div>
<p {isEditing[item.index] ? (
className={cn("text-sm leading-relaxed", { <Textarea
"text-gray-900": status === "current", value={planItems[item.index].plan}
"text-gray-600": onChange={(e) => {
status === "completed" || setPlanItems?.((prev) => {
["remaining", "proposed"].includes(status), return prev.map((i) => {
})} if (i.index === item.index) {
> return {
{item.plan} ...i,
</p> plan: e.target.value,
};
}
return i;
});
});
}}
/>
) : (
<p
className={cn("text-sm leading-relaxed", {
"text-gray-900": status === "current",
"text-gray-600":
status === "completed" ||
["remaining", "proposed"].includes(status),
})}
>
{planItems[item.index].plan}
</p>
)}
{/* Summary for completed tasks */} {/* Summary for completed tasks */}
{item.summary && status === "completed" && ( {item.summary && status === "completed" && (
@ -122,6 +189,37 @@ export function PlanViewer({
</div> </div>
); );
})} })}
{isProposedPlan && (
<div
key="add-new-plan-item"
className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 transition-colors"
>
<Textarea
placeholder="Add new plan item"
value={newPlanItem}
onChange={(e) => setNewPlanItem(e.target.value)}
/>
<Button
onClick={() => {
setPlanItems?.((prev) => [
...prev,
{
index: prev.length,
plan: newPlanItem,
completed: false,
summary: undefined,
},
]);
setNewPlanItem("");
}}
className="mt-auto"
size="sm"
variant="outline"
>
Add
</Button>
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,46 @@
import { PlanViewer } from "./plan-viewer";
import { Button } from "../ui/button";
import { useProposedPlan } from "../thread/agent-inbox/hooks/useProposedPlan";
import { PlanItem } from "@open-swe/shared/open-swe/types";
export function ProposedPlan({
originalPlanItems,
}: {
originalPlanItems: PlanItem[];
}) {
const {
planItems,
setPlanItems,
changesMade,
handleResumePlan,
handleRejectPlan,
} = useProposedPlan(originalPlanItems);
if (!planItems.length) return null;
return (
<div className="my-4 flex flex-col gap-4">
<PlanViewer
planItems={planItems}
setPlanItems={setPlanItems}
isProposedPlan={true}
/>
<div className="flex w-full flex-row items-center justify-center gap-2">
<Button
onClick={handleResumePlan}
className="w-full"
variant="brand"
>
{changesMade ? "Submit" : "Approve"}
</Button>
<Button
onClick={handleRejectPlan}
className="w-full"
variant="destructive"
>
Reject
</Button>
</div>
</div>
);
}

View file

@ -8,8 +8,6 @@ import { MarkdownText } from "../../markdown-text";
import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt"; import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { toast } from "sonner"; import { toast } from "sonner";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { PlanViewer } from "@/components/plan";
import { isPlanData, parsePlanData, getPlanKey } from "@/lib/plan-utils";
interface InboxItemInputProps { interface InboxItemInputProps {
interruptValue: HumanInterrupt; interruptValue: HumanInterrupt;
@ -50,36 +48,7 @@ function ResetButton({ handleReset }: { handleReset: () => void }) {
); );
} }
function ArgsRenderer({ function ArgsRenderer({ args }: { args: Record<string, any> }) {
args,
actionName,
}: {
args: Record<string, any>;
actionName: string;
}) {
if (isPlanData(args, actionName)) {
const planItems = parsePlanData(args);
const planKey = getPlanKey(args);
if (planItems.length > 0) {
return (
<div className="flex w-full flex-col items-start gap-6">
<div className="flex w-full flex-col items-start gap-1">
<p className="text-sm leading-[18px] text-wrap text-gray-600">
{prettifyText(planKey || "plan")}:
</p>
<div className="w-full max-w-full rounded-xl border border-gray-200 bg-white p-4">
<PlanViewer
planItems={planItems}
isProposedPlan
/>
</div>
</div>
</div>
);
}
}
return ( return (
<div className="flex w-full flex-col items-start gap-6"> <div className="flex w-full flex-col items-start gap-6">
{Object.entries(args).map(([k, v]) => { {Object.entries(args).map(([k, v]) => {
@ -151,10 +120,7 @@ function ResponseComponent({
</div> </div>
{showArgsInResponse && ( {showArgsInResponse && (
<ArgsRenderer <ArgsRenderer args={interruptValue.action_request.args} />
args={interruptValue.action_request.args}
actionName={interruptValue.action_request.action}
/>
)} )}
<div className="flex w-full flex-col items-start gap-[6px]"> <div className="flex w-full flex-col items-start gap-[6px]">
@ -186,12 +152,10 @@ const Response = React.memo(ResponseComponent);
function AcceptComponent({ function AcceptComponent({
streaming, streaming,
actionRequestArgs, actionRequestArgs,
actionName,
handleSubmit, handleSubmit,
}: { }: {
streaming: boolean; streaming: boolean;
actionRequestArgs: Record<string, any>; actionRequestArgs: Record<string, any>;
actionName: string;
handleSubmit: ( handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent, e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>; ) => Promise<void>;
@ -199,10 +163,7 @@ function AcceptComponent({
return ( return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6"> <div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6">
{actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && ( {actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer <ArgsRenderer args={actionRequestArgs} />
args={actionRequestArgs}
actionName={actionName}
/>
)} )}
<Button <Button
variant="brand" variant="brand"
@ -238,13 +199,10 @@ function EditAndOrAcceptComponent({
) => Promise<void>; ) => Promise<void>;
}) { }) {
const defaultRows = React.useRef<Record<string, number>>({}); const defaultRows = React.useRef<Record<string, number>>({});
const [editingField, setEditingField] = React.useState<string | null>(null);
const editResponse = humanResponse.find((r) => r.type === "edit"); const editResponse = humanResponse.find((r) => r.type === "edit");
const acceptResponse = humanResponse.find((r) => r.type === "accept"); const acceptResponse = humanResponse.find((r) => r.type === "accept");
const actionName = interruptValue.action_request.action;
if ( if (
!editResponse || !editResponse ||
typeof editResponse.args !== "object" || typeof editResponse.args !== "object" ||
@ -253,7 +211,6 @@ function EditAndOrAcceptComponent({
if (acceptResponse) { if (acceptResponse) {
return ( return (
<AcceptComponent <AcceptComponent
actionName={actionName}
actionRequestArgs={interruptValue.action_request.args} actionRequestArgs={interruptValue.action_request.args}
streaming={streaming} streaming={streaming}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
@ -315,11 +272,6 @@ function EditAndOrAcceptComponent({
? v ? v
: JSON.stringify(v, null); : JSON.stringify(v, null);
// Check if this field contains plan data and user isn't actively editing it
const isFieldBeingEdited = editingField === k;
const fieldArgs = { [k]: v };
const isFieldPlanData = isPlanData(fieldArgs, actionName);
// Calculate the default number of rows by the total length of the initial value divided by 30 // Calculate the default number of rows by the total length of the initial value divided by 30
// or 8, whichever is greater. Stored in a ref to prevent re-rendering. // or 8, whichever is greater. Stored in a ref to prevent re-rendering.
if ( if (
@ -339,49 +291,16 @@ function EditAndOrAcceptComponent({
key={`allow-edit-args--${k}-${idx}`} key={`allow-edit-args--${k}-${idx}`}
> >
<div className="flex w-full flex-col items-start gap-[6px]"> <div className="flex w-full flex-col items-start gap-[6px]">
<div className="flex w-full items-center justify-between"> <p className="min-w-fit text-sm font-medium">{prettifyText(k)}</p>
<p className="min-w-fit text-sm font-medium">
{prettifyText(k)}
</p>
{isFieldPlanData && !isFieldBeingEdited && (
<button
onClick={() => setEditingField(k)}
className="rounded border border-blue-200 px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 hover:text-blue-800"
>
Edit as Text
</button>
)}
{isFieldBeingEdited && (
<button
onClick={() => setEditingField(null)}
className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 hover:text-gray-800"
>
Show Plan View
</button>
)}
</div>
{isFieldPlanData && !isFieldBeingEdited ? ( <Textarea
// Show plan viewer for plan data when not editing disabled={streaming}
<div className="w-full rounded-lg border border-gray-200 bg-white p-3"> className="h-full"
<PlanViewer value={value}
planItems={parsePlanData(fieldArgs)} onChange={(e) => onEditChange(e.target.value, editResponse, k)}
isProposedPlan onKeyDown={handleKeyDown}
/> rows={numRows}
</div> />
) : (
// Show textarea for editing or non-plan data
<Textarea
disabled={streaming}
className="h-full"
value={value}
onChange={(e) =>
onEditChange(e.target.value, editResponse, k)
}
onKeyDown={handleKeyDown}
rows={numRows}
/>
)}
</div> </div>
</div> </div>
); );
@ -424,7 +343,6 @@ export function InboxItemInput({
hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed; hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed;
const showArgsOutsideActionCards = const showArgsOutsideActionCards =
hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed; hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed;
const actionName = interruptValue.action_request.action;
const onEditChange = ( const onEditChange = (
change: string | string[], change: string | string[],
@ -580,10 +498,7 @@ export function InboxItemInput({
return ( return (
<div className="flex w-full flex-col items-start justify-start gap-2"> <div className="flex w-full flex-col items-start justify-start gap-2">
{showArgsOutsideActionCards && ( {showArgsOutsideActionCards && (
<ArgsRenderer <ArgsRenderer args={interruptValue.action_request.args} />
args={interruptValue.action_request.args}
actionName={actionName}
/>
)} )}
<div className="flex w-full flex-col items-start gap-2"> <div className="flex w-full flex-col items-start gap-2">

View file

@ -0,0 +1,81 @@
import { HumanResponse } from "@langchain/langgraph/prebuilt";
import { useEffect, useState } from "react";
import { PlanItem } from "@open-swe/shared/open-swe/types";
import { convertPlanItemsToInterruptString } from "@/lib/plan-utils";
import { useStreamContext } from "@/providers/Stream";
import { PLAN_INTERRUPT_ACTION_TITLE } from "@open-swe/shared/constants";
export function useProposedPlan(originalPlanItems: PlanItem[]) {
const stream = useStreamContext();
const [planItems, setPlanItems] = useState<PlanItem[]>(originalPlanItems);
const [changesMade, setChangesMade] = useState(false);
useEffect(() => {
setChangesMade(
JSON.stringify(originalPlanItems) !== JSON.stringify(planItems),
);
}, [originalPlanItems, planItems]);
const handleResumePlan = () => {
let resume: HumanResponse[];
if (changesMade) {
resume = [
{
type: "edit",
args: {
action: PLAN_INTERRUPT_ACTION_TITLE,
args: {
plan: convertPlanItemsToInterruptString(planItems),
},
},
},
];
} else {
resume = [
{
type: "accept",
args: null,
},
];
}
stream.submit(
{},
{
command: {
resume,
},
config: {
recursion_limit: 400,
},
},
);
};
const handleRejectPlan = () => {
const resume: HumanResponse[] = [
{
type: "ignore",
args: null,
},
];
stream.submit(
{},
{
command: {
resume,
},
config: {
recursion_limit: 400,
},
},
);
};
return {
changesMade,
planItems,
setPlanItems,
handleResumePlan,
handleRejectPlan,
};
}

View file

@ -3,6 +3,8 @@ import { ThreadActionsView } from "./components/thread-actions-view";
import { useState } from "react"; import { useState } from "react";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt"; import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { useStreamContext } from "@/providers/Stream"; import { useStreamContext } from "@/providers/Stream";
import { parsePlanData } from "@/lib/plan-utils";
import { ProposedPlan } from "@/components/plan/proposed-plan";
interface ThreadViewProps { interface ThreadViewProps {
interrupt: HumanInterrupt | HumanInterrupt[]; interrupt: HumanInterrupt | HumanInterrupt[];
@ -35,6 +37,11 @@ export function ThreadView({ interrupt }: ThreadViewProps) {
} }
}; };
const planItems = parsePlanData(interruptObj.action_request.args);
if (planItems?.length) {
return <ProposedPlan originalPlanItems={planItems} />;
}
return ( return (
<div className="flex h-[80vh] w-full flex-col overflow-y-scroll rounded-2xl bg-gray-50/50 p-8 lg:flex-row [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent"> <div className="flex h-[80vh] w-full flex-col overflow-y-scroll rounded-2xl bg-gray-50/50 p-8 lg:flex-row [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{showSidePanel ? ( {showSidePanel ? (

View file

@ -55,6 +55,8 @@ import {
import { BaseMessage } from "@langchain/core/messages"; import { BaseMessage } from "@langchain/core/messages";
import { TaskPlanView } from "../tasks"; import { TaskPlanView } from "../tasks";
import { useTaskPlan } from "../tasks/useTaskPlan"; import { useTaskPlan } from "../tasks/useTaskPlan";
import { isProposedPlanInterrupt } from "@/lib/plan-utils";
import { HumanResponse } from "@langchain/langgraph/prebuilt";
function StickyToBottomContent(props: { function StickyToBottomContent(props: {
content: ReactNode; content: ReactNode;
@ -111,8 +113,6 @@ export function Thread() {
"chatHistoryOpen", "chatHistoryOpen",
parseAsBoolean.withDefault(false), parseAsBoolean.withDefault(false),
); );
const [baseBranch, setBaseBranch] = useQueryState("base-branch");
const [configSidebarOpen, setConfigSidebarOpen] = useState(false); const [configSidebarOpen, setConfigSidebarOpen] = useState(false);
const isTaskView = !!taskId; const isTaskView = !!taskId;
@ -227,6 +227,35 @@ export function Thread() {
setFirstTokenReceived(false); setFirstTokenReceived(false);
if (isProposedPlanInterrupt(stream.interrupt)) {
const resume: HumanResponse[] = [
{
type: "response",
args: input.trim(),
},
];
stream.submit(
{},
{
command: {
resume,
},
},
);
if (contentBlocks.length > 0) {
toast.warning(
"Content blocks were not submitted with the plan response.",
{
richColors: true,
closeButton: true,
},
);
}
setInput("");
setContentBlocks([]);
return;
}
const newHumanMessage: Message = { const newHumanMessage: Message = {
id: uuidv4(), id: uuidv4(),
type: "human", type: "human",

View file

@ -3,6 +3,8 @@ import {
PLAN_INTERRUPT_ACTION_TITLE, PLAN_INTERRUPT_ACTION_TITLE,
PLAN_INTERRUPT_DELIMITER, PLAN_INTERRUPT_DELIMITER,
} from "@open-swe/shared/constants"; } from "@open-swe/shared/constants";
import { Interrupt } from "@langchain/langgraph-sdk";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
/** /**
* Checks if the interrupt args contain plan data * Checks if the interrupt args contain plan data
@ -30,7 +32,7 @@ export function parsePlanData(args: Record<string, any>): PlanItem[] {
return [ return [
{ {
index: 0, index: 0,
plan: args.plan, plan: args.plan.trim(),
completed: false, completed: false,
summary: undefined, summary: undefined,
}, },
@ -40,7 +42,7 @@ export function parsePlanData(args: Record<string, any>): PlanItem[] {
.split(PLAN_INTERRUPT_DELIMITER) .split(PLAN_INTERRUPT_DELIMITER)
.map((item: string, index: number) => ({ .map((item: string, index: number) => ({
index, index,
plan: item, plan: item.trim(),
completed: false, completed: false,
summary: undefined, summary: undefined,
})); }));
@ -54,3 +56,23 @@ export function getPlanKey(args: Record<string, any>): string | null {
if (args.plan && typeof args.plan === "string") return "plan"; if (args.plan && typeof args.plan === "string") return "plan";
return null; return null;
} }
/**
* Converts PlanItem array to interrupt string
*/
export function convertPlanItemsToInterruptString(
planItems: PlanItem[],
): string {
return planItems.map((item) => item.plan).join(PLAN_INTERRUPT_DELIMITER);
}
export function isProposedPlanInterrupt(
interrupt: Interrupt | undefined,
): boolean {
return interrupt?.value && typeof interrupt?.value === "object"
? isPlanData(
(interrupt.value as HumanInterrupt)?.action_request?.args ?? {},
(interrupt.value as HumanInterrupt)?.action_request?.action ?? "",
)
: false;
}

View file

@ -20,7 +20,8 @@
"typescript": "^5" "typescript": "^5"
}, },
"resolutions": { "resolutions": {
"@langchain/langgraph-sdk": "^0.0.83" "@langchain/langgraph-sdk": "^0.0.83",
"@langchain/core": "^0.3.58"
}, },
"packageManager": "yarn@3.5.1" "packageManager": "yarn@3.5.1"
} }

View file

@ -18,9 +18,9 @@
}, },
"dependencies": { "dependencies": {
"@langchain/core": "^0.3.56", "@langchain/core": "^0.3.56",
"@langchain/langgraph": "^0.3.1", "@langchain/langgraph": "^0.3.3",
"@langchain/langgraph-sdk": "^0.0.83", "@langchain/langgraph-sdk": "^0.0.83",
"zod": "^3.23.8" "zod": "^3.25.32"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",

View file

@ -125,19 +125,16 @@ export const GraphAnnotation = MessagesZodState.extend({
* passed to the LLM, truncated, removed etc. The main `messages` * passed to the LLM, truncated, removed etc. The main `messages`
* key is never modified to persist the content show on the client. * key is never modified to persist the content show on the client.
*/ */
internalMessages: withLangGraph<BaseMessage[], Messages>( internalMessages: withLangGraph(z.custom<BaseMessage[]>(), {
z.custom<BaseMessage[]>(), reducer: {
{ schema: z.custom<Messages>(),
reducer: { fn: messagesStateReducer,
schema: z.custom<Messages>(),
fn: messagesStateReducer,
},
jsonSchemaExtra: {
langgraph_type: "messages",
},
default: () => [],
}, },
), jsonSchemaExtra: {
langgraph_type: "messages",
},
default: () => [],
}),
proposedPlan: z proposedPlan: z
.array(z.string()) .array(z.string())
.default(() => []) .default(() => [])

View file

@ -1694,9 +1694,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@langchain/core@npm:^0.3.56, @langchain/core@npm:^0.3.57": "@langchain/core@npm:^0.3.58":
version: 0.3.57 version: 0.3.58
resolution: "@langchain/core@npm:0.3.57" resolution: "@langchain/core@npm:0.3.58"
dependencies: dependencies:
"@cfworker/json-schema": ^4.0.2 "@cfworker/json-schema": ^4.0.2
ansi-styles: ^5.0.0 ansi-styles: ^5.0.0
@ -1708,9 +1708,9 @@ __metadata:
p-queue: ^6.6.2 p-queue: ^6.6.2
p-retry: 4 p-retry: 4
uuid: ^10.0.0 uuid: ^10.0.0
zod: ^3.22.4 zod: ^3.25.32
zod-to-json-schema: ^3.22.3 zod-to-json-schema: ^3.22.3
checksum: 5a497553209a3fe0c2a31b23c9586f586ea8b0785cb437a0b7140c308be80f0caa4397c45a4d7f0a1b7056f3509b03663f6c714217b75c20cce0a2df4fb3c105 checksum: 0dece4accb0da6c67ac9286614b846dafc8e53c39435e404781bb436562ac8f55c474b099b182b44b3b4fdab16f8a9020add5ca804a6d9811b23ec940b7f3589
languageName: node languageName: node
linkType: hard linkType: hard
@ -1838,21 +1838,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@langchain/langgraph@npm:^0.3.1": "@langchain/langgraph@npm:^0.3.3":
version: 0.3.1 version: 0.3.3
resolution: "@langchain/langgraph@npm:0.3.1" resolution: "@langchain/langgraph@npm:0.3.3"
dependencies: dependencies:
"@langchain/langgraph-checkpoint": ~0.0.18 "@langchain/langgraph-checkpoint": ~0.0.18
"@langchain/langgraph-sdk": ~0.0.32 "@langchain/langgraph-sdk": ~0.0.32
uuid: ^10.0.0 uuid: ^10.0.0
zod: ^3.23.8 zod: ^3.25.32
peerDependencies: peerDependencies:
"@langchain/core": ">=0.2.36 <0.3.0 || >=0.3.40 < 0.4.0" "@langchain/core": ">=0.3.58 < 0.4.0"
zod-to-json-schema: ^3.x zod-to-json-schema: ^3.x
peerDependenciesMeta: peerDependenciesMeta:
zod-to-json-schema: zod-to-json-schema:
optional: true optional: true
checksum: 25f4a338635c00a0bb56be483438eef29a133b3900cec91b0a32e880ba0b6ee719ea0365f957cc7a5c25218f50f10b83975f2198565aafc27d17127fabf188cc checksum: 26a9fc9003f0c57f892f7d45f1d99ab1b028bdbe73a32a2f5cbc1786da854bc875c6008c157e314aa338af376c9ae546f136dac8d1b8e3807040f3d3c6fa3c73
languageName: node languageName: node
linkType: hard linkType: hard
@ -2246,7 +2246,7 @@ __metadata:
"@langchain/anthropic": ^0.3.20 "@langchain/anthropic": ^0.3.20
"@langchain/core": ^0.3.56 "@langchain/core": ^0.3.56
"@langchain/google-genai": ^0.2.9 "@langchain/google-genai": ^0.2.9
"@langchain/langgraph": ^0.3.1 "@langchain/langgraph": ^0.3.3
"@langchain/langgraph-cli": latest "@langchain/langgraph-cli": latest
"@langchain/langgraph-sdk": ^0.0.83 "@langchain/langgraph-sdk": ^0.0.83
"@langchain/openai": ^0.5.10 "@langchain/openai": ^0.5.10
@ -2271,7 +2271,7 @@ __metadata:
typescript: ~5.7.2 typescript: ~5.7.2
typescript-eslint: ^8.22.0 typescript-eslint: ^8.22.0
uuid: ^11.0.5 uuid: ^11.0.5
zod: ^3.23.8 zod: ^3.25.32
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -2282,7 +2282,7 @@ __metadata:
"@eslint/eslintrc": ^3.1.0 "@eslint/eslintrc": ^3.1.0
"@eslint/js": ^9.19.0 "@eslint/js": ^9.19.0
"@langchain/core": ^0.3.56 "@langchain/core": ^0.3.56
"@langchain/langgraph": ^0.3.1 "@langchain/langgraph": ^0.3.3
"@langchain/langgraph-sdk": ^0.0.83 "@langchain/langgraph-sdk": ^0.0.83
"@tsconfig/recommended": ^1.0.8 "@tsconfig/recommended": ^1.0.8
"@types/node": ^22.13.5 "@types/node": ^22.13.5
@ -2295,7 +2295,7 @@ __metadata:
prettier: ^3.5.2 prettier: ^3.5.2
typescript: ~5.7.2 typescript: ~5.7.2
typescript-eslint: ^8.22.0 typescript-eslint: ^8.22.0
zod: ^3.23.8 zod: ^3.25.32
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -2305,7 +2305,7 @@ __metadata:
dependencies: dependencies:
"@eslint/js": ^9.19.0 "@eslint/js": ^9.19.0
"@langchain/core": ^0.3.57 "@langchain/core": ^0.3.57
"@langchain/langgraph": ^0.3.1 "@langchain/langgraph": ^0.3.3
"@langchain/langgraph-sdk": ^0.0.83 "@langchain/langgraph-sdk": ^0.0.83
"@open-swe/shared": "*" "@open-swe/shared": "*"
"@radix-ui/react-alert-dialog": ^1.1.14 "@radix-ui/react-alert-dialog": ^1.1.14
@ -2374,7 +2374,7 @@ __metadata:
typescript-eslint: ^8.22.0 typescript-eslint: ^8.22.0
use-stick-to-bottom: ^1.0.46 use-stick-to-bottom: ^1.0.46
uuid: ^11.1.0 uuid: ^11.1.0
zod: ^3.24.2 zod: ^3.25.32
zustand: ^5.0.5 zustand: ^5.0.5
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -14526,10 +14526,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zod@npm:^3.24.2": "zod@npm:^3.25.32":
version: 3.25.32 version: 3.25.64
resolution: "zod@npm:3.25.32" resolution: "zod@npm:3.25.64"
checksum: 27528887ebb778c70ec753e19c8cde453d36cd2bf4682e2f19a441cd9418f87ea85f6da550ab9b157a625b4d19b9c65921e11bf88a76702051528ad4c5bcf2f0 checksum: 9f0e86430ed52ef56930712485573454f9b8d64697e24e3139581489520993658d2dee5e939e9e820c1c720afa202fdca58a3ef096f8d91b0d04eb4285bc00ca
languageName: node languageName: node
linkType: hard linkType: hard