diff --git a/script/build-schema-document.ts b/script/build-schema-document.ts index 2a84ef907..be25717d1 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,17 +1,64 @@ import { z } from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" +function removeDefaultedFromRequired(schema: Record): Record { + if (typeof schema !== "object" || schema === null) return schema + + const result = { ...schema } + + if (Array.isArray(result.required) && result.properties && typeof result.properties === "object") { + const props = result.properties as Record> + result.required = (result.required as string[]).filter((key) => { + const prop = props[key] + return prop && !("default" in prop) + }) + if ((result.required as string[]).length === 0) { + delete result.required + } + } + + if (result.properties && typeof result.properties === "object") { + const newProps: Record = {} + for (const [key, value] of Object.entries(result.properties as Record)) { + newProps[key] = removeDefaultedFromRequired(value as Record) + } + result.properties = newProps + } + + if (result.items && typeof result.items === "object") { + result.items = removeDefaultedFromRequired(result.items as Record) + } + + for (const key of ["allOf", "anyOf", "oneOf"]) { + if (Array.isArray(result[key])) { + result[key] = (result[key] as Record[]).map((s) => removeDefaultedFromRequired(s)) + } + } + + for (const key of ["$defs", "definitions"]) { + if (result[key] && typeof result[key] === "object") { + const newDefs: Record = {} + for (const [defKey, defValue] of Object.entries(result[key] as Record)) { + newDefs[defKey] = removeDefaultedFromRequired(defValue as Record) + } + result[key] = newDefs + } + } + + return result +} + export function createOhMyOpenCodeJsonSchema(): Record { const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { target: "draft-7", unrepresentable: "any", }) as Record - return { + return removeDefaultedFromRequired({ $schema: "http://json-schema.org/draft-07/schema#", $id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", title: "Oh My OpenCode Configuration", description: "Configuration schema for oh-my-opencode plugin", ...jsonSchema, - } + }) } diff --git a/src/agents/athena/athena-junior-agent.ts b/src/agents/athena/athena-junior-agent.ts index 7bf4f5a39..11404b747 100644 --- a/src/agents/athena/athena-junior-agent.ts +++ b/src/agents/athena/athena-junior-agent.ts @@ -25,7 +25,7 @@ export const ATHENA_JUNIOR_PROMPT_METADATA: AgentPromptMetadata = { } export function createAthenaJuniorAgent(model: string): AgentConfig { - const restrictions = createAgentToolRestrictions(["call_omo_agent", "question"]) + const restrictions = createAgentToolRestrictions(["call_omo_agent", "question", "switch_agent"]) return { description: "Non-interactive council orchestrator for programmatic multi-model synthesis. Returns structured JSON without user interaction. (Athena-Junior - OhMyOpenCode)", diff --git a/src/agents/athena/non-interactive-prompt.ts b/src/agents/athena/non-interactive-prompt.ts index b5f36ae73..c93b284de 100644 --- a/src/agents/athena/non-interactive-prompt.ts +++ b/src/agents/athena/non-interactive-prompt.ts @@ -90,6 +90,14 @@ Track every task_id from the response for use in Step 5. - cancel_retrying_on_quorum = {CANCEL_RETRYING_ON_QUORUM} - Quorum enforcement: minimum 2 successful members required before synthesis. +If retry_on_fail > 0 and failed members exist: +1. Re-launch failed members via athena_council with the same prompt_file and members parameter set to the failed member names. +2. Return to Step 5 to wait for their completion via background_wait. +3. Call council_finalize again to collect retried results. +4. Continue retrying until retry count exhausted or quorum met. +- If retry_failed_if_others_finished is true, only retry after all non-failed members have completed. +- If cancel_retrying_on_quorum is true, stop retrying once quorum (2+ successful) is met. + ### Step 10: Synthesize using council_finalize runtime guidance. - Read every member's archive_file with Read tool. - Apply the injected from council_finalize. diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts index a4f404760..c8d712e28 100644 --- a/src/config/schema/agent-names.ts +++ b/src/config/schema/agent-names.ts @@ -42,6 +42,7 @@ export const OverridableAgentNameSchema = z.enum([ "multimodal-looker", "atlas", "athena", + "athena-junior", "council-member", ]) diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index 4fef9ffe0..ccc0f8a3b 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -58,10 +58,10 @@ export const AgentOverrideConfigSchema = z.object({ export const AthenaOverrideConfigSchema = AgentOverrideConfigSchema.extend({ council: AthenaConfigSchema.shape.council.optional(), - bulk_launch: AthenaConfigSchema.shape.bulk_launch, - non_interactive_mode: AthenaConfigSchema.shape.non_interactive_mode, - non_interactive_members: AthenaConfigSchema.shape.non_interactive_members, - non_interactive_member_list: AthenaConfigSchema.shape.non_interactive_member_list, + bulk_launch: AthenaConfigSchema.shape.bulk_launch.optional(), + non_interactive_mode: AthenaConfigSchema.shape.non_interactive_mode.optional(), + non_interactive_members: AthenaConfigSchema.shape.non_interactive_members.optional(), + non_interactive_member_list: AthenaConfigSchema.shape.non_interactive_member_list.optional(), }) export const AgentOverridesSchema = z.object({ @@ -83,6 +83,7 @@ export const AgentOverridesSchema = z.object({ atlas: AgentOverrideConfigSchema.optional(), "council-member": AgentOverrideConfigSchema.optional(), athena: AthenaOverrideConfigSchema.optional(), + "athena-junior": AthenaOverrideConfigSchema.optional(), }) export type AgentOverrideConfig = z.infer diff --git a/src/hooks/athena-sisyphus-only/path-policy.ts b/src/hooks/athena-sisyphus-only/path-policy.ts index ccb7c2350..c6f8da102 100644 --- a/src/hooks/athena-sisyphus-only/path-policy.ts +++ b/src/hooks/athena-sisyphus-only/path-policy.ts @@ -16,13 +16,13 @@ export function isAllowedPath(filePath: string, workspaceRoot: string): boolean // 2. Get relative path from workspace root const rel = relative(workspaceRoot, resolved) - // 3. Reject if escapes root (starts with ".." or is absolute) - if (rel.startsWith("..") || isAbsolute(rel)) { + // 3. Reject if escapes root (traversal or absolute path) + if ((rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) || isAbsolute(rel)) { return false } - // 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive) - if (!/\.sisyphus[/\\]/i.test(rel)) { + // 4. Check if .sisyphus is a complete path segment + if (!/(^|[\/\\])\.sisyphus[\/\\]/i.test(rel)) { return false } diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 5d42c89ad..e30dd81bc 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -286,6 +286,7 @@ export function createToolRegistry(args: { athena_council: createAthenaCouncilTool({ backgroundManager: managers.backgroundManager, councilConfig: pluginConfig.agents?.athena?.council, + directory: ctx.directory, }), } diff --git a/src/tools/athena-council/tools.test.ts b/src/tools/athena-council/tools.test.ts index 833db5bc7..14d21a19d 100644 --- a/src/tools/athena-council/tools.test.ts +++ b/src/tools/athena-council/tools.test.ts @@ -17,7 +17,8 @@ import type { BackgroundManager } from "../../features/background-agent" import type { CouncilConfig } from "../../config/schema/athena" const TEST_TMP_DIR = join(tmpdir(), "athena-council-test") -const PROMPT_FILE = join(TEST_TMP_DIR, "test-prompt.md") +const SISYPHUS_TMP_DIR = join(TEST_TMP_DIR, ".sisyphus", "tmp") +const PROMPT_FILE = join(SISYPHUS_TMP_DIR, "test-prompt.md") const makeManager = (): BackgroundManager => ({ @@ -46,7 +47,7 @@ const makeCouncilConfig = (members?: Array<{ name: string; model: string; varian describe("createAthenaCouncilTool", () => { beforeEach(async () => { - await mkdir(TEST_TMP_DIR, { recursive: true }) + await mkdir(SISYPHUS_TMP_DIR, { recursive: true }) await writeFile(PROMPT_FILE, "prompt file content", "utf-8") mockLaunchCouncilMember.mockImplementation(async (member: { name: string; model: string }) => ({ member, @@ -61,7 +62,7 @@ describe("createAthenaCouncilTool", () => { describe("#given council is not configured (undefined)", () => { describe("#when execute is called", () => { it("#then returns error message about council not configured", async () => { - const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: undefined }) + const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: undefined, directory: TEST_TMP_DIR }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) expect(result).toContain("Council not configured") }) @@ -72,7 +73,7 @@ describe("createAthenaCouncilTool", () => { describe("#when execute is called", () => { it("#then returns error message about council not configured", async () => { const config = makeCouncilConfig([]) - const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: config }) + const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: config, directory: TEST_TMP_DIR }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) expect(result).toContain("Council not configured") }) @@ -85,10 +86,12 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) - const result = await tool.execute({ prompt_file: "/nonexistent/prompt.md" }, makeToolContext()) + const nonExistentFile = join(SISYPHUS_TMP_DIR, "nonexistent.md") + const result = await tool.execute({ prompt_file: nonExistentFile }, makeToolContext()) expect(result).toContain("Failed to read prompt file") - expect(result).toContain("/nonexistent/prompt.md") + expect(result).toContain("nonexistent.md") }) }) }) @@ -99,6 +102,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute( { prompt_file: PROMPT_FILE, members: ["NonExistentMember"] }, @@ -116,6 +120,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute( { prompt_file: PROMPT_FILE, members: ["Claude Opus"] }, @@ -137,6 +142,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) const jsonMatch = result.match(/\{[\s\S]*\}/) @@ -151,6 +157,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) expect(result).toContain("background_wait") @@ -176,6 +183,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) const jsonMatch = result.match(/\{[\s\S]*\}/) @@ -188,6 +196,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) const jsonMatch = result.match(/\{[\s\S]*\}/) @@ -211,6 +220,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) expect(result).toContain("All council member launches failed") @@ -221,6 +231,7 @@ describe("createAthenaCouncilTool", () => { const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: makeCouncilConfig(), + directory: TEST_TMP_DIR, }) const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext()) expect(result).toContain("Claude Opus") diff --git a/src/tools/athena-council/tools.ts b/src/tools/athena-council/tools.ts index d1c85d9ed..51c1864d3 100644 --- a/src/tools/athena-council/tools.ts +++ b/src/tools/athena-council/tools.ts @@ -1,5 +1,6 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { readFile } from "node:fs/promises" +import { resolve } from "node:path" import type { BackgroundManager } from "../../features/background-agent" import type { CouncilConfig, CouncilMemberConfig } from "../../config/schema/athena" import { launchCouncilMember, type CouncilLaunchContext } from "./council-launcher" @@ -86,9 +87,10 @@ async function waitForSessionIds( if (task?.sessionID) { result.set(taskId, task.sessionID) pending.delete(taskId) + } else if (task?.status === "error" || task?.status === "cancelled" || task?.status === "interrupt") { + pending.delete(taskId) } } - if (pending.size > 0) { await new Promise((resolve) => setTimeout(resolve, SESSION_WAIT_INTERVAL_MS)) } @@ -100,8 +102,9 @@ async function waitForSessionIds( export function createAthenaCouncilTool(args: { backgroundManager: BackgroundManager councilConfig: CouncilConfig | undefined + directory: string }): ToolDefinition { - const { backgroundManager, councilConfig } = args + const { backgroundManager, councilConfig, directory } = args const description = buildToolDescription(councilConfig) return tool({ @@ -124,7 +127,12 @@ export function createAthenaCouncilTool(args: { let promptContent: string try { - promptContent = await readFile(toolArgs.prompt_file, "utf-8") + const resolvedPath = resolve(directory, toolArgs.prompt_file) + const expectedPrefix = resolve(directory, ".sisyphus/tmp") + if (!resolvedPath.startsWith(expectedPrefix)) { + return `Invalid prompt_file path: expected path within .sisyphus/tmp/, got: ${toolArgs.prompt_file}` + } + promptContent = await readFile(resolvedPath, "utf-8") } catch (err) { return `Failed to read prompt file: ${toolArgs.prompt_file}. Error: ${String(err)}` }