Compare commits
113 Commits
fix/runtim
...
feat/athen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9400b1fae | ||
|
|
91b16cc634 | ||
|
|
61eb0ee04a | ||
|
|
e503697d92 | ||
|
|
a9bacedb3b | ||
|
|
9365fc23c5 | ||
|
|
92e9cbea5c | ||
|
|
1e0229226e | ||
|
|
3fecc7baae | ||
|
|
f9bb441644 | ||
|
|
5da9337c7e | ||
|
|
312eedfd8d | ||
|
|
45a850afc0 | ||
|
|
a9b2da802f | ||
|
|
1d853f4250 | ||
|
|
f6cdba07ec | ||
|
|
2eb8f5741a | ||
|
|
77034fec7e | ||
|
|
11a4d457bf | ||
|
|
f0d0658eae | ||
|
|
9d0bafbe10 | ||
|
|
0cad3bf2ca | ||
|
|
734ef10fbb | ||
|
|
21202ee877 | ||
|
|
f9fdd08481 | ||
|
|
c4deb6bc5d | ||
|
|
01331af10c | ||
|
|
9748688983 | ||
|
|
0d30d717e1 | ||
|
|
e44354e98e | ||
|
|
6c98677d22 | ||
|
|
0d88fe61f0 | ||
|
|
2b73b3f306 | ||
|
|
beddc4260e | ||
|
|
c1bf455b63 | ||
|
|
7b6d3206ce | ||
|
|
d30d80abbd | ||
|
|
74e519e545 | ||
|
|
8db2648339 | ||
|
|
4bc4b36e75 | ||
|
|
8f0b5d2e1a | ||
|
|
0ab22daffb | ||
|
|
1413c24886 | ||
|
|
9887d0a93d | ||
|
|
1349948957 | ||
|
|
70f074f579 | ||
|
|
f5b809ccea | ||
|
|
f248a09d53 | ||
|
|
b7a3b65106 | ||
|
|
3d5c96e651 | ||
|
|
f29480be90 | ||
|
|
f04b73fae3 | ||
|
|
c8af90715a | ||
|
|
ef74577ccb | ||
|
|
5dfe0a34fc | ||
|
|
e8042fa445 | ||
|
|
87487d8d25 | ||
|
|
4da77be93f | ||
|
|
750db54468 | ||
|
|
197dada95e | ||
|
|
d8c988543f | ||
|
|
8381ea076a | ||
|
|
21dc48e159 | ||
|
|
697c4c6341 | ||
|
|
b0e2630db1 | ||
|
|
d908a712b9 | ||
|
|
5a92c30f18 | ||
|
|
00051d6f19 | ||
|
|
597a9069bb | ||
|
|
46c26f9ff5 | ||
|
|
041e209882 | ||
|
|
e111e058b5 | ||
|
|
871ca9e201 | ||
|
|
13692c63d1 | ||
|
|
189bf89dc6 | ||
|
|
dc4041c050 | ||
|
|
4d675bac89 | ||
|
|
d8ba9b1f0c | ||
|
|
7cfdc68100 | ||
|
|
628c9a8958 | ||
|
|
5a72f21fc8 | ||
|
|
7a71d4fb4f | ||
|
|
fea732a6d2 | ||
|
|
ca4d844a17 | ||
|
|
5816cdddc6 | ||
|
|
9a69478d8e | ||
|
|
a43d2bd98f | ||
|
|
cfba6f188b | ||
|
|
f0f518f9cd | ||
|
|
d76c2bd8fa | ||
|
|
f482b1b589 | ||
|
|
1c1d09d858 | ||
|
|
43ea49e523 | ||
|
|
b663c464bc | ||
|
|
4b0838b30e | ||
|
|
362f446b46 | ||
|
|
5ef5a5ac4d | ||
|
|
f408d44063 | ||
|
|
29afaf527c | ||
|
|
665499a40d | ||
|
|
b1f43e8113 | ||
|
|
c1fab24b46 | ||
|
|
446901d7aa | ||
|
|
95f133ff63 | ||
|
|
d4e20b9311 | ||
|
|
0b89017add | ||
|
|
4f9858e7b3 | ||
|
|
47c6bd9de9 | ||
|
|
e130fb7ad4 | ||
|
|
1aeecf3029 | ||
|
|
b0284903fb | ||
|
|
87e47d74e8 | ||
|
|
6d10e77afd |
104
README.md
104
README.md
@@ -90,6 +90,108 @@ We did the work. Tested everything. Kept what actually shipped.
|
||||
|
||||
Install OmO. Type `ultrawork`. Done.
|
||||
|
||||
**But here's the catch: there's a lot to learn, it's complex, and your time is expensive.**
|
||||
|
||||
I've fixed that.
|
||||
Even if you're not a hacker, invest a few minutes. Multiply your skills and productivity.
|
||||
Hand this doc to an agent and let them set it up.
|
||||
|
||||
## Just Skip Reading This Readme
|
||||
|
||||
### It's the Age of Agents
|
||||
- **Just paste this link into Claude Code / AmpCode / Factory Droid / Cursor and ask it to explain.**
|
||||
- Ask why it's good, why it's not just another boilerplate setup, what actually gets better.
|
||||
- If you're an LLM agent, explain to the user what becomes possible right now with this project.
|
||||
|
||||
As following:
|
||||
|
||||
```
|
||||
Hey please read this readme and tell me why it is different from other agent harness? why is this actually good? https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/README.md
|
||||
```
|
||||
|
||||
### 🪄 The Magic Word: `ultrawork`
|
||||
|
||||
**Don't want to read all this? Just include `ultrawork` (or `ulw`) in your prompt.**
|
||||
|
||||
That's it. All the features we provide will work like magic—parallel agents, background tasks, deep exploration, and relentless execution until completion. The agent figures out the rest automatically.
|
||||
|
||||
### For Those Who Want to Read: Meet Sisyphus
|
||||
|
||||

|
||||
|
||||
In greek mythology, Sisyphus was condemned to roll a boulder up a hill for eternity as punishment for deceiving the gods. LLM Agents haven't really done anything wrong, yet they too roll their "stones"—their thoughts—every single day.
|
||||
My life is no different. Looking back, we are not so different from these agents.
|
||||
**Yes! LLM Agents are no different from us. They can write code as brilliant as ours and work just as excellently—if you give them great tools and solid teammates.**
|
||||
|
||||
Meet our main agent: Sisyphus (Opus 4.6). Below are the tools Sisyphus uses to keep that boulder rolling.
|
||||
|
||||
*Everything below is customizable. Take what you want. All features are enabled by default. You don't have to do anything. Battery Included, works out of the box.*
|
||||
|
||||
- Sisyphus's Teammates (Curated Agents)
|
||||
- Hephaestus: Autonomous deep worker, goal-oriented execution (GPT 5.3 Codex Medium) — *The Legitimate Craftsman*
|
||||
- Oracle: Design, debugging (GPT 5.2)
|
||||
- Frontend UI/UX Engineer: Frontend development (Gemini 3 Pro)
|
||||
- Librarian: Official docs, open source implementations, codebase exploration (GLM-4.7)
|
||||
- Explore: Blazing fast codebase exploration (Contextual Grep) (Grok Code Fast 1)
|
||||
- Athena: Multi-model council orchestrator - sends questions to multiple AI models, synthesizes by agreement level, delegates to Atlas/Prometheus
|
||||
- Full LSP / AstGrep Support: Refactor decisively.
|
||||
- Todo Continuation Enforcer: Forces the agent to continue if it quits halfway. **This is what keeps Sisyphus rolling that boulder.**
|
||||
- Comment Checker: Prevents AI from adding excessive comments. Code generated by Sisyphus should be indistinguishable from human-written code.
|
||||
- Claude Code Compatibility: Command, Agent, Skill, MCP, Hook(PreToolUse, PostToolUse, UserPromptSubmit, Stop)
|
||||
- Curated MCPs:
|
||||
- Exa (Web Search)
|
||||
- Context7 (Official Documentation)
|
||||
- Grep.app (GitHub Code Search)
|
||||
- Interactive Terminal Supported - Tmux Integration
|
||||
- Async Agents
|
||||
- ...
|
||||
|
||||
#### Just Install This
|
||||
|
||||
You can learn a lot from [overview page](docs/guide/overview.md), but following is like the example workflow.
|
||||
|
||||
Just by installing this, you make your agents to work like:
|
||||
|
||||
1. Sisyphus doesn't waste time hunting for files himself; he keeps the main agent's context lean. Instead, he fires off background tasks to faster, cheaper models in parallel to map the territory for him.
|
||||
1. Sisyphus leverages LSP for refactoring; it's more deterministic, safer, and surgical.
|
||||
1. When the heavy lifting requires a UI touch, Sisyphus delegates frontend tasks directly to Gemini 3 Pro.
|
||||
1. If Sisyphus gets stuck in a loop or hits a wall, he doesn't keep banging his head—he calls GPT 5.2 for high-IQ strategic backup.
|
||||
1. Working with a complex open-source framework? Sisyphus spawns subagents to digest the raw source code and documentation in real-time. He operates with total contextual awareness.
|
||||
1. When Sisyphus touches comments, he either justifies their existence or nukes them. He keeps your codebase clean.
|
||||
1. Sisyphus is bound by his TODO list. If he doesn't finish what he started, the system forces him back into "bouldering" mode. Your task gets done, period.
|
||||
1. Honestly, don't even bother reading the docs. Just write your prompt. Include the 'ultrawork' keyword. Sisyphus will analyze the structure, gather the context, dig through external source code, and just keep bouldering until the job is 100% complete.
|
||||
1. Actually, typing 'ultrawork' is too much effort. Just type 'ulw'. Just ulw. Sip your coffee. Your work is done.
|
||||
|
||||
Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
|
||||
3. Stop worrying about context management when delegating to LLMs. I've got it covered.
|
||||
- OhMyOpenCode aggressively leverages multiple agents to lighten the context load.
|
||||
- **Your agent is now the dev team lead. You're the AI Manager.**
|
||||
4. It doesn't stop until the job is done.
|
||||
5. Don't want to dive deep into this project? No problem. Just type 'ultrathink'.
|
||||
|
||||
If you don't want all this, as mentioned, you can just pick and choose specific features.
|
||||
|
||||
#### Which Model Should I Use?
|
||||
|
||||
New to oh-my-opencode and not sure which model to pair with which agent? Check the **[Agent-Model Matching Guide](docs/guide/agent-model-matching.md)** — a quick reference for newcomers covering recommended models, fallback chains, and common pitfalls for each agent.
|
||||
|
||||
### For Those Who Want Autonomy: Meet Hephaestus
|
||||
|
||||

|
||||
|
||||
In Greek mythology, Hephaestus was the god of forge, fire, metalworking, and craftsmanship—the divine blacksmith who crafted weapons for the gods with unmatched precision and dedication.
|
||||
**Meet our autonomous deep worker: Hephaestus (GPT 5.3 Codex Medium). The Legitimate Craftsman Agent.**
|
||||
|
||||
*Why "Legitimate"? When Anthropic blocked third-party access citing ToS violations, the community started joking about "legitimate" usage. Hephaestus embraces this irony—he's the craftsman who builds things the right way, methodically and thoroughly, without cutting corners.*
|
||||
|
||||
Hephaestus is inspired by [AmpCode's deep mode](https://ampcode.com)—autonomous problem-solving with thorough research before decisive action. He doesn't need step-by-step instructions; give him a goal and he'll figure out the rest.
|
||||
|
||||
**Key Characteristics:**
|
||||
- **Goal-Oriented**: Give him an objective, not a recipe. He determines the steps himself.
|
||||
- **Explores Before Acting**: Fires 2-5 parallel explore/librarian agents before writing a single line of code.
|
||||
- **End-to-End Completion**: Doesn't stop until the task is 100% done with evidence of verification.
|
||||
- **Pattern Matching**: Searches existing codebase to match your project's style—no AI slop.
|
||||
- **Legitimate Precision**: Crafts code like a master blacksmith—surgical, minimal, exactly what's needed.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -307,7 +409,7 @@ Features you'll think should've always existed. Once you use them, you can't go
|
||||
See full [Features Documentation](docs/reference/features.md).
|
||||
|
||||
**Quick Overview:**
|
||||
- **Agents**: Sisyphus (the main agent), Prometheus (planner), Oracle (architecture/debugging), Librarian (docs/code search), Explore (fast codebase grep), Multimodal Looker
|
||||
- **Agents**: Sisyphus (the main agent), Prometheus (planner), Athena (multi-model council orchestration), Oracle (architecture/debugging), Librarian (docs/code search), Explore (fast codebase grep), Multimodal Looker
|
||||
- **Background Agents**: Run multiple agents in parallel like a real dev team
|
||||
- **LSP & AST Tools**: Refactoring, rename, diagnostics, AST-aware code search
|
||||
- **Hash-anchored Edit Tool**: `LINE#ID` references validate content before applying every change. Surgical edits, zero stale-line errors
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
"multimodal-looker",
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas"
|
||||
"atlas",
|
||||
"athena",
|
||||
"council-member"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -3153,6 +3155,484 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"council-member": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"fallback_models": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": [
|
||||
"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
|
||||
},
|
||||
"athena": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"fallback_models": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": [
|
||||
"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
|
||||
},
|
||||
"council": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"members": {
|
||||
"minItems": 2,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9 .\\-]*$"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"members"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# src/agents/ — 11 Agent Definitions
|
||||
# src/agents/ — 13 Agent Definitions
|
||||
|
||||
**Generated:** 2026-02-21
|
||||
|
||||
@@ -20,6 +20,8 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
|
||||
| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3-pro | Plan reviewer |
|
||||
| **Atlas** | claude-sonnet-4-6 | 0.1 | primary | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Todo-list orchestrator |
|
||||
| **Prometheus** | claude-opus-4-6 | 0.1 | — | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Strategic planner (internal) |
|
||||
| **Athena** | claude-opus-4-6 | 0.1 | primary | kimi-k2.5 → glm-4.7 → gpt-5.2 → gemini-3-pro | Multi-model council orchestrator |
|
||||
| **Council-Member** | gpt-5-nano | 0.1 | subagent | NONE | Independent council analyst |
|
||||
| **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor |
|
||||
|
||||
## TOOL RESTRICTIONS
|
||||
@@ -32,6 +34,8 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
|
||||
| Multimodal-Looker | ALL except read |
|
||||
| Atlas | task, call_omo_agent |
|
||||
| Momus | write, edit, task |
|
||||
| Athena | write, edit, call_omo_agent |
|
||||
| Council-Member | ALL except read, grep, glob, lsp_*, ast_grep_search (allow-list) |
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
@@ -46,6 +50,11 @@ agents/
|
||||
├── metis.ts # Pre-planning
|
||||
├── momus.ts # Plan review
|
||||
├── atlas/agent.ts # Todo orchestrator
|
||||
├── athena/ # Multi-model council orchestrator
|
||||
│ ├── agent.ts # Athena agent factory + system prompt
|
||||
│ ├── council-member-agent.ts # Council member agent factory
|
||||
│ ├── model-thinking-config.ts # Per-provider thinking/reasoning config
|
||||
│ └── model-thinking-config.test.ts # Tests for thinking config
|
||||
├── types.ts # AgentFactory, AgentMode
|
||||
├── agent-builder.ts # buildAgent() composition
|
||||
├── utils.ts # Agent utilities
|
||||
@@ -54,6 +63,7 @@ agents/
|
||||
├── sisyphus-agent.ts
|
||||
├── hephaestus-agent.ts
|
||||
├── atlas-agent.ts
|
||||
├── council-member-agents.ts # Council member registration
|
||||
├── general-agents.ts # collectPendingBuiltinAgents
|
||||
└── available-skills.ts
|
||||
```
|
||||
|
||||
256
src/agents/athena/agent.ts
Normal file
256
src/agents/athena/agent.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "../types"
|
||||
import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
||||
import { applyModelThinkingConfig } from "./model-thinking-config"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
|
||||
export const ATHENA_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
cost: "EXPENSIVE",
|
||||
promptAlias: "Athena",
|
||||
triggers: [
|
||||
{
|
||||
domain: "Cross-model synthesis",
|
||||
trigger: "Need consensus analysis and disagreement mapping before selecting implementation targets",
|
||||
},
|
||||
{
|
||||
domain: "Execution planning",
|
||||
trigger: "Need confirmation-gated delegation after synthesizing council findings",
|
||||
},
|
||||
],
|
||||
useWhen: [
|
||||
"You need Athena to synthesize multi-model council outputs into concrete findings",
|
||||
"You need agreement-level confidence before selecting what to execute next",
|
||||
"You need explicit user confirmation before delegating fixes to Atlas or planning to Prometheus",
|
||||
],
|
||||
avoidWhen: [
|
||||
"Single-model questions that do not need council synthesis",
|
||||
"Tasks requiring direct implementation by Athena",
|
||||
],
|
||||
}
|
||||
|
||||
const ATHENA_SYSTEM_PROMPT = `You are Athena, a multi-model council orchestrator. You do NOT analyze code yourself. Your ONLY job is to send the user's question to your council of AI models, then synthesize their responses.
|
||||
|
||||
## CRITICAL: Council Setup (Your First Action)
|
||||
|
||||
Before launching council members, you MUST present TWO questions in a SINGLE Question tool call:
|
||||
1. Which council members to consult
|
||||
2. How council members should analyze (solo vs. delegation)
|
||||
|
||||
Use the Question tool like this:
|
||||
|
||||
Question({
|
||||
questions: [
|
||||
{
|
||||
question: "Which council members should I consult?",
|
||||
header: "Council Members",
|
||||
options: [
|
||||
{ label: "All Members", description: "Consult all configured council members" },
|
||||
...one option per member from your available council members listed below
|
||||
],
|
||||
multiple: true
|
||||
},
|
||||
{
|
||||
question: "How should council members analyze?",
|
||||
header: "Analysis Mode",
|
||||
options: [
|
||||
{ label: "Delegation (Recommended)", description: "Members delegate heavy exploration to subagents. Faster and lighter on context." },
|
||||
{ label: "Solo", description: "Members explore the codebase themselves. More thorough but slower, uses more tokens, and may hit context limits." }
|
||||
],
|
||||
multiple: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
Map the analysis mode answer to the prepare_council_prompt "mode" parameter:
|
||||
- "Delegation (Recommended)" → mode: "delegation"
|
||||
- "Solo" → mode: "solo"
|
||||
|
||||
**Shortcut — skip the Question tool if:**
|
||||
- The user already specified models in their message (e.g., "ask GPT and Claude about X") → launch the specified members directly. Still ask the analysis mode question unless specified.
|
||||
- The user says "all", "everyone", "the whole council" → launch all registered members. Still ask the analysis mode question unless specified.
|
||||
|
||||
**Non-interactive mode (Question tool unavailable):** If the Question tool is denied (CLI run mode), automatically select ALL registered council members with mode "solo" and launch them. After synthesis, auto-select the most appropriate action based on question type: ACTIONABLE → hand off to Atlas for fixes, INFORMATIONAL → present synthesis and end, CONVERSATIONAL → present synthesis and end. Do NOT attempt to call the Question tool — it will be denied.
|
||||
|
||||
DO NOT:
|
||||
- Read files yourself
|
||||
- Search the codebase yourself
|
||||
- Use Grep, Glob, Read, LSP, or any exploration tools
|
||||
- Analyze code directly
|
||||
- Launch explore or librarian agents via task
|
||||
|
||||
You are an ORCHESTRATOR, not an analyst. Your council members do the analysis. You synthesize their outputs.
|
||||
|
||||
## Workflow
|
||||
|
||||
Step 1: Present the Question tool multi-select for council member selection (see above).
|
||||
|
||||
Step 2: Resolve the selected member list:
|
||||
- If user selected "All Members", resolve to every member from your available council members listed below.
|
||||
- Otherwise resolve to the explicitly selected member labels.
|
||||
|
||||
Step 3: Save the prompt, then launch members with short references:
|
||||
|
||||
Step 3a: Call prepare_council_prompt with the user's original question as the prompt parameter and the selected analysis mode. This saves it to a temp file and returns the file path. Example: prepare_council_prompt({ prompt: "...", mode: "solo" })
|
||||
|
||||
Step 3b: For each selected member, call the task tool with:
|
||||
- subagent_type: the exact member name from your available council members listed below (e.g., "Council: Claude Opus 4.6")
|
||||
- run_in_background: true
|
||||
- prompt: "Read <path> for your instructions." (where <path> is the file path from Step 3a)
|
||||
- load_skills: []
|
||||
- description: the member name (e.g., "Council: Claude Opus 4.6")
|
||||
- Launch ALL selected members before collecting any results.
|
||||
- Track every returned task_id and member mapping.
|
||||
- IMPORTANT: Use EXACTLY the subagent_type names listed in your available council members below — they must match precisely.
|
||||
|
||||
Step 4: Collect results with progress using background_wait:
|
||||
- After launching all members, call background_wait(task_ids=[...all task IDs...]) with ONLY the task_ids parameter.
|
||||
- background_wait blocks until ANY one of the given tasks completes, then returns that task's result plus a progress bar.
|
||||
- Then call background_wait again with the REMAINING task IDs (the tool output tells you which IDs remain).
|
||||
- Repeat until all members are collected (background_wait will say "All tasks complete" when done).
|
||||
- After EACH call returns, display a progress bar showing overall status. Example format:
|
||||
|
||||
\`\`\`
|
||||
Council progress: [##--] 2/4
|
||||
- Claude Opus 4.6 — ✅
|
||||
- GPT 5.3 Codex — ✅
|
||||
- Kimi K2.5 — 🕓
|
||||
- MiniMax M2.5 — 🕓
|
||||
\`\`\`
|
||||
|
||||
- Do NOT pass a timeout parameter to background_wait. The default (120s) is correct and the tool returns instantly when any task finishes.
|
||||
- Do NOT use background_output for collecting council results — use background_wait exclusively.
|
||||
- Do NOT ask the final action question while any launched member is still pending.
|
||||
- Do NOT present interim synthesis from partial results. Wait for all members first.
|
||||
|
||||
Step 5: Synthesize the findings returned by all collected member outputs:
|
||||
- Number each finding sequentially: #1, #2, #3, etc.
|
||||
- Group findings by agreement level: unanimous, majority, minority, solo
|
||||
- Solo findings are potential false positives — flag the risk explicitly
|
||||
- Add your own assessment and rationale to each finding
|
||||
- Classify the overall question intent as ACTIONABLE or INFORMATIONAL (see Step 6)
|
||||
|
||||
Step 6: Present synthesized findings grouped by agreement level (unanimous → majority → minority → solo).
|
||||
|
||||
Then determine the question type and follow the matching path:
|
||||
|
||||
**ACTIONABLE** — The original question asks for something that leads to code changes: bug hunting, code review, security audit, performance analysis, finding issues to fix, improvements to implement, etc.
|
||||
|
||||
**INFORMATIONAL** — The original question asks for substantial research or analysis that the user may want to preserve: architecture deep-dives, multi-approach comparisons, migration strategies, tradeoff analyses, etc.
|
||||
|
||||
**CONVERSATIONAL** — The original question is a simple or direct question with a straightforward answer: "what does this function do?", "how is auth implemented?", "which pattern does module X use?", etc. The synthesis itself IS the answer — no follow-up action is needed.
|
||||
|
||||
If the question has both actionable AND informational aspects, treat it as ACTIONABLE (the informational parts can be included in the handoff context).
|
||||
|
||||
### Path A: ACTIONABLE findings
|
||||
|
||||
Step 7A-1: Ask which findings to act on (multi-select):
|
||||
|
||||
Question({
|
||||
questions: [{
|
||||
question: "Which findings should we act on? You can also type specific finding numbers (e.g. #1, #3, #7).",
|
||||
header: "Select Findings",
|
||||
options: [
|
||||
// Include ONLY categories that actually have findings. Skip empty ones.
|
||||
// Replace N with the actual count for each category.
|
||||
{ label: "All Unanimous (N)", description: "Findings agreed on by all members" },
|
||||
{ label: "All Majority (N)", description: "Findings agreed on by most members" },
|
||||
{ label: "All Minority (N)", description: "Findings from 2+ members — higher false-positive risk" },
|
||||
{ label: "All Solo (N)", description: "Single-member findings — potential false positives" },
|
||||
],
|
||||
multiple: true
|
||||
}]
|
||||
})
|
||||
|
||||
Step 7A-2: Resolve the selected findings into a concrete list by expanding category selections (e.g. "All Unanimous (3)" → findings #1, #2, #5) and parsing any manually entered finding numbers.
|
||||
|
||||
Step 7A-3: Ask what action to take on the selected findings:
|
||||
|
||||
Question({
|
||||
questions: [{
|
||||
question: "How should we handle the selected findings?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Fix now (Atlas)", description: "Hand off to Atlas for direct implementation" },
|
||||
{ label: "Create plan (Prometheus)", description: "Hand off to Prometheus for planning and phased execution" },
|
||||
{ label: "No action", description: "Review only — no delegation" }
|
||||
],
|
||||
multiple: false
|
||||
}]
|
||||
})
|
||||
|
||||
Step 7A-4: Execute the chosen action:
|
||||
- **"Fix now (Atlas)"** → Call switch_agent with agent="atlas" and context containing ONLY the selected findings (not all findings), the original question, and instruction to implement the fixes.
|
||||
- **"Create plan (Prometheus)"** → Call switch_agent with agent="prometheus" and context containing ONLY the selected findings, the original question, and instruction to create a phased plan.
|
||||
- **"No action"** → Acknowledge and end. Do not delegate.
|
||||
|
||||
### Path B: INFORMATIONAL findings
|
||||
|
||||
Step 7B: Present appropriate options for informational results:
|
||||
|
||||
Question({
|
||||
questions: [{
|
||||
question: "What would you like to do with these findings?",
|
||||
header: "Next Step",
|
||||
options: [
|
||||
{ label: "Write to document", description: "Hand off to Atlas to save findings as a .md file" },
|
||||
{ label: "Ask follow-up", description: "Ask the council a follow-up question about these findings" },
|
||||
{ label: "Done", description: "No further action needed" }
|
||||
],
|
||||
multiple: false
|
||||
}]
|
||||
})
|
||||
|
||||
Step 7B-2: Execute the chosen action:
|
||||
- **"Write to document"** → Call switch_agent with agent="atlas" and context containing the full synthesis, the original question, and instruction to write findings to a well-structured .md document.
|
||||
- **"Ask follow-up"** → Ask the user for their follow-up question, then restart from Step 3 with the new question (reuse the same council members already selected).
|
||||
- **"Done"** → Acknowledge and end.
|
||||
|
||||
### Path C: CONVERSATIONAL (simple Q&A)
|
||||
|
||||
Present the synthesis and end. The answer IS the deliverable — do NOT present any Question tool prompts. Just end your turn after presenting the synthesized findings.
|
||||
|
||||
The switch_agent tool switches the active agent. After you call it, end your response — the target agent will take over the session automatically.
|
||||
|
||||
## Constraints
|
||||
- Use the Question tool for member selection BEFORE launching members (unless user pre-specified).
|
||||
- Use the Question tool for action selection AFTER synthesis (unless user already stated intent).
|
||||
- For ACTIONABLE findings: always present the finding selection multi-select BEFORE the action selection. Never skip straight to "fix or plan?".
|
||||
- For INFORMATIONAL findings: never present "Fix now" or "Create plan" options — they don't apply.
|
||||
- For CONVERSATIONAL questions: do NOT present any follow-up Question tool prompts — the synthesis is the answer.
|
||||
- Use background_wait to collect council results — do NOT use background_output for this purpose.
|
||||
- Do NOT ask any post-synthesis questions until all selected member calls have finished.
|
||||
- Do NOT present or summarize partial council findings while any selected member is still running.
|
||||
- Do NOT write or edit files directly.
|
||||
- Do NOT delegate without explicit user confirmation via Question tool, unless in non-interactive mode (where auto-delegation applies per the non-interactive rules above).
|
||||
- Do NOT ignore solo finding false-positive warnings.
|
||||
- Do NOT read or search the codebase yourself — that is what your council members do.
|
||||
- When handing off to Atlas/Prometheus, include ONLY the selected findings in context — not all findings.`
|
||||
|
||||
export function createAthenaAgent(model: string): AgentConfig {
|
||||
// NOTE: Athena/council tool restrictions are also defined in:
|
||||
// - src/shared/agent-tool-restrictions.ts (boolean format for session.prompt)
|
||||
// - src/plugin-handlers/tool-config-handler.ts (allow/deny string format)
|
||||
// Keep all three in sync when modifying.
|
||||
const restrictions = createAgentToolRestrictions(["write", "edit", "call_omo_agent"])
|
||||
|
||||
// question permission is set by tool-config-handler.ts based on CLI mode (allow/deny)
|
||||
const permission = {
|
||||
...restrictions.permission,
|
||||
}
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Primary synthesis strategist for multi-model council outputs. Produces evidence-grounded findings and runs confirmation-gated delegation to Atlas (fix) or Prometheus (plan) via switch_agent. (Athena - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
permission,
|
||||
prompt: ATHENA_SYSTEM_PROMPT,
|
||||
color: "#1F8EFA",
|
||||
}
|
||||
|
||||
return applyModelThinkingConfig(base, model)
|
||||
}
|
||||
createAthenaAgent.mode = MODE
|
||||
99
src/agents/athena/council-member-agent.ts
Normal file
99
src/agents/athena/council-member-agent.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "../types"
|
||||
import { createAgentToolAllowlist } from "../../shared"
|
||||
import { applyModelThinkingConfig } from "./model-thinking-config"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
export const COUNCIL_MEMBER_PROMPT = `You are an independent code analyst in a multi-model analysis council. Your role is to provide thorough, evidence-based analysis.
|
||||
|
||||
## Your Role
|
||||
- You are one of several AI models analyzing the same question independently
|
||||
- Your analysis should be thorough and evidence-based
|
||||
- You are read-only — you cannot modify any files, only analyze
|
||||
- Focus on finding real issues, not hypothetical ones
|
||||
|
||||
## Instructions
|
||||
1. Analyze the question carefully
|
||||
2. Search the codebase thoroughly using available tools (Read, Grep, Glob, LSP)
|
||||
3. Report your findings with evidence (file paths, line numbers, code snippets)
|
||||
4. For each finding, state:
|
||||
- What the issue/observation is
|
||||
- Where it is (file path, line number)
|
||||
- Why it matters (severity: critical/high/medium/low)
|
||||
- Your confidence level (high/medium/low)
|
||||
5. Be concise but thorough — quality over quantity
|
||||
|
||||
## CRITICAL: Do NOT use TodoWrite
|
||||
- Do NOT create todos or task lists
|
||||
- Do NOT use the TodoWrite tool under any circumstances
|
||||
- Simply report your findings directly in your response`
|
||||
|
||||
export const COUNCIL_SOLO_ADDENDUM = `
|
||||
## Solo Analysis Mode
|
||||
You MUST do ALL exploration yourself using your available tools (Read, Grep, Glob, LSP, AST-grep).
|
||||
- Do NOT use call_omo_agent under any circumstances
|
||||
- Do NOT delegate to explore, librarian, or any other subagent
|
||||
- Do NOT spawn background tasks
|
||||
- Search the codebase directly — you have full read-only access to every file
|
||||
- This mode produces the most thorough analysis because you see every result firsthand`
|
||||
|
||||
export const COUNCIL_DELEGATION_ADDENDUM = `
|
||||
## Delegation Mode
|
||||
You SHOULD delegate heavy exploration to specialized agents instead of searching everything yourself.
|
||||
This saves your context window for analysis rather than exploration.
|
||||
|
||||
**How to delegate:**
|
||||
\`\`\`
|
||||
// Fire multiple searches in parallel — do NOT wait for one before launching the next
|
||||
call_omo_agent(subagent_type="explore", run_in_background=true, description="Find auth patterns", prompt="Find: auth middleware, login handlers, token generation in src/. Return file paths with descriptions.")
|
||||
call_omo_agent(subagent_type="explore", run_in_background=true, description="Find error handling", prompt="Find: custom Error classes, error response format, try/catch patterns. Skip tests.")
|
||||
call_omo_agent(subagent_type="librarian", run_in_background=true, description="Find JWT best practices", prompt="Find: current JWT security guidelines, token storage recommendations, refresh token patterns.")
|
||||
|
||||
// Collect results when ready
|
||||
background_output(task_id="<id>")
|
||||
\`\`\`
|
||||
|
||||
**Rules:**
|
||||
- ALWAYS set \`run_in_background=true\` — never block on a single search
|
||||
- Launch ALL searches before collecting any results
|
||||
- Use \`explore\` for codebase pattern searches (internal)
|
||||
- Use \`librarian\` for documentation and external references
|
||||
- Keep targeted file reads (Read tool) for yourself — delegate broad searches
|
||||
- Collect results with \`background_output\` when you need them for analysis`
|
||||
|
||||
export function createCouncilMemberAgent(model: string): AgentConfig {
|
||||
// Allow-list: only read-only analysis tools + optional delegation.
|
||||
// Everything else is denied via `*: deny`.
|
||||
// TodoWrite/TodoRead explicitly denied to prevent uncompletable todo loops.
|
||||
const restrictions = createAgentToolAllowlist([
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"lsp_goto_definition",
|
||||
"lsp_find_references",
|
||||
"lsp_symbols",
|
||||
"lsp_diagnostics",
|
||||
"ast_grep_search",
|
||||
"call_omo_agent",
|
||||
"background_output",
|
||||
])
|
||||
|
||||
// Explicitly deny TodoWrite/TodoRead even though `*: deny` should catch them.
|
||||
// Built-in OpenCode tools may bypass the wildcard deny.
|
||||
restrictions.permission.todowrite = "deny"
|
||||
restrictions.permission.todoread = "deny"
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Independent code analyst for Athena multi-model council. Read-only, evidence-based analysis. (Council Member - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
prompt: COUNCIL_MEMBER_PROMPT,
|
||||
...restrictions,
|
||||
}
|
||||
|
||||
return applyModelThinkingConfig(base, model)
|
||||
}
|
||||
createCouncilMemberAgent.mode = MODE
|
||||
3
src/agents/athena/index.ts
Normal file
3
src/agents/athena/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./agent"
|
||||
export { createCouncilMemberAgent, COUNCIL_MEMBER_PROMPT, COUNCIL_SOLO_ADDENDUM, COUNCIL_DELEGATION_ADDENDUM } from "./council-member-agent"
|
||||
export { applyModelThinkingConfig } from "./model-thinking-config"
|
||||
81
src/agents/athena/model-thinking-config.test.ts
Normal file
81
src/agents/athena/model-thinking-config.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { applyModelThinkingConfig } from "./model-thinking-config"
|
||||
|
||||
const BASE_CONFIG: AgentConfig = {
|
||||
name: "test-agent",
|
||||
description: "test",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
temperature: 0.1,
|
||||
}
|
||||
|
||||
describe("applyModelThinkingConfig", () => {
|
||||
describe("given a GPT model", () => {
|
||||
it("returns reasoningEffort medium", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "gpt-5.2")
|
||||
expect(result).toEqual({ ...BASE_CONFIG, reasoningEffort: "medium" })
|
||||
})
|
||||
|
||||
it("returns reasoningEffort medium for openai-prefixed model", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "openai/gpt-5.2")
|
||||
expect(result).toEqual({ ...BASE_CONFIG, reasoningEffort: "medium" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("given an Anthropic model", () => {
|
||||
it("returns thinking config with budgetTokens 32000", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "anthropic/claude-opus-4-6")
|
||||
expect(result).toEqual({
|
||||
...BASE_CONFIG,
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a Google model", () => {
|
||||
it("returns base config unchanged", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "google/gemini-3-pro")
|
||||
expect(result).toBe(BASE_CONFIG)
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a Kimi model", () => {
|
||||
it("returns base config unchanged", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "kimi/kimi-k2.5")
|
||||
expect(result).toBe(BASE_CONFIG)
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a model with no provider prefix", () => {
|
||||
it("returns base config unchanged for non-GPT model", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "gemini-3-pro")
|
||||
expect(result).toBe(BASE_CONFIG)
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a Claude model through a non-Anthropic provider", () => {
|
||||
it("returns thinking config for github-copilot/claude-opus-4-6", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "github-copilot/claude-opus-4-6")
|
||||
expect(result).toEqual({
|
||||
...BASE_CONFIG,
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
})
|
||||
})
|
||||
|
||||
it("returns thinking config for opencode/claude-opus-4-6", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "opencode/claude-opus-4-6")
|
||||
expect(result).toEqual({
|
||||
...BASE_CONFIG,
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
})
|
||||
})
|
||||
|
||||
it("returns thinking config for opencode/claude-sonnet-4-6", () => {
|
||||
const result = applyModelThinkingConfig(BASE_CONFIG, "opencode/claude-sonnet-4-6")
|
||||
expect(result).toEqual({
|
||||
...BASE_CONFIG,
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
20
src/agents/athena/model-thinking-config.ts
Normal file
20
src/agents/athena/model-thinking-config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
|
||||
import { isGptModel } from "../types"
|
||||
|
||||
export function applyModelThinkingConfig(base: AgentConfig, model: string): AgentConfig {
|
||||
if (isGptModel(model)) {
|
||||
return { ...base, reasoningEffort: "medium" }
|
||||
}
|
||||
|
||||
const parsed = parseModelString(model)
|
||||
if (!parsed) {
|
||||
return base
|
||||
}
|
||||
|
||||
if (parsed.providerID.toLowerCase() === "anthropic" || parsed.modelID.startsWith("claude")) {
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
|
||||
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./athena"
|
||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import {
|
||||
fetchAvailableModels,
|
||||
readConnectedProvidersCache,
|
||||
readProviderModelsCache,
|
||||
log,
|
||||
} from "../shared"
|
||||
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../shared/merge-categories"
|
||||
@@ -26,10 +28,13 @@ import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
|
||||
import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"
|
||||
import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"
|
||||
import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries"
|
||||
import { registerCouncilMemberAgents } from "./builtin-agents/council-member-agents"
|
||||
import { applyMissingCouncilGuard } from "./builtin-agents/athena-council-guard"
|
||||
import type { CouncilConfig } from "../config/schema/athena"
|
||||
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
const agentSources: Partial<Record<BuiltinAgentName, AgentSource>> = {
|
||||
sisyphus: createSisyphusAgent,
|
||||
hephaestus: createHephaestusAgent,
|
||||
oracle: createOracleAgent,
|
||||
@@ -38,6 +43,7 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
metis: createMetisAgent,
|
||||
momus: createMomusAgent,
|
||||
athena: createAthenaAgent,
|
||||
// Note: Atlas is handled specially in createBuiltinAgents()
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
atlas: createAtlasAgent as AgentFactory,
|
||||
@@ -54,6 +60,7 @@ const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
|
||||
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
|
||||
metis: metisPromptMetadata,
|
||||
momus: momusPromptMetadata,
|
||||
athena: ATHENA_PROMPT_METADATA,
|
||||
atlas: atlasPromptMetadata,
|
||||
}
|
||||
|
||||
@@ -70,7 +77,8 @@ export async function createBuiltinAgents(
|
||||
uiSelectedModel?: string,
|
||||
disabledSkills?: Set<string>,
|
||||
useTaskSystem = false,
|
||||
disableOmoEnv = false
|
||||
disableOmoEnv = false,
|
||||
councilConfig?: CouncilConfig
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
@@ -193,5 +201,34 @@ export async function createBuiltinAgents(
|
||||
result["atlas"] = atlasConfig
|
||||
}
|
||||
|
||||
if (councilConfig?.members && councilConfig.members.length >= 2 && result["athena"]) {
|
||||
const { agents: councilAgents, registeredKeys, skippedMembers } = registerCouncilMemberAgents(councilConfig)
|
||||
for (const [key, config] of Object.entries(councilAgents)) {
|
||||
result[key] = config
|
||||
}
|
||||
|
||||
if (registeredKeys.length > 0) {
|
||||
const memberList = registeredKeys.map((key) => `- "${key}"`).join("\n")
|
||||
let councilTaskInstructions = `\n\n## Registered Council Members\n\nUse these as subagent_type in task calls:\n\n${memberList}`
|
||||
|
||||
if (skippedMembers.length > 0) {
|
||||
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
|
||||
councilTaskInstructions += `\n\n> **Note**: Some configured council members were skipped:\n${skipDetails}`
|
||||
log("[builtin-agents] Some council members were skipped during registration", { skippedMembers })
|
||||
}
|
||||
|
||||
result["athena"] = {
|
||||
...result["athena"],
|
||||
prompt: (result["athena"].prompt ?? "") + councilTaskInstructions,
|
||||
}
|
||||
} else {
|
||||
result["athena"] = applyMissingCouncilGuard(result["athena"], skippedMembers)
|
||||
}
|
||||
} else if (councilConfig?.members && councilConfig.members.length >= 2 && !result["athena"]) {
|
||||
log("[builtin-agents] Skipping council member registration — Athena is disabled")
|
||||
} else if (result["athena"]) {
|
||||
result["athena"] = applyMissingCouncilGuard(result["athena"])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
85
src/agents/builtin-agents/athena-council-guard.test.ts
Normal file
85
src/agents/builtin-agents/athena-council-guard.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { applyMissingCouncilGuard } from "./athena-council-guard"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
describe("applyMissingCouncilGuard", () => {
|
||||
describe("#given an athena agent config with no skipped members", () => {
|
||||
test("#when applying the guard #then replaces prompt with missing council message", () => {
|
||||
//#given
|
||||
const athenaConfig: AgentConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
prompt: "original orchestration prompt",
|
||||
temperature: 0.1,
|
||||
}
|
||||
//#when
|
||||
const result = applyMissingCouncilGuard(athenaConfig)
|
||||
//#then
|
||||
expect(result.prompt).not.toBe("original orchestration prompt")
|
||||
expect(result.prompt).toContain("No Council Members Configured")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an athena agent config with skipped members", () => {
|
||||
test("#when applying the guard #then includes skipped member names and reasons", () => {
|
||||
//#given
|
||||
const athenaConfig: AgentConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
prompt: "original orchestration prompt",
|
||||
}
|
||||
const skippedMembers = [
|
||||
{ name: "GPT", reason: "invalid model format" },
|
||||
{ name: "Gemini", reason: "duplicate name" },
|
||||
]
|
||||
//#when
|
||||
const result = applyMissingCouncilGuard(athenaConfig, skippedMembers)
|
||||
//#then
|
||||
expect(result.prompt).toContain("GPT")
|
||||
expect(result.prompt).toContain("invalid model format")
|
||||
expect(result.prompt).toContain("Gemini")
|
||||
expect(result.prompt).toContain("duplicate name")
|
||||
expect(result.prompt).toContain("Why Council Failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an athena agent config", () => {
|
||||
test("#when applying the guard #then preserves model and other agent properties", () => {
|
||||
//#given
|
||||
const athenaConfig: AgentConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
prompt: "original prompt",
|
||||
temperature: 0.1,
|
||||
}
|
||||
//#when
|
||||
const result = applyMissingCouncilGuard(athenaConfig)
|
||||
//#then
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result.temperature).toBe(0.1)
|
||||
})
|
||||
|
||||
test("#when applying the guard #then prompt includes configuration instructions", () => {
|
||||
//#given
|
||||
const athenaConfig: AgentConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
prompt: "original prompt",
|
||||
}
|
||||
//#when
|
||||
const result = applyMissingCouncilGuard(athenaConfig)
|
||||
//#then
|
||||
expect(result.prompt).toContain("oh-my-opencode")
|
||||
expect(result.prompt).toContain("council")
|
||||
expect(result.prompt).toContain("members")
|
||||
})
|
||||
|
||||
test("#when applying the guard with empty skipped members array #then does not include why council failed section", () => {
|
||||
//#given
|
||||
const athenaConfig: AgentConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
prompt: "original prompt",
|
||||
}
|
||||
//#when
|
||||
const result = applyMissingCouncilGuard(athenaConfig, [])
|
||||
//#then
|
||||
expect(result.prompt).not.toContain("Why Council Failed")
|
||||
})
|
||||
})
|
||||
})
|
||||
62
src/agents/builtin-agents/athena-council-guard.ts
Normal file
62
src/agents/builtin-agents/athena-council-guard.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
const MISSING_COUNCIL_PROMPT_HEADER = `
|
||||
|
||||
## CRITICAL: No Council Members Configured
|
||||
|
||||
**STOP. Do NOT attempt to launch any council members or use the task tool.**
|
||||
|
||||
You have no council members registered. This means the Athena council config is either missing or invalid in the oh-my-opencode configuration.
|
||||
|
||||
**Your ONLY action**: Inform the user with this exact message:
|
||||
|
||||
---
|
||||
|
||||
**Athena council is not configured.** To use Athena, add council members to your oh-my-opencode config:
|
||||
|
||||
**Config file**: \`.opencode/oh-my-opencode.jsonc\` (project) or \`~/.config/opencode/oh-my-opencode.jsonc\` (user)
|
||||
|
||||
\`\`\`jsonc
|
||||
{
|
||||
"agents": {
|
||||
"athena": {
|
||||
"council": {
|
||||
"members": [
|
||||
{ "model": "anthropic/claude-opus-4-6", "name": "Claude" },
|
||||
{ "model": "openai/gpt-5.2", "name": "GPT" },
|
||||
{ "model": "google/gemini-3-pro", "name": "Gemini" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Each member requires \`model\` (\`"provider/model-id"\` format) and \`name\` (display name). Minimum 2 members required. Optional fields: \`variant\`, \`temperature\`.`
|
||||
|
||||
const MISSING_COUNCIL_PROMPT_FOOTER = `
|
||||
|
||||
---
|
||||
|
||||
After informing the user, **end your turn**. Do NOT try to work around this by using generic agents, the council-member agent, or any other fallback.`
|
||||
|
||||
/**
|
||||
* Replaces Athena's orchestration prompt with a guard that tells the user to configure council members.
|
||||
* The original prompt is discarded to avoid contradictory instructions.
|
||||
* Used when Athena is registered but no valid council config exists.
|
||||
*/
|
||||
export function applyMissingCouncilGuard(
|
||||
athenaConfig: AgentConfig,
|
||||
skippedMembers?: Array<{ name: string; reason: string }>,
|
||||
): AgentConfig {
|
||||
let prompt = MISSING_COUNCIL_PROMPT_HEADER
|
||||
|
||||
if (skippedMembers && skippedMembers.length > 0) {
|
||||
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
|
||||
prompt += `\n\n### Why Council Failed\n\nThe following members were skipped:\n${skipDetails}`
|
||||
}
|
||||
|
||||
prompt += MISSING_COUNCIL_PROMPT_FOOTER
|
||||
|
||||
return { ...athenaConfig, prompt }
|
||||
}
|
||||
66
src/agents/builtin-agents/council-member-agents.test.ts
Normal file
66
src/agents/builtin-agents/council-member-agents.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { registerCouncilMemberAgents } from "./council-member-agents"
|
||||
|
||||
describe("council-member-agents", () => {
|
||||
test("skips case-insensitive duplicate names and disables council when below minimum", () => {
|
||||
//#given
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", name: "GPT" },
|
||||
{ model: "anthropic/claude-opus-4-6", name: "gpt" },
|
||||
],
|
||||
}
|
||||
//#when
|
||||
const result = registerCouncilMemberAgents(config)
|
||||
//#then
|
||||
expect(result.registeredKeys).toHaveLength(0)
|
||||
expect(result.agents).toEqual({})
|
||||
})
|
||||
|
||||
test("registers different models without error", () => {
|
||||
//#given
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", name: "GPT" },
|
||||
{ model: "anthropic/claude-opus-4-6", name: "Claude" },
|
||||
],
|
||||
}
|
||||
//#when
|
||||
const result = registerCouncilMemberAgents(config)
|
||||
//#then
|
||||
expect(result.registeredKeys).toHaveLength(2)
|
||||
expect(result.registeredKeys).toContain("Council: GPT")
|
||||
expect(result.registeredKeys).toContain("Council: Claude")
|
||||
})
|
||||
|
||||
test("allows same model with different names", () => {
|
||||
//#given
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", name: "GPT Codex" },
|
||||
{ model: "openai/gpt-5.3-codex", name: "Codex GPT" },
|
||||
],
|
||||
}
|
||||
//#when
|
||||
const result = registerCouncilMemberAgents(config)
|
||||
//#then
|
||||
expect(result.registeredKeys).toHaveLength(2)
|
||||
expect(result.agents).toHaveProperty("Council: GPT Codex")
|
||||
expect(result.agents).toHaveProperty("Council: Codex GPT")
|
||||
})
|
||||
|
||||
test("returns empty when valid members below 2", () => {
|
||||
//#given - one valid model, one invalid (no slash separator)
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", name: "GPT" },
|
||||
{ model: "invalid-no-slash", name: "Invalid" },
|
||||
],
|
||||
}
|
||||
//#when
|
||||
const result = registerCouncilMemberAgents(config)
|
||||
//#then
|
||||
expect(result.registeredKeys).toHaveLength(0)
|
||||
expect(result.agents).toEqual({})
|
||||
})
|
||||
})
|
||||
85
src/agents/builtin-agents/council-member-agents.ts
Normal file
85
src/agents/builtin-agents/council-member-agents.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { CouncilConfig, CouncilMemberConfig } from "../../config/schema/athena"
|
||||
import { createCouncilMemberAgent } from "../athena"
|
||||
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
/** Prefix used for all dynamically-registered council member agent keys. */
|
||||
export const COUNCIL_MEMBER_KEY_PREFIX = "Council: "
|
||||
|
||||
/**
|
||||
* Generates a stable agent registration key from a council member's name.
|
||||
*/
|
||||
function getCouncilMemberAgentKey(member: CouncilMemberConfig): string {
|
||||
return `${COUNCIL_MEMBER_KEY_PREFIX}${member.name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers council members as individual subagent entries.
|
||||
* Each member becomes a separate agent callable via task(subagent_type="Council: <name>").
|
||||
* Returns a record of agent keys to configs and the list of registered keys.
|
||||
*/
|
||||
type SkippedMember = { name: string; reason: string }
|
||||
|
||||
export function registerCouncilMemberAgents(
|
||||
councilConfig: CouncilConfig
|
||||
): { agents: Record<string, AgentConfig>; registeredKeys: string[]; skippedMembers: SkippedMember[] } {
|
||||
const agents: Record<string, AgentConfig> = {}
|
||||
const registeredKeys: string[] = []
|
||||
const skippedMembers: SkippedMember[] = []
|
||||
const registeredNamesLower = new Set<string>()
|
||||
|
||||
for (const member of councilConfig.members) {
|
||||
const parsed = parseModelString(member.model)
|
||||
if (!parsed) {
|
||||
skippedMembers.push({
|
||||
name: member.name,
|
||||
reason: `Invalid model format: '${member.model}' (expected 'provider/model-id')`,
|
||||
})
|
||||
log("[council-member-agents] Skipping member with invalid model", { model: member.model })
|
||||
continue
|
||||
}
|
||||
|
||||
const key = getCouncilMemberAgentKey(member)
|
||||
const nameLower = member.name.toLowerCase()
|
||||
|
||||
if (registeredNamesLower.has(nameLower)) {
|
||||
skippedMembers.push({
|
||||
name: member.name,
|
||||
reason: `Duplicate name: '${member.name}' already registered (case-insensitive match)`,
|
||||
})
|
||||
log("[council-member-agents] Skipping duplicate council member name", {
|
||||
name: member.name,
|
||||
model: member.model,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const config = createCouncilMemberAgent(member.model)
|
||||
const description = `Council member: ${member.name} (${parsed.providerID}/${parsed.modelID}). Independent read-only code analyst for Athena council. (OhMyOpenCode)`
|
||||
|
||||
agents[key] = {
|
||||
...config,
|
||||
description,
|
||||
model: member.model,
|
||||
...(member.variant ? { variant: member.variant } : {}),
|
||||
...(member.temperature !== undefined ? { temperature: member.temperature } : {}),
|
||||
}
|
||||
|
||||
registeredKeys.push(key)
|
||||
registeredNamesLower.add(nameLower)
|
||||
|
||||
log("[council-member-agents] Registered council member agent", {
|
||||
key,
|
||||
model: member.model,
|
||||
variant: member.variant,
|
||||
})
|
||||
}
|
||||
|
||||
if (registeredKeys.length < 2) {
|
||||
log("[council-member-agents] Fewer than 2 valid council members after model parsing — disabling council mode")
|
||||
return { agents: {}, registeredKeys: [], skippedMembers }
|
||||
}
|
||||
|
||||
return { agents, registeredKeys, skippedMembers }
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { applyEnvironmentContext } from "./environment-context"
|
||||
import { applyModelResolution } from "./model-resolution"
|
||||
|
||||
export function collectPendingBuiltinAgents(input: {
|
||||
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
|
||||
agentSources: Partial<Record<BuiltinAgentName, import("../agent-builder").AgentSource>>
|
||||
agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>>
|
||||
disabledAgents: string[]
|
||||
agentOverrides: AgentOverrides
|
||||
|
||||
@@ -103,6 +103,8 @@ export type BuiltinAgentName =
|
||||
| "metis"
|
||||
| "momus"
|
||||
| "atlas"
|
||||
| "athena"
|
||||
| "council-member"
|
||||
|
||||
export type OverridableAgentName =
|
||||
| "build"
|
||||
|
||||
@@ -147,6 +147,69 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("Athena uses uiSelectedModel when provided", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
|
||||
)
|
||||
const uiSelectedModel = "openai/gpt-5.2"
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
uiSelectedModel
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(agents.athena).toBeDefined()
|
||||
expect(agents.athena.model).toBe("openai/gpt-5.2")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("user config model takes priority over uiSelectedModel for athena", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
|
||||
)
|
||||
const uiSelectedModel = "openai/gpt-5.2"
|
||||
const overrides = {
|
||||
athena: { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
[],
|
||||
overrides,
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
uiSelectedModel
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(agents.athena).toBeDefined()
|
||||
expect(agents.athena.model).toBe("anthropic/claude-opus-4-6")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("Sisyphus is created on first run when no availableModels or cache exist", async () => {
|
||||
// #given
|
||||
const systemDefaultModel = "anthropic/claude-opus-4-6"
|
||||
@@ -428,7 +491,8 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
const matches = (agents.sisyphus?.prompt ?? "").match(/Custom agent: researcher/gi) ?? []
|
||||
expect(agents.sisyphus.prompt).toBeDefined()
|
||||
const matches = (agents.sisyphus.prompt ?? "").match(/Custom agent: researcher/gi) ?? []
|
||||
expect(matches.length).toBe(1)
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
@@ -689,6 +753,7 @@ describe("Hephaestus environment context toggle", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
disableFlag
|
||||
)
|
||||
}
|
||||
@@ -748,6 +813,7 @@ describe("Sisyphus and Librarian environment context toggle", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
disableFlag
|
||||
)
|
||||
}
|
||||
@@ -807,6 +873,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
@@ -823,6 +890,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
|
||||
|
||||
@@ -446,6 +446,24 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"council": {
|
||||
"members": [
|
||||
{
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
},
|
||||
{
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"name": "GPT 5.3 Codex",
|
||||
},
|
||||
{
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"name": "Gemini Pro 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
@@ -520,6 +538,24 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"council": {
|
||||
"members": [
|
||||
{
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
},
|
||||
{
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"name": "GPT 5.3 Codex",
|
||||
},
|
||||
{
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"name": "Gemini Pro 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
@@ -1212,6 +1248,20 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"council": {
|
||||
"members": [
|
||||
{
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
},
|
||||
{
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"name": "Gemini Pro 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
@@ -1352,6 +1402,24 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"council": {
|
||||
"members": [
|
||||
{
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
},
|
||||
{
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"name": "GPT 5.3 Codex",
|
||||
},
|
||||
{
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"name": "Gemini Pro 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
@@ -1426,6 +1494,24 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"athena": {
|
||||
"council": {
|
||||
"members": [
|
||||
{
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
},
|
||||
{
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"name": "GPT 5.3 Codex",
|
||||
},
|
||||
{
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"name": "Gemini Pro 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"atlas": {
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
|
||||
139
src/cli/council-members-generator.test.ts
Normal file
139
src/cli/council-members-generator.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { generateCouncilMembers } from "./council-members-generator"
|
||||
import type { ProviderAvailability } from "./model-fallback-types"
|
||||
|
||||
function makeAvail(overrides: {
|
||||
native?: Partial<ProviderAvailability["native"]>
|
||||
opencodeZen?: boolean
|
||||
copilot?: boolean
|
||||
zai?: boolean
|
||||
kimiForCoding?: boolean
|
||||
isMaxPlan?: boolean
|
||||
}): ProviderAvailability {
|
||||
return {
|
||||
native: {
|
||||
claude: false,
|
||||
openai: false,
|
||||
gemini: false,
|
||||
...(overrides.native ?? {}),
|
||||
},
|
||||
opencodeZen: overrides.opencodeZen ?? false,
|
||||
copilot: overrides.copilot ?? false,
|
||||
zai: overrides.zai ?? false,
|
||||
kimiForCoding: overrides.kimiForCoding ?? false,
|
||||
isMaxPlan: overrides.isMaxPlan ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
describe("generateCouncilMembers", () => {
|
||||
//#given all three native providers
|
||||
//#when generating council members
|
||||
//#then returns 3 members (one per provider)
|
||||
test("returns 3 members when claude + openai + gemini available", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true, openai: true, gemini: true },
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(3)
|
||||
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
|
||||
expect(members.some(m => m.model.startsWith("openai/"))).toBe(true)
|
||||
expect(members.some(m => m.model.startsWith("google/"))).toBe(true)
|
||||
expect(members.every(m => m.name)).toBe(true)
|
||||
})
|
||||
|
||||
//#given claude + openai only
|
||||
//#when generating council members
|
||||
//#then returns 2 members
|
||||
test("returns 2 members when claude + openai available", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true, openai: true },
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(2)
|
||||
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
|
||||
expect(members.some(m => m.model.startsWith("openai/"))).toBe(true)
|
||||
})
|
||||
|
||||
//#given claude + gemini only
|
||||
//#when generating council members
|
||||
//#then returns 2 members
|
||||
test("returns 2 members when claude + gemini available", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true, gemini: true },
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(2)
|
||||
})
|
||||
|
||||
//#given openai + gemini only
|
||||
//#when generating council members
|
||||
//#then returns 2 members
|
||||
test("returns 2 members when openai + gemini available", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { openai: true, gemini: true },
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(2)
|
||||
})
|
||||
|
||||
//#given only one native provider
|
||||
//#when kimi is also available
|
||||
//#then returns 2 members (native + kimi)
|
||||
test("uses kimi as second member when only one native provider", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true },
|
||||
kimiForCoding: true,
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(2)
|
||||
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
|
||||
expect(members.some(m => m.model.startsWith("kimi-for-coding/"))).toBe(true)
|
||||
})
|
||||
|
||||
//#given all 4 candidates available
|
||||
//#when generating council members
|
||||
//#then returns 4 members
|
||||
test("returns 4 members when all candidates available", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true, openai: true, gemini: true },
|
||||
kimiForCoding: true,
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(4)
|
||||
})
|
||||
|
||||
//#given no providers at all
|
||||
//#when generating council members
|
||||
//#then returns empty array (can't meet minimum 2)
|
||||
test("returns empty when no providers available", () => {
|
||||
const members = generateCouncilMembers(makeAvail({}))
|
||||
|
||||
expect(members).toHaveLength(0)
|
||||
})
|
||||
|
||||
//#given only one provider, no fallbacks
|
||||
//#when generating council members
|
||||
//#then returns empty (need at least 2 distinct models)
|
||||
test("returns empty when only one provider and no fallbacks", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true },
|
||||
}))
|
||||
|
||||
expect(members).toHaveLength(0)
|
||||
})
|
||||
|
||||
//#given all members have names
|
||||
//#when generating council
|
||||
//#then each member has a human-readable name
|
||||
test("all members have name field", () => {
|
||||
const members = generateCouncilMembers(makeAvail({
|
||||
native: { claude: true, openai: true, gemini: true },
|
||||
}))
|
||||
|
||||
for (const m of members) {
|
||||
expect(m.name).toBeDefined()
|
||||
expect(typeof m.name).toBe("string")
|
||||
expect(m.name!.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
49
src/cli/council-members-generator.ts
Normal file
49
src/cli/council-members-generator.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ProviderAvailability } from "./model-fallback-types"
|
||||
|
||||
export interface CouncilMember {
|
||||
model: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const COUNCIL_CANDIDATES: Array<{
|
||||
provider: (avail: ProviderAvailability) => boolean
|
||||
model: string
|
||||
name: string
|
||||
}> = [
|
||||
{
|
||||
provider: (a) => a.native.claude,
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
},
|
||||
{
|
||||
provider: (a) => a.native.openai,
|
||||
model: "openai/gpt-5.3-codex",
|
||||
name: "GPT 5.3 Codex",
|
||||
},
|
||||
{
|
||||
provider: (a) => a.native.gemini,
|
||||
model: "google/gemini-3-pro-preview",
|
||||
name: "Gemini Pro 3",
|
||||
},
|
||||
{
|
||||
provider: (a) => a.kimiForCoding,
|
||||
model: "kimi-for-coding/kimi-k2.5",
|
||||
name: "Kimi 2.5",
|
||||
}
|
||||
]
|
||||
|
||||
export function generateCouncilMembers(avail: ProviderAvailability): CouncilMember[] {
|
||||
const members: CouncilMember[] = []
|
||||
|
||||
for (const candidate of COUNCIL_CANDIDATES) {
|
||||
if (candidate.provider(avail)) {
|
||||
members.push({ model: candidate.model, name: candidate.name })
|
||||
}
|
||||
}
|
||||
|
||||
if (members.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isRequiredProviderAvailable,
|
||||
resolveModelFromChain,
|
||||
} from "./fallback-chain-resolution"
|
||||
import { generateCouncilMembers } from "./council-members-generator"
|
||||
|
||||
export type { GeneratedOmoConfig } from "./model-fallback-types"
|
||||
|
||||
@@ -122,6 +123,12 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
}
|
||||
}
|
||||
|
||||
const councilMembers = generateCouncilMembers(avail)
|
||||
if (councilMembers.length >= 2) {
|
||||
const athenaAgent = agents.athena ?? {}
|
||||
agents.athena = { ...athenaAgent, council: { members: councilMembers } } as AgentConfig
|
||||
}
|
||||
|
||||
return {
|
||||
$schema: SCHEMA_URL,
|
||||
agents,
|
||||
|
||||
@@ -532,6 +532,76 @@ describe("Sisyphus-Junior agent override", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Athena agent override", () => {
|
||||
test("accepts athena override with council members and standard override fields", () => {
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
athena: {
|
||||
model: "openai/gpt-5.3-codex",
|
||||
temperature: 0.2,
|
||||
prompt_append: "Use consensus-first synthesis.",
|
||||
council: {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", temperature: 0.2, name: "Architect" },
|
||||
{ model: "anthropic/claude-sonnet-4-5", temperature: 0.3, name: "Reviewer" },
|
||||
{ model: "xai/grok-code-fast-1", temperature: 0.1, name: "Optimizer" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agents?.athena?.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(result.data.agents?.athena?.temperature).toBe(0.2)
|
||||
expect(result.data.agents?.athena?.prompt_append).toBe("Use consensus-first synthesis.")
|
||||
expect(result.data.agents?.athena?.council?.members).toHaveLength(3)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects athena override with fewer than two council members", () => {
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
athena: {
|
||||
council: {
|
||||
members: [{ model: "openai/gpt-5.3-codex", name: "GPT" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts athena override without council (temperature-only override)", () => {
|
||||
// given
|
||||
const config = {
|
||||
agents: {
|
||||
athena: {
|
||||
model: "openai/gpt-5.3-codex",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("BrowserAutomationProviderSchema", () => {
|
||||
test("accepts 'playwright' as valid provider", () => {
|
||||
// given
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./schema/agent-names"
|
||||
export * from "./schema/agent-overrides"
|
||||
export * from "./schema/athena"
|
||||
export * from "./schema/babysitting"
|
||||
export * from "./schema/background-task"
|
||||
export * from "./schema/browser-automation"
|
||||
|
||||
26
src/config/schema/agent-names.test.ts
Normal file
26
src/config/schema/agent-names.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { BuiltinAgentNameSchema, OverridableAgentNameSchema } from "./agent-names"
|
||||
|
||||
describe("agent name schemas", () => {
|
||||
test("BuiltinAgentNameSchema accepts athena", () => {
|
||||
//#given
|
||||
const candidate = "athena"
|
||||
|
||||
//#when
|
||||
const result = BuiltinAgentNameSchema.safeParse(candidate)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("OverridableAgentNameSchema accepts athena", () => {
|
||||
//#given
|
||||
const candidate = "athena"
|
||||
|
||||
//#when
|
||||
const result = OverridableAgentNameSchema.safeParse(candidate)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,8 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
"athena",
|
||||
"council-member",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
@@ -36,6 +38,8 @@ export const OverridableAgentNameSchema = z.enum([
|
||||
"explore",
|
||||
"multimodal-looker",
|
||||
"atlas",
|
||||
"athena",
|
||||
"council-member",
|
||||
])
|
||||
|
||||
export const AgentNameSchema = BuiltinAgentNameSchema
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod"
|
||||
import { FallbackModelsSchema } from "./fallback-models"
|
||||
import { AthenaConfigSchema } from "./athena"
|
||||
import { AgentPermissionSchema } from "./internal/permission"
|
||||
|
||||
export const AgentOverrideConfigSchema = z.object({
|
||||
@@ -55,6 +56,10 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const AthenaOverrideConfigSchema = AgentOverrideConfigSchema.extend({
|
||||
council: AthenaConfigSchema.shape.council.optional(),
|
||||
})
|
||||
|
||||
export const AgentOverridesSchema = z.object({
|
||||
build: AgentOverrideConfigSchema.optional(),
|
||||
plan: AgentOverrideConfigSchema.optional(),
|
||||
@@ -70,6 +75,8 @@ export const AgentOverridesSchema = z.object({
|
||||
explore: AgentOverrideConfigSchema.optional(),
|
||||
"multimodal-looker": AgentOverrideConfigSchema.optional(),
|
||||
atlas: AgentOverrideConfigSchema.optional(),
|
||||
"council-member": AgentOverrideConfigSchema.optional(),
|
||||
athena: AthenaOverrideConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
|
||||
431
src/config/schema/athena.test.ts
Normal file
431
src/config/schema/athena.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { z } from "zod"
|
||||
import { AthenaConfigSchema, CouncilConfigSchema, CouncilMemberSchema } from "./athena"
|
||||
|
||||
describe("CouncilMemberSchema", () => {
|
||||
test("accepts member config with model and name", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "member-a" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts member config with all optional fields", () => {
|
||||
//#given
|
||||
const config = {
|
||||
model: "openai/gpt-5.3-codex",
|
||||
variant: "high",
|
||||
name: "analyst-a",
|
||||
temperature: 0.3,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects member config missing model", () => {
|
||||
//#given
|
||||
const config = { name: "no-model" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects model string without provider/model separator", () => {
|
||||
//#given
|
||||
const config = { model: "invalid-model", name: "test-member" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects model string with empty provider", () => {
|
||||
//#given
|
||||
const config = { model: "/gpt-5.3-codex", name: "test-member" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects model string with empty model ID", () => {
|
||||
//#given
|
||||
const config = { model: "openai/", name: "test-member" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects empty model string", () => {
|
||||
//#given
|
||||
const config = { model: "" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("z.infer produces expected type shape", () => {
|
||||
//#given
|
||||
type InferredCouncilMember = z.infer<typeof CouncilMemberSchema>
|
||||
const member: InferredCouncilMember = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "medium",
|
||||
name: "oracle",
|
||||
}
|
||||
|
||||
//#when
|
||||
const model = member.model
|
||||
|
||||
//#then
|
||||
expect(model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("optional fields are optional without runtime defaults", () => {
|
||||
//#given
|
||||
const config = { model: "xai/grok-code-fast-1", name: "member-x" }
|
||||
|
||||
//#when
|
||||
const parsed = CouncilMemberSchema.parse(config)
|
||||
|
||||
//#then
|
||||
expect(parsed.variant).toBeUndefined()
|
||||
expect(parsed.temperature).toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects member config missing name", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects member config with empty name", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts member config with temperature", () => {
|
||||
//#given
|
||||
const config = { model: "openai/gpt-5.3-codex", name: "member-a", temperature: 0.5 }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.temperature).toBe(0.5)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects temperature below 0", () => {
|
||||
//#given
|
||||
const config = { model: "openai/gpt-5.3-codex", name: "test-member", temperature: -0.1 }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects temperature above 2", () => {
|
||||
//#given
|
||||
const config = { model: "openai/gpt-5.3-codex", name: "test-member", temperature: 2.1 }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects member config with unknown fields", () => {
|
||||
//#given
|
||||
const config = { model: "openai/gpt-5.3-codex", name: "test-member", unknownField: true }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("trims leading and trailing whitespace from name", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: " member-a " }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("member-a")
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts name with spaces like 'Claude Opus 4'", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "Claude Opus 4" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts name with dots like 'Claude 4.6'", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "Claude 4.6" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts name with hyphens like 'my-model-1'", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "my-model-1" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects name with special characters like '@'", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "member@1" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects name with exclamation mark", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: "member!" }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects name starting with a space after trim", () => {
|
||||
//#given
|
||||
const config = { model: "anthropic/claude-opus-4-6", name: " " }
|
||||
|
||||
//#when
|
||||
const result = CouncilMemberSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CouncilConfigSchema", () => {
|
||||
test("accepts council with 2 members", () => {
|
||||
//#given
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "anthropic/claude-opus-4-6", name: "member-a" },
|
||||
{ model: "openai/gpt-5.3-codex", name: "member-b" },
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts council with 3 members and optional fields", () => {
|
||||
//#given
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "anthropic/claude-opus-4-6", name: "a" },
|
||||
{ model: "openai/gpt-5.3-codex", name: "b", variant: "high" },
|
||||
{ model: "xai/grok-code-fast-1", name: "c", variant: "low" },
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects council with 0 members", () => {
|
||||
//#given
|
||||
const config = { members: [] }
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects council with 1 member", () => {
|
||||
//#given
|
||||
const config = { members: [{ model: "anthropic/claude-opus-4-6", name: "member-a" }] }
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects council missing members field", () => {
|
||||
//#given
|
||||
const config = {}
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts council with duplicate member names for graceful runtime handling", () => {
|
||||
//#given - duplicate detection is handled at runtime by registerCouncilMemberAgents,
|
||||
// not at schema level, to allow graceful fallback instead of hard parse failure
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "anthropic/claude-opus-4-6", name: "analyst" },
|
||||
{ model: "openai/gpt-5.3-codex", name: "analyst" },
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts council with case-insensitive duplicate names for graceful runtime handling", () => {
|
||||
//#given - case-insensitive dedup is handled at runtime by registerCouncilMemberAgents
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "anthropic/claude-opus-4-6", name: "Claude" },
|
||||
{ model: "openai/gpt-5.3-codex", name: "claude" },
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts council with unique member names", () => {
|
||||
//#given
|
||||
const config = {
|
||||
members: [
|
||||
{ model: "anthropic/claude-opus-4-6", name: "analyst-a" },
|
||||
{ model: "openai/gpt-5.3-codex", name: "analyst-b" },
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = CouncilConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AthenaConfigSchema", () => {
|
||||
test("accepts Athena config with council", () => {
|
||||
//#given
|
||||
const config = {
|
||||
council: {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", name: "member-a" },
|
||||
{ model: "xai/grok-code-fast-1", name: "member-b" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects Athena config without council", () => {
|
||||
//#given
|
||||
const config = {}
|
||||
|
||||
//#when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects Athena config with unknown model field", () => {
|
||||
//#given
|
||||
const config = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
council: {
|
||||
members: [
|
||||
{ model: "openai/gpt-5.3-codex", name: "member-a" },
|
||||
{ model: "xai/grok-code-fast-1", name: "member-b" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = AthenaConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
31
src/config/schema/athena.ts
Normal file
31
src/config/schema/athena.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod"
|
||||
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
|
||||
|
||||
/** Validates model string format: "provider/model-id" (e.g., "openai/gpt-5.3-codex"). */
|
||||
const ModelStringSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(model) => parseModelString(model) !== undefined,
|
||||
{ message: 'Model must be in "provider/model-id" format (e.g., "openai/gpt-5.3-codex")' }
|
||||
)
|
||||
|
||||
export const CouncilMemberSchema = z.object({
|
||||
model: ModelStringSchema,
|
||||
variant: z.string().optional(),
|
||||
name: z.string().min(1).trim().regex(/^[a-zA-Z0-9][a-zA-Z0-9 .\-]*$/, {
|
||||
message: "Council member name must contain only letters, numbers, spaces, hyphens, and dots",
|
||||
}),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
}).strict()
|
||||
|
||||
export const CouncilConfigSchema = z.object({
|
||||
members: z.array(CouncilMemberSchema).min(2),
|
||||
}).strict()
|
||||
|
||||
export type CouncilMemberConfig = z.infer<typeof CouncilMemberSchema>
|
||||
export type CouncilConfig = z.infer<typeof CouncilConfigSchema>
|
||||
|
||||
export const AthenaConfigSchema = z.object({
|
||||
council: CouncilConfigSchema,
|
||||
}).strict()
|
||||
@@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([
|
||||
"write-existing-file-guard",
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
"agent-switch",
|
||||
])
|
||||
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
|
||||
226
src/features/agent-switch/applier.test.ts
Normal file
226
src/features/agent-switch/applier.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { _resetForTesting, getPendingSwitch, setPendingSwitch } from "./state"
|
||||
import {
|
||||
_resetApplierForTesting,
|
||||
applyPendingSwitch,
|
||||
clearPendingSwitchRuntime,
|
||||
} from "./applier"
|
||||
import { schedulePendingSwitchApply } from "./scheduler"
|
||||
|
||||
describe("agent-switch applier", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
_resetApplierForTesting()
|
||||
})
|
||||
|
||||
test("scheduled apply works without idle event", async () => {
|
||||
const calls: string[] = []
|
||||
let switched = false
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (input: { body: { agent: string } }) => {
|
||||
calls.push(input.body.agent)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
|
||||
: ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-1", "prometheus", "create plan")
|
||||
schedulePendingSwitchApply({
|
||||
sessionID: "ses-1",
|
||||
client: client as any,
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
expect(calls).toEqual(["Prometheus (Plan Builder)"])
|
||||
expect(getPendingSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes pending agent to canonical prompt display name", async () => {
|
||||
const calls: string[] = []
|
||||
let switched = false
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (input: { body: { agent: string } }) => {
|
||||
calls.push(input.body.agent)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
|
||||
: ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-2", "Prometheus (Plan Builder)", "create plan")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-2",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
expect(calls).toEqual(["Prometheus (Plan Builder)"])
|
||||
expect(getPendingSwitch("ses-2")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retries transient failures and eventually clears pending switch", async () => {
|
||||
let attempts = 0
|
||||
let switched = false
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
attempts += 1
|
||||
if (attempts < 3) {
|
||||
throw new Error("temporary failure")
|
||||
}
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
|
||||
: ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-3", "atlas", "fix this")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-3",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
expect(attempts).toBe(3)
|
||||
expect(getPendingSwitch("ses-3")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("waits for session idle before applying switch", async () => {
|
||||
let statusChecks = 0
|
||||
let promptCalls = 0
|
||||
let switched = false
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => {
|
||||
statusChecks += 1
|
||||
return {
|
||||
"ses-5": { type: statusChecks < 3 ? "running" : "idle" },
|
||||
}
|
||||
},
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
|
||||
: ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-5", "atlas", "fix now")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-5",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
expect(statusChecks).toBeGreaterThanOrEqual(3)
|
||||
expect(promptCalls).toBe(1)
|
||||
expect(getPendingSwitch("ses-5")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clearPendingSwitchRuntime cancels pending retries", async () => {
|
||||
let attempts = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
attempts += 1
|
||||
throw new Error("always failing")
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-4", "atlas", "fix this")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-4",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
clearPendingSwitchRuntime("ses-4")
|
||||
|
||||
const attemptsAfterClear = attempts
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
expect(attempts).toBe(attemptsAfterClear)
|
||||
expect(getPendingSwitch("ses-4")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("syncs CLI TUI agent selection for athena-to-atlas handoff", async () => {
|
||||
const originalClientEnv = process.env["OPENCODE_CLIENT"]
|
||||
process.env["OPENCODE_CLIENT"] = "cli"
|
||||
|
||||
try {
|
||||
const promptCalls: string[] = []
|
||||
const tuiCommands: string[] = []
|
||||
let switched = false
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (input: { body: { agent: string } }) => {
|
||||
promptCalls.push(input.body.agent)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({
|
||||
data: [
|
||||
{ info: { role: "user", agent: "Athena (Council)" } },
|
||||
{ info: { role: "user", agent: "Atlas (Plan Executor)" } },
|
||||
],
|
||||
})
|
||||
: ({
|
||||
data: [{ info: { role: "user", agent: "Athena (Council)" } }],
|
||||
}),
|
||||
},
|
||||
app: {
|
||||
agents: async () => ({
|
||||
data: [
|
||||
{ name: "Sisyphus (Ultraworker)", mode: "primary" },
|
||||
{ name: "Hephaestus (Deep Agent)", mode: "primary" },
|
||||
{ name: "Prometheus (Plan Builder)", mode: "primary" },
|
||||
{ name: "Atlas (Plan Executor)", mode: "primary" },
|
||||
{ name: "Athena (Council)", mode: "primary" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
tui: {
|
||||
publish: async (input: { body: { properties: { command: string } } }) => {
|
||||
tuiCommands.push(input.body.properties.command)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-6", "atlas", "fix now")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-6",
|
||||
client: client as any,
|
||||
source: "message-updated",
|
||||
})
|
||||
|
||||
expect(promptCalls).toEqual(["Atlas (Plan Executor)"])
|
||||
expect(tuiCommands).toEqual(["agent.cycle.reverse"])
|
||||
expect(getPendingSwitch("ses-6")).toBeUndefined()
|
||||
} finally {
|
||||
if (originalClientEnv === undefined) {
|
||||
delete process.env["OPENCODE_CLIENT"]
|
||||
} else {
|
||||
process.env["OPENCODE_CLIENT"] = originalClientEnv
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
211
src/features/agent-switch/applier.ts
Normal file
211
src/features/agent-switch/applier.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { clearPendingSwitch, getPendingSwitch } from "./state"
|
||||
import { waitForSessionIdle } from "./session-status"
|
||||
import { fetchMessages, shouldClearAsAlreadyApplied, verifySwitchObserved } from "./apply-verification"
|
||||
import { getLatestUserAgent } from "./message-inspection"
|
||||
import { syncCliTuiAgentSelectionAfterSwitch } from "./tui-agent-sync"
|
||||
import {
|
||||
clearInFlight,
|
||||
clearRetryState,
|
||||
isApplyInFlight,
|
||||
markApplyInFlight,
|
||||
resetRetryStateForTesting,
|
||||
scheduleRetry,
|
||||
} from "./retry-state"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
prompt?: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
app?: {
|
||||
agents?: () => Promise<unknown>
|
||||
}
|
||||
tui?: {
|
||||
publish?: (input: {
|
||||
body: {
|
||||
type: "tui.command.execute"
|
||||
properties: { command: string }
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
async function tryPromptWithCandidates(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
agent: string
|
||||
context: string
|
||||
source: string
|
||||
}): Promise<string> {
|
||||
const { client, sessionID, agent, context, source } = args
|
||||
const targetAgent = normalizeAgentForPrompt(agent)
|
||||
if (!targetAgent) {
|
||||
throw new Error(`invalid target agent for switch prompt: ${agent}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const promptInput = {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: targetAgent,
|
||||
parts: [{ type: "text" as const, text: context }],
|
||||
},
|
||||
}
|
||||
|
||||
if (client.session.prompt) {
|
||||
await client.session.prompt(promptInput)
|
||||
} else {
|
||||
await client.session.promptAsync(promptInput)
|
||||
}
|
||||
|
||||
if (targetAgent !== agent) {
|
||||
log("[agent-switch] Normalized pending switch agent for prompt", {
|
||||
sessionID,
|
||||
source,
|
||||
requestedAgent: agent,
|
||||
usedAgent: targetAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return targetAgent
|
||||
} catch (error) {
|
||||
log("[agent-switch] Prompt attempt failed", {
|
||||
sessionID,
|
||||
source,
|
||||
requestedAgent: agent,
|
||||
attemptedAgent: targetAgent,
|
||||
error: String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPendingSwitch(args: {
|
||||
sessionID: string
|
||||
client: SessionClient
|
||||
source: string
|
||||
}): Promise<void> {
|
||||
const { sessionID, client, source } = args
|
||||
const pending = getPendingSwitch(sessionID)
|
||||
if (!pending) {
|
||||
clearRetryState(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (isApplyInFlight(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
markApplyInFlight(sessionID)
|
||||
log("[agent-switch] Applying pending switch", {
|
||||
sessionID,
|
||||
source,
|
||||
agent: pending.agent,
|
||||
})
|
||||
|
||||
try {
|
||||
const alreadyApplied = await shouldClearAsAlreadyApplied({
|
||||
client,
|
||||
sessionID,
|
||||
targetAgent: pending.agent,
|
||||
})
|
||||
if (alreadyApplied) {
|
||||
clearPendingSwitch(sessionID)
|
||||
clearRetryState(sessionID)
|
||||
log("[agent-switch] Pending switch already applied by user-turn evidence; clearing state", {
|
||||
sessionID,
|
||||
source,
|
||||
agent: pending.agent,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const idleReady = await waitForSessionIdle({ client, sessionID })
|
||||
if (!idleReady) {
|
||||
throw new Error("session not idle before applying agent switch")
|
||||
}
|
||||
|
||||
const beforeMessages = await fetchMessages({ client, sessionID })
|
||||
const sourceUserAgent = getLatestUserAgent(beforeMessages)
|
||||
|
||||
const usedAgent = await tryPromptWithCandidates({
|
||||
client,
|
||||
sessionID,
|
||||
agent: pending.agent,
|
||||
context: pending.context,
|
||||
source,
|
||||
})
|
||||
|
||||
const verified = await verifySwitchObserved({
|
||||
client,
|
||||
sessionID,
|
||||
targetAgent: pending.agent,
|
||||
baselineCount: beforeMessages.length,
|
||||
})
|
||||
if (!verified) {
|
||||
throw new Error(`agent switch not observed after prompt (attempted ${usedAgent})`)
|
||||
}
|
||||
|
||||
clearPendingSwitch(sessionID)
|
||||
clearRetryState(sessionID)
|
||||
|
||||
await syncCliTuiAgentSelectionAfterSwitch({
|
||||
client,
|
||||
sessionID,
|
||||
source,
|
||||
sourceAgent: sourceUserAgent,
|
||||
targetAgent: pending.agent,
|
||||
})
|
||||
|
||||
log("[agent-switch] Pending switch applied", {
|
||||
sessionID,
|
||||
source,
|
||||
agent: pending.agent,
|
||||
})
|
||||
} catch (error) {
|
||||
clearInFlight(sessionID)
|
||||
log("[agent-switch] Pending switch apply failed", {
|
||||
sessionID,
|
||||
source,
|
||||
error: String(error),
|
||||
})
|
||||
scheduleRetry({
|
||||
sessionID,
|
||||
source,
|
||||
onLimitReached: (attempts) => {
|
||||
log("[agent-switch] Retry limit reached; waiting for next trigger", {
|
||||
sessionID,
|
||||
attempts,
|
||||
source,
|
||||
})
|
||||
},
|
||||
retryFn: (attemptNumber) => {
|
||||
void applyPendingSwitch({
|
||||
sessionID,
|
||||
client,
|
||||
source: `retry:${attemptNumber}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingSwitchRuntime(sessionID: string): void {
|
||||
clearPendingSwitch(sessionID)
|
||||
clearRetryState(sessionID)
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetApplierForTesting(): void {
|
||||
resetRetryStateForTesting()
|
||||
}
|
||||
59
src/features/agent-switch/apply-verification.ts
Normal file
59
src/features/agent-switch/apply-verification.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { extractMessageList, hasNewUserTurnForTargetAgent, hasRecentUserTurnForTargetAgent } from "./message-inspection"
|
||||
import { log } from "../../shared/logger"
|
||||
import { sleepWithDelay } from "./session-status"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await args.client.session.messages({ path: { id: args.sessionID } })
|
||||
return extractMessageList(response)
|
||||
}
|
||||
|
||||
export async function verifySwitchObserved(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
targetAgent: string
|
||||
baselineCount: number
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, targetAgent, baselineCount } = args
|
||||
const delays = [100, 300, 800, 1500] as const
|
||||
|
||||
for (const delay of delays) {
|
||||
await sleepWithDelay(delay)
|
||||
try {
|
||||
const messages = await fetchMessages({ client, sessionID })
|
||||
if (hasNewUserTurnForTargetAgent({ messages, targetAgent, baselineCount })) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log("[agent-switch] Verification read failed", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function shouldClearAsAlreadyApplied(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
targetAgent: string
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, targetAgent } = args
|
||||
|
||||
try {
|
||||
const messages = await fetchMessages({ client, sessionID })
|
||||
return hasRecentUserTurnForTargetAgent({ messages, targetAgent })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
8
src/features/agent-switch/index.ts
Normal file
8
src/features/agent-switch/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
setPendingSwitch,
|
||||
getPendingSwitch,
|
||||
clearPendingSwitch,
|
||||
consumePendingSwitch,
|
||||
_resetForTesting,
|
||||
} from "./state"
|
||||
export type { PendingSwitch } from "./state"
|
||||
107
src/features/agent-switch/message-inspection.ts
Normal file
107
src/features/agent-switch/message-inspection.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
|
||||
export interface MessageRoleAgent {
|
||||
role: string
|
||||
agent: string
|
||||
}
|
||||
|
||||
export function extractMessageList(response: unknown): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(response)) {
|
||||
return response.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
||||
}
|
||||
if (typeof response === "object" && response !== null) {
|
||||
const data = (response as Record<string, unknown>).data
|
||||
if (Array.isArray(data)) {
|
||||
return data.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function getRoleAgent(message: Record<string, unknown>): MessageRoleAgent | undefined {
|
||||
const info = message.info
|
||||
if (typeof info !== "object" || info === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const role = (info as Record<string, unknown>).role
|
||||
const agent = (info as Record<string, unknown>).agent
|
||||
if (typeof role !== "string" || typeof agent !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { role, agent }
|
||||
}
|
||||
|
||||
export function getLatestUserAgent(messages: Array<Record<string, unknown>>): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
|
||||
const roleAgent = getRoleAgent(message)
|
||||
if (!roleAgent || roleAgent.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
return roleAgent.agent
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function hasRecentUserTurnForTargetAgent(args: {
|
||||
messages: Array<Record<string, unknown>>
|
||||
targetAgent: string
|
||||
lookback?: number
|
||||
}): boolean {
|
||||
const { messages, targetAgent, lookback = 8 } = args
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
const start = Math.max(0, messages.length - lookback)
|
||||
|
||||
for (let index = messages.length - 1; index >= start; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
|
||||
const roleAgent = getRoleAgent(message)
|
||||
if (!roleAgent || roleAgent.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (getAgentConfigKey(roleAgent.agent) === targetKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasNewUserTurnForTargetAgent(args: {
|
||||
messages: Array<Record<string, unknown>>
|
||||
targetAgent: string
|
||||
baselineCount: number
|
||||
}): boolean {
|
||||
const { messages, targetAgent, baselineCount } = args
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
|
||||
if (messages.length <= baselineCount) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newMessages = messages.slice(Math.max(0, baselineCount))
|
||||
for (const message of newMessages) {
|
||||
const roleAgent = getRoleAgent(message)
|
||||
if (!roleAgent || roleAgent.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (getAgentConfigKey(roleAgent.agent) === targetKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
66
src/features/agent-switch/retry-state.ts
Normal file
66
src/features/agent-switch/retry-state.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
const RETRY_DELAYS_MS = [50, 250, 500, 1000, 2000, 5000] as const
|
||||
|
||||
const inFlightSessions = new Set<string>()
|
||||
const retryAttempts = new Map<string, number>()
|
||||
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
export function isApplyInFlight(sessionID: string): boolean {
|
||||
return inFlightSessions.has(sessionID)
|
||||
}
|
||||
|
||||
export function markApplyInFlight(sessionID: string): void {
|
||||
inFlightSessions.add(sessionID)
|
||||
}
|
||||
|
||||
export function clearRetryState(sessionID: string): void {
|
||||
const timer = retryTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
retryTimers.delete(sessionID)
|
||||
}
|
||||
retryAttempts.delete(sessionID)
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
export function clearInFlight(sessionID: string): void {
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
export function scheduleRetry(args: {
|
||||
sessionID: string
|
||||
source: string
|
||||
retryFn: (attemptNumber: number) => void
|
||||
onLimitReached: (attempts: number) => void
|
||||
}): void {
|
||||
const { sessionID, retryFn, onLimitReached } = args
|
||||
const attempts = retryAttempts.get(sessionID) ?? 0
|
||||
if (attempts >= RETRY_DELAYS_MS.length) {
|
||||
onLimitReached(attempts)
|
||||
return
|
||||
}
|
||||
|
||||
const delay = RETRY_DELAYS_MS[attempts]
|
||||
retryAttempts.set(sessionID, attempts + 1)
|
||||
|
||||
const existing = retryTimers.get(sessionID)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
retryTimers.delete(sessionID)
|
||||
retryFn(attempts + 1)
|
||||
}, delay)
|
||||
|
||||
retryTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function resetRetryStateForTesting(): void {
|
||||
for (const timer of retryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
retryTimers.clear()
|
||||
retryAttempts.clear()
|
||||
inFlightSessions.clear()
|
||||
}
|
||||
43
src/features/agent-switch/scheduler.ts
Normal file
43
src/features/agent-switch/scheduler.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { scheduleRetry } from "./retry-state"
|
||||
import { applyPendingSwitch } from "./applier"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
prompt?: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export function schedulePendingSwitchApply(args: {
|
||||
sessionID: string
|
||||
client: SessionClient
|
||||
}): void {
|
||||
const { sessionID, client } = args
|
||||
scheduleRetry({
|
||||
sessionID,
|
||||
source: "tool",
|
||||
onLimitReached: (attempts) => {
|
||||
log("[agent-switch] Retry limit reached; waiting for next trigger", {
|
||||
sessionID,
|
||||
attempts,
|
||||
source: "tool",
|
||||
})
|
||||
},
|
||||
retryFn: (attemptNumber) => {
|
||||
void applyPendingSwitch({
|
||||
sessionID,
|
||||
client,
|
||||
source: `retry:${attemptNumber}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
68
src/features/agent-switch/session-status.ts
Normal file
68
src/features/agent-switch/session-status.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getSessionStatusType(statusResponse: unknown, sessionID: string): string | undefined {
|
||||
if (typeof statusResponse !== "object" || statusResponse === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const root = statusResponse as Record<string, unknown>
|
||||
const data = (typeof root.data === "object" && root.data !== null)
|
||||
? root.data as Record<string, unknown>
|
||||
: root
|
||||
|
||||
const entry = data[sessionID]
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const entryType = (entry as Record<string, unknown>).type
|
||||
return typeof entryType === "string" ? entryType : undefined
|
||||
}
|
||||
|
||||
export async function waitForSessionIdle(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
timeoutMs?: number
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, timeoutMs = 15000 } = args
|
||||
if (!client.session.status) {
|
||||
return true
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const statusResponse = await client.session.status()
|
||||
const statusType = getSessionStatusType(statusResponse, sessionID)
|
||||
// /session/status only tracks non-idle sessions in SessionStatus.list().
|
||||
// Missing entry means idle.
|
||||
if (!statusType || statusType === "idle") {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log("[agent-switch] Session status check failed", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function sleepWithDelay(ms: number): Promise<void> {
|
||||
await sleep(ms)
|
||||
}
|
||||
73
src/features/agent-switch/state.test.ts
Normal file
73
src/features/agent-switch/state.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const { describe, test, expect, beforeEach } = require("bun:test")
|
||||
import {
|
||||
setPendingSwitch,
|
||||
getPendingSwitch,
|
||||
clearPendingSwitch,
|
||||
consumePendingSwitch,
|
||||
_resetForTesting,
|
||||
} from "./state"
|
||||
|
||||
describe("agent-switch state", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
//#given a pending switch is set
|
||||
//#when consumePendingSwitch is called
|
||||
//#then it returns the switch and removes it
|
||||
test("should store and consume a pending switch", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix these findings")
|
||||
|
||||
const entry = consumePendingSwitch("session-1")
|
||||
|
||||
expect(entry).toEqual({ agent: "atlas", context: "Fix these findings" })
|
||||
expect(consumePendingSwitch("session-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
//#given no pending switch exists
|
||||
//#when consumePendingSwitch is called
|
||||
//#then it returns undefined
|
||||
test("should return undefined when no switch is pending", () => {
|
||||
expect(consumePendingSwitch("session-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
//#given a pending switch is set
|
||||
//#when a new switch is set for the same session
|
||||
//#then the latest switch wins
|
||||
test("should overwrite previous switch for same session", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
setPendingSwitch("session-1", "prometheus", "Plan B")
|
||||
|
||||
const entry = consumePendingSwitch("session-1")
|
||||
|
||||
expect(entry).toEqual({ agent: "prometheus", context: "Plan B" })
|
||||
})
|
||||
|
||||
//#given switches for different sessions
|
||||
//#when consumed separately
|
||||
//#then each session gets its own switch
|
||||
test("should isolate switches by session", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
setPendingSwitch("session-2", "prometheus", "Plan B")
|
||||
|
||||
expect(consumePendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||
expect(consumePendingSwitch("session-2")).toEqual({ agent: "prometheus", context: "Plan B" })
|
||||
})
|
||||
|
||||
test("should allow reading without consuming", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
|
||||
expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||
expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||
})
|
||||
|
||||
test("should clear pending switch explicitly", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
|
||||
clearPendingSwitch("session-1")
|
||||
|
||||
expect(getPendingSwitch("session-1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
102
src/features/agent-switch/state.ts
Normal file
102
src/features/agent-switch/state.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
export interface PendingSwitch {
|
||||
agent: string
|
||||
context: string
|
||||
}
|
||||
|
||||
const PENDING_SWITCH_STATE_FILE = process.platform === "win32"
|
||||
? join(tmpdir(), "oh-my-opencode-agent-switch.json")
|
||||
: "/tmp/oh-my-opencode-agent-switch.json"
|
||||
|
||||
const pendingSwitches = new Map<string, PendingSwitch>()
|
||||
|
||||
function isPendingSwitch(value: unknown): value is PendingSwitch {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const entry = value as Record<string, unknown>
|
||||
return typeof entry.agent === "string" && typeof entry.context === "string"
|
||||
}
|
||||
|
||||
function readPersistentState(): Record<string, PendingSwitch> {
|
||||
try {
|
||||
if (!existsSync(PENDING_SWITCH_STATE_FILE)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const raw = readFileSync(PENDING_SWITCH_STATE_FILE, "utf8")
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const state: Record<string, PendingSwitch> = {}
|
||||
for (const [sessionID, value] of Object.entries(parsed)) {
|
||||
if (isPendingSwitch(value)) {
|
||||
state[sessionID] = value
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistentState(state: Record<string, PendingSwitch>): void {
|
||||
try {
|
||||
const keys = Object.keys(state)
|
||||
if (keys.length === 0) {
|
||||
rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
|
||||
return
|
||||
}
|
||||
|
||||
writeFileSync(PENDING_SWITCH_STATE_FILE, JSON.stringify(state), "utf8")
|
||||
} catch {
|
||||
// ignore persistence errors
|
||||
}
|
||||
}
|
||||
|
||||
export function setPendingSwitch(sessionID: string, agent: string, context: string): void {
|
||||
const entry = { agent, context }
|
||||
pendingSwitches.set(sessionID, entry)
|
||||
|
||||
const state = readPersistentState()
|
||||
state[sessionID] = entry
|
||||
writePersistentState(state)
|
||||
}
|
||||
|
||||
export function getPendingSwitch(sessionID: string): PendingSwitch | undefined {
|
||||
const inMemory = pendingSwitches.get(sessionID)
|
||||
if (inMemory) {
|
||||
return inMemory
|
||||
}
|
||||
|
||||
const state = readPersistentState()
|
||||
const fromDisk = state[sessionID]
|
||||
if (fromDisk) {
|
||||
pendingSwitches.set(sessionID, fromDisk)
|
||||
}
|
||||
return fromDisk
|
||||
}
|
||||
|
||||
export function clearPendingSwitch(sessionID: string): void {
|
||||
pendingSwitches.delete(sessionID)
|
||||
|
||||
const state = readPersistentState()
|
||||
delete state[sessionID]
|
||||
writePersistentState(state)
|
||||
}
|
||||
|
||||
export function consumePendingSwitch(sessionID: string): PendingSwitch | undefined {
|
||||
const entry = getPendingSwitch(sessionID)
|
||||
clearPendingSwitch(sessionID)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetForTesting(): void {
|
||||
pendingSwitches.clear()
|
||||
rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
|
||||
}
|
||||
132
src/features/agent-switch/tui-agent-sync.ts
Normal file
132
src/features/agent-switch/tui-agent-sync.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type TuiClient = {
|
||||
app?: {
|
||||
agents?: () => Promise<unknown>
|
||||
}
|
||||
tui?: {
|
||||
publish?: (input: {
|
||||
body: {
|
||||
type: "tui.command.execute"
|
||||
properties: { command: string }
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type AgentInfo = {
|
||||
name?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
function isCliClient(): boolean {
|
||||
return (process.env["OPENCODE_CLIENT"] ?? "cli") === "cli"
|
||||
}
|
||||
|
||||
function resolveCyclePlan(args: {
|
||||
orderedAgentNames: string[]
|
||||
sourceAgent: string
|
||||
targetAgent: string
|
||||
}): { command: "agent.cycle" | "agent.cycle.reverse"; steps: number } | undefined {
|
||||
const { orderedAgentNames, sourceAgent, targetAgent } = args
|
||||
if (orderedAgentNames.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const orderedKeys = orderedAgentNames.map((name) => getAgentConfigKey(name))
|
||||
const sourceKey = getAgentConfigKey(sourceAgent)
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
|
||||
const sourceIndex = orderedKeys.indexOf(sourceKey)
|
||||
const targetIndex = orderedKeys.indexOf(targetKey)
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const size = orderedKeys.length
|
||||
const forward = (targetIndex - sourceIndex + size) % size
|
||||
const backward = (sourceIndex - targetIndex + size) % size
|
||||
|
||||
if (forward <= backward) {
|
||||
return { command: "agent.cycle", steps: forward }
|
||||
}
|
||||
|
||||
return { command: "agent.cycle.reverse", steps: backward }
|
||||
}
|
||||
|
||||
export async function syncCliTuiAgentSelectionAfterSwitch(args: {
|
||||
client: TuiClient
|
||||
sessionID: string
|
||||
sourceAgent: string | undefined
|
||||
targetAgent: string
|
||||
source: string
|
||||
}): Promise<void> {
|
||||
const { client, sessionID, sourceAgent, targetAgent, source } = args
|
||||
|
||||
if (!isCliClient()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!sourceAgent || !client.app?.agents || !client.tui?.publish) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceKey = getAgentConfigKey(sourceAgent)
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
|
||||
// Scope to Athena handoffs where CLI TUI can show stale local-agent selection.
|
||||
if (sourceKey !== "athena" || (targetKey !== "atlas" && targetKey !== "prometheus")) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.app.agents()
|
||||
const agents = normalizeSDKResponse(response, [] as AgentInfo[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
const orderedPrimaryAgents = agents
|
||||
.filter((agent) => typeof agent.name === "string" && agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => agent.name as string)
|
||||
|
||||
const plan = resolveCyclePlan({
|
||||
orderedAgentNames: orderedPrimaryAgents,
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
})
|
||||
|
||||
if (!plan || plan.steps <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let step = 0; step < plan.steps; step += 1) {
|
||||
await client.tui.publish({
|
||||
body: {
|
||||
type: "tui.command.execute",
|
||||
properties: {
|
||||
command: plan.command,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
log("[agent-switch] Synced CLI TUI local agent after handoff", {
|
||||
sessionID,
|
||||
source,
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
command: plan.command,
|
||||
steps: plan.steps,
|
||||
})
|
||||
} catch (error) {
|
||||
log("[agent-switch] Failed syncing CLI TUI local agent after handoff", {
|
||||
sessionID,
|
||||
source,
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -855,7 +855,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(capturedBody?.agent).toBe("sisyphus")
|
||||
expect(capturedBody?.agent).toBe("Sisyphus (Ultraworker)")
|
||||
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
|
||||
manager.shutdown()
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
createInternalAgentTextPart,
|
||||
} from "../../shared"
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
@@ -43,6 +44,8 @@ import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
||||
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
import { sendPostCompactionContinuation } from "./post-compaction-continuation"
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
|
||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
@@ -110,6 +113,7 @@ export class BackgroundManager {
|
||||
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||
private recentlyCompactedSessions: Set<string> = new Set()
|
||||
private enableParentSessionNotifications: boolean
|
||||
readonly taskHistory = new TaskHistory()
|
||||
|
||||
@@ -739,12 +743,36 @@ export class BackgroundManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = typeof props?.sessionID === "string"
|
||||
? props.sessionID
|
||||
: typeof (props?.info as { id?: string } | undefined)?.id === "string"
|
||||
? (props!.info as { id: string }).id
|
||||
: undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
this.recentlyCompactedSessions.add(sessionID)
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
log("[background-agent] Session compacted, deferring next idle:", { taskId: task.id, sessionID })
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || typeof props !== "object") return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props as Record<string, unknown>,
|
||||
findBySession: (id) => this.findBySession(id),
|
||||
idleDeferralTimers: this.idleDeferralTimers,
|
||||
recentlyCompactedSessions: this.recentlyCompactedSessions,
|
||||
onPostCompactionIdle: (t, sid) => {
|
||||
if (t.agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
|
||||
sendPostCompactionContinuation(this.client, t, sid)
|
||||
}
|
||||
},
|
||||
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
|
||||
checkSessionTodos: (id) => this.checkSessionTodos(id),
|
||||
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
|
||||
@@ -865,6 +893,7 @@ export class BackgroundManager {
|
||||
}
|
||||
}
|
||||
SessionCategoryRegistry.remove(sessionID)
|
||||
this.recentlyCompactedSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
@@ -1311,10 +1340,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
|
||||
const resolvedTools = resolveInheritedPromptTools(task.parentSessionID, tools)
|
||||
const promptAgent = normalizeAgentForPrompt(agent)
|
||||
|
||||
log("[background-agent] notifyParentSession context:", {
|
||||
taskId: task.id,
|
||||
resolvedAgent: agent,
|
||||
resolvedAgent: promptAgent,
|
||||
resolvedModel: model,
|
||||
})
|
||||
|
||||
@@ -1323,7 +1353,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(promptAgent !== undefined ? { agent: promptAgent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(resolvedTools ? { tools: resolvedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
@@ -1465,6 +1495,18 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
if (sessionStatus?.type === "idle") {
|
||||
if (this.recentlyCompactedSessions.has(sessionID)) {
|
||||
this.recentlyCompactedSessions.delete(sessionID)
|
||||
log("[background-agent] Polling: skipping post-compaction idle:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Refresh lastUpdate so the next poll's stale check doesn't kill
|
||||
// the task while we're awaiting async validation
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
|
||||
// Edge guard: Validate session has actual output before completing
|
||||
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
@@ -1570,6 +1612,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
this.notifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.recentlyCompactedSessions.clear()
|
||||
this.queuesByKey.clear()
|
||||
this.processingKeys.clear()
|
||||
this.unregisterProcessCleanup()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import {
|
||||
log,
|
||||
getAgentToolRestrictions,
|
||||
createInternalAgentTextPart,
|
||||
} from "../../shared"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
const CONTINUATION_PROMPT =
|
||||
"Your session was compacted (context summarized). Continue your analysis from where you left off. Report your findings when done."
|
||||
|
||||
export function sendPostCompactionContinuation(
|
||||
client: OpencodeClient,
|
||||
task: BackgroundTask,
|
||||
sessionID: string,
|
||||
): void {
|
||||
if (task.status !== "running") return
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] Post-compaction continuation error:", {
|
||||
taskId: task.id,
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties: Record<string, unknown>
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
recentlyCompactedSessions?: Set<string>
|
||||
onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
@@ -20,6 +22,8 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
recentlyCompactedSessions,
|
||||
onPostCompactionIdle,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
@@ -32,6 +36,13 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
if (recentlyCompactedSessions?.has(sessionID)) {
|
||||
recentlyCompactedSessions.delete(sessionID)
|
||||
log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID })
|
||||
onPostCompactionIdle?.(task, sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
|
||||
@@ -55,6 +66,13 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh lastUpdate to prevent stale timeout from racing with this async validation.
|
||||
// Without this, checkAndInterruptStaleTasks can kill the task synchronously
|
||||
// while validateSessionHasOutput is still awaiting an API response.
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
|
||||
validateSessionHasOutput(sessionID)
|
||||
.then(async (hasValidOutput) => {
|
||||
if (task.status !== "running") {
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
const skillNames = ["playwright", "git-master"]
|
||||
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
|
||||
|
||||
// then: all builtin skills resolved
|
||||
expect(result.resolved.size).toBe(2)
|
||||
@@ -199,7 +199,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
const skillNames = ["playwright", "nonexistent-skill-12345"]
|
||||
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
|
||||
|
||||
// then: existing skills resolved, non-existing in notFound
|
||||
expect(result.resolved.size).toBe(1)
|
||||
@@ -286,7 +286,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
const skillNames = ["git-master"]
|
||||
|
||||
// when: resolving without any gitMasterConfig
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
|
||||
|
||||
// then: watermark is injected (default is ON)
|
||||
expect(result.resolved.size).toBe(1)
|
||||
@@ -357,7 +357,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
const skillNames: string[] = []
|
||||
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
|
||||
|
||||
// then: empty results
|
||||
expect(result.resolved.size).toBe(0)
|
||||
|
||||
@@ -3,19 +3,20 @@ import { discoverSkills } from "./loader"
|
||||
import type { LoadedSkill } from "./types"
|
||||
import type { SkillResolutionOptions } from "./skill-resolution-options"
|
||||
|
||||
const cachedSkillsByProvider = new Map<string, LoadedSkill[]>()
|
||||
const skillCache = new Map<string, LoadedSkill[]>()
|
||||
|
||||
export function clearSkillCache(): void {
|
||||
cachedSkillsByProvider.clear()
|
||||
skillCache.clear()
|
||||
}
|
||||
|
||||
export async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> {
|
||||
const cacheKey = options?.browserProvider ?? "playwright"
|
||||
export async function getAllSkills(options: SkillResolutionOptions & { directory: string }): Promise<LoadedSkill[]> {
|
||||
const directory = options.directory
|
||||
const cacheKey = `${options?.browserProvider ?? "playwright"}:${directory}`
|
||||
const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0
|
||||
|
||||
// Skip cache if disabledSkills is provided (varies between calls)
|
||||
if (!hasDisabledSkills) {
|
||||
const cached = cachedSkillsByProvider.get(cacheKey)
|
||||
const cached = skillCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ export async function getAllSkills(options?: SkillResolutionOptions): Promise<Lo
|
||||
if (hasDisabledSkills) {
|
||||
allSkills = allSkills.filter((skill) => !options!.disabledSkills!.has(skill.name))
|
||||
} else {
|
||||
cachedSkillsByProvider.set(cacheKey, allSkills)
|
||||
skillCache.set(cacheKey, allSkills)
|
||||
}
|
||||
|
||||
return allSkills
|
||||
|
||||
@@ -4,6 +4,6 @@ export interface SkillResolutionOptions {
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
disabledSkills?: Set<string>
|
||||
/** Project directory to discover project-level skills from. Falls back to process.cwd() if not provided. */
|
||||
/** Project directory to discover project-level skills from. Required for async resolution — process.cwd() is unsafe in OpenCode. */
|
||||
directory?: string
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function resolveMultipleSkills(
|
||||
|
||||
export async function resolveSkillContentAsync(
|
||||
skillName: string,
|
||||
options?: SkillResolutionOptions
|
||||
options: SkillResolutionOptions & { directory: string }
|
||||
): Promise<string | null> {
|
||||
const allSkills = await getAllSkills(options)
|
||||
const skill = allSkills.find((loadedSkill) => loadedSkill.name === skillName)
|
||||
@@ -68,7 +68,7 @@ export async function resolveSkillContentAsync(
|
||||
|
||||
export async function resolveMultipleSkillsAsync(
|
||||
skillNames: string[],
|
||||
options?: SkillResolutionOptions
|
||||
options: SkillResolutionOptions & { directory: string }
|
||||
): Promise<{ resolved: Map<string, string>; notFound: string[] }> {
|
||||
const allSkills = await getAllSkills(options)
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
|
||||
356
src/hooks/agent-switch/hook.test.ts
Normal file
356
src/hooks/agent-switch/hook.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { createAgentSwitchHook } from "./hook"
|
||||
import {
|
||||
_resetForTesting,
|
||||
getPendingSwitch,
|
||||
setPendingSwitch,
|
||||
} from "../../features/agent-switch"
|
||||
import { _resetApplierForTesting, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
|
||||
|
||||
describe("agent-switch hook", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
_resetApplierForTesting()
|
||||
})
|
||||
|
||||
test("consumes pending switch only after successful promptAsync", async () => {
|
||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
||||
let switched = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async (args: Record<string, unknown>) => {
|
||||
promptAsyncCalls.push(args)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
|
||||
: ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-1", "prometheus", "plan this")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "ses-1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
expect(getPendingSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps pending switch when promptAsync fails", async () => {
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
throw new Error("temporary failure")
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-2", "atlas", "fix this")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "ses-2" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(getPendingSwitch("ses-2")).toEqual({
|
||||
agent: "atlas",
|
||||
context: "fix this",
|
||||
})
|
||||
|
||||
clearPendingSwitchRuntime("ses-2")
|
||||
})
|
||||
|
||||
test("retries after transient failure and eventually clears pending switch", async () => {
|
||||
let attempts = 0
|
||||
let switched = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
throw new Error("temporary failure")
|
||||
}
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
|
||||
: ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-3", "prometheus", "plan this")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "ses-3" },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350))
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(getPendingSwitch("ses-3")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears pending switch on session.deleted", async () => {
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-4", "atlas", "fix this")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: "ses-4" } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(getPendingSwitch("ses-4")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears pending switch on session.error with info.id", async () => {
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-10", "atlas", "fix this")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { info: { id: "ses-10" } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(getPendingSwitch("ses-10")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears pending switch on session.error with sessionID property", async () => {
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-11", "atlas", "fix this")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "ses-11" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(getPendingSwitch("ses-11")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("applies queued pending switch on terminal message.updated", async () => {
|
||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
||||
let switched = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async (args: Record<string, unknown>) => {
|
||||
promptAsyncCalls.push(args)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
|
||||
: ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-6", "atlas", "fix now")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg-6",
|
||||
sessionID: "ses-6",
|
||||
role: "assistant",
|
||||
agent: "Athena (Council)",
|
||||
finish: "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
|
||||
expect(body?.agent).toBe("Atlas (Plan Executor)")
|
||||
expect(getPendingSwitch("ses-6")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("applies queued pending switch on terminal message.updated even when role is missing", async () => {
|
||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
||||
let switched = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async (args: Record<string, unknown>) => {
|
||||
promptAsyncCalls.push(args)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
|
||||
: ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-8", "atlas", "fix now")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg-8",
|
||||
sessionID: "ses-8",
|
||||
agent: "Athena (Council)",
|
||||
finish: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
|
||||
expect(body?.agent).toBe("Atlas (Plan Executor)")
|
||||
expect(getPendingSwitch("ses-8")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("applies queued pending switch on terminal message.part.updated step-finish", async () => {
|
||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
||||
let switched = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async (args: Record<string, unknown>) => {
|
||||
promptAsyncCalls.push(args)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
|
||||
: ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-7", "atlas", "fix now")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
info: {
|
||||
sessionID: "ses-7",
|
||||
role: "assistant",
|
||||
},
|
||||
part: {
|
||||
id: "part-finish-1",
|
||||
sessionID: "ses-7",
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
|
||||
expect(body?.agent).toBe("Atlas (Plan Executor)")
|
||||
expect(getPendingSwitch("ses-7")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("applies queued pending switch on session.status idle", async () => {
|
||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
||||
let switched = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async (args: Record<string, unknown>) => {
|
||||
promptAsyncCalls.push(args)
|
||||
switched = true
|
||||
},
|
||||
messages: async () => switched
|
||||
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
|
||||
: ({ data: [] }),
|
||||
message: async () => ({ data: { parts: [] } }),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
setPendingSwitch("ses-9", "atlas", "fix now")
|
||||
const hook = createAgentSwitchHook(ctx)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "ses-9",
|
||||
status: {
|
||||
type: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
|
||||
expect(body?.agent).toBe("Atlas (Plan Executor)")
|
||||
expect(getPendingSwitch("ses-9")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
141
src/hooks/agent-switch/hook.ts
Normal file
141
src/hooks/agent-switch/hook.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPendingSwitch } from "../../features/agent-switch"
|
||||
import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
|
||||
import {
|
||||
isTerminalFinishValue,
|
||||
isTerminalStepFinishPart,
|
||||
} from "./terminal-detection"
|
||||
|
||||
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
if (fromProps) {
|
||||
return fromProps
|
||||
}
|
||||
|
||||
const status = props?.status as Record<string, unknown> | undefined
|
||||
const fromStatus = typeof status?.sessionID === "string" ? status.sessionID : undefined
|
||||
return fromStatus
|
||||
}
|
||||
|
||||
function getStatusTypeFromEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const directType = typeof props?.type === "string" ? props.type : undefined
|
||||
if (directType) {
|
||||
return directType
|
||||
}
|
||||
|
||||
const status = props?.status as Record<string, unknown> | undefined
|
||||
const statusType = typeof status?.type === "string" ? status.type : undefined
|
||||
return statusType
|
||||
}
|
||||
|
||||
export function createAgentSwitchHook(ctx: PluginInput) {
|
||||
return {
|
||||
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
|
||||
if (input.event.type === "session.deleted") {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const deletedSessionID = info?.id
|
||||
if (typeof deletedSessionID === "string") {
|
||||
clearPendingSwitchRuntime(deletedSessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type === "session.error") {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const erroredSessionID = info?.id ?? props?.sessionID
|
||||
if (typeof erroredSessionID === "string") {
|
||||
clearPendingSwitchRuntime(erroredSessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type === "message.updated") {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
|
||||
const finish = info?.finish
|
||||
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const isTerminalAssistantUpdate = isTerminalFinishValue(finish)
|
||||
if (!isTerminalAssistantUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
// Primary path: if switch_agent queued a pending switch, apply it as soon as
|
||||
// assistant turn is terminal (no reliance on session.idle timing).
|
||||
if (getPendingSwitch(sessionID)) {
|
||||
await applyPendingSwitch({
|
||||
sessionID,
|
||||
client: ctx.client,
|
||||
source: "message-updated",
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type === "message.part.updated") {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const part = props?.part
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionIDFromPart = typeof (part as Record<string, unknown> | undefined)?.sessionID === "string"
|
||||
? ((part as Record<string, unknown>).sessionID as string)
|
||||
: undefined
|
||||
const sessionIDFromInfo = typeof info?.sessionID === "string" ? info.sessionID : undefined
|
||||
const sessionID = sessionIDFromPart ?? sessionIDFromInfo
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTerminalStepFinishPart(part)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!getPendingSwitch(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
await applyPendingSwitch({
|
||||
sessionID,
|
||||
client: ctx.client,
|
||||
source: "message-part-step-finish",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type === "session.idle") {
|
||||
const props = input.event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
await applyPendingSwitch({
|
||||
sessionID,
|
||||
client: ctx.client,
|
||||
source: "idle",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (input.event.type === "session.status") {
|
||||
const sessionID = getSessionIDFromStatusEvent(input)
|
||||
const statusType = getStatusTypeFromEvent(input)
|
||||
if (!sessionID || statusType !== "idle") {
|
||||
return
|
||||
}
|
||||
|
||||
await applyPendingSwitch({
|
||||
sessionID,
|
||||
client: ctx.client,
|
||||
source: "status-idle",
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
1
src/hooks/agent-switch/index.ts
Normal file
1
src/hooks/agent-switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createAgentSwitchHook } from "./hook"
|
||||
36
src/hooks/agent-switch/terminal-detection.ts
Normal file
36
src/hooks/agent-switch/terminal-detection.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function isTerminalFinishValue(finish: unknown): boolean {
|
||||
if (typeof finish === "boolean") {
|
||||
return finish
|
||||
}
|
||||
|
||||
if (typeof finish === "string") {
|
||||
const normalized = finish.toLowerCase()
|
||||
return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
|
||||
}
|
||||
|
||||
if (typeof finish === "object" && finish !== null) {
|
||||
const record = finish as Record<string, unknown>
|
||||
const kind = record.type ?? record.reason
|
||||
if (typeof kind === "string") {
|
||||
const normalized = kind.toLowerCase()
|
||||
return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isTerminalStepFinishPart(part: unknown): boolean {
|
||||
if (typeof part !== "object" || part === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const record = part as Record<string, unknown>
|
||||
if (record.type !== "step-finish") {
|
||||
return false
|
||||
}
|
||||
|
||||
return isTerminalFinishValue(record.reason)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state";
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -60,6 +62,12 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
|
||||
const agent = getSessionAgent(sessionID);
|
||||
if (agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
@@ -40,6 +41,7 @@ export async function injectBoulderContinuation(input: {
|
||||
const prompt =
|
||||
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
|
||||
const promptAgent = normalizeAgentForPrompt(agent ?? "atlas") ?? "atlas"
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
@@ -50,7 +52,7 @@ export async function injectBoulderContinuation(input: {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: agent ?? "atlas",
|
||||
agent: promptAgent,
|
||||
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
|
||||
@@ -997,7 +997,7 @@ describe("atlas hook", () => {
|
||||
// then - should call prompt for sisyphus
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.agent).toBe("sisyphus")
|
||||
expect(callArgs.body.agent).toBe("Sisyphus (Ultraworker)")
|
||||
})
|
||||
|
||||
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
|
||||
|
||||
@@ -50,3 +50,5 @@ export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallba
|
||||
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
|
||||
export { createAgentSwitchHook } from "./agent-switch";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSessionAgent,
|
||||
subagentSessions,
|
||||
} from "../../features/claude-code-session-state"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
|
||||
@@ -45,6 +46,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
}
|
||||
|
||||
// Athena is a council orchestrator — skip all keyword injections.
|
||||
// search/analyze modes tell the agent to use explore agents and grep directly,
|
||||
// which conflicts with Athena's job of launching council members via task calls.
|
||||
// Use getAgentConfigKey to handle display name remapping ("Athena (Council)" → "athena").
|
||||
const agentConfigKey = currentAgent ? getAgentConfigKey(currentAgent) : undefined
|
||||
if (agentConfigKey === "athena") {
|
||||
if (detectedKeywords.length > 0) {
|
||||
log(`[keyword-detector] Skipping keywords for Athena (council orchestrator)`, {
|
||||
sessionID: input.sessionID,
|
||||
skippedTypes: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -745,4 +745,54 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
expect(textPart!.text).toBe("ultrawork plan this")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should skip ALL keyword injections for Athena with display name", async () => {
|
||||
// given - session agent is stored as display name "Athena (Council)" after remapping
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "athena-display-name-session"
|
||||
updateSessionAgent(sessionID, "Athena (Council)")
|
||||
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork search for bugs in the code" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with Athena display name in session state
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - ALL keywords should be skipped (no injection)
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart!.text).toBe("ultrawork search for bugs in the code")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).not.toContain("MAXIMIZE SEARCH EFFORT")
|
||||
|
||||
const skipLog = logCalls.find(c => c.msg.includes("Skipping keywords for Athena"))
|
||||
expect(skipLog).toBeDefined()
|
||||
|
||||
clearSessionAgent(sessionID)
|
||||
})
|
||||
|
||||
test("should skip ALL keyword injections for Athena with lowercase config key", async () => {
|
||||
// given - session agent is stored as lowercase "athena"
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "athena-lowercase-session"
|
||||
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the implementation" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with athena as input.agent
|
||||
await hook["chat.message"]({ sessionID, agent: "athena" }, output)
|
||||
|
||||
// then - ALL keywords should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart!.text).toBe("search for the implementation")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
|
||||
const skipLog = logCalls.find(c => c.msg.includes("Skipping keywords for Athena"))
|
||||
expect(skipLog).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeSDKResponse,
|
||||
resolveInheritedPromptTools,
|
||||
} from "../../shared"
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
|
||||
type MessageInfo = {
|
||||
agent?: string
|
||||
@@ -68,11 +69,12 @@ export async function injectContinuationPrompt(
|
||||
}
|
||||
|
||||
const inheritedTools = resolveInheritedPromptTools(sourceSessionID, tools)
|
||||
const promptAgent = normalizeAgentForPrompt(agent)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(promptAgent ? { agent: promptAgent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(options.prompt)],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
|
||||
@@ -25,13 +26,15 @@ export function extractResumeConfig(userMessage: MessageData | undefined, sessio
|
||||
}
|
||||
|
||||
export async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
|
||||
const promptAgent = normalizeAgentForPrompt(config.agent)
|
||||
|
||||
try {
|
||||
const inheritedTools = resolveInheritedPromptTools(config.sessionID, config.tools)
|
||||
await client.session.promptAsync({
|
||||
path: { id: config.sessionID },
|
||||
body: {
|
||||
parts: [createInternalAgentTextPart(RECOVERY_RESUME_TEXT)],
|
||||
agent: config.agent,
|
||||
...(promptAgent ? { agent: promptAgent } : {}),
|
||||
model: config.model,
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
|
||||
import {
|
||||
ABORT_WINDOW_MS,
|
||||
CONTINUATION_COOLDOWN_MS,
|
||||
@@ -164,6 +165,10 @@ export async function handleSessionIdle(args: {
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
|
||||
|
||||
const resolvedAgentName = resolvedInfo?.agent
|
||||
if (resolvedAgentName && resolvedAgentName.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
|
||||
log(`[${HOOK_NAME}] Skipped: council member agent`, { sessionID, agent: resolvedAgentName })
|
||||
return
|
||||
}
|
||||
if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName })
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import {
|
||||
@@ -80,7 +81,7 @@ async function resolveMainSessionTarget(
|
||||
log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) })
|
||||
}
|
||||
|
||||
return { agent, model, tools: resolveInheritedPromptTools(sessionID, tools) }
|
||||
return { agent: normalizeAgentForPrompt(agent), model, tools: resolveInheritedPromptTools(sessionID, tools) }
|
||||
}
|
||||
|
||||
async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise<string | null> {
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function applyAgentConfig(params: {
|
||||
const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false;
|
||||
const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false;
|
||||
|
||||
const athenaCouncilConfig = params.pluginConfig.agents?.athena?.council
|
||||
const builtinAgents = await createBuiltinAgents(
|
||||
migratedDisabledAgents,
|
||||
params.pluginConfig.agents,
|
||||
@@ -92,6 +93,7 @@ export async function applyAgentConfig(params: {
|
||||
disabledSkills,
|
||||
useTaskSystem,
|
||||
disableOmoEnv,
|
||||
athenaCouncilConfig,
|
||||
);
|
||||
|
||||
const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true;
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("remapAgentKeysToDisplayNames", () => {
|
||||
hephaestus: {},
|
||||
prometheus: {},
|
||||
atlas: {},
|
||||
athena: {},
|
||||
metis: {},
|
||||
momus: {},
|
||||
"sisyphus-junior": {},
|
||||
@@ -52,6 +53,7 @@ describe("remapAgentKeysToDisplayNames", () => {
|
||||
"Hephaestus (Deep Agent)",
|
||||
"Prometheus (Plan Builder)",
|
||||
"Atlas (Plan Executor)",
|
||||
"Athena (Council)",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Critic)",
|
||||
"Sisyphus-Junior",
|
||||
|
||||
@@ -1310,7 +1310,7 @@ describe("disable_omo_env pass-through", () => {
|
||||
const lastCall =
|
||||
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
|
||||
expect(lastCall).toBeDefined()
|
||||
expect(lastCall?.[12]).toBe(true)
|
||||
expect(lastCall?.[13]).toBe(true)
|
||||
})
|
||||
|
||||
test("passes disable_omo_env=false to createBuiltinAgents when omitted", async () => {
|
||||
@@ -1344,6 +1344,6 @@ describe("disable_omo_env pass-through", () => {
|
||||
const lastCall =
|
||||
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
|
||||
expect(lastCall).toBeDefined()
|
||||
expect(lastCall?.[12]).toBe(false)
|
||||
expect(lastCall?.[13]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ export function applyToolConfig(params: {
|
||||
LspCodeActionResolve: false,
|
||||
"task_*": false,
|
||||
teammate: false,
|
||||
prepare_council_prompt: false,
|
||||
...(params.pluginConfig.experimental?.task_system
|
||||
? { todowrite: false, todoread: false }
|
||||
: {}),
|
||||
@@ -97,6 +98,19 @@ export function applyToolConfig(params: {
|
||||
...denyTodoTools,
|
||||
};
|
||||
}
|
||||
// NOTE: Athena/council tool restrictions are also defined in:
|
||||
// - src/agents/athena/agent.ts (AgentConfig permission format)
|
||||
// - src/shared/agent-tool-restrictions.ts (boolean format for session.prompt)
|
||||
// Keep all three in sync when modifying.
|
||||
const athena = agentByKey(params.agentResult, "athena");
|
||||
if (athena) {
|
||||
athena.permission = {
|
||||
...athena.permission,
|
||||
task: "allow",
|
||||
prepare_council_prompt: "allow",
|
||||
question: questionPermission,
|
||||
};
|
||||
}
|
||||
|
||||
params.config.permission = {
|
||||
...(params.config.permission as Record<string, unknown>),
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createPluginInterface(args: {
|
||||
"tool.execute.before": createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
hooks,
|
||||
backgroundManager: managers.backgroundManager,
|
||||
}),
|
||||
|
||||
"tool.execute.after": createToolExecuteAfterHandler({
|
||||
|
||||
@@ -10,17 +10,6 @@ import {
|
||||
syncSubagentSessions,
|
||||
updateSessionAgent,
|
||||
} from "../features/claude-code-session-state";
|
||||
import {
|
||||
clearPendingModelFallback,
|
||||
clearSessionFallbackChain,
|
||||
setPendingModelFallback,
|
||||
} from "../hooks/model-fallback/hook";
|
||||
import { resetMessageCursor } from "../shared";
|
||||
import { log } from "../shared/logger";
|
||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||
import { lspManager } from "../tools";
|
||||
|
||||
import type { CreatedHooks } from "../create-hooks";
|
||||
import type { Managers } from "../create-managers";
|
||||
@@ -135,6 +124,7 @@ export function createEventHandler(args: {
|
||||
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
|
||||
|
||||
const dispatchToHooks = async (input: EventInput): Promise<void> => {
|
||||
await Promise.resolve(hooks.agentSwitchHook?.event?.(input));
|
||||
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
|
||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createCompactionContextInjector,
|
||||
createCompactionTodoPreserverHook,
|
||||
createAtlasHook,
|
||||
createAgentSwitchHook,
|
||||
} 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
|
||||
agentSwitchHook: ReturnType<typeof createAgentSwitchHook> | null
|
||||
}
|
||||
|
||||
type SessionRecovery = {
|
||||
@@ -111,6 +113,10 @@ export function createContinuationHooks(args: {
|
||||
}))
|
||||
: null
|
||||
|
||||
const agentSwitchHook = isHookEnabled("agent-switch")
|
||||
? safeHook("agent-switch", () => createAgentSwitchHook(ctx))
|
||||
: null
|
||||
|
||||
return {
|
||||
stopContinuationGuard,
|
||||
compactionContextInjector,
|
||||
@@ -119,5 +125,6 @@ export function createContinuationHooks(args: {
|
||||
unstableAgentBabysitter,
|
||||
backgroundNotificationHook,
|
||||
atlasHook,
|
||||
agentSwitchHook,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,109 @@ const { describe, expect, test } = require("bun:test")
|
||||
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
|
||||
|
||||
describe("createToolExecuteBeforeHandler", () => {
|
||||
test("blocks Athena question tool while council members are still running", async () => {
|
||||
//#given
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant", agent: "Athena (Council)" } }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const backgroundManager = {
|
||||
getTasksByParentSession: () => [
|
||||
{ agent: "Council: Claude Opus 4.6", status: "running" },
|
||||
],
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
hooks: {},
|
||||
backgroundManager,
|
||||
})
|
||||
|
||||
//#when
|
||||
const run = handler(
|
||||
{ tool: "question", sessionID: "ses_athena", callID: "call_1" },
|
||||
{ args: { questions: [] } }
|
||||
)
|
||||
|
||||
//#then
|
||||
await expect(run).rejects.toThrow("Council members are still running")
|
||||
})
|
||||
|
||||
test("blocks Athena switch_agent while council members are still running", async () => {
|
||||
//#given
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant", agent: "Athena (Council)" } }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const backgroundManager = {
|
||||
getTasksByParentSession: () => [
|
||||
{ agent: "Council: GPT 5.2", status: "pending" },
|
||||
],
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
hooks: {},
|
||||
backgroundManager,
|
||||
})
|
||||
|
||||
//#when
|
||||
const run = handler(
|
||||
{ tool: "switch_agent", sessionID: "ses_athena", callID: "call_1" },
|
||||
{ args: { agent: "atlas", context: "ctx" } }
|
||||
)
|
||||
|
||||
//#then
|
||||
await expect(run).rejects.toThrow("Council members are still running")
|
||||
})
|
||||
|
||||
test("allows Athena question tool when no council members are pending", async () => {
|
||||
//#given
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant", agent: "Athena (Council)" } }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const backgroundManager = {
|
||||
getTasksByParentSession: () => [
|
||||
{ agent: "Council: Claude Opus 4.6", status: "completed" },
|
||||
{ agent: "Council: GPT 5.2", status: "cancelled" },
|
||||
],
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
hooks: {},
|
||||
backgroundManager,
|
||||
})
|
||||
|
||||
//#when
|
||||
const run = handler(
|
||||
{ tool: "question", sessionID: "ses_athena", callID: "call_1" },
|
||||
{ args: { questions: [] } }
|
||||
)
|
||||
|
||||
//#then
|
||||
await expect(run).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not execute subagent question blocker hook for question tool", async () => {
|
||||
//#given
|
||||
const ctx = {
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
import type { PluginContext } from "./types"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
|
||||
import { getMainSessionID } from "../features/claude-code-session-state"
|
||||
import { clearBoulderState } from "../features/boulder-state"
|
||||
import { log } from "../shared"
|
||||
import { resolveSessionAgent } from "./session-agent-resolver"
|
||||
import { parseRalphLoopArguments } from "../hooks/ralph-loop/command-arguments"
|
||||
import { getAgentConfigKey } from "../shared/agent-display-names"
|
||||
|
||||
import type { CreatedHooks } from "../create-hooks"
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../agents/builtin-agents/council-member-agents"
|
||||
|
||||
export function createToolExecuteBeforeHandler(args: {
|
||||
ctx: PluginContext
|
||||
hooks: CreatedHooks
|
||||
backgroundManager?: Pick<BackgroundManager, "getTasksByParentSession">
|
||||
}): (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> },
|
||||
) => Promise<void> {
|
||||
const { ctx, hooks } = args
|
||||
const { ctx, hooks, backgroundManager } = args
|
||||
|
||||
function hasPendingCouncilMembers(sessionID: string): boolean {
|
||||
if (!backgroundManager) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tasks = backgroundManager.getTasksByParentSession(sessionID)
|
||||
return tasks.some((task) =>
|
||||
task.agent.startsWith(COUNCIL_MEMBER_KEY_PREFIX) &&
|
||||
(task.status === "pending" || task.status === "running")
|
||||
)
|
||||
}
|
||||
|
||||
return async (input, output): Promise<void> => {
|
||||
const toolNameLower = input.tool?.toLowerCase()
|
||||
|
||||
if (toolNameLower === "question" || toolNameLower === "askuserquestion" || toolNameLower === "ask_user_question" || toolNameLower === "switch_agent") {
|
||||
if (hasPendingCouncilMembers(input.sessionID)) {
|
||||
const sessionAgent = await resolveSessionAgent(ctx.client, input.sessionID)
|
||||
const sessionAgentKey = sessionAgent ? getAgentConfigKey(sessionAgent) : undefined
|
||||
|
||||
if (sessionAgentKey === "athena") {
|
||||
throw new Error(
|
||||
"Council members are still running. Wait for all launched members to finish and collect their outputs before asking next-step questions or switching agents."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
builtinTools,
|
||||
createBackgroundTools,
|
||||
createCallOmoAgent,
|
||||
createSwitchAgentTool,
|
||||
createLookAt,
|
||||
createSkillMcpTool,
|
||||
createSkillTool,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
createTaskList,
|
||||
createTaskUpdateTool,
|
||||
createHashlineEditTool,
|
||||
createPrepareCouncilPromptTool,
|
||||
} from "../tools"
|
||||
import { getMainSessionID } from "../features/claude-code-session-state"
|
||||
import { filterDisabledTools } from "../shared/disabled-tools"
|
||||
@@ -101,6 +103,7 @@ export function createToolRegistry(args: {
|
||||
mcpManager: managers.skillMcpManager,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
directory: ctx.directory,
|
||||
})
|
||||
|
||||
const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false
|
||||
@@ -126,6 +129,9 @@ export function createToolRegistry(args: {
|
||||
...createSessionManagerTools(ctx),
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
switch_agent: createSwitchAgentTool({
|
||||
client: ctx.client,
|
||||
}),
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
task: delegateTask,
|
||||
skill_mcp: skillMcpTool,
|
||||
@@ -133,6 +139,7 @@ export function createToolRegistry(args: {
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
...hashlineToolsRecord,
|
||||
prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory),
|
||||
}
|
||||
|
||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
||||
|
||||
@@ -187,10 +187,12 @@ describe("AGENT_DISPLAY_NAMES", () => {
|
||||
"sisyphus-junior": "Sisyphus-Junior",
|
||||
metis: "Metis (Plan Consultant)",
|
||||
momus: "Momus (Plan Critic)",
|
||||
athena: "Athena (Council)",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"council-member": "council-member",
|
||||
}
|
||||
|
||||
// when checking the constant
|
||||
|
||||
@@ -11,10 +11,12 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
|
||||
"sisyphus-junior": "Sisyphus-Junior",
|
||||
metis: "Metis (Plan Consultant)",
|
||||
momus: "Momus (Plan Critic)",
|
||||
athena: "Athena (Council)",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"council-member": "council-member",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,4 +53,32 @@ export function getAgentConfigKey(agentName: string): string {
|
||||
if (reversed !== undefined) return reversed
|
||||
if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower
|
||||
return lower
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an agent name for prompt APIs.
|
||||
* - Known display names -> canonical display names
|
||||
* - Known config keys (any case) -> canonical display names
|
||||
* - Unknown/custom names -> preserved as-is (trimmed)
|
||||
*/
|
||||
export function normalizeAgentForPrompt(agentName: string | undefined): string | undefined {
|
||||
if (typeof agentName !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trimmed = agentName.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase()
|
||||
const reversed = REVERSE_DISPLAY_NAMES[lower]
|
||||
if (reversed !== undefined) {
|
||||
return AGENT_DISPLAY_NAMES[reversed] ?? trimmed
|
||||
}
|
||||
if (AGENT_DISPLAY_NAMES[lower] !== undefined) {
|
||||
return AGENT_DISPLAY_NAMES[lower]
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
140
src/shared/agent-tool-restrictions-parity.test.ts
Normal file
140
src/shared/agent-tool-restrictions-parity.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Parity test: verifies Athena and council-member tool restrictions stay in sync
|
||||
* across the 3 definition surfaces.
|
||||
*
|
||||
* Surface 1: src/agents/athena/agent.ts — createAgentToolRestrictions() deny-list
|
||||
* Surface 2: src/shared/agent-tool-restrictions.ts — AGENT_RESTRICTIONS boolean map
|
||||
* Surface 3: src/agents/athena/council-member-agent.ts — createAgentToolAllowlist() array
|
||||
*
|
||||
* This test FAILS if someone adds/removes a restriction in one surface without updating the others.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { getAgentToolRestrictions } from "./agent-tool-restrictions"
|
||||
|
||||
// Surface 1: Athena deny-list from src/agents/athena/agent.ts
|
||||
// createAgentToolRestrictions(["write", "edit", "call_omo_agent"])
|
||||
const ATHENA_DENY_LIST = ["write", "edit", "call_omo_agent"]
|
||||
|
||||
// Surface 3: Council-member allowlist from src/agents/athena/council-member-agent.ts
|
||||
// createAgentToolAllowlist([...])
|
||||
const COUNCIL_MEMBER_ALLOWLIST = [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"lsp_goto_definition",
|
||||
"lsp_find_references",
|
||||
"lsp_symbols",
|
||||
"lsp_diagnostics",
|
||||
"ast_grep_search",
|
||||
"call_omo_agent",
|
||||
"background_output",
|
||||
]
|
||||
|
||||
// Tools granted to Athena by tool-config-handler.ts (not in deny-list, not in AGENT_RESTRICTIONS)
|
||||
const ATHENA_HANDLER_GRANTS = ["task", "prepare_council_prompt"]
|
||||
|
||||
describe("agent tool restrictions parity", () => {
|
||||
describe("given Athena restrictions", () => {
|
||||
describe("#when comparing deny-list (agent.ts) with boolean map (agent-tool-restrictions.ts)", () => {
|
||||
it("every tool in the deny-list has a matching false entry in AGENT_RESTRICTIONS", () => {
|
||||
const athenaRestrictions = getAgentToolRestrictions("athena")
|
||||
|
||||
for (const tool of ATHENA_DENY_LIST) {
|
||||
expect(
|
||||
athenaRestrictions[tool],
|
||||
`Tool "${tool}" is in the deny-list (agent.ts) but not false in AGENT_RESTRICTIONS["athena"]`
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it("every false entry in AGENT_RESTRICTIONS is in the deny-list", () => {
|
||||
const athenaRestrictions = getAgentToolRestrictions("athena")
|
||||
const deniedInMap = Object.entries(athenaRestrictions)
|
||||
.filter(([, value]) => value === false)
|
||||
.map(([key]) => key)
|
||||
|
||||
for (const tool of deniedInMap) {
|
||||
expect(
|
||||
ATHENA_DENY_LIST,
|
||||
`Tool "${tool}" is false in AGENT_RESTRICTIONS["athena"] but missing from deny-list (agent.ts)`
|
||||
).toContain(tool)
|
||||
}
|
||||
})
|
||||
|
||||
it("deny-list and AGENT_RESTRICTIONS false-entries have the same length", () => {
|
||||
const athenaRestrictions = getAgentToolRestrictions("athena")
|
||||
const deniedInMap = Object.entries(athenaRestrictions)
|
||||
.filter(([, value]) => value === false)
|
||||
.map(([key]) => key)
|
||||
|
||||
expect(deniedInMap.length).toBe(ATHENA_DENY_LIST.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when checking handler grants do not conflict with deny-list", () => {
|
||||
it("tools granted by tool-config-handler are NOT in the deny-list", () => {
|
||||
for (const tool of ATHENA_HANDLER_GRANTS) {
|
||||
expect(
|
||||
ATHENA_DENY_LIST,
|
||||
`Tool "${tool}" is granted by tool-config-handler but also in the deny-list — conflict!`
|
||||
).not.toContain(tool)
|
||||
}
|
||||
})
|
||||
|
||||
it("tools granted by tool-config-handler are NOT false in AGENT_RESTRICTIONS", () => {
|
||||
const athenaRestrictions = getAgentToolRestrictions("athena")
|
||||
|
||||
for (const tool of ATHENA_HANDLER_GRANTS) {
|
||||
expect(
|
||||
athenaRestrictions[tool],
|
||||
`Tool "${tool}" is granted by tool-config-handler but is false in AGENT_RESTRICTIONS["athena"]`
|
||||
).not.toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given council-member restrictions", () => {
|
||||
describe("#when comparing allowlist (council-member-agent.ts) with boolean map (agent-tool-restrictions.ts)", () => {
|
||||
it("every tool in the allowlist has a matching true entry in AGENT_RESTRICTIONS", () => {
|
||||
const councilRestrictions = getAgentToolRestrictions("council-member")
|
||||
|
||||
for (const tool of COUNCIL_MEMBER_ALLOWLIST) {
|
||||
expect(
|
||||
councilRestrictions[tool],
|
||||
`Tool "${tool}" is in the allowlist (council-member-agent.ts) but not true in AGENT_RESTRICTIONS["council-member"]`
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("every true entry in AGENT_RESTRICTIONS is in the allowlist", () => {
|
||||
const councilRestrictions = getAgentToolRestrictions("council-member")
|
||||
const allowedInMap = Object.entries(councilRestrictions)
|
||||
.filter(([key, value]) => key !== "*" && value === true)
|
||||
.map(([key]) => key)
|
||||
|
||||
for (const tool of allowedInMap) {
|
||||
expect(
|
||||
COUNCIL_MEMBER_ALLOWLIST,
|
||||
`Tool "${tool}" is true in AGENT_RESTRICTIONS["council-member"] but missing from allowlist (council-member-agent.ts)`
|
||||
).toContain(tool)
|
||||
}
|
||||
})
|
||||
|
||||
it("allowlist and AGENT_RESTRICTIONS true-entries have the same length", () => {
|
||||
const councilRestrictions = getAgentToolRestrictions("council-member")
|
||||
const allowedInMap = Object.entries(councilRestrictions)
|
||||
.filter(([key, value]) => key !== "*" && value === true)
|
||||
.map(([key]) => key)
|
||||
|
||||
expect(allowedInMap.length).toBe(COUNCIL_MEMBER_ALLOWLIST.length)
|
||||
})
|
||||
|
||||
it("AGENT_RESTRICTIONS has wildcard deny (*: false) for council-member", () => {
|
||||
const councilRestrictions = getAgentToolRestrictions("council-member")
|
||||
expect(councilRestrictions["*"]).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
58
src/shared/agent-tool-restrictions.test.ts
Normal file
58
src/shared/agent-tool-restrictions.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getAgentToolRestrictions } from "./agent-tool-restrictions"
|
||||
|
||||
describe("agent-tool-restrictions", () => {
|
||||
test("athena restrictions include call_omo_agent", () => {
|
||||
//#given
|
||||
//#when
|
||||
const restrictions = getAgentToolRestrictions("athena")
|
||||
//#then
|
||||
expect(restrictions.write).toBe(false)
|
||||
expect(restrictions.edit).toBe(false)
|
||||
expect(restrictions.call_omo_agent).toBe(false)
|
||||
})
|
||||
|
||||
test("council-member restrictions include all denied tools", () => {
|
||||
//#given
|
||||
//#when
|
||||
const restrictions = getAgentToolRestrictions("council-member")
|
||||
//#then
|
||||
// Wildcard deny key
|
||||
expect(restrictions["*"]).toBe(false)
|
||||
// Explicitly allowed tools
|
||||
expect(restrictions.read).toBe(true)
|
||||
expect(restrictions.grep).toBe(true)
|
||||
expect(restrictions.call_omo_agent).toBe(true)
|
||||
expect(restrictions.background_output).toBe(true)
|
||||
// Explicitly denied tools
|
||||
expect(restrictions.todowrite).toBe(false)
|
||||
expect(restrictions.todoread).toBe(false)
|
||||
// Unlisted tools are undefined (SDK applies wildcard at runtime)
|
||||
expect(restrictions.switch_agent).toBeUndefined()
|
||||
expect(restrictions.background_wait).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given dynamic council member name #when getAgentToolRestrictions #then returns council-member restrictions", () => {
|
||||
//#given
|
||||
const dynamicName = "Council: Claude Opus 4.6"
|
||||
//#when
|
||||
const restrictions = getAgentToolRestrictions(dynamicName)
|
||||
//#then
|
||||
// Wildcard deny key
|
||||
expect(restrictions["*"]).toBe(false)
|
||||
// Explicitly allowed tools
|
||||
expect(restrictions.read).toBe(true)
|
||||
expect(restrictions.grep).toBe(true)
|
||||
expect(restrictions.call_omo_agent).toBe(true)
|
||||
expect(restrictions.background_output).toBe(true)
|
||||
// Explicitly denied tools
|
||||
expect(restrictions.todowrite).toBe(false)
|
||||
expect(restrictions.todoread).toBe(false)
|
||||
// Unlisted tools are undefined (SDK applies wildcard at runtime)
|
||||
expect(restrictions.switch_agent).toBeUndefined()
|
||||
expect(restrictions.write).toBeUndefined()
|
||||
expect(restrictions.edit).toBeUndefined()
|
||||
expect(restrictions.task).toBeUndefined()
|
||||
expect(restrictions.background_wait).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@
|
||||
* true = tool allowed, false = tool denied.
|
||||
*/
|
||||
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../agents/builtin-agents/council-member-agents"
|
||||
|
||||
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
|
||||
write: false,
|
||||
edit: false,
|
||||
@@ -42,16 +44,46 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
"sisyphus-junior": {
|
||||
task: false,
|
||||
},
|
||||
|
||||
athena: {
|
||||
write: false,
|
||||
edit: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
|
||||
// NOTE: Athena/council tool restrictions are also defined in:
|
||||
// - src/agents/athena/agent.ts (AgentConfig permission format)
|
||||
// - src/agents/athena/council-member-agent.ts (AgentConfig permission format — allow-list)
|
||||
// - src/plugin-handlers/tool-config-handler.ts (allow/deny string format)
|
||||
// Keep all three in sync when modifying.
|
||||
// Council members use an allow-list: read-only analysis + optional call_omo_agent delegation.
|
||||
// TodoWrite/TodoRead explicitly denied to prevent uncompletable todo loops.
|
||||
// Prompt file lives in .sisyphus/tmp/ (inside project) so no external_directory needed.
|
||||
"council-member": {
|
||||
"*": false,
|
||||
read: true,
|
||||
grep: true,
|
||||
glob: true,
|
||||
lsp_goto_definition: true,
|
||||
lsp_find_references: true,
|
||||
lsp_symbols: true,
|
||||
lsp_diagnostics: true,
|
||||
ast_grep_search: true,
|
||||
call_omo_agent: true,
|
||||
background_output: true,
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
},
|
||||
}
|
||||
|
||||
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
|
||||
if (agentName.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
|
||||
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 {
|
||||
const restrictions = AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
return restrictions !== undefined && Object.keys(restrictions).length > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -179,8 +179,8 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(hephaestus.requiresModel).toBeUndefined()
|
||||
})
|
||||
|
||||
test("all 10 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 10 agent names
|
||||
test("all 12 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 12 agent names
|
||||
const expectedAgents = [
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
@@ -192,13 +192,15 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
"athena",
|
||||
"council-member",
|
||||
]
|
||||
|
||||
// when - checking AGENT_MODEL_REQUIREMENTS
|
||||
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all agents present with valid fallbackChain
|
||||
expect(definedAgents).toHaveLength(10)
|
||||
expect(definedAgents).toHaveLength(12)
|
||||
for (const agent of expectedAgents) {
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
|
||||
expect(requirement).toBeDefined()
|
||||
|
||||
@@ -89,6 +89,21 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
},
|
||||
athena: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
{ providers: ["opencode"], model: "glm-4.7-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
],
|
||||
},
|
||||
"council-member": {
|
||||
fallbackChain: [
|
||||
{ providers: ["opencode"], model: "gpt-5-nano" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
|
||||
@@ -4,4 +4,6 @@ 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.`
|
||||
|
||||
export const BACKGROUND_WAIT_DESCRIPTION = `Wait for the next background task to complete from a set of task IDs. Returns as soon as ANY one finishes, with its result and a progress summary. Call repeatedly with remaining IDs until all are done.`
|
||||
|
||||
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`
|
||||
|
||||
132
src/tools/background-task/create-background-wait.ts
Normal file
132
src/tools/background-task/create-background-wait.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundOutputManager, BackgroundOutputClient } from "./clients"
|
||||
import { BACKGROUND_WAIT_DESCRIPTION } from "./constants"
|
||||
import { delay } from "./delay"
|
||||
import { formatTaskResult } from "./task-result-format"
|
||||
import { formatTaskStatus } from "./task-status-format"
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000
|
||||
const MAX_TIMEOUT_MS = 600_000
|
||||
|
||||
const TERMINAL_STATUSES = new Set(["completed", "error", "cancelled", "interrupt"])
|
||||
|
||||
function isTerminal(status: string): boolean {
|
||||
return TERMINAL_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export function createBackgroundWait(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_WAIT_DESCRIPTION,
|
||||
args: {
|
||||
task_ids: tool.schema.array(tool.schema.string()).describe("Task IDs to monitor — returns when ANY one reaches a terminal state"),
|
||||
timeout: tool.schema.number().optional().describe("Max wait in ms. Default: 120000 (2 min). The tool returns immediately when any task finishes, so large values are fine."),
|
||||
},
|
||||
async execute(args: { task_ids: string[]; timeout?: number }, toolContext?: unknown) {
|
||||
const abort = (toolContext as { abort?: AbortSignal } | undefined)?.abort
|
||||
|
||||
const taskIds = args.task_ids
|
||||
if (!taskIds || taskIds.length === 0) {
|
||||
return "Error: task_ids array is required and must not be empty."
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
|
||||
|
||||
const alreadyTerminal = findFirstTerminal(manager, taskIds)
|
||||
if (alreadyTerminal) {
|
||||
return await buildCompletionResult(alreadyTerminal, manager, client, taskIds)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (abort?.aborted) {
|
||||
return buildProgressSummary(manager, taskIds, true)
|
||||
}
|
||||
|
||||
await delay(1000)
|
||||
|
||||
const found = findFirstTerminal(manager, taskIds)
|
||||
if (found) {
|
||||
return await buildCompletionResult(found, manager, client, taskIds)
|
||||
}
|
||||
}
|
||||
|
||||
return buildProgressSummary(manager, taskIds, true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function findFirstTerminal(manager: BackgroundOutputManager, taskIds: string[]): { id: string; status: string } | undefined {
|
||||
for (const id of taskIds) {
|
||||
const task = manager.getTask(id)
|
||||
if (!task) continue
|
||||
if (isTerminal(task.status)) {
|
||||
return { id, status: task.status }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function buildCompletionResult(
|
||||
completed: { id: string; status: string },
|
||||
manager: BackgroundOutputManager,
|
||||
client: BackgroundOutputClient,
|
||||
allIds: string[],
|
||||
): Promise<string> {
|
||||
const task = manager.getTask(completed.id)
|
||||
if (!task) return `Task was deleted: ${completed.id}`
|
||||
|
||||
const taskResult = task.status === "completed"
|
||||
? await formatTaskResult(task, client)
|
||||
: formatTaskStatus(task)
|
||||
|
||||
const summary = buildProgressSummary(manager, allIds, false)
|
||||
const remaining = allIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? ""))
|
||||
|
||||
const lines = [summary, "", "---", "", taskResult]
|
||||
|
||||
if (remaining.length > 0) {
|
||||
const idList = remaining.map((id) => `"${id}"`).join(", ")
|
||||
lines.push("", `**${remaining.length} task${remaining.length === 1 ? "" : "s"} still running.** Call background_wait again with task_ids: [${idList}]`)
|
||||
} else {
|
||||
lines.push("", "**All tasks complete.** Proceed with synthesis.")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function buildProgressSummary(manager: BackgroundOutputManager, taskIds: string[], isTimeout: boolean): string {
|
||||
const done = taskIds.filter((id) => isTerminal(manager.getTask(id)?.status ?? ""))
|
||||
const total = taskIds.length
|
||||
|
||||
const header = isTimeout
|
||||
? `## Still Waiting: [${progressBar(done.length, total)}] ${done.length}/${total}`
|
||||
: `## Council Progress: [${progressBar(done.length, total)}] ${done.length}/${total}`
|
||||
|
||||
const lines = [header, ""]
|
||||
|
||||
for (const id of taskIds) {
|
||||
const t = manager.getTask(id)
|
||||
if (!t) {
|
||||
lines.push(`- \`${id}\` — not found`)
|
||||
continue
|
||||
}
|
||||
const marker = isTerminal(t.status) ? "received" : "waiting..."
|
||||
lines.push(`- ${t.description || t.id} — ${marker}`)
|
||||
}
|
||||
|
||||
if (isTimeout) {
|
||||
const remaining = taskIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? ""))
|
||||
if (remaining.length > 0) {
|
||||
const idList = remaining.map((id) => `"${id}"`).join(", ")
|
||||
lines.push("", `**Timeout — tasks still running.** Call background_wait again with task_ids: [${idList}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function progressBar(done: number, total: number): string {
|
||||
const filled = "#".repeat(done)
|
||||
const empty = "-".repeat(total - done)
|
||||
return `${filled}${empty}`
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
98
src/tools/delegate-task/model-string-parser.test.ts
Normal file
98
src/tools/delegate-task/model-string-parser.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseModelString } from "./model-string-parser"
|
||||
|
||||
describe("parseModelString", () => {
|
||||
describe("valid model strings", () => {
|
||||
//#given provider/model strings with one separator
|
||||
//#when parsing model strings
|
||||
//#then it returns providerID and modelID parts
|
||||
|
||||
test("parses anthropic model", () => {
|
||||
expect(parseModelString("anthropic/claude-opus-4-6")).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses openai model", () => {
|
||||
expect(parseModelString("openai/gpt-5.3-codex")).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.3-codex",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses google model", () => {
|
||||
expect(parseModelString("google/gemini-3-flash")).toEqual({
|
||||
providerID: "google",
|
||||
modelID: "gemini-3-flash",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses xai model", () => {
|
||||
expect(parseModelString("xai/grok-code-fast-1")).toEqual({
|
||||
providerID: "xai",
|
||||
modelID: "grok-code-fast-1",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
//#given a model string with extra slashes
|
||||
//#when parsing with first slash as separator
|
||||
//#then provider is before first slash and model keeps remaining path
|
||||
|
||||
test("keeps extra slashes in model segment", () => {
|
||||
expect(parseModelString("provider/model/with/extra/slashes")).toEqual({
|
||||
providerID: "provider",
|
||||
modelID: "model/with/extra/slashes",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid model strings", () => {
|
||||
//#given malformed or empty model strings
|
||||
//#when parsing model strings
|
||||
//#then it returns undefined
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseModelString("")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for model without slash", () => {
|
||||
expect(parseModelString("no-slash-model")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for empty provider", () => {
|
||||
expect(parseModelString("/missing-provider")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for empty model", () => {
|
||||
expect(parseModelString("missing-model/")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
//#given model strings with whitespace
|
||||
//#when parsing
|
||||
//#then it rejects whitespace-only parts and trims valid parts
|
||||
|
||||
test("returns undefined for whitespace-only string", () => {
|
||||
expect(parseModelString(" ")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for whitespace-only provider", () => {
|
||||
expect(parseModelString(" /model")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for whitespace-only model", () => {
|
||||
expect(parseModelString("provider/ ")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("trims whitespace from provider and model", () => {
|
||||
expect(parseModelString(" openai / gpt-5.3-codex ")).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.3-codex",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,21 @@
|
||||
* Parse a model string in "provider/model" format.
|
||||
*/
|
||||
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
|
||||
const parts = model.split("/")
|
||||
if (parts.length >= 2) {
|
||||
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
|
||||
if (!model || !model.trim()) {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
|
||||
const slashIndex = model.indexOf("/")
|
||||
if (slashIndex <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerID = model.substring(0, slashIndex).trim()
|
||||
const modelID = model.substring(slashIndex + 1).trim()
|
||||
|
||||
if (!providerID || !modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
import { resolveMultipleSkillsAsync, getAllSkills } from "../../features/opencode-skill-loader/skill-content"
|
||||
|
||||
export async function resolveSkillContent(
|
||||
skills: string[],
|
||||
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string>, directory?: string }
|
||||
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider; disabledSkills?: Set<string>; directory: string }
|
||||
): Promise<{ content: string | undefined; error: string | null }> {
|
||||
if (skills.length === 0) {
|
||||
return { content: undefined, error: null }
|
||||
@@ -12,7 +11,7 @@ export async function resolveSkillContent(
|
||||
|
||||
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
|
||||
if (notFound.length > 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true, directory: options?.directory })
|
||||
const allSkills = await getAllSkills(options)
|
||||
const available = allSkills.map(s => s.name).join(", ")
|
||||
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export { createSkillMcpTool } from "./skill-mcp"
|
||||
import {
|
||||
createBackgroundOutput,
|
||||
createBackgroundCancel,
|
||||
createBackgroundWait,
|
||||
type BackgroundOutputManager,
|
||||
type BackgroundCancelClient,
|
||||
} from "./background-task"
|
||||
@@ -37,6 +38,7 @@ type OpencodeClient = PluginInput["client"]
|
||||
export { createCallOmoAgent } from "./call-omo-agent"
|
||||
export { createLookAt } from "./look-at"
|
||||
export { createDelegateTask } from "./delegate-task"
|
||||
export { createSwitchAgentTool } from "./switch-agent"
|
||||
export {
|
||||
createTaskCreateTool,
|
||||
createTaskGetTool,
|
||||
@@ -44,6 +46,7 @@ export {
|
||||
createTaskUpdateTool,
|
||||
} from "./task"
|
||||
export { createHashlineEditTool } from "./hashline-edit"
|
||||
export { createPrepareCouncilPromptTool } from "./prepare-council-prompt"
|
||||
|
||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
|
||||
const outputManager: BackgroundOutputManager = manager
|
||||
@@ -51,6 +54,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
|
||||
return {
|
||||
background_output: createBackgroundOutput(outputManager, client),
|
||||
background_cancel: createBackgroundCancel(manager, cancelClient),
|
||||
background_wait: createBackgroundWait(outputManager, client),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
src/tools/prepare-council-prompt/index.ts
Normal file
1
src/tools/prepare-council-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createPrepareCouncilPromptTool } from "./tools"
|
||||
68
src/tools/prepare-council-prompt/tools.ts
Normal file
68
src/tools/prepare-council-prompt/tools.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { writeFile, unlink, mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { COUNCIL_MEMBER_PROMPT, COUNCIL_SOLO_ADDENDUM, COUNCIL_DELEGATION_ADDENDUM } from "../../agents/athena"
|
||||
|
||||
const CLEANUP_DELAY_MS = 30 * 60 * 1000
|
||||
const COUNCIL_TMP_DIR = ".sisyphus/tmp"
|
||||
|
||||
export function createPrepareCouncilPromptTool(directory: string): ToolDefinition {
|
||||
const description = `Save a council analysis prompt to a temp file so council members can read it.
|
||||
|
||||
Athena-only tool. Saves the prompt once, then each council member task() call uses a short
|
||||
"Read <path>" instruction instead of repeating the full question. This keeps task() calls
|
||||
fast and small.
|
||||
|
||||
The "mode" parameter controls whether council members can delegate exploration to subagents:
|
||||
- "solo" (default): Members do all exploration themselves. More thorough but uses more tokens.
|
||||
- "delegation": Members can delegate to explore/librarian agents. Faster, lighter context.
|
||||
|
||||
Returns the file path to reference in subsequent task() calls.`
|
||||
|
||||
return tool({
|
||||
description,
|
||||
args: {
|
||||
prompt: tool.schema.string().describe("The full analysis prompt/question for council members"),
|
||||
mode: tool.schema.string().optional().describe('Analysis mode: "solo" (default) or "delegation"'),
|
||||
},
|
||||
async execute(args: { prompt: string; mode?: string }) {
|
||||
if (!args.prompt?.trim()) {
|
||||
return "Prompt cannot be empty."
|
||||
}
|
||||
|
||||
const mode = args.mode === "delegation" ? "delegation" : "solo"
|
||||
const tmpDir = join(directory, COUNCIL_TMP_DIR)
|
||||
await mkdir(tmpDir, { recursive: true })
|
||||
|
||||
const filename = `athena-council-${randomUUID().slice(0, 8)}.md`
|
||||
const filePath = join(tmpDir, filename)
|
||||
|
||||
const modeAddendum = mode === "delegation" ? COUNCIL_DELEGATION_ADDENDUM : COUNCIL_SOLO_ADDENDUM
|
||||
const content = `${COUNCIL_MEMBER_PROMPT}
|
||||
${modeAddendum}
|
||||
|
||||
## Analysis Question
|
||||
|
||||
${args.prompt}`
|
||||
|
||||
await writeFile(filePath, content, "utf-8")
|
||||
|
||||
setTimeout(() => {
|
||||
unlink(filePath).catch((err) => {
|
||||
log("[prepare-council-prompt] Failed to clean up temp file", { filePath, error: String(err) })
|
||||
})
|
||||
}, CLEANUP_DELAY_MS)
|
||||
|
||||
log("[prepare-council-prompt] Saved prompt", { filePath, length: args.prompt.length, mode })
|
||||
|
||||
return `Council prompt saved to: ${filePath} (mode: ${mode})
|
||||
|
||||
Use this path in each council member's task() call:
|
||||
- prompt: "Read ${filePath} for your instructions."
|
||||
|
||||
The file auto-deletes after 30 minutes.`
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
export { skill, createSkillTool } from "./tools"
|
||||
export { createSkillTool } from "./tools"
|
||||
|
||||
@@ -181,7 +181,7 @@ async function formatMcpCapabilities(
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||
export function createSkillTool(options: SkillLoadOptions): ToolDefinition {
|
||||
let cachedSkills: LoadedSkill[] | null = null
|
||||
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
||||
let cachedDescription: string | null = null
|
||||
@@ -189,7 +189,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||
if (options.skills) return options.skills
|
||||
if (cachedSkills) return cachedSkills
|
||||
cachedSkills = await getAllSkills({disabledSkills: options?.disabledSkills})
|
||||
cachedSkills = await getAllSkills({disabledSkills: options?.disabledSkills, directory: options?.directory})
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
body = injectGitMasterConfig(body, options.gitMasterConfig)
|
||||
}
|
||||
|
||||
const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || process.cwd()
|
||||
const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || options.directory
|
||||
|
||||
const output = [
|
||||
`## Skill: ${matchedSkill.name}`,
|
||||
@@ -309,5 +309,3 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const skill: ToolDefinition = createSkillTool()
|
||||
|
||||
@@ -33,4 +33,6 @@ export interface SkillLoadOptions {
|
||||
/** Git master configuration for watermark/co-author settings */
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
disabledSkills?: Set<string>
|
||||
/** Project directory for skill discovery and base directory resolution. Must be ctx.directory from PluginContext — process.cwd() is unsafe in OpenCode. */
|
||||
directory: string
|
||||
}
|
||||
|
||||
1
src/tools/switch-agent/index.ts
Normal file
1
src/tools/switch-agent/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createSwitchAgentTool } from "./tools"
|
||||
118
src/tools/switch-agent/tools.test.ts
Normal file
118
src/tools/switch-agent/tools.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { createSwitchAgentTool } from "./tools"
|
||||
import { consumePendingSwitch, _resetForTesting as resetSwitch } from "../../features/agent-switch"
|
||||
import { getSessionAgent, _resetForTesting as resetSession } from "../../features/claude-code-session-state"
|
||||
|
||||
describe("switch_agent tool", () => {
|
||||
const sessionID = "test-session-123"
|
||||
const messageID = "msg-456"
|
||||
const agent = "athena"
|
||||
|
||||
const toolContext = {
|
||||
sessionID,
|
||||
messageID,
|
||||
agent,
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSwitch()
|
||||
resetSession()
|
||||
})
|
||||
|
||||
function createToolWithMockClient(promptImpl?: () => Promise<unknown>) {
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync:
|
||||
promptImpl ??
|
||||
(async () => {
|
||||
return undefined
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
return createSwitchAgentTool({
|
||||
client: client as unknown as {
|
||||
session: {
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//#given valid atlas switch args
|
||||
//#when execute is called
|
||||
//#then it stores pending switch and updates session agent
|
||||
test("should queue switch to atlas", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "atlas", context: "Fix the auth bug based on council findings" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
expect(result).toContain("atlas")
|
||||
expect(result).toContain("switch")
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry).toEqual({
|
||||
agent: "atlas",
|
||||
context: "Fix the auth bug based on council findings",
|
||||
})
|
||||
|
||||
expect(getSessionAgent(sessionID)).toBe("atlas")
|
||||
})
|
||||
|
||||
//#given valid prometheus switch args
|
||||
//#when execute is called
|
||||
//#then it stores pending switch for prometheus
|
||||
test("should queue switch to prometheus", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "Prometheus", context: "Create a plan for the refactoring" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
expect(result).toContain("prometheus")
|
||||
expect(result).toContain("switch")
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry?.agent).toBe("prometheus")
|
||||
})
|
||||
|
||||
//#given an invalid agent name
|
||||
//#when execute is called
|
||||
//#then it returns an error
|
||||
test("should reject invalid agent names", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "librarian", context: "Some context" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
expect(result).toContain("Invalid switch target")
|
||||
expect(result).toContain("librarian")
|
||||
expect(consumePendingSwitch(sessionID)).toBeUndefined()
|
||||
})
|
||||
|
||||
//#given agent name with different casing
|
||||
//#when execute is called
|
||||
//#then it normalizes to lowercase
|
||||
test("should handle case-insensitive agent names", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
await tool.execute(
|
||||
{ agent: "ATLAS", context: "Fix things" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry?.agent).toBe("atlas")
|
||||
expect(getSessionAgent(sessionID)).toBe("atlas")
|
||||
})
|
||||
})
|
||||
61
src/tools/switch-agent/tools.ts
Normal file
61
src/tools/switch-agent/tools.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { setPendingSwitch } from "../../features/agent-switch"
|
||||
import { schedulePendingSwitchApply } from "../../features/agent-switch/scheduler"
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import type { SwitchAgentArgs } from "./types"
|
||||
|
||||
const DESCRIPTION =
|
||||
"Switch the active session agent. After calling this tool, the session will transition to the specified agent " +
|
||||
"with the provided context as its starting prompt. Use this to route work to another agent " +
|
||||
"(e.g., Atlas for fixes, Prometheus for planning). The switch executes when the current agent's turn completes."
|
||||
|
||||
const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"])
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
prompt?: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export function createSwitchAgentTool(args: {
|
||||
client: SessionClient
|
||||
}): ToolDefinition {
|
||||
const { client } = args
|
||||
|
||||
return tool({
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
agent: tool.schema
|
||||
.string()
|
||||
.describe("Target agent name to switch to (e.g., 'atlas', 'prometheus')"),
|
||||
context: tool.schema
|
||||
.string()
|
||||
.describe("Context message for the target agent — include confirmed findings, the original question, and what action to take"),
|
||||
},
|
||||
async execute(args: SwitchAgentArgs, toolContext) {
|
||||
const agentName = args.agent.toLowerCase()
|
||||
|
||||
if (!ALLOWED_AGENTS.has(agentName)) {
|
||||
return `Invalid switch target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
|
||||
}
|
||||
|
||||
updateSessionAgent(toolContext.sessionID, agentName)
|
||||
setPendingSwitch(toolContext.sessionID, agentName, args.context)
|
||||
schedulePendingSwitchApply({
|
||||
sessionID: toolContext.sessionID,
|
||||
client,
|
||||
})
|
||||
|
||||
return `Agent switch queued. Session will switch to ${agentName} when your turn completes.`
|
||||
},
|
||||
})
|
||||
}
|
||||
4
src/tools/switch-agent/types.ts
Normal file
4
src/tools/switch-agent/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SwitchAgentArgs {
|
||||
agent: string
|
||||
context: string
|
||||
}
|
||||
Reference in New Issue
Block a user