diff --git a/docs/how-to/configuration/node-types/agent.md b/docs/how-to/configuration/node-types/agent.md index 2acbeeca2..972ed68ee 100644 --- a/docs/how-to/configuration/node-types/agent.md +++ b/docs/how-to/configuration/node-types/agent.md @@ -93,6 +93,7 @@ agents: | [`knowledge`](../knowledge.md) | Knowledge sources for this agent | | [`workers`](../toolsets/workers.md) | Worker agents which will be available as tools | | `requires_tool_confirmation` | How to handle tool confirmation (always/never/per_tool) | +| `mode` | Client-visible agent mode for OpenCode: `primary` (switcher only, default), `subagent` (at-mention `@agent` popup only), or `all` (both). Visibility only — does not affect execution or delegation | | `debug` | Enable debug output for this agent | | [`environment`](../execution-environments.md) | Execution environment configuration for this agent | | `usage_limits` | Usage limits for this agent | diff --git a/docs/how-to/servers/opencode-server.md b/docs/how-to/servers/opencode-server.md index 895d47b48..48036fde1 100644 --- a/docs/how-to/servers/opencode-server.md +++ b/docs/how-to/servers/opencode-server.md @@ -147,6 +147,37 @@ agents: - type: search ``` +### Agent Visibility in the OpenCode Client + +The OpenCode client exposes two surface areas for agents: the **switcher** +(agent tabs) and the **at-mention** (`@agent`) popup for delegation. Which +surface an agent appears in is controlled by its `mode`: + +| `mode` | Switcher | At-mention (`@`) popup | Use case | +|--------|----------|------------------------|----------| +| `primary` (default) | ✅ | ❌ | Main interactive agents | +| `subagent` | ❌ | ✅ | Specialists to delegate to (e.g. `@visionary` multimodal analyst) | +| `all` | ✅ | ✅ | Both interactive and delegable | + +```yaml +agents: + lead: + type: native + mode: primary # switcher only + ... + visionary: + type: native + mode: subagent # at-mention only — delegate with @visionary + ... +``` + +`mode` is a *visibility* declaration only. It does not change agent +execution, delegation authorization, or any other protocol's behavior. +Any agent can still be delegated to via the `task` tool regardless of its +`mode`. To make at-mention delegation fully functional, the delegating +agent additionally needs the `background_task` capability so the `task` +tool is available to the model. + ## API Endpoints ### Core Endpoints diff --git a/docs/rfcs/draft/RFC-0060-agent-mode-for-opencode-mention.md b/docs/rfcs/draft/RFC-0060-agent-mode-for-opencode-mention.md new file mode 100644 index 000000000..b6610751e --- /dev/null +++ b/docs/rfcs/draft/RFC-0060-agent-mode-for-opencode-mention.md @@ -0,0 +1,588 @@ +--- +rfc_id: RFC-0060 +title: Agent Mode Declaration for OpenCode At-Mention Visibility +status: DRAFT +author: pinjun.mo +reviewers: [] +created: 2026-08-17 +last_updated: 2026-08-17 +decision_date: +related_prds: [] +related_rfcs: + - RFC-0034 (BackgroundTask Architecture Redesign for AgentPool) + - RFC-0013 (Subagent Event Stream Unification for OpenCode Protocol) +--- + +# RFC-0060: Agent Mode Declaration for OpenCode At-Mention Visibility + +## Overview + +This RFC proposes adding a `mode` field to the wolfharness agent manifest so that users can declare whether each agent is a **primary** agent (shown in the OpenCode switcher), a **subagent** (shown in the at-mention `@` popup), or **both** (`all`). Today, wolfharness's OpenCode server hardcodes every agent as `mode="primary"`, which causes OpenCode clients to filter all custom agents out of the at-mention popup — making `@visionary`-style delegation impossible. + +This change is scoped to the OpenCode protocol server in `packages/agentpool`. It does not alter agent execution semantics, delegation behavior, or other protocols (ACP, AG-UI, MCP). + +## Table of Contents + +- [Background & Context](#background--context) +- [Problem Statement](#problem-statement) +- [Goals & Non-Goals](#goals--non-goals) +- [Evaluation Criteria](#evaluation-criteria) +- [Options Analysis](#options-analysis) +- [Recommendation](#recommendation) +- [Technical Design](#technical-design) +- [Security Considerations](#security-considerations) +- [Implementation Plan](#implementation-plan) +- [Open Questions](#open-questions) +- [Decision Record](#decision-record) +- [References](#references) + +--- + +## Background & Context + +### Current State + +wolfharness exposes agents to OpenCode clients through `GET /agent` in `src/wolfharness_server/opencode_server/routes/agent_routes.py`. The current implementation hardcodes `mode="primary"` for every agent in the manifest: + +```python +agents = [ + Agent( + name=name, + display_name=agent.display_name, + description=agent.description or f"Agent: {name}", + mode="primary", # hardcoded + default=(name == default_name), + ) + for name, agent in ctx.manifest.agents.items() +] +``` + +The wolfharness OpenCode server model already declares the full mode vocabulary: + +```python +# src/wolfharness_server/opencode_server/models/agent.py +AgentMode = Literal["subagent", "primary", "all"] +class Agent(OpenCodeBaseModel): + ... + mode: AgentMode = "primary" +``` + +The manifest agent config (`NativeAgentConfig`) has `display_name`, `description`, `tools`, `capabilities`, `model`, and other runtime fields — but **no mode declaration field**. + +### Historical Context + +OpenCode (upstream client) determines at-mention visibility client-side in two places: + +1. **Switcher** (`packages/app/src/context/local.tsx:71`): + ```ts + const list = createMemo(() => + sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden) + ) + ``` + The switcher shows every agent whose `mode !== "subagent"`. + +2. **At-mention popup** (`packages/app/src/components/prompt-input-v2.tsx:275`): + ```ts + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ id: `agent:${agent.name}`, kind: "agent", label: `@${agent.name}`, ... })) + ``` + The at-mention popup shows every agent whose `mode !== "primary"`. + +Native OpenCode derives `mode` from per-agent config files (`{agent,agents}/*.md` markdown with frontmatter). An agent with `mode: subagent` appears in the at-mention popup only; `mode: primary` appears in the switcher only; `mode: all` appears in both. + +Earlier RFC-0013 unified subagent event streaming for the OpenCode protocol, and RFC-0034 redesigned background-task delegation — but neither addressed how *static* multi-agent manifests surface their agent hierarchy to the client. + +### Glossary + +| Term | Definition | +|------|------------| +| **primary** | Agent mode shown in the OpenCode switcher (main agent tabs). | +| **subagent** | Agent mode shown in the at-mention (`@`) popup for delegation. | +| **all** | Agent mode visible in both switcher and at-mention popup. | +| **Agent** | The wolfharness OpenCode server response type (`AgentMode = Literal["subagent","primary","all"]`). | +| **AgentPart** | OpenCode message part created when a user types `@agent-name`; the server converts it into a prompt instructing the model to call the `task` tool. | +| **task tool** | Background-delegation tool (from `BackgroundTaskCapability`) that spawns a subtask for another agent. | + +--- + +## Problem Statement + +### The Problem + +Users of the OpenCode integration cannot delegate to custom agents via at-mention. When a user configures multiple agents (e.g. a `viking_tester` main agent plus a `visionary` multimodal analyst) and types `@visionary`, the at-mention popup does not list `visionary` — it only lists files and built-ins. + +### Evidence + +- `agent_routes.py:154` hardcodes `mode="primary"` for every manifest agent. +- OpenCode client `prompt-input-v2.tsx:275` filters at-mention candidates to `mode !== "primary"`. +- Observed behavior on a running server (`GET /agent` returns `visionary` with `"mode":"primary","default":false`), while `@visionary` produces no suggestion in the client popup. +- The `task`/`background_task` capability and the `AgentPart` handling in `converters.py:216` are already implemented — the only missing piece is agent visibility metadata flowing from manifest to client. + +### Impact of Inaction + +- **Cost**: Every user who wants multi-agent delegation in OpenCode must work around the gap — either by relying on the model to self-select a `task` tool call (fragile, poor UX) or by forking the server. +- **Risk**: The gap between "agents configured" and "agents usable" creates a misleading DX: configs advertise agents that cannot actually be reached by the user. +- **Opportunity**: Without a fix, wolfharness's multi-agent story is invisible in the primary client UX; with it, `@`-driven delegation becomes a first-class interaction that matches native OpenCode's agent model. + +--- + +## Goals & Non-Goals + +### Goals (In Scope) + +1. Add a `mode` field to the manifest agent config (`NativeAgentConfig`) with vocabulary `subagent | primary | all`, defaulting to `primary` for backward compatibility. +2. Surface the declared mode through `GET /agent` so the OpenCode client can render switcher and at-mention correctly. +3. Preserve existing behavior when `mode` is not declared (all agents remain `primary` — status quo). +4. Keep the change confined to the OpenCode protocol server layer + manifest config validation. + +### Non-Goals (Out of Scope) + +1. Changing agent **execution** semantics — a `subagent` is still a first-class pool agent; this RFC only changes *visibility metadata*. +2. Introducing server-side enforcement that a `subagent` cannot be used as the default/main agent — that decision is client-enforced today and out of scope. +3. Changing other protocols (ACP, AG-UI, MCP, A2A) — they have their own identity/visibility mechanisms. +4. Building a UI or documentation site for mode management. +5. Auto-deriving mode from graph/team structure (e.g. "agents referenced by `task` are subagents") — deferred as a future enhancement. + +### Success Criteria + +- [ ] A YAML config declaring `mode: subagent` on an agent results in that agent appearing in the at-mention popup (`/agent` returns `mode: "subagent"`). +- [ ] A YAML config declaring `mode: primary` on an agent results in that agent appearing only in the switcher (default, backward compatible). +- [ ] A YAML config declaring `mode: all` results in the agent appearing in both. +- [ ] Omitting `mode` on all agents reproduces current behavior exactly (no regression). +- [ ] Existing wolfharness tests pass without modification (unless a test explicitly asserts `mode="primary"` hardcoding, which would be updated). + +--- + +## Evaluation Criteria + +Weight: High = strongest driver for this decision. + +| Criterion | Weight | Description | Minimum Threshold | +|-----------|--------|-------------|-------------------| +| **Backward Compatibility** | High | Existing configs without `mode` must behave identically | Must pass: zero behavior change when field absent | +| **Implementation Cost** | High | Development effort in days | Low (< 2 days) | +| **Fidelity to OpenCode semantics** | High | Matches native OpenCode `primary/subagent/all` model | Must align with client filter logic | +| **Maintainability** | Medium | Changes are small, local, and readable | Single server layer file + config field | +| **Flexibility** | Medium | Supports future extensions (e.g. auto-derive mode) | Field design does not preclude future work | +| **Testability** | Medium | Easy to write unit tests on route output | Tests on `list_agents` with mocked manifest | + +--- + +## Options Analysis + +### Option 1: Manifest-declared `mode` field (Recommended Candidate) + +**Description** + +Add `mode: Literal["subagent", "primary", "all"] = "primary"` to `NativeAgentConfig`. Update `list_agents` to read `agent.mode` instead of the hardcoded literal. Users declare per-agent visibility in YAML: + +```yaml +agents: + viking_tester: + type: native + mode: primary # default; shown in switcher + ... + visionary: + type: native + mode: subagent # shown in at-mention only + ... + logician: + type: native + mode: all # shown in both + ... +``` + +Validation is handled by Pydantic at config-parse time (invalid values rejected before server start). The `Agent` wire type already supports the full vocabulary. + +**Advantages** + +- Explicit, declarative, and matches native OpenCode's per-agent config semantics. +- Backward compatible by construction (default `primary` = today's behavior). +- Single, low-risk change surface: one config field + one route lookup. +- Pydantic gives free schema validation and JSON Schema documentation. +- `all` mode enables the useful "switcher + delegable" hybrid that upstream OpenCode supports. + +**Disadvantages** + +- Each agent's mode is static — a rename from "subagent" to "primary" requires an edit and a server restart. +- Adds a field users must learn about (mitigated by a clear default). +- No automatic consistency check (e.g. "an agent that only exists to be delegated should probably be `subagent`") — user responsibility. + +**Evaluation Against Criteria** + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| Backward Compatibility | 5 | Default `primary` reproduces exact current behavior | +| Implementation Cost | 5 | ~1 field + ~3-line route change + tests | +| Fidelity to OpenCode semantics | 5 | Mirrors upstream `primary/subagent/all` exactly | +| Maintainability | 5 | Tiny diff, no architectural change | +| Flexibility | 4 | Field design permits future auto-derivation | +| Testability | 5 | Trivial unit tests on `list_agents` | + +**Effort Estimate** + +- Complexity: Low +- Resources: 1 engineer, 0.5–1 day including tests +- Dependencies: None beyond wolfharness own manifest + OpenCode routes + +**Risk Assessment** + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Configs with typo'd mode values fail at startup | Low | Medium | Pydantic Literal rejection error is descriptive; document in YAML reference | +| Tests asserting `mode="primary"` hardcoding break | Medium | Low | Update those specific assertions; they encode the bug, not a contract | +| Client caches old `/agent` response | Medium | Low | Standard server restart; no wire-format change for existing `mode` values | + +--- + +### Option 2: Auto-derive mode from config structure + +**Description** + +Infer mode from existing manifest structure without a new field. For example: the agent named by `default_agent` (or `main_agent_name`) becomes `primary`; every other agent becomes `subagent`. Optionally, agents referenced in `graph:`/`connections:` or delegation instructions become `subagent`. + +**Advantages** + +- Zero config changes — existing YAML files gain at-mention visibility automatically. +- "Main agent is primary, everyone else is delegable" is a sensible default for team-style configs. + +**Disadvantages** + +- Hidden magic: users cannot express `all` (both switcher + at-mention) or override the heuristic. +- Breaks today's behavior *by default*: agent sets without `default_agent` would silently change mode; agents currently expected in the switcher would disappear from it. +- `diag-agent.yaml`-style configs with multiple "main" agents (e.g. `engineer`, `reviewer`) would all become subagents, collapsing the switcher. +- Less explicit and harder to reason about than a declared field. + +**Evaluation Against Criteria** + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| Backward Compatibility | 2 | Breaks current switcher content by default | +| Implementation Cost | 4 | Heuristic in `list_agents` only | +| Fidelity to OpenCode semantics | 3 | No way to express `all`; lossy mapping | +| Maintainability | 2 | Hidden rule, hard to discover or override | +| Flexibility | 2 | Heuristic blocks user control | +| Testability | 3 | Behavior depends on manifest shape | + +**Effort Estimate** + +- Complexity: Low-Medium +- Resources: 1 engineer, ~0.5 day +- Dependencies: None + +**Risk Assessment** + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Existing multi-agent configs silently lose switcher agents | High | High | Auto-derivation is too aggressive; must add escape hatch, which becomes Option 1 anyway | +| Users cannot express `all` | High | Medium | No `all` in heuristic model | +| Confusing debug sessions ("why is my agent not in the switcher now?") | Medium | Medium | Requires docs + debugging | + +--- + +### Option 3: Config-level opt-out flag (`hidden`) instead of mode + +**Description** + +Add a boolean `hidden: bool = False` to `NativeAgentConfig`. Pass it through to the wire `Agent.hidden` field. The client's at-mention filter is `!agent.hidden && agent.mode !== "primary"` — but since all wolfharness agents are `primary`, `hidden` alone still does not surface them in at-mention. To make delegation visible, this option would require **also** changing the server default mode to `"all"` or making `hidden`/mode interact. + +**Advantages** + +- Reuses an existing OpenCode field (`Agent.hidden`) already in the wire model. +- `hidden` is useful on its own (suppress an agent from all client surfaces). + +**Disadvantages** + +- **Does not solve the problem alone**: the at-mention filter's first clause is `mode !== "primary"`, so without mode changes, `hidden: false` does not add anything to the popup. +- Requires the same mode work as Option 1 to achieve visibility, making it strictly more complex. +- Two interacting fields (`hidden` + `mode`) create a confusing matrix for users. + +**Evaluation Against Criteria** + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| Backward Compatibility | 4 | New field absent = no behavior change | +| Implementation Cost | 4 | Two fields + route changes | +| Fidelity to OpenCode semantics | 3 | Requires mode change anyway; `hidden` alone insufficient | +| Maintainability | 3 | Two interacting flags | +| Flexibility | 3 | `hidden` orthogonal but incomplete | +| Testability | 3 | Two code paths to cover | + +**Effort Estimate** + +- Complexity: Medium +- Resources: 1 engineer, ~1 day (but incomplete without Option 1's mode wiring) +- Dependencies: Needs mode work from Option 1 + +**Risk Assessment** + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Ship `hidden`-only and discover at-mention still broken | High | High | `hidden` does not affect the `mode !== "primary"` clause | +| Field interaction confusion | Medium | Medium | Document matrix | + +--- + +### Options Comparison Summary + +| Criterion | Option 1 (mode field) | Option 2 (auto-derive) | Option 3 (hidden flag) | +|-----------|----------------------|------------------------|------------------------| +| Backward Compatibility | 5 | 2 | 4 | +| Implementation Cost | 5 | 4 | 4 | +| Fidelity to OpenCode semantics | 5 | 3 | 3 | +| Maintainability | 5 | 2 | 3 | +| Flexibility | 4 | 2 | 3 | +| Testability | 5 | 3 | 3 | +| **Weighted Total** (60% tech, 40% ops) | **4.80** | **2.70** | **3.20** | + +--- + +## Recommendation + +### Recommended Option + +**Option 1: Manifest-declared `mode` field** + +### Justification + +Option 1 scores highest across every criterion. It is the only option that is fully backward compatible *and* fully faithful to OpenCode's native `primary/subagent/all` model. The implementation is minimal (single config field + route lookup + tests), the semantics are explicit and user-controlled, and `all` mode gives users the hybrid visibility that upstream OpenCode supports. Option 2 hides behavior in a heuristic that collapses multi-agent switchers by default — unacceptable for a library where configs already rely on multiple visible agents. Option 3 is strictly a subset of Option 1's work and cannot solve the stated problem alone because the at-mention filter's primary gate is `mode`, not `hidden`. + +### Accepted Trade-offs + +1. **Static mode requires config edits to change visibility**: Acceptable because agent role changes are infrequent (adding/removing a delegable specialist), and declarative YAML is the project's configuration philosophy. +2. **No automatic mode validation/consistency**: Acceptable because the field is soft metadata; a misconfigured agent is visible or hidden, never mis-executing. Pydantic rejects invalid enum values at parse time regardless. + +### Conditions + +- `mode` must default to `"primary"` to preserve existing behavior exactly. +- The wire `Agent.mode` field already exists; no client-visible wire format change is introduced (existing values are a subset of the Literal). +- Manifest `mode` should appear in the YAML configuration reference documentation with the three-value vocabulary. + +--- + +## Technical Design + +> Preliminary design for review; finalize after acceptance. + +### Architecture Overview + +``` +┌─────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────┐ +│ Manifest (YAML) │ │ OpenCode server (agentpool) │ │ OpenCode client │ +│ mode: subagent │─────▶│ list_agents reads mode │─────▶│ switcher: │ +│ mode: all │ │ Agent.mode = cfg.mode │ │ mode!=subagent │ +│ mode: primary │ │ │ │ at-mention: │ +└─────────────────────┘ └──────────────────────────────┘ │ mode!=primary │ + └──────────────────┘ +``` + +### Key Components + +#### 1. Manifest config field (`NativeAgentConfig`) + +- Location: `src/wolfharness/models/agents.py` +- Technology: Pydantic v2 `Literal` field on `NativeAgentConfig` +- Interface: + +```python +# On NativeAgentConfig (BaseAgentConfig or NativeAgentConfig) +mode: AgentMode = "primary" # AgentMode = Literal["subagent", "primary", "all"] +``` + +Where `AgentMode` is imported from the OpenCode server model (or a shared constants module). To avoid a server→core import inversion, `AgentMode` should be defined in a core/shared location (e.g. `wolfharness_config` or a constants module) and re-exported by the OpenCode server model. + +#### 2. Route lookup (`GET /agent`) + +- Location: `src/wolfharness_server/opencode_server/routes/agent_routes.py` +- Change: + +```python +agents = [ + Agent( + name=name, + display_name=agent.display_name, + description=agent.description or f"Agent: {name}", + mode=agent.mode, # was: "primary" + default=(name == default_name), + ) + for name, agent in ctx.manifest.agents.items() +] +``` + +#### 3. Wire model (unchanged) + +- Location: `src/wolfharness_server/opencode_server/models/agent.py` +- The `Agent.mode: AgentMode = "primary"` field already exists — no change. + +### Data Model + +```python +# wolfharness_config or shared core module +AgentMode = Literal["subagent", "primary", "all"] + +# src/wolfharness/models/agents.py +class NativeAgentConfig(BaseAgentConfig): + mode: AgentMode = "primary" # NEW + ... + +# src/wolfharness_server/opencode_server/models/agent.py +AgentMode = AgentMode # re-export from core +class Agent(OpenCodeBaseModel): + mode: AgentMode = "primary" # unchanged +``` + +### API Design + +`GET /agent` response — no wire format change, only the `mode` value becomes configurable: + +``` +GET /agent +→ [{ "name": "viking_tester", "mode": "primary", "default": true, ... }, + { "name": "visionary", "mode": "subagent", "default": false, ... }, + { "name": "logician", "mode": "all", "default": false, ... }] +``` + +--- + +## Security Considerations + +### Threat Analysis + +| Threat | Impact | Likelihood | Mitigation | +|--------|--------|------------|------------| +| A config author mistakenly marks a privileged agent `subagent`, hiding it from default switcher | Low | Low | Mode is declarative metadata; the agent remains fully functional by name; `all` mode exists for hybrid needs | +| Malformed `mode` value in YAML | Low | Low | Pydantic `Literal` rejects unknown values at config parse with a descriptive error | +| Delegation to a `subagent` without authorization | Low | Low | Mode does not change delegation authorization; `BackgroundTaskCapability` already governs `task` tool usage | + +### Security Measures + +- [x] Pydantic `Literal` validation rejects unknown `mode` values at config load. +- [ ] Document that `mode` affects client *visibility* only, not execution authorization. + +### Compliance + +No regulatory requirements are affected. This is a client-visibility metadata change. + +--- + +## Implementation Plan + +### Phases + +#### Phase 1: Core manifest field + +- **Scope**: Define `AgentMode` in a shared core location and add `mode` to `NativeAgentConfig`. +- **Deliverables**: `mode` field with default `"primary"`, validated by Pydantic. +- **Dependencies**: None. + +#### Phase 2: Route wiring + +- **Scope**: Read `agent.mode` in `list_agents`. +- **Deliverables**: `GET /agent` returns configured mode values. +- **Dependencies**: Phase 1. + +#### Phase 3: Tests + docs + +- **Scope**: Unit tests for `list_agents` (mode passthrough, default behavior, all three values). Update YAML reference docs. +- **Deliverables**: Green test suite; documented YAML field. +- **Dependencies**: Phase 2. + +### Milestones + +| Milestone | Description | Target | Status | +|-----------|-------------|--------|--------| +| M1 | Manifest field + route wiring (Phase 1+2) | Day 1 | Not Started | +| M2 | Tests + docs (Phase 3) | Day 1-2 | Not Started | + +### Rollback Strategy + +Single-commit revert (config field + route line). Since the change is additive with a backward-compatible default, rollback is a plain `git revert` with no data migration. + +--- + +## Open Questions + +1. **Where should `AgentMode` be defined?** + - Context: The OpenCode server model currently owns `AgentMode`. Manifest (core) needs it too, which would invert core→server import. Options: define in `wolfharness_config` (config-layer types), core models, or a shared constants module. + - Owner: wolfharness core maintainers + - Status: Open + +2. **Should ACP/other protocols also expose mode?** + - Context: ACP has its own agent registry (`RegistryAgent`) with a different shape. Out of scope here, but should be tracked for parity. + - Owner: team + - Status: Open + +3. **Does the client-popup filter also respect `hidden`?** + - Context: We are not adding `hidden` now. If a future need arises, its interaction with `mode` should be documented. + - Owner: team + - Status: Open + +--- + +## Decision Record + +> Complete this section after review is concluded. + +### Decision + +**Status**: PENDING — DRAFT in review + +**Date**: + +**Approvers**: + +### Decision Summary + +### Key Discussion Points + +### Conditions of Approval + +### Dissenting Opinions + +--- + +## References + +### Related Documents + +- `src/wolfharness_server/opencode_server/routes/agent_routes.py` +- `src/wolfharness_server/opencode_server/models/agent.py` +- `src/wolfharness/models/agents.py` (`NativeAgentConfig`) +- `docs/rfcs/RFC-0013-subagent-event-unification.md` +- `docs/rfcs/RFC-0034-background-task-redesign.md` + +### External Resources + +- OpenCode app at-mention filter: `packages/app/src/components/prompt-input-v2.tsx` (`filter: agent.mode !== "primary"`) +- OpenCode app switcher filter: `packages/app/src/context/local.tsx` (`filter: item.mode !== "subagent"`) +- OpenCode agent config model: `packages/opencode/src/agent/agent.ts` (`mode: Schema.Literals(["subagent", "primary", "all"])`) +- OpenCode SDK agent type: `@opencode-ai/sdk/v2` `Agent` + +### Appendix + +Config example for the motivating use case: + +```yaml +# packages/xeno-agent/config/diag-agent-viking.yaml (proposed) +agents: + viking_tester: + type: native + model: glm52 + mode: primary # main diagnostic agent (switcher) + capabilities: + - *viking_kb_capability + - *background_task_capability + visionary: + type: native + model: qwen3-vl-235b + mode: subagent # multimodal analyst (at-mention only) + tools: [] + capabilities: + - *viking_kb_capability + +# User flow: +# 用户输入 "@visionary 分析这张电路图" → +# 客户端 at-mention 弹窗显示 @visionary(因为 mode=subagent)→ +# AgentPart(name="visionary") → 服务端注入 task 指令 → viking_tester 调用 task 工具委派给 visionary +``` \ No newline at end of file diff --git a/src/wolfharness_config/nodes.py b/src/wolfharness_config/nodes.py index 29458b9e1..708612c92 100644 --- a/src/wolfharness_config/nodes.py +++ b/src/wolfharness_config/nodes.py @@ -43,6 +43,20 @@ - "per_tool": Use individual tool settings (treated as "always" for ACP) """ +AgentMode = Literal["subagent", "primary", "all"] +"""Client-visible agent mode for protocol servers (e.g. OpenCode). + +Maps to the OpenCode ``Agent.mode`` field which controls client +visibility: + +- ``"primary"``: shown in the agent switcher only (default). +- ``"subagent"``: shown in the at-mention (``@agent``) popup only. +- ``"all"``: visible in both the switcher and at-mention popup. + +This is a *visibility* declaration only — it does not change agent +execution or delegation semantics. +""" + class NodeConfig(Schema): """Configuration for a Node of the messaging system.""" @@ -246,6 +260,23 @@ class BaseAgentConfig(NodeConfig): - "per_tool": Use individual tool settings """ + mode: AgentMode = Field( + default="primary", + examples=["primary", "subagent", "all"], + title="Agent mode", + ) + """Client-visible agent mode for protocol servers. + + Determines how the agent appears in OpenCode clients: + + - ``"primary"``: shown in the agent switcher only (default). + - ``"subagent"``: shown in the at-mention (``@agent``) popup only. + - ``"all"``: visible in both the switcher and at-mention popup. + + This is a *visibility* declaration only — it does not change agent + execution or delegation semantics. + """ + hooks: HooksConfig | None = Field( default=None, title="Lifecycle hooks", diff --git a/src/wolfharness_server/opencode_server/models/agent.py b/src/wolfharness_server/opencode_server/models/agent.py index ab3dd7524..2dafa2707 100644 --- a/src/wolfharness_server/opencode_server/models/agent.py +++ b/src/wolfharness_server/opencode_server/models/agent.py @@ -6,12 +6,12 @@ from pydantic import Field +from wolfharness_config.nodes import AgentMode # noqa: TC001 from wolfharness_server.opencode_server.models.base import OpenCodeBaseModel from wolfharness_server.opencode_server.models.common import ModelRef # noqa: TC001 PermissionBehavior = Literal["ask", "allow", "deny"] -AgentMode = Literal["subagent", "primary", "all"] class AgentPermission(OpenCodeBaseModel): diff --git a/src/wolfharness_server/opencode_server/routes/agent_routes.py b/src/wolfharness_server/opencode_server/routes/agent_routes.py index bf7cd316e..1660e1cc4 100644 --- a/src/wolfharness_server/opencode_server/routes/agent_routes.py +++ b/src/wolfharness_server/opencode_server/routes/agent_routes.py @@ -140,8 +140,10 @@ async def list_agents(state: StateDep) -> list[Agent]: """List available agents from the AgentPool. Returns all agents with their configurations, suitable for the agent - switcher UI. All agents are marked as primary (visible in switcher). - The default agent is always first in the returned list. + switcher UI and at-mention popup. The ``mode`` of each agent comes + from the manifest declaration (``primary``/``subagent``/``all``), + controlling client visibility. The default agent is always first in + the returned list. """ ctx = state.agent.host_context assert ctx is not None, "AgentPool is not initialized" @@ -151,7 +153,7 @@ async def list_agents(state: StateDep) -> list[Agent]: name=name, display_name=agent.display_name, description=agent.description or f"Agent: {name}", - mode="primary", + mode=agent.mode, default=(name == default_name), ) for name, agent in ctx.manifest.agents.items() diff --git a/tests/manifest/test_models.py b/tests/manifest/test_models.py index 0353f1d66..5a85beab3 100644 --- a/tests/manifest/test_models.py +++ b/tests/manifest/test_models.py @@ -91,3 +91,41 @@ def test_missing_referenced_response(): config = yamling.load_yaml(INVALID_RESPONSE_CONFIG) with pytest.raises(ValidationError): AgentsManifest.model_validate(config) + + +def _agent_config_with_mode(mode: str) -> str: + """Build a minimal agent YAML declaring the given mode.""" + return f"""\ +agents: + test_agent: + type: native + name: Test Agent + model: test + mode: {mode} + system_prompt: You are a test agent +""" + + +def test_agent_mode_subagent_parses(): + """A manifest agent may declare mode: subagent.""" + manifest = AgentsManifest.model_validate(yamling.load_yaml(_agent_config_with_mode("subagent"))) + assert manifest.agents["test_agent"].mode == "subagent" + + +def test_agent_mode_all_parses(): + """A manifest agent may declare mode: all.""" + manifest = AgentsManifest.model_validate(yamling.load_yaml(_agent_config_with_mode("all"))) + assert manifest.agents["test_agent"].mode == "all" + + +def test_agent_mode_defaults_to_primary(): + """An omitted mode field defaults to primary (backward compat).""" + manifest = AgentsManifest.model_validate(yamling.load_yaml(VALID_AGENT_CONFIG)) + assert manifest.agents["test_agent"].mode == "primary" + + +def test_agent_mode_invalid_value_rejected(): + """An unknown mode value is rejected at config parse time.""" + config = yamling.load_yaml(_agent_config_with_mode("invalid-mode")) + with pytest.raises(ValidationError): + AgentsManifest.model_validate(config) diff --git a/tests/servers/opencode_server/test_mode_consistency.py b/tests/servers/opencode_server/test_mode_consistency.py index e85ff3ccf..f83328452 100644 --- a/tests/servers/opencode_server/test_mode_consistency.py +++ b/tests/servers/opencode_server/test_mode_consistency.py @@ -4,11 +4,12 @@ - Agent.mode: Literal["subagent", "primary", "all"] — agent category (visibility) - AssistantMessage.mode: str — identifies which agent produced the message -The TUI uses Agent.mode to filter agents (exclude "subagent" from switcher). +The TUI uses Agent.mode to filter agents (exclude "subagent" from switcher, +exclude "primary" from the at-mention popup). The TUI uses AssistantMessage.agent (name) to resolve the agent for display. These tests verify: -1. /agent endpoint returns mode="primary" for all wolfharness agents (correct) +1. /agent endpoint returns each agent's declared mode from the manifest 2. Assistant messages created by _before_consumer_loop have mode=agent_name 3. chat_message_to_opencode preserves mode from ChatMessage.name 4. Subagent assistant messages have mode and agent matching the child agent @@ -32,31 +33,61 @@ # --------------------------------------------------------------------------- -@pytest.mark.unit -async def test_agent_endpoint_mode_is_primary_for_all_agents() -> None: - """GET /agent should return mode='primary' for all wolfharness agents. +def _make_agent(*, mode: str = "primary", description: str = "desc") -> MagicMock: + """Build a manifest agent mock with a declared mode. - AgentMode is Literal['subagent', 'primary', 'all']. All wolfharness agents - are primary (visible in switcher). This is correct — mode is a category, - not an agent identifier. + Mirrors real manifest parsing where Pydantic fills the default + ``mode="primary"`` when the field is omitted. """ - from wolfharness_server.opencode_server.routes.agent_routes import list_agents + agent = MagicMock() + agent.description = description + agent.display_name = None + agent.mode = mode + return agent + - agent1 = MagicMock() - agent1.description = "Agent 1" - agent1.display_name = None - agent2 = MagicMock() - agent2.description = "Agent 2" - agent2.display_name = None +async def _list_agents(*, agents: dict[str, MagicMock], main: str = "agent1"): + """Invoke list_agents with a mocked host context.""" + from wolfharness_server.opencode_server.routes.agent_routes import list_agents ctx = MagicMock() - ctx.main_agent_name = "agent1" - ctx.manifest.agents = {"agent1": agent1, "agent2": agent2} + ctx.main_agent_name = main + ctx.manifest.agents = agents state = MagicMock() state.agent.host_context = ctx - agents = await list_agents(state) + return await list_agents(state) + + +@pytest.mark.unit +async def test_agent_endpoint_surfaces_declared_mode() -> None: + """GET /agent should return each agent's declared mode from the manifest.""" + agent1 = _make_agent(mode="primary") + agent2 = _make_agent(mode="subagent") + agent3 = _make_agent(mode="all") + + agents = await _list_agents(agents={"agent1": agent1, "agent2": agent2, "agent3": agent3}) + + assert len(agents) == 3 + by_name = {a.name: a for a in agents} + assert by_name["agent1"].mode == "primary" + assert by_name["agent2"].mode == "subagent" + assert by_name["agent3"].mode == "all" + + +@pytest.mark.unit +async def test_agent_endpoint_mode_defaults_to_primary_when_undeclared() -> None: + """Agents without a mode declaration should be primary (backward compat). + + Real manifest parsing fills ``mode="primary"`` from the Pydantic default, + so every manifest agent carries an explicit ``primary`` value when the + YAML omits ``mode``. + """ + agent1 = _make_agent(mode="primary") + agent2 = _make_agent(mode="primary") + + agents = await _list_agents(agents={"agent1": agent1, "agent2": agent2}) assert len(agents) == 2 for agent in agents: @@ -64,6 +95,29 @@ async def test_agent_endpoint_mode_is_primary_for_all_agents() -> None: assert agent.name in ("agent1", "agent2") +@pytest.mark.unit +async def test_agent_endpoint_default_flag_independent_of_mode() -> None: + """The default flag must come from the manifest default, independent of mode.""" + agent1 = _make_agent(mode="subagent") + + agents = await _list_agents(agents={"agent1": agent1}, main="agent1") + + assert len(agents) == 1 + assert agents[0].mode == "subagent" + assert agents[0].default is True + + +@pytest.mark.unit +async def test_agent_endpoint_empty_manifest_fallback() -> None: + """An empty manifest returns the self-describing default agent as primary.""" + agents = await _list_agents(agents={}) + + assert len(agents) == 1 + assert agents[0].name == "default" + assert agents[0].mode == "primary" + assert agents[0].default is True + + # --------------------------------------------------------------------------- # _before_consumer_loop mode field # ---------------------------------------------------------------------------