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
This commit is contained in:
ismeth
2026-02-28 00:50:23 +01:00
committed by YeonGyu-Kim
parent 684b746ee7
commit 2b70130c08
14 changed files with 817 additions and 115 deletions
+10 -3
View File
@@ -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=["<id1>", "<id2>", "<id3>"])
// Then collect each result
background_output(task_id="<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 \`<COUNCIL_MEMBER_RESPONSE>\`, 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.
@@ -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> = {}): 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: "</COUNCIL_MEMBER_RESPONSE>" }],
},
]
//#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 </COUNCIL_MEMBER_RESPONSE>" }],
},
]
//#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 </COUNCIL_MEMBER_RESPONSE>" }],
},
]
//#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: "</COUNCIL_MEMBER_RESPONSE>" }],
},
{
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)
})
})
})
@@ -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 = "</COUNCIL_MEMBER_RESPONSE>"
const CONTINUATION_PROMPT =
"You have not yet produced your final <COUNCIL_MEMBER_RESPONSE>. Continue your analysis and wrap your findings in <COUNCIL_MEMBER_RESPONSE> 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<string, number>()
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
}
@@ -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</COUNCIL_MEMBER_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: </COUNCIL_MEMBER_RESPONSE>" }],
},
{
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</COUNCIL_MEMBER_RESPONSE>\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)
})
})
})
@@ -0,0 +1,42 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log, normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"]
const COUNCIL_RESPONSE_TAG = "</COUNCIL_MEMBER_RESPONSE>"
export async function sessionHasCouncilResponse(
client: OpencodeClient,
sessionID: string,
): Promise<boolean> {
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
}
}
+17 -2
View File
@@ -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<boolean> {
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"
@@ -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()
}
}
@@ -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")
})
})
})
@@ -11,10 +11,9 @@ export function handleSessionIdleBackgroundEvent(args: {
properties: Record<string, unknown>
findBySession: (sessionID: string) => BackgroundTask | undefined
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
recentlyCompactedSessions?: Set<string>
onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
nudgeCouncilMemberIfNeeded?: (task: BackgroundTask, sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
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) => {
+1 -1
View File
@@ -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(),
}
+4 -2
View File
@@ -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()
})
})
+2
View File
@@ -71,6 +71,8 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
ast_grep_search: true,
call_omo_agent: true,
background_output: true,
background_wait: true,
background_cancel: true,
todowrite: false,
todoread: false,
},
+2 -22
View File
@@ -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<string, ToolDefinition
"simply omit it for the default behavior. Must be a valid directory path if provided."
),
},
<<<<<<< HEAD
execute: async (args, context) => {
||||||| 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<string, unknown>
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
)
+1 -20
View File
@@ -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<string, ToolDefinition
.optional()
.describe("Limit output to first N entries. 0 or omitted means no limit."),
},
<<<<<<< HEAD
execute: async (args, context) => {
||||||| 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<string, unknown>
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