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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user