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="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.")
|
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>")
|
background_output(task_id="<id>")
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
- ALWAYS set \`run_in_background=true\` — never block on a single search
|
- 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 \`explore\` for codebase pattern searches (internal)
|
||||||
- Use \`librarian\` for documentation and external references
|
- Use \`librarian\` for documentation and external references
|
||||||
- Keep targeted file reads (Read tool) for yourself — delegate broad searches
|
- 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 {
|
export function createCouncilMemberAgent(model: string): AgentConfig {
|
||||||
// Allow-list: only read-only analysis tools + optional delegation.
|
// Allow-list: only read-only analysis tools + optional delegation.
|
||||||
@@ -97,6 +102,8 @@ export function createCouncilMemberAgent(model: string): AgentConfig {
|
|||||||
"ast_grep_search",
|
"ast_grep_search",
|
||||||
"call_omo_agent",
|
"call_omo_agent",
|
||||||
"background_output",
|
"background_output",
|
||||||
|
"background_wait",
|
||||||
|
"background_cancel",
|
||||||
])
|
])
|
||||||
|
|
||||||
// Explicitly deny TodoWrite/TodoRead even though `*: deny` should catch them.
|
// 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"
|
} from "./subagent-spawn-limits"
|
||||||
|
|
||||||
import { writeTaskOutput } from "./task-output-writer"
|
import { writeTaskOutput } from "./task-output-writer"
|
||||||
|
import { isCouncilMemberAgent, sendCouncilContinuationNudge, resetCouncilNudgeCount } from "./council-continuation-enforcer"
|
||||||
|
import { sessionHasCouncilResponse } from "./council-response-checker"
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
|
|
||||||
@@ -984,6 +986,18 @@ export class BackgroundManager {
|
|||||||
return field === "text" || field === "reasoning"
|
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 {
|
handleEvent(event: Event): void {
|
||||||
const props = event.properties
|
const props = event.properties
|
||||||
|
|
||||||
@@ -1135,7 +1149,6 @@ export class BackgroundManager {
|
|||||||
const task = this.findBySession(sessionID)
|
const task = this.findBySession(sessionID)
|
||||||
if (!task || task.status !== "running") return
|
if (!task || task.status !== "running") return
|
||||||
|
|
||||||
this.recentlyCompactedSessions.add(sessionID)
|
|
||||||
if (task.progress) {
|
if (task.progress) {
|
||||||
task.progress.lastUpdate = new Date()
|
task.progress.lastUpdate = new Date()
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1164,7 @@ export class BackgroundManager {
|
|||||||
recentlyCompactedSessions: this.recentlyCompactedSessions,
|
recentlyCompactedSessions: this.recentlyCompactedSessions,
|
||||||
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
|
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
|
||||||
checkSessionTodos: (id) => this.checkSessionTodos(id),
|
checkSessionTodos: (id) => this.checkSessionTodos(id),
|
||||||
|
nudgeCouncilMemberIfNeeded: (task, sid) => this.nudgeCouncilMemberIfNeeded(task, sid),
|
||||||
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
|
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
|
||||||
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
|
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
|
||||||
})
|
})
|
||||||
@@ -1240,7 +1254,6 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
this.rootDescendantCounts.delete(sessionID)
|
this.rootDescendantCounts.delete(sessionID)
|
||||||
SessionCategoryRegistry.remove(sessionID)
|
SessionCategoryRegistry.remove(sessionID)
|
||||||
this.recentlyCompactedSessions.delete(sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.status") {
|
if (event.type === "session.status") {
|
||||||
@@ -1705,6 +1718,7 @@ export class BackgroundManager {
|
|||||||
task.status = "completed"
|
task.status = "completed"
|
||||||
task.completedAt = new Date()
|
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 })
|
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) {
|
if (task.rootSessionID) {
|
||||||
this.unregisterRootDescendant(task.rootSessionID)
|
this.unregisterRootDescendant(task.rootSessionID)
|
||||||
@@ -2052,6 +2066,7 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionStatus = allStatuses[sessionID]
|
const sessionStatus = allStatuses[sessionID]
|
||||||
|
task.sessionState = sessionStatus?.type
|
||||||
// Handle retry before checking running state
|
// Handle retry before checking running state
|
||||||
if (sessionStatus?.type === "retry") {
|
if (sessionStatus?.type === "retry") {
|
||||||
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
|
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))
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
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>
|
properties: Record<string, unknown>
|
||||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||||
recentlyCompactedSessions?: Set<string>
|
|
||||||
onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void
|
|
||||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||||
|
nudgeCouncilMemberIfNeeded?: (task: BackgroundTask, sessionID: string) => Promise<boolean>
|
||||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||||
emitIdleEvent: (sessionID: string) => void
|
emitIdleEvent: (sessionID: string) => void
|
||||||
}): void {
|
}): void {
|
||||||
@@ -22,10 +21,9 @@ export function handleSessionIdleBackgroundEvent(args: {
|
|||||||
properties,
|
properties,
|
||||||
findBySession,
|
findBySession,
|
||||||
idleDeferralTimers,
|
idleDeferralTimers,
|
||||||
recentlyCompactedSessions,
|
|
||||||
onPostCompactionIdle,
|
|
||||||
validateSessionHasOutput,
|
validateSessionHasOutput,
|
||||||
checkSessionTodos,
|
checkSessionTodos,
|
||||||
|
nudgeCouncilMemberIfNeeded,
|
||||||
tryCompleteTask,
|
tryCompleteTask,
|
||||||
emitIdleEvent,
|
emitIdleEvent,
|
||||||
} = args
|
} = args
|
||||||
@@ -36,12 +34,6 @@ export function handleSessionIdleBackgroundEvent(args: {
|
|||||||
const task = findBySession(sessionID)
|
const task = findBySession(sessionID)
|
||||||
if (!task || task.status !== "running") return
|
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
|
const startedAt = task.startedAt
|
||||||
if (!startedAt) return
|
if (!startedAt) return
|
||||||
@@ -103,6 +95,11 @@ export function handleSessionIdleBackgroundEvent(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nudgeCouncilMemberIfNeeded) {
|
||||||
|
const nudged = await nudgeCouncilMemberIfNeeded(task, sessionID)
|
||||||
|
if (nudged) return
|
||||||
|
}
|
||||||
|
|
||||||
await tryCompleteTask(task, "session.idle event")
|
await tryCompleteTask(task, "session.idle event")
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export function createToolRegistry(args: {
|
|||||||
...taskToolsRecord,
|
...taskToolsRecord,
|
||||||
...hashlineToolsRecord,
|
...hashlineToolsRecord,
|
||||||
prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory),
|
prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory),
|
||||||
council_finalize: createCouncilFinalize(),
|
council_finalize: createCouncilFinalize(ctx.directory),
|
||||||
council_read: createCouncilRead(),
|
council_read: createCouncilRead(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ describe("agent-tool-restrictions", () => {
|
|||||||
expect(restrictions.grep).toBe(true)
|
expect(restrictions.grep).toBe(true)
|
||||||
expect(restrictions.call_omo_agent).toBe(true)
|
expect(restrictions.call_omo_agent).toBe(true)
|
||||||
expect(restrictions.background_output).toBe(true)
|
expect(restrictions.background_output).toBe(true)
|
||||||
|
expect(restrictions.background_wait).toBe(true)
|
||||||
|
expect(restrictions.background_cancel).toBe(true)
|
||||||
// Explicitly denied tools
|
// Explicitly denied tools
|
||||||
expect(restrictions.todowrite).toBe(false)
|
expect(restrictions.todowrite).toBe(false)
|
||||||
expect(restrictions.todoread).toBe(false)
|
expect(restrictions.todoread).toBe(false)
|
||||||
// Unlisted tools are undefined (SDK applies wildcard at runtime)
|
// Unlisted tools are undefined (SDK applies wildcard at runtime)
|
||||||
expect(restrictions.switch_agent).toBeUndefined()
|
expect(restrictions.switch_agent).toBeUndefined()
|
||||||
expect(restrictions.background_wait).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given dynamic council member name #when getAgentToolRestrictions #then returns council-member restrictions", () => {
|
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.grep).toBe(true)
|
||||||
expect(restrictions.call_omo_agent).toBe(true)
|
expect(restrictions.call_omo_agent).toBe(true)
|
||||||
expect(restrictions.background_output).toBe(true)
|
expect(restrictions.background_output).toBe(true)
|
||||||
|
expect(restrictions.background_wait).toBe(true)
|
||||||
|
expect(restrictions.background_cancel).toBe(true)
|
||||||
// Explicitly denied tools
|
// Explicitly denied tools
|
||||||
expect(restrictions.todowrite).toBe(false)
|
expect(restrictions.todowrite).toBe(false)
|
||||||
expect(restrictions.todoread).toBe(false)
|
expect(restrictions.todoread).toBe(false)
|
||||||
@@ -51,6 +54,5 @@ describe("agent-tool-restrictions", () => {
|
|||||||
expect(restrictions.write).toBeUndefined()
|
expect(restrictions.write).toBeUndefined()
|
||||||
expect(restrictions.edit).toBeUndefined()
|
expect(restrictions.edit).toBeUndefined()
|
||||||
expect(restrictions.task).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,
|
ast_grep_search: true,
|
||||||
call_omo_agent: true,
|
call_omo_agent: true,
|
||||||
background_output: true,
|
background_output: true,
|
||||||
|
background_wait: true,
|
||||||
|
background_cancel: true,
|
||||||
todowrite: false,
|
todowrite: false,
|
||||||
todoread: false,
|
todoread: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-22
@@ -1,6 +1,6 @@
|
|||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
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 { runRgFiles } from "./cli"
|
||||||
import { resolveGrepCliWithAutoInstall } from "./constants"
|
import { resolveGrepCliWithAutoInstall } from "./constants"
|
||||||
import { formatGlobResult } from "./result-formatter"
|
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."
|
"simply omit it for the default behavior. Must be a valid directory path if provided."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
<<<<<<< HEAD
|
|
||||||
execute: async (args, context) => {
|
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 {
|
try {
|
||||||
const cli = await resolveGrepCliWithAutoInstall()
|
const cli = await resolveGrepCliWithAutoInstall()
|
||||||
<<<<<<< HEAD
|
|
||||||
<<<<<<< HEAD
|
|
||||||
const runtimeCtx = context as Record<string, unknown>
|
const runtimeCtx = context as Record<string, unknown>
|
||||||
const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory
|
const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory
|
||||||
const searchPath = args.path ? resolve(dir, args.path) : dir
|
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 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(
|
const result = await runRgFiles(
|
||||||
{
|
{
|
||||||
pattern: args.pattern,
|
pattern: args.pattern,
|
||||||
paths: [searchPath],
|
paths,
|
||||||
},
|
},
|
||||||
cli
|
cli
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-20
@@ -1,6 +1,6 @@
|
|||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
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 { runRg, runRgCount } from "./cli"
|
||||||
import { resolveGrepCliWithAutoInstall } from "./constants"
|
import { resolveGrepCliWithAutoInstall } from "./constants"
|
||||||
import { formatGrepResult, formatCountResult } from "./result-formatter"
|
import { formatGrepResult, formatCountResult } from "./result-formatter"
|
||||||
@@ -34,31 +34,12 @@ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Limit output to first N entries. 0 or omitted means no limit."),
|
.describe("Limit output to first N entries. 0 or omitted means no limit."),
|
||||||
},
|
},
|
||||||
<<<<<<< HEAD
|
|
||||||
execute: async (args, context) => {
|
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 {
|
try {
|
||||||
const globs = args.include ? [args.include] : undefined
|
const globs = args.include ? [args.include] : undefined
|
||||||
<<<<<<< HEAD
|
|
||||||
<<<<<<< HEAD
|
|
||||||
const runtimeCtx = context as Record<string, unknown>
|
const runtimeCtx = context as Record<string, unknown>
|
||||||
const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory
|
const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory
|
||||||
const searchPath = args.path ? resolve(dir, args.path) : dir
|
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 paths = [searchPath]
|
||||||
const outputMode = args.output_mode ?? "files_with_matches"
|
const outputMode = args.output_mode ?? "files_with_matches"
|
||||||
const headLimit = args.head_limit ?? 0
|
const headLimit = args.head_limit ?? 0
|
||||||
|
|||||||
Reference in New Issue
Block a user