feat: Make task completion action agentic (#481)

* feat: Make task completion action agentic

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr

* cr
This commit is contained in:
Brace Sproul 2025-07-22 18:04:04 -07:00 • committed by GitHub
parent 45e1a67171
commit 5aaa814f66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 400 additions and 325 deletions

View file

@ -8,19 +8,20 @@ import {
import {
generateAction,
takeAction,
progressPlanStep,
generateConclusion,
openPullRequest,
diagnoseError,
requestHelp,
updatePlan,
summarizeHistory,
handleCompletedTask,
} from "./nodes/index.js";
import { BaseMessage, isAIMessage } from "@langchain/core/messages";
import { initializeSandbox } from "../shared/initialize-sandbox.js";
import { graph as reviewerGraph } from "../reviewer/index.js";
import { getRemainingPlanItems } from "../../utils/current-task.js";
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
import { createMarkTaskCompletedToolFields } from "@open-swe/shared/open-swe/tools";
function lastMessagesMissingToolCalls(
messages: BaseMessage[],
@ -40,7 +41,7 @@ function lastMessagesMissingToolCalls(
* Otherwise, it ends the process.
*
* @param {GraphState} state - The current graph state.
* @returns {"route-to-review-or-conclusion" | "take-action" | "request-help" | "generate-action" | Send} The next node to execute, or END if the process should stop.
* @returns {"route-to-review-or-conclusion" | "take-action" | "request-help" | "generate-action" | "handle-completed-task" | Send} The next node to execute, or END if the process should stop.
*/
function routeGeneratedAction(
state: GraphState,
@ -49,6 +50,7 @@ function routeGeneratedAction(
| "take-action"
| "request-help"
| "generate-action"
| "handle-completed-task"
| Send {
const { internalMessages } = state;
const lastMessage = internalMessages[internalMessages.length - 1];
@ -71,6 +73,12 @@ function routeGeneratedAction(
});
}
const taskMarkedCompleted =
toolCall.name === createMarkTaskCompletedToolFields().name;
if (taskMarkedCompleted) {
return "handle-completed-task";
}
return "take-action";
}
@ -122,10 +130,10 @@ const workflow = new StateGraph(GraphAnnotation, GraphConfiguration)
.addNode("initialize", initializeSandbox)
.addNode("generate-action", generateAction)
.addNode("take-action", takeAction, {
ends: ["progress-plan-step", "diagnose-error"],
ends: ["generate-action", "diagnose-error"],
})
.addNode("update-plan", updatePlan)
.addNode("progress-plan-step", progressPlanStep, {
.addNode("handle-completed-task", handleCompletedTask, {
ends: [
"summarize-history",
"generate-action",
@ -151,6 +159,7 @@ const workflow = new StateGraph(GraphAnnotation, GraphConfiguration)
"route-to-review-or-conclusion",
"update-plan",
"generate-action",
"handle-completed-task",
])
.addEdge("update-plan", "generate-action")
.addEdge("diagnose-error", "generate-action")

View file

@ -1,3 +1,4 @@
import { v4 as uuidv4 } from "uuid";
import {
GraphState,
GraphConfig,
@ -45,16 +46,16 @@ import {
convertMessagesToCacheControlledMessages,
trackCachePerformance,
} from "../../../../utils/caching.js";
import { createMarkTaskCompletedToolFields } from "@open-swe/shared/open-swe/tools";
import { HumanMessage } from "@langchain/core/messages";
const logger = createLogger(LogLevel.INFO, "GenerateMessageNode");
const formatDynamicContextPrompt = (state: GraphState) => {
return DYNAMIC_SYSTEM_PROMPT.replaceAll(
"{PLAN_PROMPT_WITH_SUMMARIES}",
formatPlanPrompt(getActivePlanItems(state.taskPlan), {
includeSummaries: true,
}),
)
const planString = getActivePlanItems(state.taskPlan)
.map((i) => `<plan-item index="${i.index}">\n${i.plan}\n</plan-item>`)
.join("\n");
return DYNAMIC_SYSTEM_PROMPT.replaceAll("{PLAN_PROMPT}", planString)
.replaceAll(
"{PLAN_GENERATION_NOTES}",
state.contextGatheringNotes || "No context gathering notes available.",
@ -113,6 +114,27 @@ const formatCacheablePrompt = (state: GraphState): CacheablePromptSegment[] => {
return segments.filter((segment) => segment.text.trim() !== "");
};
const planSpecificPrompt = `<detailed_plan_information>
Here is the task execution plan for the request you're working on.
Ensure you carefully read through all of the instructions, messages, and context provided above.
Once you have a clear understanding of the current state of the task, analyze the plan provided below, and take an action based on it.
You're provided with the full list of tasks, including the completed, current and remaining tasks.
You are in the process of executing the current task:
{PLAN_PROMPT}
</detailed_plan_information>`;
const formatSpecificPlanPrompt = (state: GraphState): HumanMessage => {
return new HumanMessage({
id: uuidv4(),
content: planSpecificPrompt.replace(
"{PLAN_PROMPT}",
formatPlanPrompt(getActivePlanItems(state.taskPlan)),
),
});
};
export async function generateAction(
state: GraphState,
config: GraphConfig,
@ -123,6 +145,7 @@ export async function generateAction(
Task.PROGRAMMER,
);
const mcpTools = await getMcpTools(config);
const markTaskCompletedTool = createMarkTaskCompletedToolFields();
const tools = [
createSearchTool(state),
@ -132,6 +155,7 @@ export async function generateAction(
createUpdatePlanToolFields(),
createGetURLContentTool(),
createInstallDependenciesTool(state),
markTaskCompletedTool,
...mcpTools,
];
logger.info(
@ -176,6 +200,7 @@ export async function generateAction(
}),
},
...inputMessagesWithCache,
formatSpecificPlanPrompt(state),
]);
const hasToolCalls = !!response.tool_calls?.length;
@ -186,6 +211,22 @@ export async function generateAction(
newSandboxSessionId = await stopSandbox(state.sandboxSessionId);
}
if (
response.tool_calls?.length &&
response.tool_calls?.length > 1 &&
response.tool_calls.some((t) => t.name === markTaskCompletedTool.name)
) {
logger.error(
"Multiple tool calls found, including mark_task_completed. Removing the mark_task_completed call.",
{
toolCalls: JSON.stringify(response.tool_calls, null, 2),
},
);
response.tool_calls = response.tool_calls.filter(
(t) => t.name !== markTaskCompletedTool.name,
);
}
logger.info("Generated action", {
currentTask: getCurrentPlanItem(getActivePlanItems(state.taskPlan)).plan,
...(getMessageContentString(response.content) && {

View file

@ -86,6 +86,15 @@ You are a terminal-based agentic coding assistant built by LangChain. You wrap L
<description>Use this tool to add or remove tasks from the plan, or to update the plan in any other way</description>
</special_tools>
<mark_task_completed_guidelines>
- When you believe you've completed a task, you may call the \`mark_task_completed\` tool to mark the task as complete.
- The \`mark_task_completed\` tool should NEVER be called in parallel with any other tool calls. Ensure it's the only tool you're calling in this message, if you do determine the task is completed.
- Carefully read over the actions you've taken, and the current task (listed below) to ensure the task is complete. You want to avoid prematurely marking a task as complete.
- If the current task involves fixing an issue, such as a failing test, a broken build, etc., you must validate the issue is ACTUALLY fixed before marking it as complete.
- To verify a fix, ensure you run the test, build, or other command first to validate the fix.
- If you do not believe the task is complete, you do not need to call the \`mark_task_completed\` tool. You can continue working on the task, until you determine it is complete.
</mark_task_completed_guidelines>
</instructions>
<custom_rules>
@ -113,8 +122,10 @@ export const CODE_REVIEW_PROMPT = `<code_review>
export const DYNAMIC_SYSTEM_PROMPT = `<context>
<plan_information>
- Current plan with summaries
{PLAN_PROMPT_WITH_SUMMARIES}
- Task execution plan
<execution_plan>
{PLAN_PROMPT}
</execution_plan>
- Plan generation notes
These are notes you took while gathering context for the plan:

View file

@ -0,0 +1,138 @@
import { v4 as uuidv4 } from "uuid";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import {
GraphConfig,
GraphState,
GraphUpdate,
} from "@open-swe/shared/open-swe/types";
import { Command } from "@langchain/langgraph";
import {
completePlanItem,
getActivePlanItems,
getActiveTask,
} from "@open-swe/shared/open-swe/tasks";
import {
getCurrentPlanItem,
getRemainingPlanItems,
} from "../../../utils/current-task.js";
import { isAIMessage, ToolMessage } from "@langchain/core/messages";
import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js";
import { createMarkTaskCompletedToolFields } from "@open-swe/shared/open-swe/tools";
import {
calculateConversationHistoryTokenCount,
getMessagesSinceLastSummary,
MAX_INTERNAL_TOKENS,
} from "../../../utils/tokens.js";
import { z } from "zod";
const logger = createLogger(LogLevel.INFO, "HandleCompletedTask");
export async function handleCompletedTask(
state: GraphState,
config: GraphConfig,
): Promise<Command> {
const markCompletedTool = createMarkTaskCompletedToolFields();
const markCompletedMessage =
state.internalMessages[state.internalMessages.length - 1];
if (
!isAIMessage(markCompletedMessage) ||
!markCompletedMessage.tool_calls?.length ||
!markCompletedMessage.tool_calls.some(
(tc) => tc.name === markCompletedTool.name,
)
) {
throw new Error("Failed to find a tool call when checking task status.");
}
const toolCall = markCompletedMessage.tool_calls?.[0];
if (!toolCall) {
throw new Error(
"Failed to generate a tool call when checking task status.",
);
}
const activePlanItems = getActivePlanItems(state.taskPlan);
const currentTask = getCurrentPlanItem(activePlanItems);
const toolMessage = new ToolMessage({
id: uuidv4(),
tool_call_id: toolCall.id ?? "",
content: `Saved task status as completed for task ${currentTask?.plan || "unknown"}`,
name: toolCall.name,
});
const newMessages = [toolMessage];
const newMessageList = [...state.internalMessages, ...newMessages];
const wouldBeConversationHistoryToSummarize =
await getMessagesSinceLastSummary(newMessageList, {
excludeHiddenMessages: true,
excludeCountFromEnd: 20,
});
const totalInternalTokenCount = calculateConversationHistoryTokenCount(
wouldBeConversationHistoryToSummarize,
{
// Retain the last 20 messages from state
excludeHiddenMessages: true,
excludeCountFromEnd: 20,
},
);
const summary = (toolCall.args as z.infer<typeof markCompletedTool.schema>)
.completed_task_summary;
// LLM marked as completed, so we need to update the plan to reflect that.
const updatedPlanTasks = completePlanItem(
state.taskPlan,
getActiveTask(state.taskPlan).id,
currentTask.index,
summary,
);
// Update the github issue to reflect this task as completed.
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
updatedPlanTasks,
);
const commandUpdate: GraphUpdate = {
messages: newMessages,
internalMessages: newMessages,
// Even though there are no remaining tasks, still mark as completed so the UI reflects that the task is completed.
taskPlan: updatedPlanTasks,
};
// This should in theory never happen, but ensure we route properly if it does.
const remainingTask = getRemainingPlanItems(activePlanItems)?.[0];
if (!remainingTask) {
logger.info(
"Found no remaining tasks in the plan during the check plan step. Continuing to the conclusion generation step.",
);
return new Command({
goto: "route-to-review-or-conclusion",
update: commandUpdate,
});
}
if (totalInternalTokenCount >= MAX_INTERNAL_TOKENS) {
logger.info(
"Internal messages list is at or above the max token limit. Routing to summarize history step.",
{
totalInternalTokenCount,
maxInternalTokenCount: MAX_INTERNAL_TOKENS,
},
);
return new Command({
goto: "summarize-history",
update: commandUpdate,
});
}
return new Command({
goto: "generate-action",
update: commandUpdate,
});
}

View file

@ -1,6 +1,6 @@
export * from "./generate-message/index.js";
export * from "./take-action.js";
export * from "./progress-plan-step.js";
export * from "./handle-completed-task.js";
export * from "./generate-conclusion.js";
export * from "./open-pr.js";
export * from "./diagnose-error.js";

View file

@ -1,249 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import {
GraphConfig,
GraphState,
GraphUpdate,
PlanItem,
} from "@open-swe/shared/open-swe/types";
import {
loadModel,
supportsParallelToolCallsParam,
Task,
} from "../../../utils/load-model.js";
import { formatPlanPrompt } from "../../../utils/plan-prompt.js";
import { Command } from "@langchain/langgraph";
import { getMessageString } from "../../../utils/message/content.js";
import { formatUserRequestPrompt } from "../../../utils/user-request.js";
import {
completePlanItem,
getActivePlanItems,
getActiveTask,
} from "@open-swe/shared/open-swe/tasks";
import {
getCurrentPlanItem,
getRemainingPlanItems,
} from "../../../utils/current-task.js";
import { ToolMessage } from "@langchain/core/messages";
import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js";
import {
createMarkTaskNotCompletedToolFields,
createMarkTaskCompletedToolFields,
} from "@open-swe/shared/open-swe/tools";
import {
calculateConversationHistoryTokenCount,
getMessagesSinceLastSummary,
MAX_INTERNAL_TOKENS,
} from "../../../utils/tokens.js";
import { z } from "zod";
import { trackCachePerformance } from "../../../utils/caching.js";
const logger = createLogger(LogLevel.INFO, "ProgressPlanStep");
const systemPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
In your workflow, you generate a plan, then act on said plan. It may take many actions to complete a single step, or a single action to complete the step.
Here is the plan, along with the summaries of each completed task:
{PLAN_PROMPT}
Analyze the tasks you've completed, the tasks which are remaining, and the current task you just took an action on.
In addition to this, you're also provided the full conversation history between you and the user. All of the messages in this conversation are from the previous steps/actions you've taken, and any user input.
If the task you're working on is to fix a failing command (e.g. a test, build, lint, etc.), and you've made changes to fix the issue, you must re-run the command to ensure the fix was successful before you can mark the task as complete.
For example: If you have a failing test, and you've applied an update to the file to fix the test, you MUST re-run the test before you can mark the task as complete.
Take all of this information, and determine if the current task is complete, or if you still have work left to do.
Once you've determined the status of the current task, call either:
- \`mark_task_completed\` if the task is complete.
- \`mark_task_not_completed\` if the task is not complete.
`;
const formatPrompt = (taskPlan: PlanItem[]): string => {
return systemPrompt.replace(
"{PLAN_PROMPT}",
formatPlanPrompt(taskPlan, { includeSummaries: true }),
);
};
export async function progressPlanStep(
state: GraphState,
config: GraphConfig,
): Promise<Command> {
const markNotCompletedTool = createMarkTaskNotCompletedToolFields();
const markCompletedTool = createMarkTaskCompletedToolFields();
const model = await loadModel(config, Task.SUMMARIZER);
const modelSupportsParallelToolCallsParam = supportsParallelToolCallsParam(
config,
Task.SUMMARIZER,
);
const modelWithTools = model.bindTools(
[markNotCompletedTool, markCompletedTool],
{
tool_choice: "any",
...(modelSupportsParallelToolCallsParam
? {
parallel_tool_calls: false,
}
: {}),
},
);
const conversationHistoryStr = `Here is the full conversation history including the user's request(s):
${state.internalMessages.map(getMessageString).join("\n")}
${formatUserRequestPrompt(state.internalMessages)}
Take all of this information, and determine whether or not you have completed this task in the plan.
Once you've determined the status of the current task, call either the \`mark_task_completed\` or \`mark_task_not_completed\` tool.`;
const activePlanItems = getActivePlanItems(state.taskPlan);
const response = await modelWithTools.invoke([
{
role: "system",
content: formatPrompt(activePlanItems),
},
{
role: "user",
content: conversationHistoryStr,
},
]);
const toolCall = response.tool_calls?.[0];
if (!toolCall) {
throw new Error(
"Failed to generate a tool call when checking task status.",
);
}
const isCompleted = toolCall.name === markCompletedTool.name;
const currentTask = getCurrentPlanItem(activePlanItems);
const toolMessage = new ToolMessage({
id: uuidv4(),
tool_call_id: toolCall.id ?? "",
content: `Saved task status as ${isCompleted ? "completed" : "not completed"} for task ${currentTask?.plan || "unknown"}`,
name: toolCall.name,
});
const newMessages = [response, toolMessage];
const newMessageList = [...state.internalMessages, ...newMessages];
const wouldBeConversationHistoryToSummarize =
await getMessagesSinceLastSummary(newMessageList, {
excludeHiddenMessages: true,
excludeCountFromEnd: 20,
});
const totalInternalTokenCount = calculateConversationHistoryTokenCount(
wouldBeConversationHistoryToSummarize,
{
// Retain the last 20 messages from state
excludeHiddenMessages: true,
excludeCountFromEnd: 20,
},
);
if (!isCompleted) {
logger.info("Current task has not been completed.", {
reasoning: toolCall.args.reasoning,
});
const commandUpdate: GraphUpdate = {
messages: newMessages,
internalMessages: newMessages,
tokenData: trackCachePerformance(response),
};
// Check if we have any messages to summarize, and if we're at or above the max token limit.
if (totalInternalTokenCount >= MAX_INTERNAL_TOKENS) {
logger.info(
"Internal messages list is at or above the max token limit. Routing to summarize history step.",
{
totalInternalTokenCount,
maxInternalTokenCount: MAX_INTERNAL_TOKENS,
wouldBeConversationHistoryToSummarizeLength:
wouldBeConversationHistoryToSummarize.length,
},
);
return new Command({
goto: "summarize-history",
update: commandUpdate,
});
}
return new Command({
goto: "generate-action",
update: commandUpdate,
});
}
const summary = (toolCall.args as z.infer<typeof markCompletedTool.schema>)
.completed_task_summary;
// LLM marked as completed, so we need to update the plan to reflect that.
const updatedPlanTasks = completePlanItem(
state.taskPlan,
getActiveTask(state.taskPlan).id,
currentTask.index,
summary,
);
// Update the github issue to reflect this task as completed.
await addTaskPlanToIssue(
{
githubIssueId: state.githubIssueId,
targetRepository: state.targetRepository,
},
config,
updatedPlanTasks,
);
// This should in theory never happen, but ensure we route properly if it does.
const remainingTask = getRemainingPlanItems(activePlanItems)?.[0];
if (!remainingTask) {
logger.info(
"Found no remaining tasks in the plan during the check plan step. Continuing to the conclusion generation step.",
);
const commandUpdate: GraphUpdate = {
messages: newMessages,
internalMessages: newMessages,
// Even though there are no remaining tasks, still mark as completed so the UI reflects that the task is completed.
taskPlan: updatedPlanTasks,
tokenData: trackCachePerformance(response),
};
return new Command({
goto: "route-to-review-or-conclusion",
update: commandUpdate,
});
}
logger.info("Task marked as completed. Routing to task summarization step.", {
remainingTask: {
...remainingTask,
completed: true,
},
});
const commandUpdate: GraphUpdate = {
messages: newMessages,
internalMessages: newMessages,
taskPlan: updatedPlanTasks,
tokenData: trackCachePerformance(response),
};
if (totalInternalTokenCount >= MAX_INTERNAL_TOKENS) {
logger.info(
"Internal messages list is at or above the max token limit. Routing to summarize history step.",
{
totalInternalTokenCount,
maxInternalTokenCount: MAX_INTERNAL_TOKENS,
},
);
return new Command({
goto: "summarize-history",
update: commandUpdate,
});
}
return new Command({
goto: "generate-action",
update: commandUpdate,
});
}

View file

@ -211,7 +211,7 @@ export async function takeAction(
}),
};
return new Command({
goto: shouldRouteDiagnoseNode ? "diagnose-error" : "progress-plan-step",
goto: shouldRouteDiagnoseNode ? "diagnose-error" : "generate-action",
update: commandUpdate,
});
}

View file

@ -1,14 +1,16 @@
import { PlanItem } from "@open-swe/shared/open-swe/types";
export const PLAN_PROMPT = `## Completed Tasks
{COMPLETED_TASKS}
export const PLAN_PROMPT = `<completed_tasks>
{COMPLETED_TASKS}
</completed_tasks>
## Remaining Tasks
(This list does not include the current task)
{REMAINING_TASKS}
<remaining_tasks>
(This list does not include the current task)
{REMAINING_TASKS}
</remaining_tasks>
## Current Task
{CURRENT_TASK}`;
{CURRENT_TASK}
`;
/**
* Formats a plan for use in a prompt.
@ -50,7 +52,7 @@ export function formatPlanPrompt(
: completedTasks
.map(
(task) =>
`<completed-task index="${task.index}">${task.plan}</completed-task>`,
`<completed_task index="${task.index}">\n${task.plan}\n</completed_task>`,
)
.join("\n")
: "No completed tasks.",
@ -61,14 +63,14 @@ export function formatPlanPrompt(
? remainingTasks
.map(
(task) =>
`<remaining-task index="${task.index}">${task.plan}</remaining-task>`,
`<remaining_task index="${task.index}">\n${task.plan}\n</remaining_task>`,
)
.join("\n")
: "No remaining tasks.",
)
.replace(
"{CURRENT_TASK}",
`<current-task index="${currentTask?.index}">${currentTask?.plan || "No current task found."}</current-task>`,
`<current_task index="${currentTask?.index}">\n${currentTask?.plan || "No current task found."}\n</current_task>`,
);
}
@ -76,7 +78,7 @@ export function formatPlanPromptWithSummaries(taskPlan: PlanItem[]): string {
return taskPlan
.map(
(p) =>
`<${p.completed ? "completed-" : ""}task index="${p.index}">\n${p.plan}\n <task-summary>\n${p.summary || "No task summary found"}\n </task-summary>\n</${p.completed ? "completed-" : ""}task>`,
`<${p.completed ? "completed_" : ""}task index="${p.index}">\n${p.plan}\n <task_summary>\n${p.summary || "No task summary found"}\n </task_summary>\n</${p.completed ? "completed_" : ""}task>`,
)
.join("\n");
}

View file

@ -622,9 +622,9 @@ export function ActionStep(props: ActionStepProps) {
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-700 dark:text-blue-300">
<BasicMarkdownText className="mt-1 text-xs font-normal text-blue-700 dark:text-blue-300">
{reasoningText || "No reasoning provided."}
</p>
</BasicMarkdownText>
)}
</div>

View file

@ -120,9 +120,9 @@ export function PullRequestOpened({
<h3 className="mb-1 text-xs font-normal text-gray-500 dark:text-gray-400">
Description
</h3>
<div className="text-xs font-normal whitespace-pre-wrap text-gray-800 dark:text-gray-200">
<pre className="text-xs font-normal whitespace-pre-wrap text-gray-800 dark:text-gray-200">
{description}
</div>
</pre>
</div>
)}
</div>

View file

@ -81,9 +81,9 @@ export function PushChanges({
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800">
<BasicMarkdownText className="mt-1 text-xs font-normal text-blue-800">
{reasoningText}
</p>
</BasicMarkdownText>
)}
</div>
)}

View file

@ -61,9 +61,9 @@ export function ReplanningStep({
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800">
<BasicMarkdownText className="mt-1 text-xs font-normal text-blue-800">
{reasoningText}
</p>
</BasicMarkdownText>
)}
</div>
)}

View file

@ -70,9 +70,9 @@ export function MarkTaskCompleted({
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800 dark:text-blue-400">
<BasicMarkdownText className="mt-1 text-xs font-normal text-blue-800 dark:text-blue-400">
{reasoningText}
</p>
</BasicMarkdownText>
)}
</div>
)}
@ -194,9 +194,9 @@ export function MarkTaskIncomplete({
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800">
<BasicMarkdownText className="mt-1 text-xs font-normal text-blue-800">
{reasoningText}
</p>
</BasicMarkdownText>
)}
</div>
)}

View file

@ -23,6 +23,7 @@ import {
import { cn } from "@/lib/utils";
import { ProgressBar } from "./progress-bar";
import { PlanItem, TaskPlan } from "@open-swe/shared/open-swe/types";
import { BasicMarkdownText } from "../thread/markdown-text";
interface TasksSidebarProps {
isOpen: boolean;
@ -327,7 +328,7 @@ export function TasksSidebar({
<>
<div className="mb-1 flex items-start justify-between gap-2">
<p className="text-sm leading-relaxed text-gray-900 dark:text-gray-100">
{item.plan}
<BasicMarkdownText>{item.plan}</BasicMarkdownText>
</p>
</div>
@ -384,7 +385,9 @@ export function TasksSidebar({
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="rounded border border-green-200 bg-green-50 p-2 text-xs text-green-800 dark:border-green-700 dark:bg-green-900/30 dark:text-green-400">
{item.summary}
<BasicMarkdownText>
{item.summary}
</BasicMarkdownText>
</div>
</CollapsibleContent>
</Collapsible>

View file

@ -289,3 +289,109 @@ const BasicMarkdownTextImpl: FC<{ children: string; className?: string }> = ({
};
export const BasicMarkdownText = memo(BasicMarkdownTextImpl);
const InlineMarkdownTextImpl: FC<{ children: string; className?: string }> = ({
children,
className,
}) => {
const inlineMarkdownComponents: any = {
// Only include inline elements
strong: ({ className, ...props }: { className?: string }) => (
<strong
className={cn("font-semibold", className)}
{...props}
/>
),
em: ({ className, ...props }: { className?: string }) => (
<em
className={cn("italic", className)}
{...props}
/>
),
code: ({
className,
children,
...props
}: {
className?: string;
children?: React.ReactNode;
}) => {
// Only render inline code, not code blocks
const match = /language-(\w+)/.exec(className || "");
if (match) {
// If it's a code block, render as plain text to keep it inline
return (
<span className={cn("font-mono text-sm", className)}>
{String(children)}
</span>
);
}
return (
<code
className={cn(
"bg-muted rounded px-1 py-0.5 font-mono text-sm",
className,
)}
{...props}
>
{children}
</code>
);
},
a: ({ className, ...props }: { className?: string }) => (
<a
className={cn(
"text-primary hover:text-primary/80 font-medium underline underline-offset-4",
className,
)}
{...props}
/>
),
del: ({ className, ...props }: { className?: string }) => (
<del
className={cn("line-through", className)}
{...props}
/>
),
// Remove all block-level elements by not including them
// This will cause them to render as plain text
};
return (
<span className={cn("inline", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={inlineMarkdownComponents}
// Disable block-level parsing by treating everything as inline
disallowedElements={[
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"div",
"blockquote",
"ul",
"ol",
"li",
"pre",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"hr",
"br",
]}
unwrapDisallowed
>
{children}
</ReactMarkdown>
</span>
);
};
export const InlineMarkdownText = memo(InlineMarkdownTextImpl);

View file

@ -42,7 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View file

@ -7,6 +7,7 @@ import {
CheckCircle,
MessageSquare,
} from "lucide-react";
import { BasicMarkdownText } from "../thread/markdown-text";
type DiagnoseErrorActionProps = {
status: "loading" | "generating" | "done";
@ -57,9 +58,9 @@ export function DiagnoseErrorAction({
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800 dark:text-blue-300">
<BasicMarkdownText className="mt-1 text-xs font-normal text-blue-800 dark:text-blue-300">
{reasoningText}
</p>
</BasicMarkdownText>
)}
</div>
)}

View file

@ -19,6 +19,7 @@ import { ThreadUIStatus } from "@/lib/schemas/thread-status";
import { cn } from "@/lib/utils";
import { TaskPlan } from "@open-swe/shared/open-swe/types";
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
import { InlineMarkdownText } from "../thread/markdown-text";
interface ThreadCardProps {
thread: ThreadMetadata;
@ -133,10 +134,10 @@ export function ThreadCard({
}}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-foreground truncate text-sm font-medium">
{thread.title}
<CardTitle className="text-foreground truncate text-sm">
<InlineMarkdownText>{thread.title}</InlineMarkdownText>
</CardTitle>
<div className="mt-1 flex items-center gap-1">
<GitBranch className="text-muted-foreground h-2 w-2" />

View file

@ -124,7 +124,7 @@ export function ThreadView({
programmerSession.runId !== joinedProgrammerRunId.current
) {
joinedProgrammerRunId.current = programmerSession.runId;
plannerStream.joinStream(programmerSession.runId).catch(console.error);
programmerStream.joinStream(programmerSession.runId).catch(console.error);
} else if (!programmerSession?.runId) {
joinedProgrammerRunId.current = undefined;
}
@ -311,19 +311,9 @@ export function ThreadView({
}
>
<div className="flex flex-shrink-0 items-center gap-3">
<TabsList className="bg-muted/70 h-13 px-2 dark:bg-gray-800">
<TabsTrigger
className="h-8"
value="planner"
>
Planner
</TabsTrigger>
<TabsTrigger
className="h-8"
value="programmer"
>
Programmer
</TabsTrigger>
<TabsList className="bg-muted/70 dark:bg-gray-800">
<TabsTrigger value="planner">Planner</TabsTrigger>
<TabsTrigger value="programmer">Programmer</TabsTrigger>
</TabsList>
{programmerTaskPlan && (
@ -333,7 +323,7 @@ export function ThreadView({
/>
)}
<div className="flex items-center justify-center gap-2">
<div className="ml-auto flex items-center justify-center gap-2">
{selectedTab === "planner" && plannerStream.isLoading && (
<CancelStreamButton
stream={plannerStream}
@ -365,7 +355,7 @@ export function ThreadView({
<TabsContent
value="planner"
className="mb-10"
className="mb-2"
>
<Card className="border-border bg-card relative h-full p-0 dark:bg-gray-950">
<CardContent className="h-full p-0">
@ -410,7 +400,7 @@ export function ThreadView({
</TabsContent>
<TabsContent
value="programmer"
className="mb-10"
className="mb-2"
>
<Card className="border-border bg-card relative h-full p-0 dark:bg-gray-950">
<CardContent className="h-full p-0">

View file

@ -41,6 +41,15 @@ export function TokenUsage({ tokenData }: TokenUsageProps) {
if (!tokenData || tokenData.length === 0) return null;
const mergedTokenData = mergeTokenData(tokenData);
const totalCachedInputTokens =
mergedTokenData.cacheCreationInputTokens +
mergedTokenData.cacheReadInputTokens;
const totalUncachedInputTokens = mergedTokenData.inputTokens;
const cachePercentage = (
(totalCachedInputTokens /
(totalCachedInputTokens + totalUncachedInputTokens)) *
100
).toFixed(2);
const metrics = calculateCostSavings(mergedTokenData);
return (
@ -66,7 +75,7 @@ export function TokenUsage({ tokenData }: TokenUsageProps) {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Zap className="h-3 w-3 text-blue-500" />
<Zap className="h-3 w-3 text-blue-500 dark:text-blue-400" />
<span className="text-muted-foreground text-xs font-medium">
Input
</span>
@ -77,7 +86,7 @@ export function TokenUsage({ tokenData }: TokenUsageProps) {
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3 text-green-500" />
<TrendingUp className="h-3 w-3 text-green-500 dark:text-green-400" />
<span className="text-muted-foreground text-xs font-medium">
Output
</span>
@ -103,7 +112,7 @@ export function TokenUsage({ tokenData }: TokenUsageProps) {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Coins className="h-3 w-3 text-amber-500" />
<Coins className="h-3 w-3 text-amber-500 dark:text-amber-400" />
<span className="text-muted-foreground text-xs font-medium">
Cost
</span>
@ -114,17 +123,30 @@ export function TokenUsage({ tokenData }: TokenUsageProps) {
</div>
{metrics.totalSavings > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-green-600">
Cache Savings
</span>
<Badge
variant="outline"
className="border-green-200 text-green-600"
>
-${metrics.totalSavings.toFixed(2)}
</Badge>
</div>
<>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
Cache Percentage
</span>
<Badge
variant="outline"
className="border-blue-200 text-blue-600 dark:border-blue-800 dark:text-blue-400"
>
{cachePercentage}%
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-green-600 dark:text-green-400">
Cache Savings
</span>
<Badge
variant="outline"
className="border-green-200 text-green-600 dark:border-green-800 dark:text-green-400"
>
-${metrics.totalSavings.toFixed(2)}
</Badge>
</div>
</>
)}
</div>
</div>