Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/how-to/configuration/node-types/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions docs/how-to/servers/opencode-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
588 changes: 588 additions & 0 deletions docs/rfcs/draft/RFC-0060-agent-mode-for-opencode-mention.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/wolfharness_config/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/wolfharness_server/opencode_server/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 5 additions & 3 deletions src/wolfharness_server/opencode_server/routes/agent_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions tests/manifest/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
90 changes: 72 additions & 18 deletions tests/servers/opencode_server/test_mode_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,38 +33,91 @@
# ---------------------------------------------------------------------------


@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:
assert agent.mode == "primary"
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
# ---------------------------------------------------------------------------
Expand Down
Loading