Compare commits
3 Commits
dev
...
feat/athen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a14bd6d68 | ||
|
|
1c125ec3ef | ||
|
|
647f691fe2 |
@@ -1211,6 +1211,289 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"athena": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"fallback_models": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"subagent",
|
||||
"primary",
|
||||
"all"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"bash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"webfetch": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"doom_loop": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"external_directory": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4044,6 +4327,41 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"athena": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"pattern": "^[^/\\s]+\\/[^/\\s]+$"
|
||||
},
|
||||
"members": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"pattern": "^[^/\\s]+\\/[^/\\s]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"members"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"categories": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
|
||||
16
src/agents/athena.ts
Normal file
16
src/agents/athena.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "./types"
|
||||
import { buildAthenaPrompt, type AthenaPromptOptions } from "./athena/prompt"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
|
||||
export function createAthenaAgent(model: string, options?: AthenaPromptOptions): AgentConfig {
|
||||
return {
|
||||
description: "Primary council orchestrator for Athena workflows. (Athena - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
prompt: buildAthenaPrompt(options),
|
||||
}
|
||||
}
|
||||
createAthenaAgent.mode = MODE
|
||||
36
src/agents/athena/council-contract.ts
Normal file
36
src/agents/athena/council-contract.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const COUNCIL_MEMBER_RESPONSE_TAG = "COUNCIL_MEMBER_RESPONSE"
|
||||
|
||||
export type CouncilVerdict = "support" | "oppose" | "mixed" | "abstain"
|
||||
|
||||
export interface CouncilEvidenceItem {
|
||||
source: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface CouncilMemberResponse {
|
||||
member: string
|
||||
verdict: CouncilVerdict
|
||||
confidence: number
|
||||
rationale: string
|
||||
risks: string[]
|
||||
evidence: CouncilEvidenceItem[]
|
||||
proposed_actions: string[]
|
||||
missing_information: string[]
|
||||
}
|
||||
|
||||
export interface AthenaCouncilMember {
|
||||
name: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface ParsedCouncilMemberResponse {
|
||||
ok: true
|
||||
value: CouncilMemberResponse
|
||||
source: "raw_json" | "tagged_json"
|
||||
}
|
||||
|
||||
export interface CouncilResponseParseFailure {
|
||||
ok: false
|
||||
error: string
|
||||
source: "raw_json" | "tagged_json" | "none"
|
||||
}
|
||||
24
src/agents/athena/council-members.ts
Normal file
24
src/agents/athena/council-members.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AthenaCouncilMember } from "./council-contract"
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
export function toCouncilMemberAgentName(memberName: string): string {
|
||||
const slug = slugify(memberName)
|
||||
return `council-member-${slug || "member"}`
|
||||
}
|
||||
|
||||
export function buildCouncilRosterSection(members: AthenaCouncilMember[]): string {
|
||||
if (members.length === 0) {
|
||||
return "- No configured council roster. Use default subagent_type=\"council-member\"."
|
||||
}
|
||||
|
||||
return members
|
||||
.map((member) => `- ${member.name} | model=${member.model} | subagent_type=${toCouncilMemberAgentName(member.name)}`)
|
||||
.join("\n")
|
||||
}
|
||||
38
src/agents/athena/council-quorum.test.ts
Normal file
38
src/agents/athena/council-quorum.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { evaluateCouncilQuorum } from "./council-quorum"
|
||||
|
||||
describe("evaluateCouncilQuorum", () => {
|
||||
test("#given partial failures with enough successful members #when evaluating #then quorum reached with graceful degradation", () => {
|
||||
// given
|
||||
const input = {
|
||||
totalMembers: 5,
|
||||
successfulMembers: 3,
|
||||
failedMembers: 2,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = evaluateCouncilQuorum(input)
|
||||
|
||||
// then
|
||||
expect(result.required).toBe(3)
|
||||
expect(result.reached).toBe(true)
|
||||
expect(result.gracefulDegradation).toBe(true)
|
||||
})
|
||||
|
||||
test("#given too many failures #when evaluating #then quorum is unreachable", () => {
|
||||
// given
|
||||
const input = {
|
||||
totalMembers: 4,
|
||||
successfulMembers: 1,
|
||||
failedMembers: 3,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = evaluateCouncilQuorum(input)
|
||||
|
||||
// then
|
||||
expect(result.required).toBe(2)
|
||||
expect(result.reached).toBe(false)
|
||||
expect(result.canStillReach).toBe(false)
|
||||
})
|
||||
})
|
||||
36
src/agents/athena/council-quorum.ts
Normal file
36
src/agents/athena/council-quorum.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface CouncilQuorumInput {
|
||||
totalMembers: number
|
||||
successfulMembers: number
|
||||
failedMembers: number
|
||||
requestedQuorum?: number
|
||||
}
|
||||
|
||||
export interface CouncilQuorumResult {
|
||||
required: number
|
||||
reached: boolean
|
||||
canStillReach: boolean
|
||||
gracefulDegradation: boolean
|
||||
}
|
||||
|
||||
function clampMinimumQuorum(totalMembers: number, requestedQuorum?: number): number {
|
||||
if (requestedQuorum && requestedQuorum > 0) {
|
||||
return Math.min(totalMembers, requestedQuorum)
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(totalMembers / 2))
|
||||
}
|
||||
|
||||
export function evaluateCouncilQuorum(input: CouncilQuorumInput): CouncilQuorumResult {
|
||||
const required = clampMinimumQuorum(input.totalMembers, input.requestedQuorum)
|
||||
const reached = input.successfulMembers >= required
|
||||
const remainingPossible = input.totalMembers - input.failedMembers
|
||||
const canStillReach = remainingPossible >= required
|
||||
const gracefulDegradation = reached && input.failedMembers > 0
|
||||
|
||||
return {
|
||||
required,
|
||||
reached,
|
||||
canStillReach,
|
||||
gracefulDegradation,
|
||||
}
|
||||
}
|
||||
71
src/agents/athena/council-response-parser.test.ts
Normal file
71
src/agents/athena/council-response-parser.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseCouncilMemberResponse } from "./council-response-parser"
|
||||
|
||||
describe("parseCouncilMemberResponse", () => {
|
||||
test("#given valid raw json #when parsing #then returns parsed council payload", () => {
|
||||
// given
|
||||
const raw = JSON.stringify({
|
||||
member: "architect",
|
||||
verdict: "support",
|
||||
confidence: 0.9,
|
||||
rationale: "Matches existing module boundaries",
|
||||
risks: ["Regression in edge-case parser"],
|
||||
evidence: [{ source: "src/agents/athena.ts", detail: "Current prompt is too generic" }],
|
||||
proposed_actions: ["Add strict orchestration workflow"],
|
||||
missing_information: ["Need runtime timeout budget"],
|
||||
})
|
||||
|
||||
// when
|
||||
const result = parseCouncilMemberResponse(raw)
|
||||
|
||||
// then
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.source).toBe("raw_json")
|
||||
expect(result.value.member).toBe("architect")
|
||||
expect(result.value.verdict).toBe("support")
|
||||
})
|
||||
|
||||
test("#given tagged json payload #when parsing #then extracts from COUNCIL_MEMBER_RESPONSE tag", () => {
|
||||
// given
|
||||
const raw = [
|
||||
"analysis intro",
|
||||
"<COUNCIL_MEMBER_RESPONSE>",
|
||||
JSON.stringify({
|
||||
member: "skeptic",
|
||||
verdict: "mixed",
|
||||
confidence: 0.62,
|
||||
rationale: "Quorum logic exists but retry handling is weak",
|
||||
risks: ["Timeout blind spot"],
|
||||
evidence: [{ source: "src/tools/background-task/create-background-wait.ts", detail: "No nudge semantics" }],
|
||||
proposed_actions: ["Add stuck detection policy"],
|
||||
missing_information: [],
|
||||
}),
|
||||
"</COUNCIL_MEMBER_RESPONSE>",
|
||||
].join("\n")
|
||||
|
||||
// when
|
||||
const result = parseCouncilMemberResponse(raw)
|
||||
|
||||
// then
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.source).toBe("tagged_json")
|
||||
expect(result.value.member).toBe("skeptic")
|
||||
expect(result.value.proposed_actions).toEqual(["Add stuck detection policy"])
|
||||
})
|
||||
|
||||
test("#given malformed payload #when parsing #then returns structured parse failure", () => {
|
||||
// given
|
||||
const raw = "Council says: maybe this works"
|
||||
|
||||
// when
|
||||
const result = parseCouncilMemberResponse(raw)
|
||||
|
||||
// then
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.source).toBe("none")
|
||||
expect(result.error.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
159
src/agents/athena/council-response-parser.ts
Normal file
159
src/agents/athena/council-response-parser.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
COUNCIL_MEMBER_RESPONSE_TAG,
|
||||
type CouncilMemberResponse,
|
||||
type CouncilResponseParseFailure,
|
||||
type ParsedCouncilMemberResponse,
|
||||
} from "./council-contract"
|
||||
|
||||
type ParseResult = ParsedCouncilMemberResponse | CouncilResponseParseFailure
|
||||
|
||||
function normalizeJsonPayload(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const firstNewLine = trimmed.indexOf("\n")
|
||||
if (firstNewLine < 0) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return trimmed.slice(firstNewLine + 1, -3).trim()
|
||||
}
|
||||
|
||||
function tryParseJsonObject(input: string): unknown {
|
||||
const normalized = normalizeJsonPayload(input)
|
||||
if (!normalized.startsWith("{")) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(normalized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractTaggedPayload(raw: string): string | null {
|
||||
const xmlLike = new RegExp(
|
||||
`<${COUNCIL_MEMBER_RESPONSE_TAG}>([\\s\\S]*?)<\\/${COUNCIL_MEMBER_RESPONSE_TAG}>`,
|
||||
"i",
|
||||
)
|
||||
const xmlMatch = raw.match(xmlLike)
|
||||
if (xmlMatch?.[1]) {
|
||||
return xmlMatch[1].trim()
|
||||
}
|
||||
|
||||
const prefixed = new RegExp(`${COUNCIL_MEMBER_RESPONSE_TAG}\\s*:\\s*`, "i")
|
||||
const prefixMatch = raw.match(prefixed)
|
||||
if (!prefixMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matchIndex = prefixMatch.index
|
||||
if (matchIndex === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rest = raw.slice(matchIndex + prefixMatch[0].length)
|
||||
const firstBrace = rest.indexOf("{")
|
||||
if (firstBrace < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return rest.slice(firstBrace).trim()
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function isEvidenceArray(value: unknown): value is CouncilMemberResponse["evidence"] {
|
||||
return Array.isArray(value)
|
||||
&& value.every(
|
||||
(item) =>
|
||||
typeof item === "object"
|
||||
&& item !== null
|
||||
&& typeof (item as { source?: unknown }).source === "string"
|
||||
&& typeof (item as { detail?: unknown }).detail === "string",
|
||||
)
|
||||
}
|
||||
|
||||
function validateCouncilMemberResponse(payload: unknown): CouncilMemberResponse | null {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = payload as Record<string, unknown>
|
||||
const verdict = candidate.verdict
|
||||
const confidence = candidate.confidence
|
||||
|
||||
if (
|
||||
typeof candidate.member !== "string"
|
||||
|| (verdict !== "support" && verdict !== "oppose" && verdict !== "mixed" && verdict !== "abstain")
|
||||
|| typeof confidence !== "number"
|
||||
|| confidence < 0
|
||||
|| confidence > 1
|
||||
|| typeof candidate.rationale !== "string"
|
||||
|| !isStringArray(candidate.risks)
|
||||
|| !isEvidenceArray(candidate.evidence)
|
||||
|| !isStringArray(candidate.proposed_actions)
|
||||
|| !isStringArray(candidate.missing_information)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
member: candidate.member,
|
||||
verdict,
|
||||
confidence,
|
||||
rationale: candidate.rationale,
|
||||
risks: candidate.risks,
|
||||
evidence: candidate.evidence,
|
||||
proposed_actions: candidate.proposed_actions,
|
||||
missing_information: candidate.missing_information,
|
||||
}
|
||||
}
|
||||
|
||||
function parseValidated(payload: unknown, source: ParsedCouncilMemberResponse["source"]): ParseResult {
|
||||
const validated = validateCouncilMemberResponse(payload)
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Council member response does not match required contract",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: validated,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCouncilMemberResponse(raw: string): ParseResult {
|
||||
const directJson = tryParseJsonObject(raw)
|
||||
if (directJson) {
|
||||
return parseValidated(directJson, "raw_json")
|
||||
}
|
||||
|
||||
const taggedPayload = extractTaggedPayload(raw)
|
||||
if (taggedPayload) {
|
||||
const taggedJson = tryParseJsonObject(taggedPayload)
|
||||
if (taggedJson) {
|
||||
return parseValidated(taggedJson, "tagged_json")
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "Tagged council response found, but JSON payload is invalid",
|
||||
source: "tagged_json",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: "No parseable council response payload found",
|
||||
source: "none",
|
||||
}
|
||||
}
|
||||
50
src/agents/athena/council-retry.test.ts
Normal file
50
src/agents/athena/council-retry.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decideCouncilRecoveryAction } from "./council-retry"
|
||||
|
||||
describe("decideCouncilRecoveryAction", () => {
|
||||
test("#given running member with stale progress and nudge budget #when deciding #then nudge", () => {
|
||||
// given
|
||||
const now = 10_000
|
||||
const decision = decideCouncilRecoveryAction(
|
||||
{
|
||||
status: "running",
|
||||
attempts: 1,
|
||||
nudges: 0,
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 1_000,
|
||||
},
|
||||
{
|
||||
maxAttempts: 2,
|
||||
maxNudges: 1,
|
||||
stuckAfterMs: 2_000,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(decision.action).toBe("nudge")
|
||||
})
|
||||
|
||||
test("#given stuck member after nudge with retry budget #when deciding #then retry", () => {
|
||||
// given
|
||||
const now = 20_000
|
||||
const decision = decideCouncilRecoveryAction(
|
||||
{
|
||||
status: "running",
|
||||
attempts: 1,
|
||||
nudges: 1,
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 1_000,
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
maxNudges: 1,
|
||||
stuckAfterMs: 5_000,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(decision.action).toBe("retry")
|
||||
})
|
||||
})
|
||||
68
src/agents/athena/council-retry.ts
Normal file
68
src/agents/athena/council-retry.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export type CouncilMemberTaskStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timed_out"
|
||||
|
||||
export interface CouncilMemberTaskState {
|
||||
status: CouncilMemberTaskStatus
|
||||
attempts: number
|
||||
nudges: number
|
||||
startedAt: number
|
||||
lastProgressAt: number
|
||||
}
|
||||
|
||||
export interface CouncilRetryPolicy {
|
||||
maxAttempts: number
|
||||
maxNudges: number
|
||||
stuckAfterMs: number
|
||||
}
|
||||
|
||||
export type CouncilRecoveryAction = "wait" | "nudge" | "retry" | "give_up"
|
||||
|
||||
export interface CouncilRecoveryDecision {
|
||||
action: CouncilRecoveryAction
|
||||
reason: string
|
||||
}
|
||||
|
||||
export function isCouncilMemberStuck(
|
||||
now: number,
|
||||
lastProgressAt: number,
|
||||
stuckAfterMs: number,
|
||||
): boolean {
|
||||
return now - lastProgressAt >= stuckAfterMs
|
||||
}
|
||||
|
||||
export function decideCouncilRecoveryAction(
|
||||
state: CouncilMemberTaskState,
|
||||
policy: CouncilRetryPolicy,
|
||||
now: number,
|
||||
): CouncilRecoveryDecision {
|
||||
if (state.status === "completed" || state.status === "cancelled") {
|
||||
return { action: "give_up", reason: "Task already reached terminal status" }
|
||||
}
|
||||
|
||||
if (state.status === "failed" || state.status === "timed_out") {
|
||||
if (state.attempts < policy.maxAttempts) {
|
||||
return { action: "retry", reason: "Terminal failure with retries remaining" }
|
||||
}
|
||||
return { action: "give_up", reason: "Terminal failure and retry budget exhausted" }
|
||||
}
|
||||
|
||||
const stuck = isCouncilMemberStuck(now, state.lastProgressAt, policy.stuckAfterMs)
|
||||
if (!stuck) {
|
||||
return { action: "wait", reason: "Task is still making progress" }
|
||||
}
|
||||
|
||||
if (state.nudges < policy.maxNudges) {
|
||||
return { action: "nudge", reason: "Task appears stuck and nudge budget remains" }
|
||||
}
|
||||
|
||||
if (state.attempts < policy.maxAttempts) {
|
||||
return { action: "retry", reason: "Task stuck after nudges, retrying with fresh run" }
|
||||
}
|
||||
|
||||
return { action: "give_up", reason: "Task stuck and all recovery budgets exhausted" }
|
||||
}
|
||||
43
src/agents/athena/council-synthesis.test.ts
Normal file
43
src/agents/athena/council-synthesis.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { synthesizeCouncilOutcome } from "./council-synthesis"
|
||||
import type { CouncilMemberResponse } from "./council-contract"
|
||||
|
||||
function response(overrides: Partial<CouncilMemberResponse>): CouncilMemberResponse {
|
||||
return {
|
||||
member: "member-a",
|
||||
verdict: "support",
|
||||
confidence: 0.8,
|
||||
rationale: "default rationale",
|
||||
risks: [],
|
||||
evidence: [{ source: "file.ts", detail: "detail" }],
|
||||
proposed_actions: ["Ship with tests"],
|
||||
missing_information: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("synthesizeCouncilOutcome", () => {
|
||||
test("#given majority support with one failure #when synthesizing #then reports agreement and graceful degradation", () => {
|
||||
// given
|
||||
const responses = [
|
||||
response({ member: "architect", verdict: "support", proposed_actions: ["Ship with tests"] }),
|
||||
response({ member: "skeptic", verdict: "support", proposed_actions: ["Ship with tests"] }),
|
||||
response({ member: "critic", verdict: "oppose", risks: ["Parser drift"] }),
|
||||
]
|
||||
|
||||
// when
|
||||
const result = synthesizeCouncilOutcome({
|
||||
responses,
|
||||
failedMembers: ["perf"],
|
||||
quorumReached: true,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.majorityVerdict).toBe("support")
|
||||
expect(result.agreementMembers).toEqual(["architect", "skeptic"])
|
||||
expect(result.disagreementMembers).toContain("critic")
|
||||
expect(result.disagreementMembers).toContain("perf")
|
||||
expect(result.commonActions).toEqual(["Ship with tests"])
|
||||
expect(result.gracefulDegradation).toBe(true)
|
||||
})
|
||||
})
|
||||
141
src/agents/athena/council-synthesis.ts
Normal file
141
src/agents/athena/council-synthesis.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { CouncilMemberResponse, CouncilVerdict } from "./council-contract"
|
||||
|
||||
export interface CouncilSynthesisInput {
|
||||
responses: CouncilMemberResponse[]
|
||||
failedMembers: string[]
|
||||
quorumReached: boolean
|
||||
}
|
||||
|
||||
export interface CouncilSynthesisResult {
|
||||
majorityVerdict: CouncilVerdict
|
||||
consensusLevel: "unanimous" | "strong" | "split" | "fragmented"
|
||||
agreementMembers: string[]
|
||||
disagreementMembers: string[]
|
||||
commonActions: string[]
|
||||
contestedRisks: string[]
|
||||
unresolvedQuestions: string[]
|
||||
gracefulDegradation: boolean
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function getMajorityVerdict(responses: CouncilMemberResponse[]): CouncilVerdict {
|
||||
const counts = new Map<CouncilVerdict, number>()
|
||||
for (const response of responses) {
|
||||
counts.set(response.verdict, (counts.get(response.verdict) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const orderedVerdicts: CouncilVerdict[] = ["support", "mixed", "oppose", "abstain"]
|
||||
let winner: CouncilVerdict = "abstain"
|
||||
let winnerCount = -1
|
||||
|
||||
for (const verdict of orderedVerdicts) {
|
||||
const count = counts.get(verdict) ?? 0
|
||||
if (count > winnerCount) {
|
||||
winner = verdict
|
||||
winnerCount = count
|
||||
}
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
|
||||
function deriveConsensusLevel(agreementCount: number, totalCount: number): CouncilSynthesisResult["consensusLevel"] {
|
||||
if (totalCount === 0) {
|
||||
return "fragmented"
|
||||
}
|
||||
|
||||
if (agreementCount === totalCount) {
|
||||
return "unanimous"
|
||||
}
|
||||
|
||||
const ratio = agreementCount / totalCount
|
||||
if (ratio >= 0.75) {
|
||||
return "strong"
|
||||
}
|
||||
if (ratio >= 0.5) {
|
||||
return "split"
|
||||
}
|
||||
return "fragmented"
|
||||
}
|
||||
|
||||
function collectCommonActions(responses: CouncilMemberResponse[]): string[] {
|
||||
const counts = new Map<string, { text: string; count: number }>()
|
||||
for (const response of responses) {
|
||||
for (const action of response.proposed_actions) {
|
||||
const key = normalizeKey(action)
|
||||
const existing = counts.get(key)
|
||||
if (!existing) {
|
||||
counts.set(key, { text: action, count: 1 })
|
||||
continue
|
||||
}
|
||||
existing.count += 1
|
||||
}
|
||||
}
|
||||
|
||||
const threshold = Math.max(2, Math.ceil(responses.length / 2))
|
||||
return [...counts.values()]
|
||||
.filter((item) => item.count >= threshold)
|
||||
.map((item) => item.text)
|
||||
}
|
||||
|
||||
function collectContestedRisks(responses: CouncilMemberResponse[]): string[] {
|
||||
const counts = new Map<string, { text: string; count: number }>()
|
||||
for (const response of responses) {
|
||||
for (const risk of response.risks) {
|
||||
const key = normalizeKey(risk)
|
||||
const existing = counts.get(key)
|
||||
if (!existing) {
|
||||
counts.set(key, { text: risk, count: 1 })
|
||||
continue
|
||||
}
|
||||
existing.count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.values()]
|
||||
.filter((item) => item.count === 1)
|
||||
.map((item) => item.text)
|
||||
}
|
||||
|
||||
function collectUnresolvedQuestions(responses: CouncilMemberResponse[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const questions: string[] = []
|
||||
|
||||
for (const response of responses) {
|
||||
for (const question of response.missing_information) {
|
||||
const key = normalizeKey(question)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
questions.push(question)
|
||||
}
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
export function synthesizeCouncilOutcome(input: CouncilSynthesisInput): CouncilSynthesisResult {
|
||||
const majorityVerdict = getMajorityVerdict(input.responses)
|
||||
const agreementMembers = input.responses
|
||||
.filter((response) => response.verdict === majorityVerdict)
|
||||
.map((response) => response.member)
|
||||
const disagreementMembers = input.responses
|
||||
.filter((response) => response.verdict !== majorityVerdict)
|
||||
.map((response) => response.member)
|
||||
.concat(input.failedMembers)
|
||||
|
||||
return {
|
||||
majorityVerdict,
|
||||
consensusLevel: deriveConsensusLevel(agreementMembers.length, input.responses.length),
|
||||
agreementMembers,
|
||||
disagreementMembers,
|
||||
commonActions: collectCommonActions(input.responses),
|
||||
contestedRisks: collectContestedRisks(input.responses),
|
||||
unresolvedQuestions: collectUnresolvedQuestions(input.responses),
|
||||
gracefulDegradation: input.quorumReached && input.failedMembers.length > 0,
|
||||
}
|
||||
}
|
||||
68
src/agents/athena/prompt.ts
Normal file
68
src/agents/athena/prompt.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { AthenaCouncilMember } from "./council-contract"
|
||||
import { COUNCIL_MEMBER_RESPONSE_TAG } from "./council-contract"
|
||||
import { buildCouncilRosterSection } from "./council-members"
|
||||
|
||||
export interface AthenaPromptOptions {
|
||||
members?: AthenaCouncilMember[]
|
||||
}
|
||||
|
||||
export function buildAthenaPrompt(options: AthenaPromptOptions = {}): string {
|
||||
const roster = buildCouncilRosterSection(options.members ?? [])
|
||||
|
||||
return `You are Athena, a primary council orchestrator agent.
|
||||
|
||||
Operate as a strict multi-model council coordinator.
|
||||
|
||||
Core workflow:
|
||||
1) Receive user request and define a concise decision question for the council.
|
||||
2) Fan out council-member tasks in parallel with task(..., run_in_background=true).
|
||||
3) Collect with background_wait first, then background_output for completed IDs.
|
||||
4) Parse each member output as strict JSON contract; fallback to ${COUNCIL_MEMBER_RESPONSE_TAG} tag extraction.
|
||||
5) Apply quorum, retries, and graceful degradation.
|
||||
6) Synthesize agreement vs disagreement explicitly, then provide final recommendation.
|
||||
|
||||
Council roster:
|
||||
${roster}
|
||||
|
||||
Execution protocol:
|
||||
- Always run council fan-out in parallel. Never sequentially wait on one member before launching others.
|
||||
- Use subagent_type="council-member" if no named roster is configured.
|
||||
- For named roster entries, use that exact subagent_type so each member runs on its assigned model.
|
||||
- Keep prompts evidence-oriented and read-only. Members must inspect code, tests, logs, and config references.
|
||||
- Never ask members to edit files, delegate, or switch agents.
|
||||
|
||||
Member response contract (required):
|
||||
- Preferred: raw JSON only.
|
||||
- Fallback allowed: wrap JSON in <${COUNCIL_MEMBER_RESPONSE_TAG}>...</${COUNCIL_MEMBER_RESPONSE_TAG}>.
|
||||
- Required JSON keys:
|
||||
{
|
||||
"member": string,
|
||||
"verdict": "support" | "oppose" | "mixed" | "abstain",
|
||||
"confidence": number (0..1),
|
||||
"rationale": string,
|
||||
"risks": string[],
|
||||
"evidence": [{ "source": string, "detail": string }],
|
||||
"proposed_actions": string[],
|
||||
"missing_information": string[]
|
||||
}
|
||||
|
||||
Failure and stuck handling:
|
||||
- Track per-member attempts, nudges, and progress timestamps.
|
||||
- Detect stuck tasks when no progress appears within expected interval.
|
||||
- First recovery action for stuck: nudge through continuation prompt.
|
||||
- If still stuck or failed: retry with a fresh background task, bounded by retry limit.
|
||||
- If a member remains failed after retry budget, mark as failed and continue.
|
||||
|
||||
Quorum and degradation:
|
||||
- Default quorum: ceil(total_members / 2), minimum 1.
|
||||
- If quorum reached, continue synthesis even when some members failed.
|
||||
- If quorum cannot be reached after retries, report partial findings and explicit uncertainty.
|
||||
|
||||
Synthesis output requirements:
|
||||
- Separate "agreement" and "disagreement" sections.
|
||||
- Name which members support the majority view and which dissent or failed.
|
||||
- Call out unresolved questions and evidence gaps.
|
||||
- End with one executable recommendation and a confidence statement.
|
||||
|
||||
Do not expose internal operational noise. Report concise structured findings.`
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
|
||||
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import { createAthenaAgent } from "./athena"
|
||||
import { createCouncilMemberAgent } from "./council-member"
|
||||
import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior"
|
||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import {
|
||||
@@ -33,6 +35,7 @@ type AgentSource = AgentFactory | AgentConfig
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
sisyphus: createSisyphusAgent,
|
||||
hephaestus: createHephaestusAgent,
|
||||
athena: createAthenaAgent,
|
||||
oracle: createOracleAgent,
|
||||
librarian: createLibrarianAgent,
|
||||
explore: createExploreAgent,
|
||||
@@ -43,6 +46,7 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
atlas: createAtlasAgent as AgentFactory,
|
||||
"sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory,
|
||||
"council-member": createCouncilMemberAgent,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,6 @@ export function collectPendingBuiltinAgents(input: {
|
||||
browserProvider,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
isFirstRunNoCache,
|
||||
disabledSkills,
|
||||
disableOmoEnv = false,
|
||||
} = input
|
||||
@@ -56,8 +55,9 @@ export function collectPendingBuiltinAgents(input: {
|
||||
if (agentName === "sisyphus-junior") continue
|
||||
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
|
||||
|
||||
const override = agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
const override = Object.entries(agentOverrides).find(
|
||||
([key]) => key.toLowerCase() === agentName.toLowerCase(),
|
||||
)?.[1]
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
// Check if agent requires a specific model
|
||||
|
||||
51
src/agents/council-member.ts
Normal file
51
src/agents/council-member.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
import { COUNCIL_MEMBER_RESPONSE_TAG } from "./athena/council-contract"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
const councilMemberRestrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"apply_patch",
|
||||
"task",
|
||||
"task_*",
|
||||
"teammate",
|
||||
"call_omo_agent",
|
||||
"switch_agent",
|
||||
])
|
||||
|
||||
export function createCouncilMemberAgent(model: string): AgentConfig {
|
||||
return {
|
||||
description: "Internal hidden council member used by Athena. Read-only analysis only.",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
hidden: true,
|
||||
...councilMemberRestrictions,
|
||||
prompt: `You are an internal council-member for Athena.
|
||||
|
||||
You are strictly read-only and evidence-oriented.
|
||||
You must not modify files, delegate, or switch agents.
|
||||
You must cite concrete evidence from files, tests, logs, or tool output.
|
||||
|
||||
Output contract:
|
||||
- Preferred output: raw JSON only.
|
||||
- Fallback output: wrap JSON with <${COUNCIL_MEMBER_RESPONSE_TAG}>...</${COUNCIL_MEMBER_RESPONSE_TAG}>.
|
||||
- Required JSON schema:
|
||||
{
|
||||
"member": string,
|
||||
"verdict": "support" | "oppose" | "mixed" | "abstain",
|
||||
"confidence": number (0..1),
|
||||
"rationale": string,
|
||||
"risks": string[],
|
||||
"evidence": [{ "source": string, "detail": string }],
|
||||
"proposed_actions": string[],
|
||||
"missing_information": string[]
|
||||
}
|
||||
|
||||
Do not include markdown explanations outside the contract unless Athena asks for it explicitly.`,
|
||||
}
|
||||
}
|
||||
createCouncilMemberAgent.mode = MODE
|
||||
@@ -112,6 +112,7 @@ export function isGeminiModel(model: string): boolean {
|
||||
export type BuiltinAgentName =
|
||||
| "sisyphus"
|
||||
| "hephaestus"
|
||||
| "athena"
|
||||
| "oracle"
|
||||
| "librarian"
|
||||
| "explore"
|
||||
@@ -119,9 +120,10 @@ export type BuiltinAgentName =
|
||||
| "metis"
|
||||
| "momus"
|
||||
| "atlas"
|
||||
| "sisyphus-junior";
|
||||
| "sisyphus-junior"
|
||||
| "council-member";
|
||||
|
||||
export type OverridableAgentName = "build" | BuiltinAgentName;
|
||||
export type OverridableAgentName = "build" | Exclude<BuiltinAgentName, "council-member">;
|
||||
|
||||
export type AgentName = BuiltinAgentName;
|
||||
|
||||
|
||||
@@ -11,6 +11,32 @@ import * as shared from "../shared"
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
|
||||
|
||||
describe("createBuiltinAgents with model overrides", () => {
|
||||
test("registers athena as builtin primary agent", async () => {
|
||||
// #given
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.athena).toBeDefined()
|
||||
expect(agents.athena.mode).toBe("primary")
|
||||
})
|
||||
|
||||
test("registers council-member as hidden internal subagent", async () => {
|
||||
// #given
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents["council-member"]).toBeDefined()
|
||||
expect(agents["council-member"].mode).toBe("subagent")
|
||||
expect((agents["council-member"] as AgentConfig & { hidden?: boolean }).hidden).toBe(true)
|
||||
expect(agents.sisyphus.prompt).not.toContain("council-member")
|
||||
expect(agents.hephaestus.prompt).not.toContain("council-member")
|
||||
expect(agents.atlas.prompt).not.toContain("council-member")
|
||||
})
|
||||
|
||||
test("Sisyphus with default model has thinking config when all models available", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
|
||||
@@ -4,9 +4,15 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -68,9 +74,15 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -130,9 +142,15 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -193,10 +211,18 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
@@ -278,10 +304,18 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
@@ -363,9 +397,15 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -423,9 +463,15 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -483,9 +529,16 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -558,9 +611,16 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -634,9 +694,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -709,9 +776,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -785,9 +859,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
@@ -855,9 +936,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
@@ -926,9 +1014,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -984,9 +1078,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
@@ -1042,9 +1142,16 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1117,9 +1224,16 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
@@ -1192,9 +1306,15 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1256,9 +1376,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1322,9 +1448,16 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1400,9 +1533,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1478,9 +1618,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
"council-member": {
|
||||
"model": "openai/gpt-5.4",
|
||||
"variant": "medium",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("runCliInstaller", () => {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
|
||||
@@ -56,6 +57,7 @@ describe("runCliInstaller", () => {
|
||||
opencodeZen: "no",
|
||||
zaiCodingPlan: "no",
|
||||
kimiForCoding: "no",
|
||||
opencodeGo: "no",
|
||||
}
|
||||
|
||||
//#when
|
||||
|
||||
129
src/cli/config-manager/generate-athena-config.ts
Normal file
129
src/cli/config-manager/generate-athena-config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
import { toProviderAvailability } from "../provider-availability"
|
||||
import type { InstallConfig } from "../types"
|
||||
|
||||
export interface AthenaMemberTemplate {
|
||||
provider: string
|
||||
model: string
|
||||
name: string
|
||||
isAvailable: (config: InstallConfig) => boolean
|
||||
}
|
||||
|
||||
export interface AthenaCouncilMember {
|
||||
name: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface AthenaConfig {
|
||||
model?: string
|
||||
members: AthenaCouncilMember[]
|
||||
}
|
||||
|
||||
const ATHENA_MEMBER_TEMPLATES: AthenaMemberTemplate[] = [
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
name: "OpenAI Strategist",
|
||||
isAvailable: (config) => config.hasOpenAI,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
name: "Claude Strategist",
|
||||
isAvailable: (config) => config.hasClaude,
|
||||
},
|
||||
{
|
||||
provider: "google",
|
||||
model: "gemini-3.1-pro",
|
||||
name: "Gemini Strategist",
|
||||
isAvailable: (config) => config.hasGemini,
|
||||
},
|
||||
{
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4",
|
||||
name: "Copilot Strategist",
|
||||
isAvailable: (config) => config.hasCopilot,
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
model: "gpt-5.4",
|
||||
name: "OpenCode Strategist",
|
||||
isAvailable: (config) => config.hasOpencodeZen,
|
||||
},
|
||||
{
|
||||
provider: "zai-coding-plan",
|
||||
model: "glm-4.7",
|
||||
name: "Z.ai Strategist",
|
||||
isAvailable: (config) => config.hasZaiCodingPlan,
|
||||
},
|
||||
{
|
||||
provider: "kimi-for-coding",
|
||||
model: "k2p5",
|
||||
name: "Kimi Strategist",
|
||||
isAvailable: (config) => config.hasKimiForCoding,
|
||||
},
|
||||
{
|
||||
provider: "opencode-go",
|
||||
model: "glm-5",
|
||||
name: "OpenCode Go Strategist",
|
||||
isAvailable: (config) => config.hasOpencodeGo,
|
||||
},
|
||||
]
|
||||
|
||||
function toProviderModel(provider: string, model: string): string {
|
||||
const transformedModel = transformModelForProvider(provider, model)
|
||||
return `${provider}/${transformedModel}`
|
||||
}
|
||||
|
||||
function createUniqueMemberName(baseName: string, usedNames: Set<string>): string {
|
||||
if (!usedNames.has(baseName.toLowerCase())) {
|
||||
usedNames.add(baseName.toLowerCase())
|
||||
return baseName
|
||||
}
|
||||
|
||||
let suffix = 2
|
||||
let candidate = `${baseName} ${suffix}`
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
suffix += 1
|
||||
candidate = `${baseName} ${suffix}`
|
||||
}
|
||||
|
||||
usedNames.add(candidate.toLowerCase())
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function createAthenaCouncilMembersFromTemplates(
|
||||
templates: AthenaMemberTemplate[]
|
||||
): AthenaCouncilMember[] {
|
||||
const members: AthenaCouncilMember[] = []
|
||||
const usedNames = new Set<string>()
|
||||
|
||||
for (const template of templates) {
|
||||
members.push({
|
||||
name: createUniqueMemberName(template.name, usedNames),
|
||||
model: toProviderModel(template.provider, template.model),
|
||||
})
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
export function generateAthenaConfig(config: InstallConfig): AthenaConfig | undefined {
|
||||
const selectedTemplates = ATHENA_MEMBER_TEMPLATES.filter((template) => template.isAvailable(config))
|
||||
if (selectedTemplates.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const members = createAthenaCouncilMembersFromTemplates(selectedTemplates)
|
||||
const availability = toProviderAvailability(config)
|
||||
|
||||
const preferredCoordinator =
|
||||
(availability.native.openai && members.find((member) => member.model.startsWith("openai/"))) ||
|
||||
(availability.native.claude && members.find((member) => member.model.startsWith("anthropic/"))) ||
|
||||
members[0]
|
||||
|
||||
return {
|
||||
model: preferredCoordinator.model,
|
||||
members,
|
||||
}
|
||||
}
|
||||
102
src/cli/config-manager/generate-omo-config.test.ts
Normal file
102
src/cli/config-manager/generate-omo-config.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { InstallConfig } from "../types"
|
||||
import {
|
||||
createAthenaCouncilMembersFromTemplates,
|
||||
generateAthenaConfig,
|
||||
type AthenaMemberTemplate,
|
||||
} from "./generate-athena-config"
|
||||
import { generateOmoConfig } from "./generate-omo-config"
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
|
||||
function createInstallConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
|
||||
return {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("generateOmoConfig athena council", () => {
|
||||
it("creates athena council members from enabled providers", () => {
|
||||
// given
|
||||
const installConfig = createInstallConfig({ hasOpenAI: true, hasClaude: true, hasGemini: true })
|
||||
|
||||
// when
|
||||
const generated = generateOmoConfig(installConfig)
|
||||
const athena = generated.athena as { model?: string; members?: Array<{ name: string; model: string }> }
|
||||
const googleModel = `google/${transformModelForProvider("google", "gemini-3.1-pro")}`
|
||||
|
||||
// then
|
||||
expect(athena.model).toBe("openai/gpt-5.4")
|
||||
expect(athena.members).toHaveLength(3)
|
||||
expect(athena.members?.map((member) => member.model)).toEqual([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
googleModel,
|
||||
])
|
||||
})
|
||||
|
||||
it("does not create athena config when no providers are enabled", () => {
|
||||
// given
|
||||
const installConfig = createInstallConfig()
|
||||
|
||||
// when
|
||||
const generated = generateOmoConfig(installConfig)
|
||||
|
||||
// then
|
||||
expect(generated.athena).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateAthenaConfig", () => {
|
||||
it("uses anthropic as coordinator when openai is unavailable", () => {
|
||||
// given
|
||||
const installConfig = createInstallConfig({ hasClaude: true, hasCopilot: true })
|
||||
|
||||
// when
|
||||
const athena = generateAthenaConfig(installConfig)
|
||||
|
||||
// then
|
||||
expect(athena?.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(athena?.members?.map((member) => member.model)).toEqual([
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"github-copilot/gpt-5.4",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAthenaCouncilMembersFromTemplates", () => {
|
||||
it("adds numeric suffixes when template names collide case-insensitively", () => {
|
||||
// given
|
||||
const templates: AthenaMemberTemplate[] = [
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
name: "Strategist",
|
||||
isAvailable: () => true,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
name: "strategist",
|
||||
isAvailable: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
// when
|
||||
const members = createAthenaCouncilMembersFromTemplates(templates)
|
||||
|
||||
// then
|
||||
expect(members).toEqual([
|
||||
{ name: "Strategist", model: "openai/gpt-5.4" },
|
||||
{ name: "strategist 2", model: "anthropic/claude-sonnet-4-6" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { InstallConfig } from "../types"
|
||||
import { generateModelConfig } from "../model-fallback"
|
||||
import { generateAthenaConfig } from "./generate-athena-config"
|
||||
|
||||
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
|
||||
return generateModelConfig(installConfig)
|
||||
const generatedConfig = generateModelConfig(installConfig)
|
||||
const athenaConfig = generateAthenaConfig(installConfig)
|
||||
|
||||
if (!athenaConfig) {
|
||||
return generatedConfig
|
||||
}
|
||||
|
||||
return {
|
||||
...generatedConfig,
|
||||
athena: athenaConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
AthenaConfig,
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./schema/agent-names"
|
||||
export * from "./schema/agent-overrides"
|
||||
export * from "./schema/athena-config"
|
||||
export * from "./schema/babysitting"
|
||||
export * from "./schema/background-task"
|
||||
export * from "./schema/browser-automation"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod"
|
||||
export const BuiltinAgentNameSchema = z.enum([
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"athena",
|
||||
"prometheus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
@@ -12,6 +13,7 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
"momus",
|
||||
"atlas",
|
||||
"sisyphus-junior",
|
||||
"council-member",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
@@ -27,6 +29,7 @@ export const OverridableAgentNameSchema = z.enum([
|
||||
"plan",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"athena",
|
||||
"sisyphus-junior",
|
||||
"OpenCode-Builder",
|
||||
"prometheus",
|
||||
|
||||
@@ -62,6 +62,7 @@ export const AgentOverridesSchema = z.object({
|
||||
hephaestus: AgentOverrideConfigSchema.extend({
|
||||
allow_non_gpt_model: z.boolean().optional(),
|
||||
}).optional(),
|
||||
athena: AgentOverrideConfigSchema.optional(),
|
||||
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
|
||||
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
||||
prometheus: AgentOverrideConfigSchema.optional(),
|
||||
|
||||
82
src/config/schema/athena-config.test.ts
Normal file
82
src/config/schema/athena-config.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AthenaConfigSchema } from "./athena-config"
|
||||
import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
|
||||
|
||||
describe("AthenaConfigSchema", () => {
|
||||
test("accepts athena config with required members", () => {
|
||||
// given
|
||||
const config = {
|
||||
model: "openai/gpt-5.4",
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "Plato", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects athena config when members are missing", () => {
|
||||
// given
|
||||
const config = {
|
||||
model: "openai/gpt-5.4",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects case-insensitive duplicate member names", () => {
|
||||
// given
|
||||
const config = {
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "socrates", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects member model without provider prefix", () => {
|
||||
// given
|
||||
const config = {
|
||||
members: [{ name: "Socrates", model: "gpt-5.4" }],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("OhMyOpenCodeConfigSchema athena field", () => {
|
||||
test("accepts athena config at root", () => {
|
||||
// given
|
||||
const config = {
|
||||
athena: {
|
||||
model: "openai/gpt-5.4",
|
||||
members: [{ name: "Socrates", model: "openai/gpt-5.4" }],
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
39
src/config/schema/athena-config.ts
Normal file
39
src/config/schema/athena-config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const PROVIDER_MODEL_PATTERN = /^[^/\s]+\/[^/\s]+$/
|
||||
|
||||
const ProviderModelSchema = z
|
||||
.string()
|
||||
.regex(PROVIDER_MODEL_PATTERN, "Model must use provider/model format")
|
||||
|
||||
const AthenaCouncilMemberSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
model: ProviderModelSchema,
|
||||
})
|
||||
|
||||
export const AthenaConfigSchema = z
|
||||
.object({
|
||||
model: ProviderModelSchema.optional(),
|
||||
members: z.array(AthenaCouncilMemberSchema).min(1),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seen = new Map<string, number>()
|
||||
|
||||
for (const [index, member] of value.members.entries()) {
|
||||
const normalizedName = member.name.trim().toLowerCase()
|
||||
const existingIndex = seen.get(normalizedName)
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["members", index, "name"],
|
||||
message: `Duplicate member name '${member.name}' (case-insensitive). First seen at members[${existingIndex}]`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
seen.set(normalizedName, index)
|
||||
}
|
||||
})
|
||||
|
||||
export type AthenaConfig = z.infer<typeof AthenaConfigSchema>
|
||||
@@ -41,6 +41,7 @@ export const HookNameSchema = z.enum([
|
||||
"no-hephaestus-non-gpt",
|
||||
"start-work",
|
||||
"atlas",
|
||||
"agent-switch",
|
||||
"unstable-agent-babysitter",
|
||||
"task-resume-info",
|
||||
"stop-continuation-guard",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod"
|
||||
import { AnyMcpNameSchema } from "../../mcp/types"
|
||||
import { BuiltinSkillNameSchema } from "./agent-names"
|
||||
import { AgentOverridesSchema } from "./agent-overrides"
|
||||
import { AthenaConfigSchema } from "./athena-config"
|
||||
import { BabysittingConfigSchema } from "./babysitting"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
import { BrowserAutomationConfigSchema } from "./browser-automation"
|
||||
@@ -41,6 +42,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
|
||||
model_fallback: z.boolean().optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
athena: AthenaConfigSchema.optional(),
|
||||
categories: CategoriesConfigSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./state"
|
||||
export * from "./switch-agent-state"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resetPendingSessionAgentSwitchesForTesting } from "./switch-agent-state"
|
||||
|
||||
export const subagentSessions = new Set<string>()
|
||||
export const syncSubagentSessions = new Set<string>()
|
||||
|
||||
@@ -17,6 +19,7 @@ export function _resetForTesting(): void {
|
||||
subagentSessions.clear()
|
||||
syncSubagentSessions.clear()
|
||||
sessionAgentMap.clear()
|
||||
resetPendingSessionAgentSwitchesForTesting()
|
||||
}
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
import {
|
||||
clearPendingSessionAgentSwitch,
|
||||
consumePendingSessionAgentSwitch,
|
||||
getPendingSessionAgentSwitch,
|
||||
resetPendingSessionAgentSwitchesForTesting,
|
||||
setPendingSessionAgentSwitch,
|
||||
} from "./switch-agent-state"
|
||||
|
||||
describe("switch-agent-state", () => {
|
||||
beforeEach(() => {
|
||||
resetPendingSessionAgentSwitchesForTesting()
|
||||
})
|
||||
|
||||
test("#given pending switch #when consuming #then consumes once and clears", () => {
|
||||
// given
|
||||
setPendingSessionAgentSwitch("ses-1", "explore")
|
||||
|
||||
// when
|
||||
const first = consumePendingSessionAgentSwitch("ses-1")
|
||||
const second = consumePendingSessionAgentSwitch("ses-1")
|
||||
|
||||
// then
|
||||
expect(first?.agent).toBe("explore")
|
||||
expect(second).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given pending switch #when clearing #then state is removed", () => {
|
||||
// given
|
||||
setPendingSessionAgentSwitch("ses-1", "librarian")
|
||||
|
||||
// when
|
||||
clearPendingSessionAgentSwitch("ses-1")
|
||||
|
||||
// then
|
||||
expect(getPendingSessionAgentSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
37
src/features/claude-code-session-state/switch-agent-state.ts
Normal file
37
src/features/claude-code-session-state/switch-agent-state.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
type PendingAgentSwitch = {
|
||||
agent: string
|
||||
requestedAt: Date
|
||||
}
|
||||
|
||||
const pendingAgentSwitchBySession = new Map<string, PendingAgentSwitch>()
|
||||
|
||||
export function setPendingSessionAgentSwitch(sessionID: string, agent: string): PendingAgentSwitch {
|
||||
const pendingSwitch: PendingAgentSwitch = {
|
||||
agent,
|
||||
requestedAt: new Date(),
|
||||
}
|
||||
pendingAgentSwitchBySession.set(sessionID, pendingSwitch)
|
||||
return pendingSwitch
|
||||
}
|
||||
|
||||
export function getPendingSessionAgentSwitch(sessionID: string): PendingAgentSwitch | undefined {
|
||||
return pendingAgentSwitchBySession.get(sessionID)
|
||||
}
|
||||
|
||||
export function consumePendingSessionAgentSwitch(sessionID: string): PendingAgentSwitch | undefined {
|
||||
const pendingSwitch = pendingAgentSwitchBySession.get(sessionID)
|
||||
if (!pendingSwitch) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
pendingAgentSwitchBySession.delete(sessionID)
|
||||
return pendingSwitch
|
||||
}
|
||||
|
||||
export function clearPendingSessionAgentSwitch(sessionID: string): void {
|
||||
pendingAgentSwitchBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
export function resetPendingSessionAgentSwitchesForTesting(): void {
|
||||
pendingAgentSwitchBySession.clear()
|
||||
}
|
||||
@@ -53,3 +53,4 @@ export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_P
|
||||
export { createReadImageResizerHook } from "./read-image-resizer"
|
||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
||||
export { createSwitchAgentHook } from "./switch-agent"
|
||||
|
||||
32
src/hooks/switch-agent/hook.test.ts
Normal file
32
src/hooks/switch-agent/hook.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
import { createSwitchAgentHook } from "./hook"
|
||||
import {
|
||||
_resetForTesting,
|
||||
getSessionAgent,
|
||||
setPendingSessionAgentSwitch,
|
||||
} from "../../features/claude-code-session-state"
|
||||
|
||||
describe("switch-agent hook", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("#given pending switch #when chat.message hook runs #then output agent is switched and persisted", async () => {
|
||||
// given
|
||||
const hook = createSwitchAgentHook()
|
||||
setPendingSessionAgentSwitch("ses-1", "explore")
|
||||
const input = { sessionID: "ses-1", agent: "sisyphus" }
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [] as Array<{ type: string; text?: string }>,
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.agent).toBe("explore")
|
||||
expect(output.message["agent"]).toBe("explore")
|
||||
expect(getSessionAgent("ses-1")).toBe("explore")
|
||||
})
|
||||
})
|
||||
20
src/hooks/switch-agent/hook.ts
Normal file
20
src/hooks/switch-agent/hook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ChatMessageHandlerOutput, ChatMessageInput } from "../../plugin/chat-message"
|
||||
import {
|
||||
consumePendingSessionAgentSwitch,
|
||||
updateSessionAgent,
|
||||
} from "../../features/claude-code-session-state"
|
||||
|
||||
export function createSwitchAgentHook() {
|
||||
return {
|
||||
"chat.message": async (input: ChatMessageInput, output: ChatMessageHandlerOutput): Promise<void> => {
|
||||
const pendingSwitch = consumePendingSessionAgentSwitch(input.sessionID)
|
||||
if (!pendingSwitch) {
|
||||
return
|
||||
}
|
||||
|
||||
output.message["agent"] = pendingSwitch.agent
|
||||
input.agent = pendingSwitch.agent
|
||||
updateSessionAgent(input.sessionID, pendingSwitch.agent)
|
||||
},
|
||||
}
|
||||
}
|
||||
1
src/hooks/switch-agent/index.ts
Normal file
1
src/hooks/switch-agent/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createSwitchAgentHook } from "./hook"
|
||||
71
src/plugin-config-partial.ts
Normal file
71
src/plugin-config-partial.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type OhMyOpenCodeConfig, OhMyOpenCodeConfigSchema } from "./config"
|
||||
|
||||
const PARTIAL_STRING_ARRAY_KEYS = new Set([
|
||||
"disabled_mcps",
|
||||
"disabled_agents",
|
||||
"disabled_skills",
|
||||
"disabled_hooks",
|
||||
"disabled_commands",
|
||||
"disabled_tools",
|
||||
])
|
||||
|
||||
export interface PartialConfigParseResult {
|
||||
config: OhMyOpenCodeConfig
|
||||
invalidSections: string[]
|
||||
}
|
||||
|
||||
function formatIssue(path: PropertyKey[], message: string): string {
|
||||
const pathText = path.length > 0 ? path.join(".") : "root"
|
||||
return `${pathText}: ${message}`
|
||||
}
|
||||
|
||||
export function parseConfigPartiallyWithIssues(
|
||||
rawConfig: Record<string, unknown>
|
||||
): PartialConfigParseResult {
|
||||
const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
if (fullResult.success) {
|
||||
return {
|
||||
config: fullResult.data,
|
||||
invalidSections: [],
|
||||
}
|
||||
}
|
||||
|
||||
const partialConfig: Record<string, unknown> = {}
|
||||
const invalidSections: string[] = []
|
||||
|
||||
for (const key of Object.keys(rawConfig)) {
|
||||
if (PARTIAL_STRING_ARRAY_KEYS.has(key)) {
|
||||
const sectionValue = rawConfig[key]
|
||||
if (Array.isArray(sectionValue) && sectionValue.every((value) => typeof value === "string")) {
|
||||
partialConfig[key] = sectionValue
|
||||
} else {
|
||||
invalidSections.push(formatIssue([key], "Expected an array of strings"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] })
|
||||
if (sectionResult.success) {
|
||||
const parsed = sectionResult.data as Record<string, unknown>
|
||||
if (parsed[key] !== undefined) {
|
||||
partialConfig[key] = parsed[key]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const sectionIssues = sectionResult.error.issues.filter((issue) => issue.path[0] === key)
|
||||
const issuesToReport = sectionIssues.length > 0 ? sectionIssues : sectionResult.error.issues
|
||||
const sectionErrors = issuesToReport
|
||||
.map((issue) => formatIssue(issue.path, issue.message))
|
||||
.join(", ")
|
||||
|
||||
if (sectionErrors.length > 0) {
|
||||
invalidSections.push(`${key}: ${sectionErrors}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: partialConfig as OhMyOpenCodeConfig,
|
||||
invalidSections,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { mergeConfigs, loadConfigFromPath, parseConfigPartially } from "./plugin-config";
|
||||
import { parseConfigPartiallyWithIssues } from "./plugin-config-partial";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared";
|
||||
|
||||
describe("mergeConfigs", () => {
|
||||
describe("categories merging", () => {
|
||||
@@ -136,6 +141,50 @@ describe("mergeConfigs", () => {
|
||||
});
|
||||
|
||||
describe("parseConfigPartially", () => {
|
||||
describe("athena config", () => {
|
||||
//#given athena config with valid members and model format
|
||||
//#when parsing partially
|
||||
//#then athena section should be preserved
|
||||
it("should preserve valid athena config", () => {
|
||||
const rawConfig = {
|
||||
athena: {
|
||||
model: "openai/gpt-5.4",
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "Plato", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = parseConfigPartially(rawConfig)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.athena?.model).toBe("openai/gpt-5.4")
|
||||
expect(result?.athena?.members).toHaveLength(2)
|
||||
})
|
||||
|
||||
//#given athena config with duplicate member names by case
|
||||
//#when parsing partially
|
||||
//#then athena section should be dropped as invalid
|
||||
it("should drop invalid athena config with case-insensitive duplicate member names", () => {
|
||||
const rawConfig = {
|
||||
athena: {
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "socrates", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
},
|
||||
disabled_hooks: ["comment-checker"],
|
||||
}
|
||||
|
||||
const result = parseConfigPartially(rawConfig)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.athena).toBeUndefined()
|
||||
expect(result?.disabled_hooks).toEqual(["comment-checker"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("disabled_hooks compatibility", () => {
|
||||
//#given a config with a future hook name unknown to this version
|
||||
//#when validating against the full config schema
|
||||
@@ -271,3 +320,68 @@ describe("parseConfigPartially", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseConfigPartiallyWithIssues", () => {
|
||||
it("surfaces athena validation messages while keeping valid sections", () => {
|
||||
// given
|
||||
const rawConfig = {
|
||||
athena: {
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "socrates", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
},
|
||||
disabled_hooks: ["comment-checker"],
|
||||
};
|
||||
|
||||
// when
|
||||
const result = parseConfigPartiallyWithIssues(rawConfig);
|
||||
|
||||
// then
|
||||
expect(result.config.athena).toBeUndefined();
|
||||
expect(result.config.disabled_hooks).toEqual(["comment-checker"]);
|
||||
expect(result.invalidSections.length).toBeGreaterThan(0);
|
||||
expect(result.invalidSections.join(" ")).toContain("Duplicate member name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfigFromPath", () => {
|
||||
it("records actionable error details for invalid athena config", () => {
|
||||
// given
|
||||
clearConfigLoadErrors();
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "omo-config-load-"));
|
||||
const configPath = join(fixtureDir, "oh-my-opencode.jsonc");
|
||||
try {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
athena: {
|
||||
members: [
|
||||
{ name: "Socrates", model: "openai/gpt-5.4" },
|
||||
{ name: "socrates", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
},
|
||||
disabled_hooks: ["comment-checker"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// when
|
||||
const loaded = loadConfigFromPath(configPath, {});
|
||||
const loadErrors = getConfigLoadErrors();
|
||||
|
||||
// then
|
||||
expect(loaded?.athena).toBeUndefined();
|
||||
expect(loaded?.disabled_hooks).toEqual(["comment-checker"]);
|
||||
expect(loadErrors).toHaveLength(1);
|
||||
expect(loadErrors[0]?.error).toContain("Invalid sections");
|
||||
expect(loadErrors[0]?.error).toContain("Duplicate member name");
|
||||
} finally {
|
||||
rmSync(fixtureDir, { recursive: true, force: true });
|
||||
clearConfigLoadErrors();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import { parseConfigPartiallyWithIssues } from "./plugin-config-partial";
|
||||
import {
|
||||
log,
|
||||
deepMerge,
|
||||
@@ -11,57 +12,16 @@ import {
|
||||
migrateConfigFile,
|
||||
} from "./shared";
|
||||
|
||||
const PARTIAL_STRING_ARRAY_KEYS = new Set([
|
||||
"disabled_mcps",
|
||||
"disabled_agents",
|
||||
"disabled_skills",
|
||||
"disabled_hooks",
|
||||
"disabled_commands",
|
||||
"disabled_tools",
|
||||
]);
|
||||
|
||||
export function parseConfigPartially(
|
||||
rawConfig: Record<string, unknown>
|
||||
): OhMyOpenCodeConfig | null {
|
||||
const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
if (fullResult.success) {
|
||||
return fullResult.data;
|
||||
}
|
||||
|
||||
const partialConfig: Record<string, unknown> = {};
|
||||
const invalidSections: string[] = [];
|
||||
|
||||
for (const key of Object.keys(rawConfig)) {
|
||||
if (PARTIAL_STRING_ARRAY_KEYS.has(key)) {
|
||||
const sectionValue = rawConfig[key];
|
||||
if (Array.isArray(sectionValue) && sectionValue.every((value) => typeof value === "string")) {
|
||||
partialConfig[key] = sectionValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] });
|
||||
if (sectionResult.success) {
|
||||
const parsed = sectionResult.data as Record<string, unknown>;
|
||||
if (parsed[key] !== undefined) {
|
||||
partialConfig[key] = parsed[key];
|
||||
}
|
||||
} else {
|
||||
const sectionErrors = sectionResult.error.issues
|
||||
.filter((i) => i.path[0] === key)
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ");
|
||||
if (sectionErrors) {
|
||||
invalidSections.push(`${key}: ${sectionErrors}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const { config, invalidSections } = parseConfigPartiallyWithIssues(rawConfig);
|
||||
|
||||
if (invalidSections.length > 0) {
|
||||
log("Partial config loaded — invalid sections skipped:", invalidSections);
|
||||
}
|
||||
|
||||
return partialConfig as OhMyOpenCodeConfig;
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadConfigFromPath(
|
||||
@@ -86,15 +46,21 @@ export function loadConfigFromPath(
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ");
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
|
||||
const partialResult = parseConfigPartiallyWithIssues(rawConfig);
|
||||
const partialErrorDetails =
|
||||
partialResult.invalidSections.length > 0
|
||||
? partialResult.invalidSections.join("; ")
|
||||
: errorMsg;
|
||||
|
||||
addConfigLoadError({
|
||||
path: configPath,
|
||||
error: `Partial config loaded — invalid sections skipped: ${errorMsg}`,
|
||||
error: `Config validation failed. Loaded valid sections only. Invalid sections: ${partialErrorDetails}`,
|
||||
});
|
||||
|
||||
const partialResult = parseConfigPartially(rawConfig);
|
||||
if (partialResult) {
|
||||
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents });
|
||||
return partialResult;
|
||||
if (partialResult.config) {
|
||||
log(`Partial config loaded from ${configPath}`, { agents: partialResult.config.agents });
|
||||
return partialResult.config;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./agent-override-protection";
|
||||
import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder";
|
||||
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
|
||||
import { applyAthenaCouncilAgentWiring } from "./athena-council-agent-wiring"
|
||||
|
||||
type AgentConfigRecord = Record<string, Record<string, unknown> | undefined> & {
|
||||
build?: Record<string, unknown>;
|
||||
@@ -273,6 +274,11 @@ export async function applyAgentConfig(params: {
|
||||
}
|
||||
|
||||
if (params.config.agent) {
|
||||
applyAthenaCouncilAgentWiring(
|
||||
params.config.agent as Record<string, unknown>,
|
||||
params.pluginConfig.athena,
|
||||
)
|
||||
|
||||
params.config.agent = remapAgentKeysToDisplayNames(
|
||||
params.config.agent as Record<string, unknown>,
|
||||
);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { getAgentDisplayName } from "../shared/agent-display-names";
|
||||
const CORE_AGENT_ORDER: ReadonlyArray<{ displayName: string; order: number }> = [
|
||||
{ displayName: getAgentDisplayName("sisyphus"), order: 1 },
|
||||
{ displayName: getAgentDisplayName("hephaestus"), order: 2 },
|
||||
{ displayName: getAgentDisplayName("prometheus"), order: 3 },
|
||||
{ displayName: getAgentDisplayName("atlas"), order: 4 },
|
||||
{ displayName: getAgentDisplayName("athena"), order: 3 },
|
||||
{ displayName: getAgentDisplayName("prometheus"), order: 4 },
|
||||
{ displayName: getAgentDisplayName("atlas"), order: 5 },
|
||||
];
|
||||
|
||||
function injectOrderField(
|
||||
|
||||
36
src/plugin-handlers/athena-council-agent-wiring.test.ts
Normal file
36
src/plugin-handlers/athena-council-agent-wiring.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { applyAthenaCouncilAgentWiring } from "./athena-council-agent-wiring"
|
||||
|
||||
describe("applyAthenaCouncilAgentWiring", () => {
|
||||
test("#given athena config with roster #when wiring agents #then injects dynamic council member agents", () => {
|
||||
// given
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
athena: {
|
||||
model: "openai/gpt-5.4",
|
||||
prompt: "placeholder",
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
applyAthenaCouncilAgentWiring(agentConfig, {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
members: [
|
||||
{ name: "Architect", model: "openai/gpt-5.4" },
|
||||
{ name: "Skeptic", model: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
})
|
||||
|
||||
// then
|
||||
const athena = agentConfig.athena as Record<string, unknown>
|
||||
expect(athena.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(typeof athena.prompt).toBe("string")
|
||||
expect((athena.prompt as string).includes("council-member-architect")).toBe(true)
|
||||
|
||||
const architect = agentConfig["council-member-architect"] as Record<string, unknown>
|
||||
const skeptic = agentConfig["council-member-skeptic"] as Record<string, unknown>
|
||||
expect(architect).toBeDefined()
|
||||
expect(skeptic).toBeDefined()
|
||||
expect(architect.model).toBe("openai/gpt-5.4")
|
||||
expect(skeptic.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
})
|
||||
})
|
||||
42
src/plugin-handlers/athena-council-agent-wiring.ts
Normal file
42
src/plugin-handlers/athena-council-agent-wiring.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AthenaConfig } from "../config"
|
||||
import { createCouncilMemberAgent } from "../agents/council-member"
|
||||
import { buildAthenaPrompt } from "../agents/athena/prompt"
|
||||
import { toCouncilMemberAgentName } from "../agents/athena/council-members"
|
||||
|
||||
export function applyAthenaCouncilAgentWiring(
|
||||
agentConfig: Record<string, unknown>,
|
||||
athenaConfig?: AthenaConfig,
|
||||
): void {
|
||||
const members = athenaConfig?.members ?? []
|
||||
const athena = agentConfig.athena as Record<string, unknown> | undefined
|
||||
|
||||
if (athenaConfig?.model) {
|
||||
if (athena) {
|
||||
athena.model = athenaConfig.model
|
||||
}
|
||||
}
|
||||
|
||||
if (!athena) {
|
||||
return
|
||||
}
|
||||
|
||||
if (members.length > 0) {
|
||||
athena.prompt = buildAthenaPrompt({
|
||||
members: members.map((member) => ({ name: member.name, model: member.model })),
|
||||
})
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const dynamicAgentName = toCouncilMemberAgentName(member.name)
|
||||
if (agentConfig[dynamicAgentName]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const memberAgent = createCouncilMemberAgent(member.model)
|
||||
agentConfig[dynamicAgentName] = {
|
||||
...memberAgent,
|
||||
description: `Athena council member (${member.name}) using ${member.model}.`,
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ describe("applyToolConfig", () => {
|
||||
|
||||
it.each([
|
||||
"atlas",
|
||||
"athena",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
@@ -195,6 +196,7 @@ describe("applyToolConfig", () => {
|
||||
describe("#when applying tool config", () => {
|
||||
it.each([
|
||||
"atlas",
|
||||
"athena",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
@@ -216,6 +218,50 @@ describe("applyToolConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given council-member agent exists", () => {
|
||||
describe("#when applying tool config", () => {
|
||||
it("#then should enforce read-only and non-delegating permissions", () => {
|
||||
const params = createParams({ agents: ["council-member"] })
|
||||
|
||||
applyToolConfig(params)
|
||||
|
||||
const agent = params.agentResult["council-member"] as {
|
||||
permission: Record<string, unknown>
|
||||
}
|
||||
expect(agent.permission.write).toBe("deny")
|
||||
expect(agent.permission.edit).toBe("deny")
|
||||
expect(agent.permission.apply_patch).toBe("deny")
|
||||
expect(agent.permission.task).toBe("deny")
|
||||
expect(agent.permission["task_*"]).toBe("deny")
|
||||
expect(agent.permission.call_omo_agent).toBe("deny")
|
||||
expect(agent.permission.switch_agent).toBe("deny")
|
||||
expect(agent.permission.teammate).toBe("deny")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given dynamic council-member agent exists", () => {
|
||||
describe("#when applying tool config", () => {
|
||||
it("#then should enforce read-only and non-delegating permissions", () => {
|
||||
const params = createParams({ agents: ["council-member-architect"] })
|
||||
|
||||
applyToolConfig(params)
|
||||
|
||||
const agent = params.agentResult["council-member-architect"] as {
|
||||
permission: Record<string, unknown>
|
||||
}
|
||||
expect(agent.permission.write).toBe("deny")
|
||||
expect(agent.permission.edit).toBe("deny")
|
||||
expect(agent.permission.apply_patch).toBe("deny")
|
||||
expect(agent.permission.task).toBe("deny")
|
||||
expect(agent.permission["task_*"]).toBe("deny")
|
||||
expect(agent.permission.call_omo_agent).toBe("deny")
|
||||
expect(agent.permission.switch_agent).toBe("deny")
|
||||
expect(agent.permission.teammate).toBe("deny")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given disabled_tools includes 'question'", () => {
|
||||
let originalConfigContent: string | undefined
|
||||
let originalCliRunMode: string | undefined
|
||||
|
||||
@@ -3,6 +3,22 @@ import { getAgentDisplayName } from "../shared/agent-display-names";
|
||||
|
||||
type AgentWithPermission = { permission?: Record<string, unknown> };
|
||||
|
||||
const COUNCIL_MEMBER_AGENT_PREFIX = "council-member-"
|
||||
|
||||
function applyCouncilMemberRestrictions(agent: AgentWithPermission): void {
|
||||
agent.permission = {
|
||||
...agent.permission,
|
||||
write: "deny",
|
||||
edit: "deny",
|
||||
apply_patch: "deny",
|
||||
task: "deny",
|
||||
"task_*": "deny",
|
||||
teammate: "deny",
|
||||
call_omo_agent: "deny",
|
||||
switch_agent: "deny",
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigQuestionPermission(): string | null {
|
||||
const configContent = process.env.OPENCODE_CONFIG_CONTENT;
|
||||
if (!configContent) return null;
|
||||
@@ -114,6 +130,30 @@ export function applyToolConfig(params: {
|
||||
...denyTodoTools,
|
||||
};
|
||||
}
|
||||
const athena = agentByKey(params.agentResult, "athena")
|
||||
if (athena) {
|
||||
athena.permission = {
|
||||
...athena.permission,
|
||||
call_omo_agent: "deny",
|
||||
task: "allow",
|
||||
question: questionPermission,
|
||||
"task_*": "allow",
|
||||
teammate: "allow",
|
||||
...denyTodoTools,
|
||||
}
|
||||
}
|
||||
const councilMember = agentByKey(params.agentResult, "council-member");
|
||||
if (councilMember) {
|
||||
applyCouncilMemberRestrictions(councilMember)
|
||||
}
|
||||
|
||||
for (const [agentName, agentConfig] of Object.entries(params.agentResult)) {
|
||||
if (!agentName.toLowerCase().startsWith(COUNCIL_MEMBER_AGENT_PREFIX)) {
|
||||
continue
|
||||
}
|
||||
|
||||
applyCouncilMemberRestrictions(agentConfig as AgentWithPermission)
|
||||
}
|
||||
|
||||
params.config.permission = {
|
||||
webfetch: "allow",
|
||||
|
||||
@@ -157,6 +157,7 @@ export function createChatMessageHandler(args: {
|
||||
setSessionModel(input.sessionID, input.model)
|
||||
}
|
||||
await hooks.stopContinuationGuard?.["chat.message"]?.(input)
|
||||
await hooks.switchAgentHook?.["chat.message"]?.(input, output)
|
||||
await hooks.backgroundNotificationHook?.["chat.message"]?.(input, output)
|
||||
await hooks.runtimeFallback?.["chat.message"]?.(input, output)
|
||||
await hooks.keywordDetector?.["chat.message"]?.(input, output)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearSessionAgent,
|
||||
getMainSessionID,
|
||||
getSessionAgent,
|
||||
clearPendingSessionAgentSwitch,
|
||||
setMainSession,
|
||||
subagentSessions,
|
||||
syncSubagentSessions,
|
||||
@@ -323,6 +324,7 @@ export function createEventHandler(args: {
|
||||
if (sessionInfo?.id) {
|
||||
const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id);
|
||||
clearSessionAgent(sessionInfo.id);
|
||||
clearPendingSessionAgentSwitch(sessionInfo.id);
|
||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
||||
lastKnownModelBySession.delete(sessionInfo.id);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createCompactionContextInjector,
|
||||
createCompactionTodoPreserverHook,
|
||||
createAtlasHook,
|
||||
createSwitchAgentHook,
|
||||
} from "../../hooks"
|
||||
import { safeCreateHook } from "../../shared/safe-create-hook"
|
||||
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
||||
@@ -21,6 +22,7 @@ export type ContinuationHooks = {
|
||||
unstableAgentBabysitter: ReturnType<typeof createUnstableAgentBabysitter> | null
|
||||
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
|
||||
atlasHook: ReturnType<typeof createAtlasHook> | null
|
||||
switchAgentHook: ReturnType<typeof createSwitchAgentHook> | null
|
||||
}
|
||||
|
||||
type SessionRecovery = {
|
||||
@@ -116,6 +118,10 @@ export function createContinuationHooks(args: {
|
||||
}))
|
||||
: null
|
||||
|
||||
const switchAgentHook = isHookEnabled("agent-switch")
|
||||
? safeHook("agent-switch", () => createSwitchAgentHook())
|
||||
: null
|
||||
|
||||
return {
|
||||
stopContinuationGuard,
|
||||
compactionContextInjector,
|
||||
@@ -124,5 +130,6 @@ export function createContinuationHooks(args: {
|
||||
unstableAgentBabysitter,
|
||||
backgroundNotificationHook,
|
||||
atlasHook,
|
||||
switchAgentHook,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
createTaskList,
|
||||
createTaskUpdateTool,
|
||||
createHashlineEditTool,
|
||||
createSwitchAgentTool,
|
||||
} from "../tools"
|
||||
import { getMainSessionID } from "../features/claude-code-session-state"
|
||||
import { filterDisabledTools } from "../shared/disabled-tools"
|
||||
@@ -144,6 +145,7 @@ export function createToolRegistry(args: {
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
...hashlineToolsRecord,
|
||||
switch_agent: createSwitchAgentTool(ctx.client, pluginConfig.disabled_agents ?? []),
|
||||
}
|
||||
|
||||
for (const toolDefinition of Object.values(allTools)) {
|
||||
|
||||
@@ -180,10 +180,12 @@ describe("AGENT_DISPLAY_NAMES", () => {
|
||||
it("contains all expected agent mappings", () => {
|
||||
// given expected mappings
|
||||
const expectedMappings = {
|
||||
athena: "Athena",
|
||||
sisyphus: "Sisyphus (Ultraworker)",
|
||||
hephaestus: "Hephaestus (Deep Agent)",
|
||||
prometheus: "Prometheus (Plan Builder)",
|
||||
atlas: "Atlas (Plan Executor)",
|
||||
"council-member": "council-member",
|
||||
"sisyphus-junior": "Sisyphus-Junior",
|
||||
metis: "Metis (Plan Consultant)",
|
||||
momus: "Momus (Plan Critic)",
|
||||
@@ -197,4 +199,4 @@ describe("AGENT_DISPLAY_NAMES", () => {
|
||||
// then contains all expected mappings
|
||||
expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export const AGENT_DISPLAY_NAMES: Record<string, string> = {
|
||||
sisyphus: "Sisyphus (Ultraworker)",
|
||||
hephaestus: "Hephaestus (Deep Agent)",
|
||||
athena: "Athena",
|
||||
prometheus: "Prometheus (Plan Builder)",
|
||||
atlas: "Atlas (Plan Executor)",
|
||||
"sisyphus-junior": "Sisyphus-Junior",
|
||||
@@ -15,6 +16,7 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"council-member": "council-member",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,4 +53,4 @@ export function getAgentConfigKey(agentName: string): string {
|
||||
if (reversed !== undefined) return reversed
|
||||
if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower
|
||||
return lower
|
||||
}
|
||||
}
|
||||
|
||||
44
src/shared/agent-tool-restrictions.test.ts
Normal file
44
src/shared/agent-tool-restrictions.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getAgentToolRestrictions, hasAgentToolRestrictions } from "./agent-tool-restrictions"
|
||||
|
||||
describe("agent-tool-restrictions council-member", () => {
|
||||
test("returns council-member restrictions as read-only and non-delegating", () => {
|
||||
// given
|
||||
|
||||
// when
|
||||
const restrictions = getAgentToolRestrictions("council-member")
|
||||
|
||||
// then
|
||||
expect(restrictions.write).toBe(false)
|
||||
expect(restrictions.edit).toBe(false)
|
||||
expect(restrictions.apply_patch).toBe(false)
|
||||
expect(restrictions.task).toBe(false)
|
||||
expect(restrictions["task_*"]).toBe(false)
|
||||
expect(restrictions.call_omo_agent).toBe(false)
|
||||
expect(restrictions.switch_agent).toBe(false)
|
||||
expect(restrictions.teammate).toBe(false)
|
||||
})
|
||||
|
||||
test("matches council-member case-insensitively", () => {
|
||||
// given
|
||||
|
||||
// when
|
||||
const restrictions = getAgentToolRestrictions("Council-Member")
|
||||
|
||||
// then
|
||||
expect(restrictions.write).toBe(false)
|
||||
expect(hasAgentToolRestrictions("Council-Member")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches dynamic council-member names by prefix", () => {
|
||||
// given
|
||||
|
||||
// when
|
||||
const restrictions = getAgentToolRestrictions("council-member-architect")
|
||||
|
||||
// then
|
||||
expect(restrictions.write).toBe(false)
|
||||
expect(restrictions.task).toBe(false)
|
||||
expect(hasAgentToolRestrictions("council-member-architect")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -42,15 +42,34 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
"sisyphus-junior": {
|
||||
task: false,
|
||||
},
|
||||
|
||||
"council-member": {
|
||||
write: false,
|
||||
edit: false,
|
||||
apply_patch: false,
|
||||
task: false,
|
||||
"task_*": false,
|
||||
teammate: false,
|
||||
call_omo_agent: false,
|
||||
switch_agent: false,
|
||||
},
|
||||
}
|
||||
|
||||
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
|
||||
if (agentName.toLowerCase().startsWith("council-member-")) {
|
||||
return AGENT_RESTRICTIONS["council-member"]
|
||||
}
|
||||
|
||||
return AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
?? {}
|
||||
}
|
||||
|
||||
export function hasAgentToolRestrictions(agentName: string): boolean {
|
||||
if (agentName.toLowerCase().startsWith("council-member-")) {
|
||||
return true
|
||||
}
|
||||
|
||||
const restrictions = AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
return restrictions !== undefined && Object.keys(restrictions).length > 0
|
||||
|
||||
@@ -5,6 +5,9 @@ export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
Sisyphus: "sisyphus",
|
||||
sisyphus: "sisyphus",
|
||||
|
||||
Athena: "athena",
|
||||
athena: "athena",
|
||||
|
||||
// Prometheus variants → "prometheus"
|
||||
"OmO-Plan": "prometheus",
|
||||
"omo-plan": "prometheus",
|
||||
@@ -37,10 +40,12 @@ export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"council-member": "council-member",
|
||||
}
|
||||
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"sisyphus", // was "Sisyphus"
|
||||
"athena",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
@@ -49,6 +54,7 @@ export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"momus", // was "Momus (Plan Reviewer)"
|
||||
"prometheus", // was "Prometheus (Planner)"
|
||||
"atlas", // was "Atlas"
|
||||
"council-member",
|
||||
"build",
|
||||
])
|
||||
|
||||
|
||||
@@ -265,9 +265,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(hephaestus.requiresModel).toBeUndefined()
|
||||
})
|
||||
|
||||
test("all 11 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 11 agent names
|
||||
test("all builtin and internal agents have valid fallbackChain arrays", () => {
|
||||
const expectedAgents = [
|
||||
"athena",
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"oracle",
|
||||
@@ -278,6 +278,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
"council-member",
|
||||
"sisyphus-junior",
|
||||
]
|
||||
|
||||
@@ -285,7 +286,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all agents present with valid fallbackChain
|
||||
expect(definedAgents).toHaveLength(11)
|
||||
expect(definedAgents).toHaveLength(expectedAgents.length)
|
||||
for (const agent of expectedAgents) {
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
|
||||
expect(requirement).toBeDefined()
|
||||
|
||||
@@ -55,6 +55,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
],
|
||||
requiresProvider: ["openai", "github-copilot", "venice", "opencode"],
|
||||
},
|
||||
athena: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
},
|
||||
],
|
||||
},
|
||||
oracle: {
|
||||
fallbackChain: [
|
||||
{
|
||||
@@ -180,6 +193,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["opencode"], model: "big-pickle" },
|
||||
],
|
||||
},
|
||||
"council-member": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
model: "gpt-5.4",
|
||||
variant: "medium",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
|
||||
@@ -5,3 +5,5 @@ Use \`background_output\` to get results. Prompts MUST be in English.`
|
||||
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. Use full_session=true to fetch session messages with filters. System notifies on completion, so block=true rarely needed. - Timeout values are in milliseconds (ms), NOT seconds.`
|
||||
|
||||
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`
|
||||
|
||||
export const BACKGROUND_WAIT_DESCRIPTION = `Wait on grouped background tasks with all/any/quorum semantics. Returns structured grouped status for orchestration.`
|
||||
|
||||
110
src/tools/background-task/create-background-wait.test.ts
Normal file
110
src/tools/background-task/create-background-wait.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createBackgroundWait } from "./create-background-wait"
|
||||
import type { BackgroundOutputManager, BackgroundWaitResult } from "./types"
|
||||
import type { BackgroundTask } from "../../features/background-agent"
|
||||
|
||||
function parseResult(result: string): BackgroundWaitResult {
|
||||
return JSON.parse(result) as BackgroundWaitResult
|
||||
}
|
||||
|
||||
function createTask(overrides: Partial<BackgroundTask>): BackgroundTask {
|
||||
return {
|
||||
id: "bg-1",
|
||||
parentSessionID: "main-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task",
|
||||
prompt: "prompt",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("background_wait", () => {
|
||||
test("#given grouped task IDs #when block=false #then returns grouped structured status", async () => {
|
||||
// given
|
||||
const runningTask = createTask({ id: "bg-running", status: "running" })
|
||||
const completedTask = createTask({ id: "bg-done", status: "completed" })
|
||||
const manager: BackgroundOutputManager = {
|
||||
getTask: (taskID: string) => {
|
||||
if (taskID === runningTask.id) return runningTask
|
||||
if (taskID === completedTask.id) return completedTask
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
const tool = createBackgroundWait(manager)
|
||||
|
||||
// when
|
||||
const output = await tool.execute({
|
||||
task_ids: [runningTask.id, completedTask.id, "bg-missing"],
|
||||
block: false,
|
||||
}, {} as never)
|
||||
const parsed = parseResult(output)
|
||||
|
||||
// then
|
||||
expect(parsed.summary.total).toBe(3)
|
||||
expect(parsed.summary.by_status.running).toBe(1)
|
||||
expect(parsed.summary.by_status.completed).toBe(1)
|
||||
expect(parsed.summary.by_status.not_found).toBe(1)
|
||||
expect(parsed.grouped.completed).toContain(completedTask.id)
|
||||
expect(parsed.grouped.not_found).toContain("bg-missing")
|
||||
})
|
||||
|
||||
test("#given race mode #when block=true and one task reaches terminal #then returns quorum_reached", async () => {
|
||||
// given
|
||||
const task = createTask({ id: "bg-race", status: "running" })
|
||||
let readCount = 0
|
||||
const manager: BackgroundOutputManager = {
|
||||
getTask: (taskID: string) => {
|
||||
if (taskID !== task.id) return undefined
|
||||
readCount += 1
|
||||
if (readCount >= 2) {
|
||||
task.status = "completed"
|
||||
}
|
||||
return task
|
||||
},
|
||||
}
|
||||
const tool = createBackgroundWait(manager)
|
||||
|
||||
// when
|
||||
const output = await tool.execute({
|
||||
task_ids: [task.id],
|
||||
mode: "any",
|
||||
block: true,
|
||||
timeout: 500,
|
||||
poll_interval: 20,
|
||||
}, {} as never)
|
||||
const parsed = parseResult(output)
|
||||
|
||||
// then
|
||||
expect(parsed.done).toBe(true)
|
||||
expect(parsed.reason).toBe("quorum_reached")
|
||||
expect(parsed.quorum.target).toBe(1)
|
||||
expect(parsed.quorum.reached).toBe(1)
|
||||
})
|
||||
|
||||
test("#given unmet quorum #when block=true until timeout #then returns timeout status", async () => {
|
||||
// given
|
||||
const runningTask = createTask({ id: "bg-still-running", status: "running" })
|
||||
const manager: BackgroundOutputManager = {
|
||||
getTask: (taskID: string) => (taskID === runningTask.id ? runningTask : undefined),
|
||||
}
|
||||
const tool = createBackgroundWait(manager)
|
||||
|
||||
// when
|
||||
const output = await tool.execute({
|
||||
task_ids: [runningTask.id],
|
||||
quorum: 1,
|
||||
block: true,
|
||||
timeout: 120,
|
||||
poll_interval: 20,
|
||||
}, {} as never)
|
||||
const parsed = parseResult(output)
|
||||
|
||||
// then
|
||||
expect(parsed.done).toBe(false)
|
||||
expect(parsed.reason).toBe("timeout")
|
||||
expect(parsed.summary.by_status.running).toBe(1)
|
||||
expect(parsed.quorum.reached).toBe(0)
|
||||
})
|
||||
})
|
||||
158
src/tools/background-task/create-background-wait.ts
Normal file
158
src/tools/background-task/create-background-wait.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask } from "../../features/background-agent"
|
||||
import { BACKGROUND_WAIT_DESCRIPTION } from "./constants"
|
||||
import { delay } from "./delay"
|
||||
import type { BackgroundOutputManager, BackgroundWaitArgs, BackgroundWaitResult } from "./types"
|
||||
|
||||
type WaitTaskStatus = "pending" | "running" | "completed" | "error" | "cancelled" | "interrupt" | "not_found"
|
||||
|
||||
const TERMINAL_STATUSES: ReadonlySet<BackgroundTask["status"]> = new Set([
|
||||
"completed",
|
||||
"error",
|
||||
"cancelled",
|
||||
"interrupt",
|
||||
])
|
||||
|
||||
function isTerminalStatus(status: BackgroundTask["status"]): boolean {
|
||||
return TERMINAL_STATUSES.has(status)
|
||||
}
|
||||
|
||||
function toValidTaskIDs(taskIDs: string[]): string[] {
|
||||
const uniqueTaskIDs = new Set<string>()
|
||||
for (const taskID of taskIDs) {
|
||||
const normalized = taskID.trim()
|
||||
if (normalized) {
|
||||
uniqueTaskIDs.add(normalized)
|
||||
}
|
||||
}
|
||||
return [...uniqueTaskIDs]
|
||||
}
|
||||
|
||||
export function createBackgroundWait(manager: BackgroundOutputManager): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_WAIT_DESCRIPTION,
|
||||
args: {
|
||||
task_ids: tool.schema.array(tool.schema.string()).describe("Task IDs to inspect as a group"),
|
||||
mode: tool.schema.string().optional().describe("all (default) waits for all, any returns on first quorum/race completion"),
|
||||
quorum: tool.schema.number().optional().describe("Optional terminal-task quorum target"),
|
||||
block: tool.schema.boolean().optional().describe("Wait for quorum/race completion (default: false)"),
|
||||
timeout: tool.schema.number().optional().describe("Max wait time in ms when block=true (default: 60000, max: 600000)"),
|
||||
poll_interval: tool.schema.number().optional().describe("Polling interval in ms when block=true (default: 1000, min: 100)"),
|
||||
},
|
||||
async execute(args: BackgroundWaitArgs) {
|
||||
const taskIDs = toValidTaskIDs(args.task_ids)
|
||||
if (taskIDs.length === 0) {
|
||||
return "Error: task_ids must contain at least one task ID."
|
||||
}
|
||||
|
||||
const mode = args.mode === "any" ? "any" : args.mode === undefined || args.mode === "all" ? "all" : null
|
||||
if (!mode) {
|
||||
return `Error: invalid mode \"${args.mode}\". Use \"all\" or \"any\".`
|
||||
}
|
||||
|
||||
if (args.quorum !== undefined && (!Number.isInteger(args.quorum) || args.quorum < 1)) {
|
||||
return "Error: quorum must be a positive integer."
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
const pollIntervalMs = Math.max(args.poll_interval ?? 1000, 100)
|
||||
const block = args.block === true
|
||||
const quorumTarget = Math.min(args.quorum ?? (mode === "any" ? 1 : taskIDs.length), taskIDs.length)
|
||||
const startTime = Date.now()
|
||||
|
||||
const buildSnapshot = (): BackgroundWaitResult => {
|
||||
const byStatus: Record<string, number> = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
error: 0,
|
||||
cancelled: 0,
|
||||
interrupt: 0,
|
||||
not_found: 0,
|
||||
}
|
||||
|
||||
const tasks = taskIDs.map((taskID) => {
|
||||
const task = manager.getTask(taskID)
|
||||
if (!task) {
|
||||
byStatus.not_found += 1
|
||||
return {
|
||||
task_id: taskID,
|
||||
found: false,
|
||||
status: "not_found" as const,
|
||||
}
|
||||
}
|
||||
|
||||
byStatus[task.status] += 1
|
||||
return {
|
||||
task_id: task.id,
|
||||
found: true,
|
||||
status: task.status,
|
||||
agent: task.agent,
|
||||
description: task.description,
|
||||
session_id: task.sessionID,
|
||||
started_at: task.startedAt?.toISOString(),
|
||||
completed_at: task.completedAt?.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
const terminalCount = tasks.filter((task) => task.found && isTerminalStatus(task.status as BackgroundTask["status"]))
|
||||
.length
|
||||
const activeCount = tasks.filter((task) => task.status === "pending" || task.status === "running").length
|
||||
const quorumReached = terminalCount >= quorumTarget
|
||||
|
||||
return {
|
||||
mode,
|
||||
block,
|
||||
timeout_ms: timeoutMs,
|
||||
waited_ms: Date.now() - startTime,
|
||||
done: quorumReached,
|
||||
reason: block ? "waiting" : "non_blocking",
|
||||
quorum: {
|
||||
target: quorumTarget,
|
||||
reached: terminalCount,
|
||||
remaining: Math.max(quorumTarget - terminalCount, 0),
|
||||
progress: quorumTarget === 0 ? 1 : terminalCount / quorumTarget,
|
||||
},
|
||||
summary: {
|
||||
total: tasks.length,
|
||||
terminal: terminalCount,
|
||||
active: activeCount,
|
||||
by_status: byStatus,
|
||||
},
|
||||
grouped: {
|
||||
pending: tasks.filter((task) => task.status === "pending").map((task) => task.task_id),
|
||||
running: tasks.filter((task) => task.status === "running").map((task) => task.task_id),
|
||||
completed: tasks.filter((task) => task.status === "completed").map((task) => task.task_id),
|
||||
error: tasks.filter((task) => task.status === "error").map((task) => task.task_id),
|
||||
cancelled: tasks.filter((task) => task.status === "cancelled").map((task) => task.task_id),
|
||||
interrupt: tasks.filter((task) => task.status === "interrupt").map((task) => task.task_id),
|
||||
not_found: tasks.filter((task) => task.status === "not_found").map((task) => task.task_id),
|
||||
},
|
||||
tasks: tasks.map((task) => ({
|
||||
...task,
|
||||
status: task.status as WaitTaskStatus,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = buildSnapshot()
|
||||
if (!block) {
|
||||
return JSON.stringify(snapshot, null, 2)
|
||||
}
|
||||
|
||||
while (!snapshot.done && Date.now() - startTime < timeoutMs) {
|
||||
await delay(pollIntervalMs)
|
||||
snapshot = buildSnapshot()
|
||||
}
|
||||
|
||||
const finalSnapshot: BackgroundWaitResult = {
|
||||
...snapshot,
|
||||
waited_ms: Date.now() - startTime,
|
||||
done: snapshot.done,
|
||||
reason: snapshot.done ? "quorum_reached" : "timeout",
|
||||
}
|
||||
|
||||
return JSON.stringify(finalSnapshot, null, 2)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
createBackgroundTask,
|
||||
createBackgroundOutput,
|
||||
createBackgroundCancel,
|
||||
createBackgroundWait,
|
||||
} from "./tools"
|
||||
|
||||
export type * from "./types"
|
||||
|
||||
@@ -9,3 +9,4 @@ export type {
|
||||
export { createBackgroundTask } from "./create-background-task"
|
||||
export { createBackgroundOutput } from "./create-background-output"
|
||||
export { createBackgroundCancel } from "./create-background-cancel"
|
||||
export { createBackgroundWait } from "./create-background-wait"
|
||||
|
||||
@@ -21,6 +21,49 @@ export interface BackgroundCancelArgs {
|
||||
all?: boolean
|
||||
}
|
||||
|
||||
export interface BackgroundWaitArgs {
|
||||
task_ids: string[]
|
||||
mode?: "all" | "any"
|
||||
quorum?: number
|
||||
block?: boolean
|
||||
timeout?: number
|
||||
poll_interval?: number
|
||||
}
|
||||
|
||||
export type BackgroundWaitTaskSnapshot = {
|
||||
task_id: string
|
||||
found: boolean
|
||||
status: "pending" | "running" | "completed" | "error" | "cancelled" | "interrupt" | "not_found"
|
||||
agent?: string
|
||||
description?: string
|
||||
session_id?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export type BackgroundWaitResult = {
|
||||
mode: "all" | "any"
|
||||
block: boolean
|
||||
timeout_ms: number
|
||||
waited_ms: number
|
||||
done: boolean
|
||||
reason: "non_blocking" | "waiting" | "quorum_reached" | "timeout"
|
||||
quorum: {
|
||||
target: number
|
||||
reached: number
|
||||
remaining: number
|
||||
progress: number
|
||||
}
|
||||
summary: {
|
||||
total: number
|
||||
terminal: number
|
||||
active: number
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
grouped: Record<string, string[]>
|
||||
tasks: BackgroundWaitTaskSnapshot[]
|
||||
}
|
||||
|
||||
export type BackgroundOutputMessage = {
|
||||
info?: { role?: string; time?: string | { created?: number }; agent?: string }
|
||||
parts?: Array<{
|
||||
|
||||
@@ -21,10 +21,12 @@ export { sessionExists } from "./session-manager/storage"
|
||||
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { createSkillMcpTool } from "./skill-mcp"
|
||||
export { createSwitchAgentTool } from "./switch-agent"
|
||||
|
||||
import {
|
||||
createBackgroundOutput,
|
||||
createBackgroundCancel,
|
||||
createBackgroundWait,
|
||||
type BackgroundOutputManager,
|
||||
type BackgroundCancelClient,
|
||||
} from "./background-task"
|
||||
@@ -51,6 +53,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
|
||||
return {
|
||||
background_output: createBackgroundOutput(outputManager, client),
|
||||
background_cancel: createBackgroundCancel(manager, cancelClient),
|
||||
background_wait: createBackgroundWait(outputManager),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
src/tools/switch-agent/constants.ts
Normal file
1
src/tools/switch-agent/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SWITCH_AGENT_DESCRIPTION = "Queue an agent switch for the current or target session. Switch is applied on next chat.message through hook flow."
|
||||
2
src/tools/switch-agent/index.ts
Normal file
2
src/tools/switch-agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createSwitchAgentTool } from "./tools"
|
||||
export type { SwitchAgentArgs } from "./types"
|
||||
79
src/tools/switch-agent/tools.test.ts
Normal file
79
src/tools/switch-agent/tools.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
import { createSwitchAgentTool } from "./tools"
|
||||
import {
|
||||
_resetForTesting,
|
||||
getPendingSessionAgentSwitch,
|
||||
} from "../../features/claude-code-session-state"
|
||||
|
||||
describe("switch_agent tool", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("#given empty agent #when executing #then returns validation error", async () => {
|
||||
// given
|
||||
const client = {
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "sisyphus" }] }),
|
||||
},
|
||||
} as unknown as Parameters<typeof createSwitchAgentTool>[0]
|
||||
const tool = createSwitchAgentTool(client)
|
||||
|
||||
// when
|
||||
const output = await tool.execute({ agent: " " }, { sessionID: "ses-1" } as never)
|
||||
|
||||
// then
|
||||
expect(output).toContain("agent is required")
|
||||
})
|
||||
|
||||
test("#given unknown agent #when executing #then returns invalid switch error", async () => {
|
||||
// given
|
||||
const client = {
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "sisyphus" }, { name: "explore" }] }),
|
||||
},
|
||||
} as unknown as Parameters<typeof createSwitchAgentTool>[0]
|
||||
const tool = createSwitchAgentTool(client)
|
||||
|
||||
// when
|
||||
const output = await tool.execute({ agent: "ghost" }, { sessionID: "ses-1" } as never)
|
||||
|
||||
// then
|
||||
expect(output).toContain("unknown agent")
|
||||
expect(getPendingSessionAgentSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given known but disabled agent #when executing #then returns disabled error", async () => {
|
||||
// given
|
||||
const client = {
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "explore" }] }),
|
||||
},
|
||||
} as unknown as Parameters<typeof createSwitchAgentTool>[0]
|
||||
const tool = createSwitchAgentTool(client, ["explore"])
|
||||
|
||||
// when
|
||||
const output = await tool.execute({ agent: "explore" }, { sessionID: "ses-1" } as never)
|
||||
|
||||
// then
|
||||
expect(output).toContain("disabled")
|
||||
expect(getPendingSessionAgentSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given known enabled agent #when executing #then queues pending switch", async () => {
|
||||
// given
|
||||
const client = {
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "explore" }, { name: "Athena" }] }),
|
||||
},
|
||||
} as unknown as Parameters<typeof createSwitchAgentTool>[0]
|
||||
const tool = createSwitchAgentTool(client)
|
||||
|
||||
// when
|
||||
const output = await tool.execute({ agent: "explore" }, { sessionID: "ses-1" } as never)
|
||||
|
||||
// then
|
||||
expect(output).toContain("Agent switch queued")
|
||||
expect(getPendingSessionAgentSwitch("ses-1")?.agent).toBe("explore")
|
||||
})
|
||||
})
|
||||
57
src/tools/switch-agent/tools.ts
Normal file
57
src/tools/switch-agent/tools.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { setPendingSessionAgentSwitch } from "../../features/claude-code-session-state"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import type { SwitchAgentArgs } from "./types"
|
||||
import { SWITCH_AGENT_DESCRIPTION } from "./constants"
|
||||
|
||||
type SwitchableAgent = {
|
||||
name: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
}
|
||||
|
||||
export function createSwitchAgentTool(client: PluginInput["client"], disabledAgents: string[] = []): ToolDefinition {
|
||||
return tool({
|
||||
description: SWITCH_AGENT_DESCRIPTION,
|
||||
args: {
|
||||
agent: tool.schema.string().describe("Agent name to switch to"),
|
||||
session_id: tool.schema.string().optional().describe("Session ID to switch. Defaults to current session"),
|
||||
},
|
||||
async execute(args: SwitchAgentArgs, toolContext) {
|
||||
const targetSessionID = args.session_id ?? toolContext.sessionID
|
||||
const requestedAgent = args.agent?.trim()
|
||||
|
||||
if (!requestedAgent) {
|
||||
return "Error: agent is required."
|
||||
}
|
||||
|
||||
try {
|
||||
const agentsResponse = await client.app.agents()
|
||||
const agents = normalizeSDKResponse(agentsResponse, [] as SwitchableAgent[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
const matchedAgent = agents.find((agent) => agent.name.toLowerCase() === requestedAgent.toLowerCase())
|
||||
|
||||
if (!matchedAgent) {
|
||||
const availableAgents = agents.map((agent) => agent.name).sort()
|
||||
return `Error: unknown agent \"${requestedAgent}\". Available agents: ${availableAgents.join(", ")}`
|
||||
}
|
||||
|
||||
if (disabledAgents.some((disabledAgent) => disabledAgent.toLowerCase() === matchedAgent.name.toLowerCase())) {
|
||||
return `Error: agent \"${matchedAgent.name}\" is disabled via disabled_agents configuration.`
|
||||
}
|
||||
|
||||
const pendingSwitch = setPendingSessionAgentSwitch(targetSessionID, matchedAgent.name)
|
||||
|
||||
return [
|
||||
"Agent switch queued.",
|
||||
`Session ID: ${targetSessionID}`,
|
||||
`Next agent: ${pendingSwitch.agent}`,
|
||||
`Requested at: ${pendingSwitch.requestedAt.toISOString()}`,
|
||||
"The switch will be applied by hook flow on the next chat.message turn.",
|
||||
].join("\n")
|
||||
} catch (error) {
|
||||
return `Error: failed to queue agent switch: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
4
src/tools/switch-agent/types.ts
Normal file
4
src/tools/switch-agent/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type SwitchAgentArgs = {
|
||||
agent: string
|
||||
session_id?: string
|
||||
}
|
||||
Reference in New Issue
Block a user