mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
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:
parent
45e1a67171
commit
5aaa814f66
20 changed files with 400 additions and 325 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) && {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue