mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 23:03:40 +00:00
fix(plan-mode): use context event to filter stale plan mode messages
- Filter out old [PLAN MODE ACTIVE] and [EXECUTING PLAN] messages - Fresh context injected via before_agent_start with current state - Agent now correctly sees tools are enabled when executing - Reverted to ID-based tracking with [DONE:id] tags - Simplified execution message (no need to override old context)
This commit is contained in:
parent
b1f574f7f7
commit
fc783a5980
1 changed files with 75 additions and 133 deletions
|
|
@ -11,7 +11,7 @@
|
||||||
* - After each agent response, prompts to execute the plan or continue planning
|
* - After each agent response, prompts to execute the plan or continue planning
|
||||||
* - Shows "plan" indicator in footer when active
|
* - Shows "plan" indicator in footer when active
|
||||||
* - Extracts todo list from plan and tracks progress during execution
|
* - Extracts todo list from plan and tracks progress during execution
|
||||||
* - Uses smart matching to track progress (no ugly IDs shown to user)
|
* - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
|
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
|
||||||
|
|
@ -130,64 +130,20 @@ function isSafeCommand(command: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo item
|
// Todo item with unique ID
|
||||||
interface TodoItem {
|
interface TodoItem {
|
||||||
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
// Keywords extracted for matching
|
}
|
||||||
keywords: string[];
|
|
||||||
|
// Generate a short unique ID
|
||||||
|
function generateId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract significant keywords from text for matching.
|
* Extract todo items from assistant message and assign IDs.
|
||||||
*/
|
|
||||||
function extractKeywords(text: string): string[] {
|
|
||||||
// Remove common words and extract significant terms
|
|
||||||
const stopWords = new Set([
|
|
||||||
"the",
|
|
||||||
"a",
|
|
||||||
"an",
|
|
||||||
"to",
|
|
||||||
"for",
|
|
||||||
"of",
|
|
||||||
"in",
|
|
||||||
"on",
|
|
||||||
"at",
|
|
||||||
"by",
|
|
||||||
"with",
|
|
||||||
"using",
|
|
||||||
"and",
|
|
||||||
"or",
|
|
||||||
"use",
|
|
||||||
"run",
|
|
||||||
"execute",
|
|
||||||
"create",
|
|
||||||
"make",
|
|
||||||
"do",
|
|
||||||
"then",
|
|
||||||
"next",
|
|
||||||
"step",
|
|
||||||
"first",
|
|
||||||
"second",
|
|
||||||
"third",
|
|
||||||
"finally",
|
|
||||||
"it",
|
|
||||||
"its",
|
|
||||||
"this",
|
|
||||||
"that",
|
|
||||||
"from",
|
|
||||||
"into",
|
|
||||||
]);
|
|
||||||
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9/._-]/g, " ")
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((w) => w.length > 2 && !stopWords.has(w));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract todo items from assistant message.
|
|
||||||
*/
|
*/
|
||||||
function extractTodoItems(message: string): TodoItem[] {
|
function extractTodoItems(message: string): TodoItem[] {
|
||||||
const items: TodoItem[] = [];
|
const items: TodoItem[] = [];
|
||||||
|
|
@ -198,11 +154,7 @@ function extractTodoItems(message: string): TodoItem[] {
|
||||||
let text = match[2].trim();
|
let text = match[2].trim();
|
||||||
text = text.replace(/\*{1,2}$/, "").trim();
|
text = text.replace(/\*{1,2}$/, "").trim();
|
||||||
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
||||||
items.push({
|
items.push({ id: generateId(), text, completed: false });
|
||||||
text,
|
|
||||||
completed: false,
|
|
||||||
keywords: extractKeywords(text),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,11 +165,7 @@ function extractTodoItems(message: string): TodoItem[] {
|
||||||
let text = match[1].trim();
|
let text = match[1].trim();
|
||||||
text = text.replace(/\*{1,2}$/, "").trim();
|
text = text.replace(/\*{1,2}$/, "").trim();
|
||||||
if (text.length > 10 && !text.startsWith("`")) {
|
if (text.length > 10 && !text.startsWith("`")) {
|
||||||
items.push({
|
items.push({ id: generateId(), text, completed: false });
|
||||||
text,
|
|
||||||
completed: false,
|
|
||||||
keywords: extractKeywords(text),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -226,54 +174,15 @@ function extractTodoItems(message: string): TodoItem[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate similarity between tool action and todo item.
|
* Find [DONE:id] tags in text and return the IDs.
|
||||||
* Returns a score from 0 to 1.
|
|
||||||
*/
|
*/
|
||||||
function matchScore(todoKeywords: string[], actionText: string): number {
|
function findDoneTags(text: string): string[] {
|
||||||
if (todoKeywords.length === 0) return 0;
|
const pattern = /\[DONE:([a-z0-9]+)\]/gi;
|
||||||
|
const ids: string[] = [];
|
||||||
const actionLower = actionText.toLowerCase();
|
for (const match of text.matchAll(pattern)) {
|
||||||
let matches = 0;
|
ids.push(match[1].toLowerCase());
|
||||||
|
|
||||||
for (const keyword of todoKeywords) {
|
|
||||||
if (actionLower.includes(keyword)) {
|
|
||||||
matches++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return ids;
|
||||||
return matches / todoKeywords.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the best matching uncompleted todo for a tool action.
|
|
||||||
* Uses keyword matching with a preference for sequential order.
|
|
||||||
*/
|
|
||||||
function findBestMatch(todos: TodoItem[], toolName: string, input: Record<string, unknown>): number {
|
|
||||||
// Build action text from tool name and input
|
|
||||||
let actionText = toolName;
|
|
||||||
if (input.path) actionText += ` ${input.path}`;
|
|
||||||
if (input.command) actionText += ` ${input.command}`;
|
|
||||||
if (input.content) actionText += ` ${String(input.content).slice(0, 100)}`;
|
|
||||||
|
|
||||||
let bestIdx = -1;
|
|
||||||
let bestScore = 0.3; // Minimum threshold
|
|
||||||
|
|
||||||
for (let i = 0; i < todos.length; i++) {
|
|
||||||
if (todos[i].completed) continue;
|
|
||||||
|
|
||||||
const score = matchScore(todos[i].keywords, actionText);
|
|
||||||
|
|
||||||
// Bonus for being the first uncompleted item (sequential preference)
|
|
||||||
const isFirstUncompleted = !todos.slice(0, i).some((t) => !t.completed);
|
|
||||||
const adjustedScore = isFirstUncompleted ? score + 0.1 : score;
|
|
||||||
|
|
||||||
if (adjustedScore > bestScore) {
|
|
||||||
bestScore = adjustedScore;
|
|
||||||
bestIdx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestIdx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function planModeHook(pi: HookAPI) {
|
export default function planModeHook(pi: HookAPI) {
|
||||||
|
|
@ -299,7 +208,7 @@ export default function planModeHook(pi: HookAPI) {
|
||||||
ctx.ui.setStatus("plan-mode", undefined);
|
ctx.ui.setStatus("plan-mode", undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show widget during execution
|
// Show widget during execution (no IDs shown to user)
|
||||||
if (executionMode && todoItems.length > 0) {
|
if (executionMode && todoItems.length > 0) {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const item of todoItems) {
|
for (const item of todoItems) {
|
||||||
|
|
@ -382,17 +291,27 @@ export default function planModeHook(pi: HookAPI) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track progress via tool results
|
// Filter out stale plan mode context messages from LLM context
|
||||||
pi.on("tool_result", async (event, ctx) => {
|
// This ensures the agent only sees the CURRENT state (plan mode on/off)
|
||||||
if (!executionMode || todoItems.length === 0) return;
|
(pi as any).on("context", async (event: { messages: Array<{ role: string; content: unknown }> }) => {
|
||||||
if (event.isError) return;
|
// Remove any previous plan-mode-context or plan-execution-context messages
|
||||||
|
// They'll be re-injected with current state via before_agent_start
|
||||||
// Find best matching todo item
|
const filtered = event.messages.filter((m) => {
|
||||||
const matchIdx = findBestMatch(todoItems, event.toolName, event.input);
|
if (m.role === "user" && Array.isArray(m.content)) {
|
||||||
if (matchIdx >= 0 && !todoItems[matchIdx].completed) {
|
// Check for our custom message types in user messages
|
||||||
todoItems[matchIdx].completed = true;
|
const hasOldContext = (m.content as Array<{ type: string; text?: string }>).some(
|
||||||
updateStatus(ctx);
|
(c) =>
|
||||||
}
|
c.type === "text" &&
|
||||||
|
c.text &&
|
||||||
|
(c.text.includes("[PLAN MODE ACTIVE]") ||
|
||||||
|
c.text.includes("[PLAN MODE DISABLED") ||
|
||||||
|
c.text.includes("[EXECUTING PLAN]")),
|
||||||
|
);
|
||||||
|
if (hasOldContext) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return { messages: filtered };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inject plan mode context
|
// Inject plan mode context
|
||||||
|
|
@ -424,14 +343,19 @@ Do NOT attempt to make changes - just describe what you would do.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
if (executionMode && todoItems.length > 0) {
|
||||||
const completed = todoItems.filter((t) => t.completed).length;
|
const remaining = todoItems.filter((t) => !t.completed);
|
||||||
const remaining = todoItems.filter((t) => !t.completed).map((t) => t.text);
|
const todoList = remaining.map((t) => `- [${t.id}] ${t.text}`).join("\n");
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
customType: "plan-execution-context",
|
customType: "plan-execution-context",
|
||||||
content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete]
|
content: `[EXECUTING PLAN]
|
||||||
Plan mode is OFF. You have FULL access to: read, write, edit, bash.
|
Plan mode is OFF. You have FULL access to: read, write, edit, bash.
|
||||||
${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. ${t}`).join("\n")}` : "All steps complete!"}`,
|
|
||||||
|
Remaining steps:
|
||||||
|
${todoList}
|
||||||
|
|
||||||
|
IMPORTANT: After completing each step, output [DONE:id] to mark it complete.
|
||||||
|
Example: [DONE:${remaining[0]?.id || "abc123"}]`,
|
||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -440,8 +364,28 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}.
|
||||||
|
|
||||||
// After agent finishes
|
// After agent finishes
|
||||||
pi.on("agent_end", async (event, ctx) => {
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
// Check if all complete
|
// Check for done tags in execution mode
|
||||||
if (executionMode && todoItems.length > 0) {
|
if (executionMode && todoItems.length > 0) {
|
||||||
|
const messages = event.messages;
|
||||||
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||||
|
if (lastAssistant && Array.isArray(lastAssistant.content)) {
|
||||||
|
const textContent = lastAssistant.content
|
||||||
|
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Find and mark completed items
|
||||||
|
const doneIds = findDoneTags(textContent);
|
||||||
|
for (const id of doneIds) {
|
||||||
|
const item = todoItems.find((t) => t.id === id);
|
||||||
|
if (item && !item.completed) {
|
||||||
|
item.completed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStatus(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all complete
|
||||||
const allComplete = todoItems.every((t) => t.completed);
|
const allComplete = todoItems.every((t) => t.completed);
|
||||||
if (allComplete) {
|
if (allComplete) {
|
||||||
// Show final completed list in chat
|
// Show final completed list in chat
|
||||||
|
|
@ -485,7 +429,7 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}.
|
||||||
|
|
||||||
const hasTodos = todoItems.length > 0;
|
const hasTodos = todoItems.length > 0;
|
||||||
|
|
||||||
// Show todo list in chat (no IDs, just numbered)
|
// Show todo list in chat (no IDs shown to user, just numbered)
|
||||||
if (hasTodos) {
|
if (hasTodos) {
|
||||||
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
||||||
pi.sendMessage(
|
pi.sendMessage(
|
||||||
|
|
@ -510,13 +454,11 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}.
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
|
|
||||||
|
// Simple execution message - context event filters old plan mode messages
|
||||||
|
// and before_agent_start injects fresh execution context with IDs
|
||||||
const execMessage = hasTodos
|
const execMessage = hasTodos
|
||||||
? `[PLAN MODE DISABLED - EXECUTE NOW]
|
? `Execute the plan. Start with: ${todoItems[0].text}`
|
||||||
You now have FULL access to all tools: read, write, edit, bash.
|
: "Execute the plan you just created.";
|
||||||
Execute the plan step by step. Start with: ${todoItems[0].text}`
|
|
||||||
: `[PLAN MODE DISABLED - EXECUTE NOW]
|
|
||||||
You now have FULL access to all tools: read, write, edit, bash.
|
|
||||||
Execute the plan you just created. Proceed step by step.`;
|
|
||||||
|
|
||||||
pi.sendMessage(
|
pi.sendMessage(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue