浏览代码

fix(council): wire model fallback chain + apply default perms to councillor agents

- buildCouncillorAgents now attaches cfg.models to _modelArray and clears
  the primary config.model when a fallback chain is configured, restoring
  multi-model fallback (Greptile P1).
- councillorAgents now pass through applyDefaultPermissions so cancel_task
  is explicitly denied and skill permissions apply (Greptile P2).
- docs/council.md rewritten for the flattened dispatch design; removed stale
  council_session/timeout/councillor_retries/serial/dispatch_mode refs.
Michael Henke 2 周之前
父节点
当前提交
8358464f2e
共有 4 个文件被更改,包括 238 次插入152 次删除
  1. 78 151
      docs/council.md
  2. 145 0
      src/agents/council-agents.test.ts
  3. 9 0
      src/agents/council-agents.ts
  4. 6 1
      src/agents/index.ts

+ 78 - 151
docs/council.md

@@ -5,14 +5,13 @@ Multi-model consensus for cases where you want more than one model's judgment.
 ## Table of Contents
 
 - [Overview](#overview)
-- [The Important Mental Model](#the-important-mental-model)
 - [Quick Setup](#quick-setup)
 - [Configuration](#configuration)
+- [Model Fallback Chain](#model-fallback-chain)
 - [Choosing the Council Model vs Councillor Models](#choosing-the-council-model-vs-councillor-models)
 - [Preset Examples](#preset-examples)
 - [Role Prompts](#role-prompts)
 - [Usage](#usage)
-- [Timeouts, Retries, and Failures](#timeouts-retries-and-failures)
 - [Compatibility Notes](#compatibility-notes)
 - [Troubleshooting](#troubleshooting)
 
@@ -20,8 +19,8 @@ Multi-model consensus for cases where you want more than one model's judgment.
 
 ## Overview
 
-The **Council agent** runs several **councillors** in parallel, then the
-**Council agent itself** synthesizes their outputs into one answer.
+The **Council agent** runs several **councillors** in parallel, then
+synthesizes their outputs into one answer.
 
 ### What you get
 
@@ -32,15 +31,21 @@ The **Council agent** runs several **councillors** in parallel, then the
 
 ### How it works
 
+Each councillor in a preset is registered as a dynamic subagent named
+`councillor-<name>` (e.g. `councillor-alpha`, `councillor-beta`), each
+with its own configured model. The orchestrator dispatches all councillors
+in parallel via OpenCode's native `task()` tool at depth 1, and each
+councillor appears as its own TUI pane.
+
 ```text
 User / Orchestrator
         |
         v
 Council agent (@council, your configured synthesizer model)
         |
-        +--> launches Councillor A (preset model)
-        +--> launches Councillor B (preset model)
-        +--> launches Councillor C (preset model)
+        +--> task(): councillor-alpha (configured model)
+        +--> task(): councillor-beta  (configured model)
+        +--> task(): councillor-gamma (configured model)
         |
         v
 Council agent synthesizes councillor results
@@ -49,26 +54,8 @@ Council agent synthesizes councillor results
 Final answer
 ```
 
----
-
-## The Important Mental Model
-
-There are **two separate model layers**:
-
-1. **The Council agent model**
-   - This is the model behind `@council` itself.
-   - It does the final synthesis.
-   - Configure it like any other agent: via your active preset's `council`
-     entry or `agents.council` override.
-
-2. **The councillor models**
-   - These are the models that actually fan out in parallel.
-   - Configure them under `council.presets.<preset>.<councillor>.model`.
-
-If you only remember one thing, remember this:
-
-> `@council` uses the normal agent config for the synthesizer model, and
-> `council.presets` for the fan-out councillor models.
+The council agent waits for all councillors to respond (or fail), then
+synthesizes their results into a single report.
 
 ---
 
@@ -114,9 +101,7 @@ Then use it directly:
 {
   "council": {
     "default_preset": "default",
-    "timeout": 180000,
-    "councillor_execution_mode": "parallel",
-    "councillor_retries": 3,
+
     "presets": {
       "default": {
         "alpha": { "model": "openai/gpt-5.6-luna" }
@@ -130,9 +115,6 @@ Then use it directly:
 |---------|------|---------|-------------|
 | `presets` | object | - | **Required.** Named councillor presets |
 | `default_preset` | string | `"default"` | Preset used when none is specified |
-| `timeout` | number | `180000` | Per-councillor timeout in ms |
-| `councillor_execution_mode` | string | `"parallel"` | `parallel` runs all councillors concurrently; `serial` runs them one at a time |
-| `councillor_retries` | number | `3` | Retries per councillor on empty provider responses |
 
 ### Councillor config
 
@@ -144,35 +126,7 @@ Each entry inside a preset is one councillor:
 | `variant` | string | No | Optional variant/reasoning setting (applies to chain entries without their own) |
 | `prompt` | string | No | Optional role guidance prepended to the user prompt |
 
-#### Councillor model fallback
-
-`model` also accepts an ordered chain. When the primary model fails or times
-out, the councillor advances to the next model instead of dropping out of the
-council. Entries are `provider/model` strings or `{ "id", "variant" }` objects:
-
-```jsonc
-{
-  "council": {
-    "presets": {
-      "review": {
-        "reviewer": {
-          "model": [
-            "openai/gpt-5.6",
-            { "id": "google/gemini-3-pro", "variant": "high" },
-            "anthropic/claude-opus-4-6"
-          ],
-          "prompt": "Focus on bugs, edge cases, and failure modes."
-        }
-      }
-    }
-  }
-}
-```
-
-Empty-response retries (`councillor_retries`) apply per model before the chain
-advances. A single string keeps the previous single-model behavior.
-
-### Council agent config
+### Council agent (synthesizer) config
 
 The **synthesizer model** is **not** configured inside `council.presets`.
 
@@ -202,8 +156,47 @@ Or with a global override:
 
 ---
 
+## Model Fallback Chain
+
+When `model` is a string, the councillor uses that single model.
+
+When `model` is an array, the councillor walks the chain in order:
+
+```jsonc
+{
+  "council": {
+    "presets": {
+      "review": {
+        "reviewer": {
+          "model": [
+            "openai/gpt-5.6",
+            { "id": "google/gemini-3-pro", "variant": "high" },
+            "anthropic/claude-opus-4-6"
+          ],
+          "prompt": "Focus on bugs, edge cases, and failure modes."
+        }
+      }
+    }
+  }
+}
+```
+
+Entries are `provider/model` strings or `{ "id", "variant" }` objects. The
+councillor tries each entry in order until one responds. Empty responses are
+retried once per entry; other failures advance to the next entry. The
+councillor only fails once every entry in the chain is exhausted.
+
+---
+
 ## Choosing the Council Model vs Councillor Models
 
+There are **two separate model layers**:
+
+1. **The Council agent model** — the model behind `@council` itself, which
+   does the final synthesis.
+2. **The councillor models** — the models that actually fan out in parallel,
+   configured under `council.presets.<preset>.<councillor>.model`.
+
 ### Configure the Council agent when you want to change
 
 - the **final synthesizer model**
@@ -269,25 +262,6 @@ Councillor models always come from:
 }
 ```
 
-### Serial mode for single-model systems
-
-```jsonc
-{
-  "council": {
-    "councillor_execution_mode": "serial",
-    "presets": {
-      "default": {
-        "alpha": { "model": "openai/gpt-5.6-luna" },
-        "beta": { "model": "openai/gpt-5.6-luna" }
-      }
-    }
-  }
-}
-```
-
-Use `serial` when parallel councillor launches would contend for the same
-underlying provider/session limits.
-
 ---
 
 ## Role Prompts
@@ -329,65 +303,39 @@ The councillor sees:
 
 ## Usage
 
-### Direct invocation
+### Invocation
 
 ```text
 @council Should we use a job queue or an outbox pattern here?
 ```
 
-### Via orchestrator delegation
+The orchestrator may also delegate to `@council` for high-stakes or
+ambiguous decisions.
+
+### What you see
 
-The orchestrator may delegate to `@council` for high-stakes or ambiguous
-decisions, but it does so sparingly because council is usually the most
-expensive path.
+Each councillor appears as its own TUI pane, dispatched in parallel. As they
+complete, their responses stream into the panes. Once all councillors have
+responded (or failed), the council agent synthesizes their results.
 
-### Output format
+### Output
 
 Council responses include:
 
-1. `Council Response` - the synthesized final answer.
-2. `Councillor Details` - each responding councillor's individual response,
+1. **Council Response** — the synthesized final answer.
+2. **Councillor Details** — each responding councillor's individual response,
    using the councillor names from the configured preset.
-3. `Council Summary` - agreement, disagreement resolution, remaining
+3. **Council Summary** — agreement, disagreement resolution, remaining
    uncertainty, and a consensus confidence rating of `unanimous`, `majority`,
    or `split`.
 
-### Output footer
-
-Council responses include a footer like:
+A footer tracks participation:
 
 ```text
 ---
 *Council: 2/3 councillors responded (alpha: gpt-5.6-luna, beta: gemini-3-pro)*
 ```
 
----
-
-## Timeouts, Retries, and Failures
-
-### Timeout behavior
-
-- `timeout` is **per councillor**
-- timed-out councillors are marked `timed_out`
-- council still synthesizes from successful results
-
-### Model chain fallback
-
-When a councillor's `model` is an array, the councillors walks the chain in
-order. Empty-response retries apply per model; any other failure or timeout
-advances to the next model. The councillor only fails once every model in the
-chain is exhausted, and the reported model reflects the one that responded.
-
-### Empty response retries
-
-Some providers silently return zero tokens. Council treats that as a retryable
-failure.
-
-- `councillor_retries` defaults to `3`
-- retries only happen for **empty provider responses**
-- normal failures and timeouts are returned immediately (they advance to the
-  next model in the chain, if any)
-
 ### Failure behavior
 
 | Scenario | Behavior |
@@ -402,21 +350,12 @@ failure.
 
 ### Deprecated `master` fields
 
-Older examples used these fields:
-
-- `council.master`
-- `council.master_timeout`
-- `council.master_fallback`
+Older configs used `council.master` and several other `master`-prefixed
+fields. These fields are deprecated and ignored.
 
-They are deprecated.
-
-Current behavior:
-
-- `master_timeout` is ignored
-- `master_fallback` is ignored
-- `master` is deprecated, but `master.model` is still accepted as a temporary
-  fallback for the **Council agent model only** when no explicit `council`
-  agent model is configured elsewhere
+`master.model` is still accepted as a temporary fallback for the **Council
+agent model only** when no explicit `council` agent model is configured
+elsewhere.
 
 Prefer this instead:
 
@@ -442,7 +381,7 @@ Prefer this instead:
 
 ### `@council` is missing
 
-Council tools are only registered when `config.council` exists.
+Council is only available when `config.council` exists.
 
 Make sure your config includes a `council` block with at least one preset.
 
@@ -454,20 +393,8 @@ Check:
 2. it exists under `council.presets`
 3. `default_preset` points to a real preset when omitted at runtime
 
-### All councillors timed out
-
-Try:
-
-```jsonc
-{
-  "council": {
-    "timeout": 300000
-  }
-}
-```
-
-Also verify the configured model IDs exist in your OpenCode environment.
-
-### Council recursion prevented
+### All councillors fail
 
-Council is meant to be a leaf agent. The councillor agent type has `council_session: 'deny'` permission, so councillors cannot invoke the council tool. OpenCode's native `subagent_depth` limit also prevents runaway delegation. Avoid recursive council chains.
+Verify the configured model IDs exist in your OpenCode environment. Each
+councillor model must be a valid `provider/model` identifier your OpenCode
+setup can reach.

+ 145 - 0
src/agents/council-agents.test.ts

@@ -0,0 +1,145 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { buildCouncillorAgents } from './council-agents';
+
+/**
+ * Build a minimal CouncilConfig for use in tests.
+ * We cast through `unknown` to avoid repeating the full post-transform shape
+ * which includes `_deprecated` / `_legacyMasterModel` and optional fields.
+ */
+function makeConfig(overrides: Record<string, unknown>): PluginConfig {
+  return {
+    council: {
+      presets: {},
+      default_preset: 'default',
+      _deprecated: undefined,
+      _legacyMasterModel: undefined,
+      ...overrides,
+    },
+  } as unknown as PluginConfig;
+}
+
+describe('buildCouncillorAgents', () => {
+  test('returns empty array when config is undefined', () => {
+    const agents = buildCouncillorAgents(undefined, new Set());
+    expect(agents).toEqual([]);
+  });
+
+  test('returns empty array when no council config', () => {
+    const agents = buildCouncillorAgents({} as PluginConfig, new Set());
+    expect(agents).toEqual([]);
+  });
+
+  test('returns empty array when preset does not exist', () => {
+    const agents = buildCouncillorAgents(
+      makeConfig({ presets: {} }),
+      new Set(),
+    );
+    expect(agents).toEqual([]);
+  });
+
+  test('single-model councillor has config.model set and no _modelArray', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          beta: {
+            model: 'google/gemini-3-pro',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+
+    const [agent] = agents;
+    expect(agent.name).toBe('councillor-beta');
+    expect(agent.config.model).toBe('google/gemini-3-pro');
+    expect(agent._modelArray).toBeUndefined();
+  });
+
+  test('multi-model councillor has _modelArray and config.model undefined', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          alpha: {
+            model: 'openai/gpt-5.6',
+            variant: undefined,
+            prompt: undefined,
+            models: [
+              { id: 'openai/gpt-5.6' },
+              { id: 'anthropic/claude-opus' },
+            ],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+
+    const [agent] = agents;
+    expect(agent.name).toBe('councillor-alpha');
+    expect(agent.config.model).toBeUndefined();
+    expect(agent._modelArray).toEqual([
+      { id: 'openai/gpt-5.6' },
+      { id: 'anthropic/claude-opus' },
+    ]);
+  });
+
+  test('disabled councillor is excluded', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          alpha: {
+            model: 'openai/gpt-5.6',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'openai/gpt-5.6' }],
+          },
+          beta: {
+            model: 'google/gemini-3-pro',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set(['alpha']));
+    expect(agents).toHaveLength(1);
+    expect(agents[0].name).toBe('councillor-beta');
+  });
+
+  test('uses default_preset when specified', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          alpha: {
+            model: 'openai/gpt-5.6',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'openai/gpt-5.6' }],
+          },
+        },
+        custom: {
+          gamma: {
+            model: 'google/gemini-3-pro',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+      default_preset: 'custom',
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+    expect(agents[0].name).toBe('councillor-gamma');
+  });
+});

+ 9 - 0
src/agents/council-agents.ts

@@ -29,6 +29,15 @@ export function buildCouncillorAgents(
 
     const agentName = `${COUNCILLOR_AGENT_PREFIX}${name}`;
     const base = createCouncillorAgent(cfg.model, undefined, cfg.prompt);
+
+    // If a fallback chain is configured, attach _modelArray for runtime
+    // resolution and clear the primary model so the single-model field
+    // doesn't override the chain (mirrors orchestrator.ts pattern).
+    if (cfg.models.length > 1) {
+      base._modelArray = cfg.models;
+      base.config.model = undefined;
+    }
+
     agents.push({ ...base, name: agentName });
   }
 

+ 6 - 1
src/agents/index.ts

@@ -509,7 +509,12 @@ export function createAgents(
   // Build dynamic councillor agents from council config (flatten mode).
   // Each councillor becomes a dispatchable subagent with its own model,
   // so the orchestrator can task() them with native panes at depth 1.
-  const councillorAgents = buildCouncillorAgents(config, disabled);
+  const councillorAgents = buildCouncillorAgents(config, disabled).map(
+    (agent) => {
+      applyDefaultPermissions(agent, undefined, config?.disabled_skills);
+      return agent;
+    },
+  );
 
   const allSubAgents = [
     ...builtInSubAgents,