fix(council-archive): simplify finalize output and archive reads
This commit is contained in:
@@ -127,12 +127,14 @@ Step 4: Track progress with background_wait (metadata only):
|
|||||||
|
|
||||||
Step 4.1: Collect results with council_finalize (after ALL members complete):
|
Step 4.1: Collect results with council_finalize (after ALL members complete):
|
||||||
- Once all members have reached terminal state, call:
|
- Once all members have reached terminal state, call:
|
||||||
council_finalize(task_ids=[...all task IDs...], name="{topic-slug}")
|
council_finalize(task_ids=[...all task IDs...], name="{topic-slug}", question="{original user question}", prompt_file="{path from prepare_council_prompt}")
|
||||||
where {topic-slug} is a short descriptive slug of the council topic (e.g., "check-bg-wait-issues", "auth-review").
|
where {topic-slug} is a short descriptive slug of the council topic (e.g., "check-bg-wait-issues", "auth-review").
|
||||||
- council_finalize reads raw output files, extracts <COUNCIL_MEMBER_RESPONSE> content, writes per-member archive files, and returns structured JSON.
|
Pass "question" with the original user question that triggered this council.
|
||||||
|
Pass "prompt_file" with the temp file path returned by prepare_council_prompt (it will be moved into the archive).
|
||||||
|
- council_finalize reads raw output files, extracts clean response content from <COUNCIL_MEMBER_RESPONSE>, writes per-member archive files, and returns structured JSON.
|
||||||
- The returned JSON has: archive_dir, meta_file, and members array.
|
- The returned JSON has: archive_dir, meta_file, and members array.
|
||||||
- Each member entry has: task_id, member, has_response, response_complete, result (or result_truncated if >8000 chars), archive_file.
|
- Each member entry has: task_id, member, has_response, response_complete, and archive_file.
|
||||||
- For members with result_truncated: true, use council_read(file_path=<archive_file>) to get the full content.
|
- council_finalize does NOT return member content inline. Read member content from archive_file via council_read(file_path=<archive_file>), which returns raw archive content directly (no tag extraction step).
|
||||||
|
|
||||||
Step 4.5: Detect failed or stuck members.
|
Step 4.5: Detect failed or stuck members.
|
||||||
For each member in the background_wait JSON response, check:
|
For each member in the background_wait JSON response, check:
|
||||||
@@ -145,7 +147,7 @@ Step 4.6: Verify completed members have valid responses.
|
|||||||
For each member in the council_finalize result, check:
|
For each member in the council_finalize result, check:
|
||||||
- has_response: true AND response_complete: true → ✅ Use this result for synthesis.
|
- has_response: true AND response_complete: true → ✅ Use this result for synthesis.
|
||||||
- has_response: true AND response_complete: false → Member started but didn't finish. Nudge: call task(session_id=<member_session_id>, run_in_background=true, write_output_to_file=true, prompt="Your analysis is incomplete. Please finish and wrap your final analysis in <COUNCIL_MEMBER_RESPONSE>...</COUNCIL_MEMBER_RESPONSE> tags."). After nudge completes, call council_finalize again with the nudged task IDs.
|
- has_response: true AND response_complete: false → Member started but didn't finish. Nudge: call task(session_id=<member_session_id>, run_in_background=true, write_output_to_file=true, prompt="Your analysis is incomplete. Please finish and wrap your final analysis in <COUNCIL_MEMBER_RESPONSE>...</COUNCIL_MEMBER_RESPONSE> tags."). After nudge completes, call council_finalize again with the nudged task IDs.
|
||||||
- has_response: false AND status: "completed" → Member completed but didn't use tags. Nudge similarly.
|
- has_response: false and background_wait status for the same task_id is "completed" → Member completed but didn't use tags. Nudge similarly.
|
||||||
- has_response: false AND error → Member failed to produce output. Apply retry logic (Step 4.7).
|
- has_response: false AND error → Member failed to produce output. Apply retry logic (Step 4.7).
|
||||||
|
|
||||||
Step 4.7: Retry failed members (if configured).
|
Step 4.7: Retry failed members (if configured).
|
||||||
@@ -216,6 +218,8 @@ If the question has both AUDIT and other aspects, use AUDIT format with ACTIONAB
|
|||||||
|
|
||||||
Step 6: Synthesize the collected council member outputs using the format selected in Step 5.
|
Step 6: Synthesize the collected council member outputs using the format selected in Step 5.
|
||||||
|
|
||||||
|
Before synthesis, for every member with has_response=true and archive_file present, read the member output from archive_file using council_read and use that content as the source for synthesis.
|
||||||
|
|
||||||
**Universal requirements (ALL formats):**
|
**Universal requirements (ALL formats):**
|
||||||
- Track which members agree and disagree on each point — agreement level is your confidence signal
|
- Track which members agree and disagree on each point — agreement level is your confidence signal
|
||||||
- When only 1 member raises a point, flag it as lower confidence
|
- When only 1 member raises a point, flag it as lower confidence
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
||||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
|
import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { createCouncilFinalize } from "./create-council-finalize"
|
import { createCouncilFinalize } from "./create-council-finalize"
|
||||||
@@ -88,7 +88,7 @@ afterEach(async () => {
|
|||||||
describe("council archive integration flow", () => {
|
describe("council archive integration flow", () => {
|
||||||
describe("#given 3 council members with valid output files", () => {
|
describe("#given 3 council members with valid output files", () => {
|
||||||
describe("#when finalize is called and then each archive is read", () => {
|
describe("#when finalize is called and then each archive is read", () => {
|
||||||
it("#then creates archive with correct structure and council_read extracts content from task outputs", async () => {
|
it("#then creates archive with correct structure and council_read extracts content from archives", async () => {
|
||||||
const agents = [
|
const agents = [
|
||||||
{ id: "bg_opus", agent: "Council: Claude Opus", response: "Opus deep analysis of architecture" },
|
{ id: "bg_opus", agent: "Council: Claude Opus", response: "Opus deep analysis of architecture" },
|
||||||
{ id: "bg_gpt", agent: "Council: GPT-5", response: "GPT pragmatic code review" },
|
{ id: "bg_gpt", agent: "Council: GPT-5", response: "GPT pragmatic code review" },
|
||||||
@@ -119,8 +119,6 @@ describe("council archive integration flow", () => {
|
|||||||
expect(member.task_id).toBe(agents[i].id)
|
expect(member.task_id).toBe(agents[i].id)
|
||||||
expect(member.has_response).toBe(true)
|
expect(member.has_response).toBe(true)
|
||||||
expect(member.response_complete).toBe(true)
|
expect(member.response_complete).toBe(true)
|
||||||
expect(member.result).toBe(agents[i].response)
|
|
||||||
expect(member.result_truncated).toBeUndefined()
|
|
||||||
expect(member.error).toBeUndefined()
|
expect(member.error).toBeUndefined()
|
||||||
expect(member.archive_file).toBeDefined()
|
expect(member.archive_file).toBeDefined()
|
||||||
}
|
}
|
||||||
@@ -142,8 +140,7 @@ describe("council archive integration flow", () => {
|
|||||||
const readTool = createCouncilRead(tmpDir)
|
const readTool = createCouncilRead(tmpDir)
|
||||||
|
|
||||||
for (let i = 0; i < agents.length; i++) {
|
for (let i = 0; i < agents.length; i++) {
|
||||||
const taskOutputPath = join(".sisyphus", "task-outputs", `${agents[i].id}.md`)
|
const readResult = await readTool.execute({ file_path: result.members[i].archive_file! }, toolContext)
|
||||||
const readResult = await readTool.execute({ file_path: taskOutputPath }, toolContext)
|
|
||||||
const parsed = JSON.parse(readResult)
|
const parsed = JSON.parse(readResult)
|
||||||
|
|
||||||
expect(parsed.has_response).toBe(true)
|
expect(parsed.has_response).toBe(true)
|
||||||
@@ -156,7 +153,7 @@ describe("council archive integration flow", () => {
|
|||||||
|
|
||||||
describe("#given a member with incomplete tags (no closing tag)", () => {
|
describe("#given a member with incomplete tags (no closing tag)", () => {
|
||||||
describe("#when finalize is called and archive is read", () => {
|
describe("#when finalize is called and archive is read", () => {
|
||||||
it("#then has_response is true and response_complete is false in both finalize and read", async () => {
|
it("#then finalize marks incomplete but council_read still returns raw archived content", async () => {
|
||||||
const taskId = "bg_partial"
|
const taskId = "bg_partial"
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
||||||
@@ -180,12 +177,11 @@ describe("council archive integration flow", () => {
|
|||||||
expect(archiveContent).toBe("Analysis still in progress...")
|
expect(archiveContent).toBe("Analysis still in progress...")
|
||||||
|
|
||||||
const readTool = createCouncilRead(tmpDir)
|
const readTool = createCouncilRead(tmpDir)
|
||||||
const taskOutputPath = join(".sisyphus", "task-outputs", `${taskId}.md`)
|
const readResult = await readTool.execute({ file_path: member.archive_file! }, toolContext)
|
||||||
const readResult = await readTool.execute({ file_path: taskOutputPath }, toolContext)
|
|
||||||
const parsed = JSON.parse(readResult)
|
const parsed = JSON.parse(readResult)
|
||||||
|
|
||||||
expect(parsed.has_response).toBe(true)
|
expect(parsed.has_response).toBe(true)
|
||||||
expect(parsed.response_complete).toBe(false)
|
expect(parsed.response_complete).toBe(true)
|
||||||
expect(parsed.result).toBe("Analysis still in progress...")
|
expect(parsed.result).toBe("Analysis still in progress...")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -210,7 +206,6 @@ describe("council archive integration flow", () => {
|
|||||||
const member = result.members[0]
|
const member = result.members[0]
|
||||||
expect(member.has_response).toBe(false)
|
expect(member.has_response).toBe(false)
|
||||||
expect(member.archive_file).toBeUndefined()
|
expect(member.archive_file).toBeUndefined()
|
||||||
expect(member.result).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -239,21 +234,21 @@ describe("council archive integration flow", () => {
|
|||||||
expect(result.members).toHaveLength(3)
|
expect(result.members).toHaveLength(3)
|
||||||
|
|
||||||
expect(result.members[0].has_response).toBe(true)
|
expect(result.members[0].has_response).toBe(true)
|
||||||
expect(result.members[0].result).toBe("First analysis")
|
expect(result.members[0].archive_file).toBeDefined()
|
||||||
|
|
||||||
expect(result.members[1].has_response).toBe(false)
|
expect(result.members[1].has_response).toBe(false)
|
||||||
expect(result.members[1].error).toBe("Task output file not found")
|
expect(result.members[1].error).toBe("Task output file not found")
|
||||||
expect(result.members[1].member).toBe("unknown")
|
expect(result.members[1].member).toBe("unknown")
|
||||||
|
|
||||||
expect(result.members[2].has_response).toBe(true)
|
expect(result.members[2].has_response).toBe(true)
|
||||||
expect(result.members[2].result).toBe("Third analysis")
|
expect(result.members[2].archive_file).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given a very large council response exceeding 8000 chars", () => {
|
describe("#given a very large council response exceeding 8000 chars", () => {
|
||||||
describe("#when finalize is called and then archive is read", () => {
|
describe("#when finalize is called and then archive is read", () => {
|
||||||
it("#then finalize truncates to 500 char preview but full content is available via council_read", async () => {
|
it("#then finalize stores full output and council_read returns full response", async () => {
|
||||||
const largeResponse = "A".repeat(9000)
|
const largeResponse = "A".repeat(9000)
|
||||||
const taskId = "bg_large"
|
const taskId = "bg_large"
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -271,17 +266,13 @@ describe("council archive integration flow", () => {
|
|||||||
|
|
||||||
const member = result.members[0]
|
const member = result.members[0]
|
||||||
expect(member.has_response).toBe(true)
|
expect(member.has_response).toBe(true)
|
||||||
expect(member.result_truncated).toBe(true)
|
|
||||||
expect(member.result).toHaveLength(500)
|
|
||||||
expect(member.result).toBe("A".repeat(500))
|
|
||||||
expect(member.archive_file).toBeDefined()
|
expect(member.archive_file).toBeDefined()
|
||||||
|
|
||||||
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
|
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
|
||||||
expect(fullContent).toHaveLength(9000)
|
expect(fullContent).toHaveLength(9000)
|
||||||
|
|
||||||
const readTool = createCouncilRead(tmpDir)
|
const readTool = createCouncilRead(tmpDir)
|
||||||
const taskOutputPath = join(".sisyphus", "task-outputs", `${taskId}.md`)
|
const readResult = await readTool.execute({ file_path: member.archive_file! }, toolContext)
|
||||||
const readResult = await readTool.execute({ file_path: taskOutputPath }, toolContext)
|
|
||||||
const parsed = JSON.parse(readResult)
|
const parsed = JSON.parse(readResult)
|
||||||
|
|
||||||
expect(parsed.has_response).toBe(true)
|
expect(parsed.has_response).toBe(true)
|
||||||
@@ -291,6 +282,76 @@ describe("council archive integration flow", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("#given question and prompt_file params", () => {
|
||||||
|
describe("#when finalize is called with question and prompt_file", () => {
|
||||||
|
it("#then meta.yaml includes question and prompt_file, and prompt file is moved to archive", async () => {
|
||||||
|
const taskId = "bg_with_meta"
|
||||||
|
await writeFile(
|
||||||
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
||||||
|
mockTaskOutput("Council: Opus", "Analysis result"),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
const tmpPromptDir = join(tmpDir, ".sisyphus", "tmp")
|
||||||
|
await mkdir(tmpPromptDir, { recursive: true })
|
||||||
|
const promptFile = join(".sisyphus", "tmp", "athena-council-test.md")
|
||||||
|
await writeFile(join(tmpDir, promptFile), "Council prompt content here", "utf-8")
|
||||||
|
|
||||||
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
||||||
|
const resultStr = await finalizeTool.execute(
|
||||||
|
{
|
||||||
|
task_ids: [taskId],
|
||||||
|
name: "meta-test",
|
||||||
|
question: "What is the best architecture for this app?",
|
||||||
|
prompt_file: promptFile,
|
||||||
|
},
|
||||||
|
toolContext,
|
||||||
|
)
|
||||||
|
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
|
||||||
|
expect(metaContent).toContain("question: |")
|
||||||
|
expect(metaContent).toContain(" What is the best architecture for this app?")
|
||||||
|
expect(metaContent).toContain("prompt_file:")
|
||||||
|
expect(metaContent).toContain("council-prompt.md")
|
||||||
|
|
||||||
|
const promptDest = join(tmpDir, result.archive_dir, "council-prompt.md")
|
||||||
|
const promptContent = await readFile(promptDest, "utf-8")
|
||||||
|
expect(promptContent).toBe("Council prompt content here")
|
||||||
|
|
||||||
|
const originalExists = await stat(join(tmpDir, promptFile)).then(() => true).catch(() => false)
|
||||||
|
expect(originalExists).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when finalize is called with question only (no prompt_file)", () => {
|
||||||
|
it("#then meta.yaml includes question but no prompt_file", async () => {
|
||||||
|
const taskId = "bg_question_only"
|
||||||
|
await writeFile(
|
||||||
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
||||||
|
mockTaskOutput("Council: GPT", "GPT analysis"),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
||||||
|
const resultStr = await finalizeTool.execute(
|
||||||
|
{
|
||||||
|
task_ids: [taskId],
|
||||||
|
name: "question-only",
|
||||||
|
question: "How should we handle auth?",
|
||||||
|
},
|
||||||
|
toolContext,
|
||||||
|
)
|
||||||
|
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
|
||||||
|
expect(metaContent).toContain("question: |")
|
||||||
|
expect(metaContent).toContain(" How should we handle auth?")
|
||||||
|
expect(metaContent).not.toContain("prompt_file:")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("#given a completed council task in background_wait", () => {
|
describe("#given a completed council task in background_wait", () => {
|
||||||
describe("#when background_wait returns for that task", () => {
|
describe("#when background_wait returns for that task", () => {
|
||||||
it("#then completed_tasks entry has no result payload", async () => {
|
it("#then completed_tasks entry has no result payload", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { createCouncilFinalize } from "./create-council-finalize"
|
import { createCouncilFinalize } from "./create-council-finalize"
|
||||||
|
import { createCouncilRead } from "./create-council-read"
|
||||||
import type { CouncilFinalizeResult } from "./types"
|
import type { CouncilFinalizeResult } from "./types"
|
||||||
|
|
||||||
function mockTaskOutput(agent: string, responseBody: string, complete = true): string {
|
function mockTaskOutput(agent: string, responseBody: string, complete = true): string {
|
||||||
@@ -71,8 +72,8 @@ describe("createCouncilFinalize", () => {
|
|||||||
expect(member.task_id).toBe(agents[i].id)
|
expect(member.task_id).toBe(agents[i].id)
|
||||||
expect(member.has_response).toBe(true)
|
expect(member.has_response).toBe(true)
|
||||||
expect(member.response_complete).toBe(true)
|
expect(member.response_complete).toBe(true)
|
||||||
expect(member.result).toBe(agents[i].response)
|
expect(member).not.toHaveProperty("result")
|
||||||
expect(member.result_truncated).toBeUndefined()
|
expect(member).not.toHaveProperty("result_truncated")
|
||||||
expect(member.error).toBeUndefined()
|
expect(member.error).toBeUndefined()
|
||||||
expect(member.archive_file).toBeDefined()
|
expect(member.archive_file).toBeDefined()
|
||||||
}
|
}
|
||||||
@@ -113,19 +114,23 @@ describe("createCouncilFinalize", () => {
|
|||||||
expect(result.members).toHaveLength(3)
|
expect(result.members).toHaveLength(3)
|
||||||
|
|
||||||
expect(result.members[0].has_response).toBe(true)
|
expect(result.members[0].has_response).toBe(true)
|
||||||
expect(result.members[0].result).toBe("Opus findings")
|
expect(result.members[0].archive_file).toBeDefined()
|
||||||
|
expect(result.members[0]).not.toHaveProperty("result")
|
||||||
|
expect(result.members[0]).not.toHaveProperty("result_truncated")
|
||||||
|
|
||||||
expect(result.members[1].has_response).toBe(false)
|
expect(result.members[1].has_response).toBe(false)
|
||||||
expect(result.members[1].error).toBe("Task output file not found")
|
expect(result.members[1].error).toBe("Task output file not found")
|
||||||
expect(result.members[1].member).toBe("unknown")
|
expect(result.members[1].member).toBe("unknown")
|
||||||
|
|
||||||
expect(result.members[2].has_response).toBe(true)
|
expect(result.members[2].has_response).toBe(true)
|
||||||
expect(result.members[2].result).toBe("Gemini findings")
|
expect(result.members[2].archive_file).toBeDefined()
|
||||||
|
expect(result.members[2]).not.toHaveProperty("result")
|
||||||
|
expect(result.members[2]).not.toHaveProperty("result_truncated")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given large response exceeding 8000 chars", () => {
|
describe("#given large response exceeding 8000 chars", () => {
|
||||||
it("#then truncates result and sets result_truncated flag", async () => {
|
it("#then keeps full content in archive and council_read returns full response", async () => {
|
||||||
const largeResponse = "x".repeat(9000)
|
const largeResponse = "x".repeat(9000)
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(tmpDir, ".sisyphus", "task-outputs", "bg_large.md"),
|
join(tmpDir, ".sisyphus", "task-outputs", "bg_large.md"),
|
||||||
@@ -142,12 +147,18 @@ describe("createCouncilFinalize", () => {
|
|||||||
|
|
||||||
const member = result.members[0]
|
const member = result.members[0]
|
||||||
expect(member.has_response).toBe(true)
|
expect(member.has_response).toBe(true)
|
||||||
expect(member.result_truncated).toBe(true)
|
expect(member.archive_file).toBeDefined()
|
||||||
expect(member.result).toHaveLength(500)
|
expect(member).not.toHaveProperty("result")
|
||||||
expect(member.result).toBe("x".repeat(500))
|
expect(member).not.toHaveProperty("result_truncated")
|
||||||
|
|
||||||
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
|
const readTool = createCouncilRead(tmpDir)
|
||||||
expect(fullContent).toHaveLength(9000)
|
const readResult = await readTool.execute({ file_path: member.archive_file! }, mockCtx)
|
||||||
|
const parsed = JSON.parse(readResult)
|
||||||
|
|
||||||
|
expect(parsed.has_response).toBe(true)
|
||||||
|
expect(parsed.response_complete).toBe(true)
|
||||||
|
expect(parsed.result).toHaveLength(9000)
|
||||||
|
expect(parsed.result).toBe(largeResponse)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,9 +194,42 @@ describe("createCouncilFinalize", () => {
|
|||||||
const member = result.members[0]
|
const member = result.members[0]
|
||||||
expect(member.has_response).toBe(true)
|
expect(member.has_response).toBe(true)
|
||||||
expect(member.response_complete).toBe(true)
|
expect(member.response_complete).toBe(true)
|
||||||
expect(member.result).toBe("")
|
|
||||||
expect(member.result_truncated).toBeUndefined()
|
|
||||||
expect(member.archive_file).toBeDefined()
|
expect(member.archive_file).toBeDefined()
|
||||||
|
expect(member).not.toHaveProperty("result")
|
||||||
|
expect(member).not.toHaveProperty("result_truncated")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given two members that slugify to same member slug", () => {
|
||||||
|
it("#then each member gets a unique archive_file keyed by task id", async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(tmpDir, ".sisyphus", "task-outputs", "bg_alpha.md"),
|
||||||
|
mockTaskOutput("Agent A+B", "First response"),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
await writeFile(
|
||||||
|
join(tmpDir, ".sisyphus", "task-outputs", "bg_beta.md"),
|
||||||
|
mockTaskOutput("Agent A B", "Second response"),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolDef = createCouncilFinalize(tmpDir)
|
||||||
|
const resultStr = await toolDef.execute(
|
||||||
|
{ task_ids: ["bg_alpha", "bg_beta"], name: "collision" },
|
||||||
|
mockCtx,
|
||||||
|
)
|
||||||
|
const result: CouncilFinalizeResult = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
const firstArchive = result.members[0].archive_file
|
||||||
|
const secondArchive = result.members[1].archive_file
|
||||||
|
expect(firstArchive).toBeDefined()
|
||||||
|
expect(secondArchive).toBeDefined()
|
||||||
|
expect(firstArchive).not.toBe(secondArchive)
|
||||||
|
|
||||||
|
const firstContent = await readFile(join(tmpDir, firstArchive!), "utf-8")
|
||||||
|
const secondContent = await readFile(join(tmpDir, secondArchive!), "utf-8")
|
||||||
|
expect(firstContent).toContain("First response")
|
||||||
|
expect(secondContent).toContain("Second response")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import { readFile, writeFile, mkdir } from "node:fs/promises"
|
import { readFile, writeFile, mkdir, rename } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { join, isAbsolute, resolve } from "node:path"
|
||||||
import { randomBytes } from "node:crypto"
|
import { randomBytes } from "node:crypto"
|
||||||
import { extractCouncilResponse } from "./council-response-extractor"
|
import { extractCouncilResponse } from "./council-response-extractor"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types"
|
import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types"
|
||||||
|
|
||||||
const RESULT_SIZE_LIMIT = 8000
|
|
||||||
const PREVIEW_SIZE = 500
|
|
||||||
|
|
||||||
interface MetaMember {
|
interface MetaMember {
|
||||||
task_id: string
|
task_id: string
|
||||||
member: string
|
member: string
|
||||||
@@ -25,6 +23,10 @@ function slugify(text: string): string {
|
|||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPosixPath(pathValue: string): string {
|
||||||
|
return pathValue.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
function extractAgentFromFrontmatter(content: string): string | null {
|
function extractAgentFromFrontmatter(content: string): string | null {
|
||||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
||||||
if (!fmMatch) return null
|
if (!fmMatch) return null
|
||||||
@@ -32,13 +34,25 @@ function extractAgentFromFrontmatter(content: string): string | null {
|
|||||||
return agentLine ? agentLine[1].trim() : null
|
return agentLine ? agentLine[1].trim() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMember[]): string {
|
function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMember[], question?: string, promptFile?: string): string {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`archive_name: ${archiveName}`,
|
`archive_name: ${archiveName}`,
|
||||||
`created_at: ${createdAt}`,
|
`created_at: ${createdAt}`,
|
||||||
"members:",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (question) {
|
||||||
|
lines.push(`question: |`)
|
||||||
|
for (const qLine of question.split("\n")) {
|
||||||
|
lines.push(` ${qLine}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promptFile) {
|
||||||
|
lines.push(`prompt_file: ${promptFile}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("members:")
|
||||||
|
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
lines.push(` - task_id: ${m.task_id}`)
|
lines.push(` - task_id: ${m.task_id}`)
|
||||||
lines.push(` member: "${m.member}"`)
|
lines.push(` member: "${m.member}"`)
|
||||||
@@ -61,12 +75,15 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
|||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.describe("Array of background task IDs whose output files should be processed"),
|
.describe("Array of background task IDs whose output files should be processed"),
|
||||||
name: tool.schema.string().describe("Council name used in the archive directory name"),
|
name: tool.schema.string().describe("Council name used in the archive directory name"),
|
||||||
|
question: tool.schema.string().optional().describe("Original user question that triggered the council"),
|
||||||
|
prompt_file: tool.schema.string().optional().describe("Path to the council prompt temp file (will be moved into the archive)"),
|
||||||
},
|
},
|
||||||
async execute(args: CouncilFinalizeArgs) {
|
async execute(args: CouncilFinalizeArgs) {
|
||||||
const base = basePath ?? process.cwd()
|
const base = basePath ?? process.cwd()
|
||||||
const hexId = randomBytes(2).toString("hex")
|
const hexId = randomBytes(2).toString("hex")
|
||||||
const archiveName = `council-${args.name}-${hexId}`
|
const archiveName = `council-${args.name}-${hexId}`
|
||||||
const relArchiveDir = join(".sisyphus", "athena", archiveName)
|
const relArchiveDir = join(".sisyphus", "athena", archiveName)
|
||||||
|
const relArchiveDirForOutput = toPosixPath(relArchiveDir)
|
||||||
const absArchiveDir = join(base, relArchiveDir)
|
const absArchiveDir = join(base, relArchiveDir)
|
||||||
|
|
||||||
await mkdir(absArchiveDir, { recursive: true })
|
await mkdir(absArchiveDir, { recursive: true })
|
||||||
@@ -76,6 +93,7 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
|||||||
|
|
||||||
for (const taskId of args.task_ids) {
|
for (const taskId of args.task_ids) {
|
||||||
const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`)
|
const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`)
|
||||||
|
const relTaskOutputForOutput = toPosixPath(relTaskOutput)
|
||||||
const absTaskOutput = join(base, relTaskOutput)
|
const absTaskOutput = join(base, relTaskOutput)
|
||||||
|
|
||||||
let fileContent: string
|
let fileContent: string
|
||||||
@@ -92,7 +110,7 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
|||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
member: "unknown",
|
member: "unknown",
|
||||||
member_slug: "unknown",
|
member_slug: "unknown",
|
||||||
task_output_path: relTaskOutput,
|
task_output_path: relTaskOutputForOutput,
|
||||||
archive_file: "",
|
archive_file: "",
|
||||||
has_response: false,
|
has_response: false,
|
||||||
response_complete: false,
|
response_complete: false,
|
||||||
@@ -101,10 +119,12 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown"
|
const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown"
|
||||||
const memberSlug = slugify(agentName)
|
const memberSlug = slugify(agentName) || "unknown"
|
||||||
|
const taskSlug = slugify(taskId) || taskId.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||||
const extraction = extractCouncilResponse(fileContent)
|
const extraction = extractCouncilResponse(fileContent)
|
||||||
|
|
||||||
const relArchiveFile = join(relArchiveDir, `${memberSlug}.md`)
|
const relArchiveFile = join(relArchiveDir, `${memberSlug}-${taskSlug}.md`)
|
||||||
|
const relArchiveFileForOutput = toPosixPath(relArchiveFile)
|
||||||
const absArchiveFile = join(base, relArchiveFile)
|
const absArchiveFile = join(base, relArchiveFile)
|
||||||
|
|
||||||
const memberResult: CouncilMemberResult = {
|
const memberResult: CouncilMemberResult = {
|
||||||
@@ -119,14 +139,7 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
|||||||
|
|
||||||
if (extraction.result !== null) {
|
if (extraction.result !== null) {
|
||||||
await writeFile(absArchiveFile, extraction.result, "utf-8")
|
await writeFile(absArchiveFile, extraction.result, "utf-8")
|
||||||
memberResult.archive_file = relArchiveFile
|
memberResult.archive_file = relArchiveFileForOutput
|
||||||
|
|
||||||
if (extraction.result.length > RESULT_SIZE_LIMIT) {
|
|
||||||
memberResult.result = extraction.result.slice(0, PREVIEW_SIZE)
|
|
||||||
memberResult.result_truncated = true
|
|
||||||
} else {
|
|
||||||
memberResult.result = extraction.result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
members.push(memberResult)
|
members.push(memberResult)
|
||||||
@@ -134,21 +147,38 @@ export function createCouncilFinalize(basePath?: string): ToolDefinition {
|
|||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
member: agentName,
|
member: agentName,
|
||||||
member_slug: memberSlug,
|
member_slug: memberSlug,
|
||||||
task_output_path: relTaskOutput,
|
task_output_path: relTaskOutputForOutput,
|
||||||
archive_file: extraction.result !== null ? relArchiveFile : "",
|
archive_file: extraction.result !== null ? relArchiveFileForOutput : "",
|
||||||
has_response: extraction.has_response,
|
has_response: extraction.has_response,
|
||||||
response_complete: extraction.response_complete,
|
response_complete: extraction.response_complete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let relPromptFile: string | undefined
|
||||||
|
if (args.prompt_file) {
|
||||||
|
try {
|
||||||
|
const promptFilename = "council-prompt.md"
|
||||||
|
const absPromptSrc = isAbsolute(args.prompt_file) ? args.prompt_file : resolve(base, args.prompt_file)
|
||||||
|
const absPromptDest = join(absArchiveDir, promptFilename)
|
||||||
|
await rename(absPromptSrc, absPromptDest).catch(async () => {
|
||||||
|
const content = await readFile(absPromptSrc, "utf-8")
|
||||||
|
await writeFile(absPromptDest, content, "utf-8")
|
||||||
|
})
|
||||||
|
relPromptFile = toPosixPath(join(relArchiveDir, promptFilename))
|
||||||
|
} catch (err) {
|
||||||
|
log("[council-finalize] Failed to move prompt file", { promptFile: args.prompt_file, error: String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const relMetaFile = join(relArchiveDir, "meta.yaml")
|
const relMetaFile = join(relArchiveDir, "meta.yaml")
|
||||||
|
const relMetaFileForOutput = toPosixPath(relMetaFile)
|
||||||
const absMetaFile = join(base, relMetaFile)
|
const absMetaFile = join(base, relMetaFile)
|
||||||
const createdAt = new Date().toISOString()
|
const createdAt = new Date().toISOString()
|
||||||
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers), "utf-8")
|
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers, args.question, relPromptFile), "utf-8")
|
||||||
|
|
||||||
const result: CouncilFinalizeResult = {
|
const result: CouncilFinalizeResult = {
|
||||||
archive_dir: relArchiveDir,
|
archive_dir: relArchiveDirForOutput,
|
||||||
meta_file: relMetaFile,
|
meta_file: relMetaFileForOutput,
|
||||||
members,
|
members,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ const toolContext = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createCouncilRead", () => {
|
describe("createCouncilRead", () => {
|
||||||
describe("#given an archive file with complete COUNCIL_MEMBER_RESPONSE tags", () => {
|
describe("#given an archive file with clean extracted content", () => {
|
||||||
it("#then returns has_response true, response_complete true, and the content", async () => {
|
it("#then returns has_response true, response_complete true, and raw file content", async () => {
|
||||||
const archivePath = join(sisyphusDir, "member-1.txt")
|
const archivePath = join(sisyphusDir, "member-1.txt")
|
||||||
await writeFile(archivePath, "Some preamble\n<COUNCIL_MEMBER_RESPONSE>Full analysis here</COUNCIL_MEMBER_RESPONSE>")
|
await writeFile(archivePath, "Full analysis here")
|
||||||
|
|
||||||
const tool = createCouncilRead(tempDir)
|
const tool = createCouncilRead(tempDir)
|
||||||
const relativePath = `.sisyphus/member-1.txt`
|
const relativePath = `.sisyphus/member-1.txt`
|
||||||
@@ -44,10 +44,10 @@ describe("createCouncilRead", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given an archive file with incomplete COUNCIL_MEMBER_RESPONSE tags", () => {
|
describe("#given an archive file containing tag-like text", () => {
|
||||||
it("#then returns has_response true, response_complete false", async () => {
|
it("#then returns raw content without re-parsing tags", async () => {
|
||||||
const archivePath = join(sisyphusDir, "member-2.txt")
|
const archivePath = join(sisyphusDir, "member-2.txt")
|
||||||
await writeFile(archivePath, "<COUNCIL_MEMBER_RESPONSE>Partial analysis still writing...")
|
await writeFile(archivePath, "<COUNCIL_MEMBER_RESPONSE>do not parse this</COUNCIL_MEMBER_RESPONSE>")
|
||||||
|
|
||||||
const tool = createCouncilRead(tempDir)
|
const tool = createCouncilRead(tempDir)
|
||||||
const relativePath = `.sisyphus/member-2.txt`
|
const relativePath = `.sisyphus/member-2.txt`
|
||||||
@@ -56,14 +56,15 @@ describe("createCouncilRead", () => {
|
|||||||
const parsed = JSON.parse(result)
|
const parsed = JSON.parse(result)
|
||||||
|
|
||||||
expect(parsed.has_response).toBe(true)
|
expect(parsed.has_response).toBe(true)
|
||||||
expect(parsed.response_complete).toBe(false)
|
expect(parsed.response_complete).toBe(true)
|
||||||
|
expect(parsed.result).toBe("<COUNCIL_MEMBER_RESPONSE>do not parse this</COUNCIL_MEMBER_RESPONSE>")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given an archive file with no COUNCIL_MEMBER_RESPONSE tags", () => {
|
describe("#given an archive file with empty content", () => {
|
||||||
it("#then returns has_response false", async () => {
|
it("#then returns empty result content", async () => {
|
||||||
const archivePath = join(sisyphusDir, "member-3.txt")
|
const archivePath = join(sisyphusDir, "member-3.txt")
|
||||||
await writeFile(archivePath, "Just some plain text without any tags.")
|
await writeFile(archivePath, "")
|
||||||
|
|
||||||
const tool = createCouncilRead(tempDir)
|
const tool = createCouncilRead(tempDir)
|
||||||
const relativePath = `.sisyphus/member-3.txt`
|
const relativePath = `.sisyphus/member-3.txt`
|
||||||
@@ -71,7 +72,9 @@ describe("createCouncilRead", () => {
|
|||||||
const result = await tool.execute({ file_path: relativePath }, toolContext)
|
const result = await tool.execute({ file_path: relativePath }, toolContext)
|
||||||
const parsed = JSON.parse(result)
|
const parsed = JSON.parse(result)
|
||||||
|
|
||||||
expect(parsed.has_response).toBe(false)
|
expect(parsed.has_response).toBe(true)
|
||||||
|
expect(parsed.response_complete).toBe(true)
|
||||||
|
expect(parsed.result).toBe("")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,4 +111,19 @@ describe("createCouncilRead", () => {
|
|||||||
expect(parsed.error).toBe("Access denied: path must be within .sisyphus/")
|
expect(parsed.error).toBe("Access denied: path must be within .sisyphus/")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("#given a Windows-style path under .sisyphus", () => {
|
||||||
|
it("#then normalizes separators and reads the file successfully", async () => {
|
||||||
|
const archivePath = join(sisyphusDir, "windows-path.txt")
|
||||||
|
await writeFile(archivePath, "Windows path response")
|
||||||
|
|
||||||
|
const tool = createCouncilRead(tempDir)
|
||||||
|
const result = await tool.execute({ file_path: ".sisyphus\\windows-path.txt" }, toolContext)
|
||||||
|
const parsed = JSON.parse(result)
|
||||||
|
|
||||||
|
expect(parsed.has_response).toBe(true)
|
||||||
|
expect(parsed.response_complete).toBe(true)
|
||||||
|
expect(parsed.result).toBe("Windows path response")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { resolve, sep } from "node:path"
|
||||||
import { extractCouncilResponse } from "./council-response-extractor"
|
|
||||||
|
function normalizeInputPath(pathValue: string): string {
|
||||||
|
return pathValue.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithinDirectory(pathToCheck: string, directory: string): boolean {
|
||||||
|
const normalizedDir = directory.endsWith(sep) ? directory : `${directory}${sep}`
|
||||||
|
return pathToCheck === directory || pathToCheck.startsWith(normalizedDir)
|
||||||
|
}
|
||||||
|
|
||||||
export function createCouncilRead(basePath?: string): ToolDefinition {
|
export function createCouncilRead(basePath?: string): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
"Read a council archive file and extract the council member response. Use this to access full results for truncated members or for follow-up/cross-check analysis.",
|
"Read a council archive file and return its raw member response content.",
|
||||||
args: {
|
args: {
|
||||||
file_path: tool.schema.string().describe("Path to the archive file (must be within .sisyphus/)"),
|
file_path: tool.schema.string().describe("Path to the archive file (must be within .sisyphus/)"),
|
||||||
},
|
},
|
||||||
async execute(args: { file_path: string }) {
|
async execute(args: { file_path: string }) {
|
||||||
if (!args.file_path.startsWith(".sisyphus/")) {
|
const normalizedInputPath = normalizeInputPath(args.file_path)
|
||||||
|
if (!normalizedInputPath.startsWith(".sisyphus/")) {
|
||||||
return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2)
|
return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base = basePath ?? process.cwd()
|
const base = basePath ?? process.cwd()
|
||||||
const absPath = join(base, args.file_path)
|
const absPath = resolve(base, normalizedInputPath)
|
||||||
|
const absSisyphusRoot = resolve(base, ".sisyphus")
|
||||||
|
if (!isPathWithinDirectory(absPath, absSisyphusRoot)) {
|
||||||
|
return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
const content = await readFile(absPath, "utf-8")
|
const content = await readFile(absPath, "utf-8")
|
||||||
const extraction = extractCouncilResponse(content)
|
return JSON.stringify({ has_response: true, response_complete: true, result: content }, null, 2)
|
||||||
return JSON.stringify(extraction, null, 2)
|
|
||||||
} catch {
|
} catch {
|
||||||
return JSON.stringify({ has_response: false, error: `File not found: ${args.file_path}` }, null, 2)
|
return JSON.stringify({ has_response: false, error: `File not found: ${normalizedInputPath}` }, null, 2)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface CouncilFinalizeArgs {
|
export interface CouncilFinalizeArgs {
|
||||||
task_ids: string[]
|
task_ids: string[]
|
||||||
name: string
|
name: string
|
||||||
|
question?: string
|
||||||
|
prompt_file?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CouncilMemberResult {
|
export interface CouncilMemberResult {
|
||||||
@@ -8,8 +10,6 @@ export interface CouncilMemberResult {
|
|||||||
member: string
|
member: string
|
||||||
has_response: boolean
|
has_response: boolean
|
||||||
response_complete?: boolean
|
response_complete?: boolean
|
||||||
result?: string
|
|
||||||
result_truncated?: boolean
|
|
||||||
archive_file?: string
|
archive_file?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user