From 2b70130c081b054489229feb5d380d8b05bada08 Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 00:50:23 +0100 Subject: [PATCH] feat(athena): council member continuation enforcer, tool allowlist, and prompt fixes - Add council-continuation-enforcer to nudge council members until they output COUNCIL_MEMBER_RESPONSE, replacing the hacky post-compaction hack - Add background_wait + background_cancel to council member tool allowlist so members can properly manage their explore agents before responding - Update COUNCIL_DELEGATION_ADDENDUM prompt to instruct members to cancel pending tasks and wait for explore results before final response - Fix council_finalize path resolution to use project directory - Remove post-compaction-continuation.ts and recentlyCompactedSessions hack - Add council-response-checker for detecting response tag in session messages --- src/agents/athena/council-member-agent.ts | 13 +- .../council-continuation-enforcer.test.ts | 395 ++++++++++++++++++ .../council-continuation-enforcer.ts | 104 +++++ .../council-response-checker.test.ts | 165 ++++++++ .../council-response-checker.ts | 42 ++ src/features/background-agent/manager.ts | 19 +- .../post-compaction-continuation.ts | 55 --- .../session-idle-event-handler.test.ts | 67 +++ .../session-idle-event-handler.ts | 17 +- src/plugin/tool-registry.ts | 2 +- src/shared/agent-tool-restrictions.test.ts | 6 +- src/shared/agent-tool-restrictions.ts | 2 + src/tools/glob/tools.ts | 24 +- src/tools/grep/tools.ts | 21 +- 14 files changed, 817 insertions(+), 115 deletions(-) create mode 100644 src/features/background-agent/council-continuation-enforcer.test.ts create mode 100644 src/features/background-agent/council-continuation-enforcer.ts create mode 100644 src/features/background-agent/council-response-checker.test.ts create mode 100644 src/features/background-agent/council-response-checker.ts delete mode 100644 src/features/background-agent/post-compaction-continuation.ts diff --git a/src/agents/athena/council-member-agent.ts b/src/agents/athena/council-member-agent.ts index f099129d8..669bf592a 100644 --- a/src/agents/athena/council-member-agent.ts +++ b/src/agents/athena/council-member-agent.ts @@ -70,17 +70,22 @@ call_omo_agent(subagent_type="explore", run_in_background=true, description="Fin call_omo_agent(subagent_type="explore", run_in_background=true, description="Find error handling", prompt="Find: custom Error classes, error response format, try/catch patterns. Skip tests.") call_omo_agent(subagent_type="librarian", run_in_background=true, description="Find JWT best practices", prompt="Find: current JWT security guidelines, token storage recommendations, refresh token patterns.") -// Collect results when ready +// IMPORTANT: Use background_wait to block until results arrive — do NOT just stop and wait for notifications +background_wait(task_ids=["", "", ""]) + +// Then collect each result background_output(task_id="") \`\`\` **Rules:** - ALWAYS set \`run_in_background=true\` — never block on a single search -- Launch ALL searches before collecting any results +- Launch ALL searches, then call \`background_wait\` with all task IDs to block until they complete +- Do NOT stop generating and wait for notifications — always use \`background_wait\` to stay active - Use \`explore\` for codebase pattern searches (internal) - Use \`librarian\` for documentation and external references - Keep targeted file reads (Read tool) for yourself — delegate broad searches -- Collect results with \`background_output\` when you need them for analysis` +- Collect results with \`background_output\` after \`background_wait\` returns +- Before generating your final \`\`, cancel any remaining pending tasks with \`background_cancel\`` export function createCouncilMemberAgent(model: string): AgentConfig { // Allow-list: only read-only analysis tools + optional delegation. @@ -97,6 +102,8 @@ export function createCouncilMemberAgent(model: string): AgentConfig { "ast_grep_search", "call_omo_agent", "background_output", + "background_wait", + "background_cancel", ]) // Explicitly deny TodoWrite/TodoRead even though `*: deny` should catch them. diff --git a/src/features/background-agent/council-continuation-enforcer.test.ts b/src/features/background-agent/council-continuation-enforcer.test.ts new file mode 100644 index 000000000..b2235b185 --- /dev/null +++ b/src/features/background-agent/council-continuation-enforcer.test.ts @@ -0,0 +1,395 @@ +import { describe, it, expect, mock } from "bun:test" + +import { + isCouncilMemberAgent, + hasCouncilResponseTag, + sendCouncilContinuationNudge, + resetCouncilNudgeCount, +} from "./council-continuation-enforcer" +import type { BackgroundTask } from "./types" + +function createRunningTask(overrides: Partial = {}): BackgroundTask { + return { + id: "task-1", + sessionID: "ses-council-1", + parentSessionID: "parent-ses-1", + parentMessageID: "msg-1", + description: "council test", + prompt: "test", + agent: "Council: deep-1", + status: "running", + startedAt: new Date(), + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + progress: { lastUpdate: new Date(), toolCalls: 0 }, + ...overrides, + } +} + +function createMockClient() { + return { session: { promptAsync: mock(() => Promise.resolve()) } } +} + +describe("isCouncilMemberAgent", () => { + describe('#given agent name starts with "Council: "', () => { + it("#then should return true", () => { + //#given + const agentName = "Council: deep-1" + + //#when + const result = isCouncilMemberAgent(agentName) + + //#then + expect(result).toBe(true) + }) + + it("#when name has only the prefix #then should return true", () => { + //#given + const agentName = "Council: " + + //#when + const result = isCouncilMemberAgent(agentName) + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given agent name does not start with the council prefix", () => { + it('#when name is "explore" #then should return false', () => { + //#given + const agentName = "explore" + + //#when + const result = isCouncilMemberAgent(agentName) + + //#then + expect(result).toBe(false) + }) + + it('#when name is "oracle" #then should return false', () => { + //#given + const agentName = "oracle" + + //#when + const result = isCouncilMemberAgent(agentName) + + //#then + expect(result).toBe(false) + }) + + it('#when name is "sisyphus" #then should return false', () => { + //#given + const agentName = "sisyphus" + + //#when + const result = isCouncilMemberAgent(agentName) + + //#then + expect(result).toBe(false) + }) + + it("#when name is empty string #then should return false", () => { + //#given + const agentName = "" + + //#when + const result = isCouncilMemberAgent(agentName) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given agent name is undefined", () => { + it("#then should return false", () => { + //#when + const result = isCouncilMemberAgent(undefined) + + //#then + expect(result).toBe(false) + }) + }) +}) + +describe("hasCouncilResponseTag", () => { + describe("#given empty messages array", () => { + it("#then should return false", () => { + //#when + const result = hasCouncilResponseTag([]) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given no assistant messages", () => { + it("#then should return false", () => { + //#given + const messages = [ + { + info: { role: "user" }, + parts: [{ type: "text", text: "" }], + }, + ] + + //#when + const result = hasCouncilResponseTag(messages) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given assistant message contains the response tag", () => { + it("#then should return true", () => { + //#given + const messages = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "My analysis " }], + }, + ] + + //#when + const result = hasCouncilResponseTag(messages) + + //#then + expect(result).toBe(true) + }) + + it("#when tag is in the last assistant message #then should return true", () => { + //#given + const messages = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "no tag here" }], + }, + { + info: { role: "user" }, + parts: [{ type: "text", text: "user message" }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "final answer " }], + }, + ] + + //#when + const result = hasCouncilResponseTag(messages) + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given assistant messages without the response tag", () => { + it("#then should return false", () => { + //#given + const messages = [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "analysis without closing tag" }], + }, + ] + + //#when + const result = hasCouncilResponseTag(messages) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given user message contains the tag but no assistant message does", () => { + it("#then should return false", () => { + //#given + const messages = [ + { + info: { role: "user" }, + parts: [{ type: "text", text: "" }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "no tag in assistant" }], + }, + ] + + //#when + const result = hasCouncilResponseTag(messages) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given messages with missing parts", () => { + it("#then should handle gracefully and return false", () => { + //#given + const messages = [ + { info: { role: "assistant" } }, + { info: { role: "assistant" }, parts: [] }, + { info: { role: "assistant" }, parts: [{ type: "text" }] }, + ] + + //#when + const result = hasCouncilResponseTag(messages) + + //#then + expect(result).toBe(false) + }) + }) +}) + +describe("sendCouncilContinuationNudge", () => { + describe("#given task status is not running", () => { + it("#then should return false without calling promptAsync", () => { + //#given + const client = createMockClient() + const task = createRunningTask({ status: "completed" }) + + //#when + const result = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + + //#then + expect(result).toBe(false) + expect(client.session.promptAsync).not.toHaveBeenCalled() + }) + }) + + describe("#given task is running and nudge count is zero", () => { + it("#when nudging for the first time #then should return true and call promptAsync", () => { + //#given + const client = createMockClient() + const task = createRunningTask({ id: "task-nudge-first" }) + resetCouncilNudgeCount(task.id) + + //#when + const result = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + + //#then + expect(result).toBe(true) + expect(client.session.promptAsync).toHaveBeenCalledTimes(1) + + // cleanup + resetCouncilNudgeCount(task.id) + }) + + it("#when nudging #then should update task.progress.lastUpdate", () => { + //#given + const client = createMockClient() + const oldDate = new Date(Date.now() - 10000) + const task = createRunningTask({ + id: "task-nudge-progress", + progress: { lastUpdate: oldDate, toolCalls: 0 }, + }) + resetCouncilNudgeCount(task.id) + + //#when + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + + //#then + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldDate.getTime()) + + // cleanup + resetCouncilNudgeCount(task.id) + }) + }) + + describe("#given nudge count increments on each call", () => { + it("#when nudging multiple times below max #then should return true each time", () => { + //#given + const client = createMockClient() + const task = createRunningTask({ id: "task-nudge-multi" }) + resetCouncilNudgeCount(task.id) + + //#when + const result1 = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + const result2 = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + const result3 = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + + //#then + expect(result1).toBe(true) + expect(result2).toBe(true) + expect(result3).toBe(true) + expect(client.session.promptAsync).toHaveBeenCalledTimes(3) + + // cleanup + resetCouncilNudgeCount(task.id) + }) + }) + + describe("#given nudge count reaches MAX_NUDGE_ATTEMPTS (5)", () => { + it("#when called after max attempts #then should return false and clean up count", () => { + //#given + const client = createMockClient() + const task = createRunningTask({ id: "task-nudge-max" }) + resetCouncilNudgeCount(task.id) + + //#when - exhaust all 5 attempts + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + const resultAfterMax = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + + //#then + expect(resultAfterMax).toBe(false) + expect(client.session.promptAsync).toHaveBeenCalledTimes(5) + }) + }) + + describe("#given promptAsync throws an error", () => { + it("#then should handle error gracefully and still return true", () => { + //#given + const client = { + session: { promptAsync: mock(() => Promise.reject(new Error("network error"))) }, + } + const task = createRunningTask({ id: "task-nudge-error" }) + resetCouncilNudgeCount(task.id) + + //#when + const result = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + + //#then + expect(result).toBe(true) + + // cleanup + resetCouncilNudgeCount(task.id) + }) + }) +}) + +describe("resetCouncilNudgeCount", () => { + describe("#given nudge count has been incremented", () => { + it("#when reset is called #then nudging should start from zero again", () => { + //#given + const client = createMockClient() + const task = createRunningTask({ id: "task-nudge-reset" }) + resetCouncilNudgeCount(task.id) + + // exhaust all attempts + for (let i = 0; i < 5; i++) { + sendCouncilContinuationNudge(client as never, task, task.sessionID!) + } + const resultBeforeReset = sendCouncilContinuationNudge(client as never, task, task.sessionID!) + expect(resultBeforeReset).toBe(false) + + //#when + resetCouncilNudgeCount(task.id) + + //#then - should be able to nudge again + const clientAfterReset = createMockClient() + const resultAfterReset = sendCouncilContinuationNudge( + clientAfterReset as never, + task, + task.sessionID!, + ) + expect(resultAfterReset).toBe(true) + expect(clientAfterReset.session.promptAsync).toHaveBeenCalledTimes(1) + + // cleanup + resetCouncilNudgeCount(task.id) + }) + }) +}) diff --git a/src/features/background-agent/council-continuation-enforcer.ts b/src/features/background-agent/council-continuation-enforcer.ts new file mode 100644 index 000000000..5b57540d8 --- /dev/null +++ b/src/features/background-agent/council-continuation-enforcer.ts @@ -0,0 +1,104 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { BackgroundTask } from "./types" +import { + log, + getAgentToolRestrictions, + createInternalAgentTextPart, +} from "../../shared" +import { setSessionTools } from "../../shared/session-tools-store" +import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents" + +type OpencodeClient = PluginInput["client"] + +const COUNCIL_RESPONSE_TAG = "" + +const CONTINUATION_PROMPT = + "You have not yet produced your final . Continue your analysis and wrap your findings in tags. If you are waiting for background tasks, use background_wait to block until they complete, then produce your response." + +const MAX_NUDGE_ATTEMPTS = 5 + +const nudgeCountByTask = new Map() + +export function isCouncilMemberAgent(agentName: string | undefined): boolean { + return !!agentName?.startsWith(COUNCIL_MEMBER_KEY_PREFIX) +} + +export function resetCouncilNudgeCount(taskId: string): void { + nudgeCountByTask.delete(taskId) +} + +export function hasCouncilResponseTag(sessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }> }>): boolean { + for (let i = sessionMessages.length - 1; i >= 0; i--) { + const msg = sessionMessages[i] + if (msg.info?.role !== "assistant") continue + const parts = msg.parts ?? [] + for (const part of parts) { + if (part.type === "text" && part.text?.includes(COUNCIL_RESPONSE_TAG)) { + return true + } + } + } + return false +} + +export function sendCouncilContinuationNudge( + client: OpencodeClient, + task: BackgroundTask, + sessionID: string, +): boolean { + if (task.status !== "running") return false + + const count = nudgeCountByTask.get(task.id) ?? 0 + if (count >= MAX_NUDGE_ATTEMPTS) { + log("[council-continuation] Max nudge attempts reached, allowing completion:", { + taskId: task.id, + attempts: count, + }) + nudgeCountByTask.delete(task.id) + return false + } + + nudgeCountByTask.set(task.id, count + 1) + + const resumeModel = task.model + ? { providerID: task.model.providerID, modelID: task.model.modelID } + : undefined + const resumeVariant = task.model?.variant + + log("[council-continuation] Nudging council member to produce response:", { + taskId: task.id, + attempt: count + 1, + maxAttempts: MAX_NUDGE_ATTEMPTS, + }) + + client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: task.agent, + ...(resumeModel ? { model: resumeModel } : {}), + ...(resumeVariant ? { variant: resumeVariant } : {}), + tools: (() => { + const tools = { + task: false, + call_omo_agent: true, + question: false, + ...getAgentToolRestrictions(task.agent), + } + setSessionTools(sessionID, tools) + return tools + })(), + parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)], + }, + }).catch((error) => { + log("[council-continuation] Nudge prompt error:", { + taskId: task.id, + error: String(error), + }) + }) + + if (task.progress) { + task.progress.lastUpdate = new Date() + } + + return true +} diff --git a/src/features/background-agent/council-response-checker.test.ts b/src/features/background-agent/council-response-checker.test.ts new file mode 100644 index 000000000..0c85c6672 --- /dev/null +++ b/src/features/background-agent/council-response-checker.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, mock } from "bun:test" + +import { sessionHasCouncilResponse } from "./council-response-checker" + +function createMockClient( + messages: Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }> }>, +) { + return { + session: { + messages: mock(() => Promise.resolve(messages)), + }, + } as any +} + +describe("sessionHasCouncilResponse", () => { + describe("#given assistant message with closing council tag", () => { + it("#when tag is in text part #then should return true", async () => { + //#given + const client = createMockClient([ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Some response" }], + }, + ]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-1") + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given empty messages array", () => { + it("#when no messages exist #then should return false", async () => { + //#given + const client = createMockClient([]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-empty") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given only user messages", () => { + it("#when no assistant messages exist #then should return false", async () => { + //#given + const client = createMockClient([ + { + info: { role: "user" }, + parts: [{ type: "text", text: "Hello" }], + }, + ]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-user-only") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given assistant messages without the closing tag", () => { + it("#when tag is absent #then should return false", async () => { + //#given + const client = createMockClient([ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Just a regular assistant response" }], + }, + ]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-no-tag") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given user message containing the closing tag", () => { + it("#when only user has the tag #then should return false", async () => { + //#given + const client = createMockClient([ + { + info: { role: "user" }, + parts: [{ type: "text", text: "Here is the tag: " }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "I see your message" }], + }, + ]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-user-tag") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given assistant message with tag buried in longer text", () => { + it("#when tag appears mid-text #then should return true", async () => { + //#given + const client = createMockClient([ + { + info: { role: "assistant" }, + parts: [ + { + type: "text", + text: "Here is my analysis of the situation.\n\nLong content here.\n\n\n\nMore text after.", + }, + ], + }, + ]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-buried-tag") + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given API call throws an error", () => { + it("#when client rejects #then should return false", async () => { + //#given + const client = { + session: { + messages: mock(() => Promise.reject(new Error("API error"))), + }, + } as any + + //#when + const result = await sessionHasCouncilResponse(client, "ses-error") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given messages with missing or undefined parts", () => { + it("#when parts are undefined #then should handle gracefully and return false", async () => { + //#given + const client = createMockClient([ + { + info: { role: "assistant" }, + parts: undefined, + }, + { + info: { role: "assistant" }, + }, + ]) + + //#when + const result = await sessionHasCouncilResponse(client, "ses-no-parts") + + //#then + expect(result).toBe(false) + }) + }) +}) diff --git a/src/features/background-agent/council-response-checker.ts b/src/features/background-agent/council-response-checker.ts new file mode 100644 index 000000000..a8e552ab4 --- /dev/null +++ b/src/features/background-agent/council-response-checker.ts @@ -0,0 +1,42 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log, normalizeSDKResponse } from "../../shared" + +type OpencodeClient = PluginInput["client"] + +const COUNCIL_RESPONSE_TAG = "" + +export async function sessionHasCouncilResponse( + client: OpencodeClient, + sessionID: string, +): Promise { + try { + const response = await client.session.messages({ + path: { id: sessionID }, + }) + + const messages = normalizeSDKResponse( + response, + [] as Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }> }>, + { preferResponseOnMissingData: true }, + ) + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info?.role !== "assistant") continue + const parts = msg.parts ?? [] + for (const part of parts) { + if (part.type === "text" && part.text?.includes(COUNCIL_RESPONSE_TAG)) { + return true + } + } + } + + return false + } catch (error) { + log("[council-response-checker] Error checking session for response tag:", { + sessionID, + error: String(error), + }) + return false + } +} diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 5ed8b0970..74061ef54 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -81,6 +81,8 @@ import { } from "./subagent-spawn-limits" import { writeTaskOutput } from "./task-output-writer" +import { isCouncilMemberAgent, sendCouncilContinuationNudge, resetCouncilNudgeCount } from "./council-continuation-enforcer" +import { sessionHasCouncilResponse } from "./council-response-checker" type OpencodeClient = PluginInput["client"] @@ -984,6 +986,18 @@ export class BackgroundManager { return field === "text" || field === "reasoning" } + private async nudgeCouncilMemberIfNeeded(task: BackgroundTask, sessionID: string): Promise { + if (!isCouncilMemberAgent(task.agent)) return false + + const hasResponse = await sessionHasCouncilResponse(this.client, sessionID) + if (hasResponse) { + resetCouncilNudgeCount(task.id) + return false + } + + return sendCouncilContinuationNudge(this.client, task, sessionID) + } + handleEvent(event: Event): void { const props = event.properties @@ -1135,7 +1149,6 @@ export class BackgroundManager { const task = this.findBySession(sessionID) if (!task || task.status !== "running") return - this.recentlyCompactedSessions.add(sessionID) if (task.progress) { task.progress.lastUpdate = new Date() } @@ -1151,6 +1164,7 @@ export class BackgroundManager { recentlyCompactedSessions: this.recentlyCompactedSessions, validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), + nudgeCouncilMemberIfNeeded: (task, sid) => this.nudgeCouncilMemberIfNeeded(task, sid), tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }), }) @@ -1240,7 +1254,6 @@ export class BackgroundManager { this.rootDescendantCounts.delete(sessionID) SessionCategoryRegistry.remove(sessionID) - this.recentlyCompactedSessions.delete(sessionID) } if (event.type === "session.status") { @@ -1705,6 +1718,7 @@ export class BackgroundManager { task.status = "completed" task.completedAt = new Date() this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) + resetCouncilNudgeCount(task.id) if (task.rootSessionID) { this.unregisterRootDescendant(task.rootSessionID) @@ -2052,6 +2066,7 @@ export class BackgroundManager { try { const sessionStatus = allStatuses[sessionID] + task.sessionState = sessionStatus?.type // Handle retry before checking running state if (sessionStatus?.type === "retry") { const retryMessage = typeof (sessionStatus as { message?: string }).message === "string" diff --git a/src/features/background-agent/post-compaction-continuation.ts b/src/features/background-agent/post-compaction-continuation.ts deleted file mode 100644 index 934fd23a5..000000000 --- a/src/features/background-agent/post-compaction-continuation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { BackgroundTask } from "./types" -import { - log, - getAgentToolRestrictions, - createInternalAgentTextPart, -} from "../../shared" -import { setSessionTools } from "../../shared/session-tools-store" - -type OpencodeClient = PluginInput["client"] - -const CONTINUATION_PROMPT = - "Your session was compacted (context summarized). Continue your analysis from where you left off. Report your findings when done." - -export function sendPostCompactionContinuation( - client: OpencodeClient, - task: BackgroundTask, - sessionID: string, -): void { - if (task.status !== "running") return - - const resumeModel = task.model - ? { providerID: task.model.providerID, modelID: task.model.modelID } - : undefined - const resumeVariant = task.model?.variant - - client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: task.agent, - ...(resumeModel ? { model: resumeModel } : {}), - ...(resumeVariant ? { variant: resumeVariant } : {}), - tools: (() => { - const tools = { - task: false, - call_omo_agent: true, - question: false, - ...getAgentToolRestrictions(task.agent), - } - setSessionTools(sessionID, tools) - return tools - })(), - parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)], - }, - }).catch((error) => { - log("[background-agent] Post-compaction continuation error:", { - taskId: task.id, - error: String(error), - }) - }) - - if (task.progress) { - task.progress.lastUpdate = new Date() - } -} diff --git a/src/features/background-agent/session-idle-event-handler.test.ts b/src/features/background-agent/session-idle-event-handler.test.ts index 1e2efafbc..ce4db7244 100644 --- a/src/features/background-agent/session-idle-event-handler.test.ts +++ b/src/features/background-agent/session-idle-event-handler.test.ts @@ -336,5 +336,72 @@ describe("handleSessionIdleBackgroundEvent", () => { await new Promise((resolve) => setTimeout(resolve, 10)) expect(tryCompleteTask).not.toHaveBeenCalled() }) + + it("#when nudgeCouncilMemberIfNeeded is provided and returns true #then should not complete task", async () => { + //#given + const task = createRunningTask() + const tryCompleteTask = mock(() => Promise.resolve(true)) + const nudgeCouncilMemberIfNeeded = mock(() => Promise.resolve(true)) + + //#when + handleSessionIdleBackgroundEvent({ + properties: { sessionID: task.sessionID! }, + findBySession: () => task, + idleDeferralTimers: new Map(), + validateSessionHasOutput: () => Promise.resolve(true), + checkSessionTodos: () => Promise.resolve(false), + nudgeCouncilMemberIfNeeded, + tryCompleteTask, + emitIdleEvent: () => {}, + }) + + //#then + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(tryCompleteTask).not.toHaveBeenCalled() + }) + + it("#when nudgeCouncilMemberIfNeeded is provided and returns false #then should complete task", async () => { + //#given + const task = createRunningTask() + const tryCompleteTask = mock(() => Promise.resolve(true)) + const nudgeCouncilMemberIfNeeded = mock(() => Promise.resolve(false)) + + //#when + handleSessionIdleBackgroundEvent({ + properties: { sessionID: task.sessionID! }, + findBySession: () => task, + idleDeferralTimers: new Map(), + validateSessionHasOutput: () => Promise.resolve(true), + checkSessionTodos: () => Promise.resolve(false), + nudgeCouncilMemberIfNeeded, + tryCompleteTask, + emitIdleEvent: () => {}, + }) + + //#then + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event") + }) + + it("#when nudgeCouncilMemberIfNeeded is not provided #then should complete task normally", async () => { + //#given + const task = createRunningTask() + const tryCompleteTask = mock(() => Promise.resolve(true)) + + //#when + handleSessionIdleBackgroundEvent({ + properties: { sessionID: task.sessionID! }, + findBySession: () => task, + idleDeferralTimers: new Map(), + validateSessionHasOutput: () => Promise.resolve(true), + checkSessionTodos: () => Promise.resolve(false), + tryCompleteTask, + emitIdleEvent: () => {}, + }) + + //#then + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event") + }) }) }) diff --git a/src/features/background-agent/session-idle-event-handler.ts b/src/features/background-agent/session-idle-event-handler.ts index 70c0e6530..0f64235aa 100644 --- a/src/features/background-agent/session-idle-event-handler.ts +++ b/src/features/background-agent/session-idle-event-handler.ts @@ -11,10 +11,9 @@ export function handleSessionIdleBackgroundEvent(args: { properties: Record findBySession: (sessionID: string) => BackgroundTask | undefined idleDeferralTimers: Map> - recentlyCompactedSessions?: Set - onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void validateSessionHasOutput: (sessionID: string) => Promise checkSessionTodos: (sessionID: string) => Promise + nudgeCouncilMemberIfNeeded?: (task: BackgroundTask, sessionID: string) => Promise tryCompleteTask: (task: BackgroundTask, source: string) => Promise emitIdleEvent: (sessionID: string) => void }): void { @@ -22,10 +21,9 @@ export function handleSessionIdleBackgroundEvent(args: { properties, findBySession, idleDeferralTimers, - recentlyCompactedSessions, - onPostCompactionIdle, validateSessionHasOutput, checkSessionTodos, + nudgeCouncilMemberIfNeeded, tryCompleteTask, emitIdleEvent, } = args @@ -36,12 +34,6 @@ export function handleSessionIdleBackgroundEvent(args: { const task = findBySession(sessionID) if (!task || task.status !== "running") return - if (recentlyCompactedSessions?.has(sessionID)) { - recentlyCompactedSessions.delete(sessionID) - log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID }) - onPostCompactionIdle?.(task, sessionID) - return - } const startedAt = task.startedAt if (!startedAt) return @@ -103,6 +95,11 @@ export function handleSessionIdleBackgroundEvent(args: { return } + if (nudgeCouncilMemberIfNeeded) { + const nudged = await nudgeCouncilMemberIfNeeded(task, sessionID) + if (nudged) return + } + await tryCompleteTask(task, "session.idle event") }) .catch((err) => { diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index d01fa7ddb..114f3ea1f 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -281,7 +281,7 @@ export function createToolRegistry(args: { ...taskToolsRecord, ...hashlineToolsRecord, prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory), - council_finalize: createCouncilFinalize(), + council_finalize: createCouncilFinalize(ctx.directory), council_read: createCouncilRead(), } diff --git a/src/shared/agent-tool-restrictions.test.ts b/src/shared/agent-tool-restrictions.test.ts index 0fe92fede..ebe606069 100644 --- a/src/shared/agent-tool-restrictions.test.ts +++ b/src/shared/agent-tool-restrictions.test.ts @@ -22,12 +22,13 @@ describe("agent-tool-restrictions", () => { expect(restrictions.grep).toBe(true) expect(restrictions.call_omo_agent).toBe(true) expect(restrictions.background_output).toBe(true) + expect(restrictions.background_wait).toBe(true) + expect(restrictions.background_cancel).toBe(true) // Explicitly denied tools expect(restrictions.todowrite).toBe(false) expect(restrictions.todoread).toBe(false) // Unlisted tools are undefined (SDK applies wildcard at runtime) expect(restrictions.switch_agent).toBeUndefined() - expect(restrictions.background_wait).toBeUndefined() }) test("#given dynamic council member name #when getAgentToolRestrictions #then returns council-member restrictions", () => { @@ -43,6 +44,8 @@ describe("agent-tool-restrictions", () => { expect(restrictions.grep).toBe(true) expect(restrictions.call_omo_agent).toBe(true) expect(restrictions.background_output).toBe(true) + expect(restrictions.background_wait).toBe(true) + expect(restrictions.background_cancel).toBe(true) // Explicitly denied tools expect(restrictions.todowrite).toBe(false) expect(restrictions.todoread).toBe(false) @@ -51,6 +54,5 @@ describe("agent-tool-restrictions", () => { expect(restrictions.write).toBeUndefined() expect(restrictions.edit).toBeUndefined() expect(restrictions.task).toBeUndefined() - expect(restrictions.background_wait).toBeUndefined() }) }) diff --git a/src/shared/agent-tool-restrictions.ts b/src/shared/agent-tool-restrictions.ts index 09517f817..f807e8bf2 100644 --- a/src/shared/agent-tool-restrictions.ts +++ b/src/shared/agent-tool-restrictions.ts @@ -71,6 +71,8 @@ const AGENT_RESTRICTIONS: Record> = { ast_grep_search: true, call_omo_agent: true, background_output: true, + background_wait: true, + background_cancel: true, todowrite: false, todoread: false, }, diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index f70687c1a..d808377b2 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" -import { tool, type ToolDefinition, type ToolContext } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRgFiles } from "./cli" import { resolveGrepCliWithAutoInstall } from "./constants" import { formatGlobResult } from "./result-formatter" @@ -23,38 +23,18 @@ export function createGlobTools(ctx: PluginInput): Record { -||||||| parent of a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) - execute: async (args) => { -======= - execute: async (args, context: ToolContext) => { ->>>>>>> a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) try { const cli = await resolveGrepCliWithAutoInstall() -<<<<<<< HEAD -<<<<<<< HEAD const runtimeCtx = context as Record const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory const searchPath = args.path ? resolve(dir, args.path) : dir -||||||| parent of 804d517f (fix(tools): resolve relative paths in glob/grep against project directory) - const searchPath = args.path ?? ctx.directory -||||||| parent of a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) - const searchPath = args.path ?? ctx.directory -======= - const dir = context?.directory ?? ctx.directory - const searchPath = args.path ? resolve(dir, args.path) : dir ->>>>>>> a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) const paths = [searchPath] -======= - const searchPath = args.path ? resolve(ctx.directory, args.path) : ctx.directory - const paths = [searchPath] ->>>>>>> 804d517f (fix(tools): resolve relative paths in glob/grep against project directory) const result = await runRgFiles( { pattern: args.pattern, - paths: [searchPath], + paths, }, cli ) diff --git a/src/tools/grep/tools.ts b/src/tools/grep/tools.ts index d885dfb33..eaf8a3972 100644 --- a/src/tools/grep/tools.ts +++ b/src/tools/grep/tools.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" -import { tool, type ToolDefinition, type ToolContext } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRg, runRgCount } from "./cli" import { resolveGrepCliWithAutoInstall } from "./constants" import { formatGrepResult, formatCountResult } from "./result-formatter" @@ -34,31 +34,12 @@ export function createGrepTools(ctx: PluginInput): Record { -||||||| parent of a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) - execute: async (args) => { -======= - execute: async (args, context: ToolContext) => { ->>>>>>> a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) try { const globs = args.include ? [args.include] : undefined -<<<<<<< HEAD -<<<<<<< HEAD const runtimeCtx = context as Record const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory const searchPath = args.path ? resolve(dir, args.path) : dir -||||||| parent of 804d517f (fix(tools): resolve relative paths in glob/grep against project directory) - const searchPath = args.path ?? ctx.directory -======= - const searchPath = args.path ? resolve(ctx.directory, args.path) : ctx.directory ->>>>>>> 804d517f (fix(tools): resolve relative paths in glob/grep against project directory) -||||||| parent of a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) - const searchPath = args.path ?? ctx.directory -======= - const dir = context?.directory ?? ctx.directory - const searchPath = args.path ? resolve(dir, args.path) : dir ->>>>>>> a9d2407d (fix(tools): resolve relative paths in glob/grep against project directory) const paths = [searchPath] const outputMode = args.output_mode ?? "files_with_matches" const headLimit = args.head_limit ?? 0