Skip to content
Merged
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
94 changes: 83 additions & 11 deletions src/role_forge/adapters/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,112 @@

---
description: <natural-language description shown in the agent picker>
model: <model-id>
tools:
- read
- edit
- execute
---
<agent system prompt>

Agent definitions are mapped to Copilot agents as follows:

* Each agent becomes one ``.github/agents/<name>.md`` file.
* The agent's ``description`` is used in the frontmatter.
* ``model`` is resolved via the target's ``model_map`` in ``roles.toml``.
* ``tools`` are derived from the agent's ``capabilities`` and mapped to
Copilot tool aliases (``read``, ``edit``, ``search``, ``execute``,
``web``, ``agent``).
* ``prompt_content`` becomes the system prompt body.

Notes:
Copilot tool alias mapping (from semantic tool ids)::

* Copilot does not support per-agent model selection in agent files; the
model is chosen by the user in the Copilot UI, so ``model_map`` is
ignored.
* Copilot's built-in tools are always available; fine-grained
``capabilities`` are not expressed in the output file. Include
capability requirements in the agent's system-prompt body if needed.
read → read
glob → search
grep → search
write → edit
edit → edit
bash → execute
webfetch → web
websearch → web
task → agent
"""

from __future__ import annotations

from typing import ClassVar

from role_forge.adapters.base import BaseAdapter, _yaml_quote
from role_forge.capabilities import CapabilitySpec, expand_capabilities
from role_forge.groups import ALL_TOOL_IDS
from role_forge.models import AgentDef, TargetConfig

# Semantic tool id → Copilot tool alias
_TOOL_NAME_MAP: dict[str, str] = {
"read": "read",
"glob": "search",
"grep": "search",
"write": "edit",
"edit": "edit",
"bash": "execute",
"webfetch": "web",
"websearch": "web",
"task": "agent",
}

_ALL_COPILOT_TOOLS: list[str] = sorted(
{alias for tool_id in ALL_TOOL_IDS if (alias := _TOOL_NAME_MAP.get(tool_id))}
)


class CopilotAdapter(BaseAdapter):
name = "copilot"
base_dir = ".github/agents"
file_suffix = ".md"
default_output_layout = "namespace"
requires_model_map = False
default_model_map: ClassVar[dict[str, str]] = {
"reasoning": "claude-sonnet-4-5",
"coding": "claude-sonnet-4",
}

def _expand_capabilities(
self,
capabilities: list[str | dict],
capability_map: dict[str, dict[str, bool]],
) -> CapabilitySpec:
return expand_capabilities(capabilities, capability_map)

def _map_tool_ids(self, spec: CapabilitySpec) -> list[str]:
"""Map expanded semantic tool ids to Copilot tool aliases."""
tools: set[str] = set()
for tool_id in spec.tool_ids:
alias = _TOOL_NAME_MAP.get(tool_id)
if alias:
tools.add(alias)
continue
tools.add(tool_id)

def _serialize_frontmatter(self, description: str) -> str:
if spec.full_access:
tools.update(_ALL_COPILOT_TOOLS)

return sorted(tools)

def _serialize_frontmatter(
self,
description: str,
model: str,
tools: list[str],
) -> str:
"""Emit Copilot agent frontmatter."""
lines = ["---"]
if description:
lines.append(f"description: {_yaml_quote(description)}")
if model:
lines.append(f"model: {model}")
if tools:
lines.append("tools:")
for tool in tools:
lines.append(f" - {tool}")
lines.append("---")
return "\n".join(lines)

Expand All @@ -53,6 +122,9 @@ def render_agent(
config: TargetConfig,
delegates: list[str],
) -> str:
del config, delegates
fm = self._serialize_frontmatter(agent.description)
spec = self._expand_capabilities(agent.capabilities, config.capability_map)
tools = self._map_tool_ids(spec)
model = self._resolve_model(agent.model, config.model_map)

fm = self._serialize_frontmatter(agent.description, model, tools)
return self._compose_document(fm, agent.prompt_content)
26 changes: 26 additions & 0 deletions tests/__snapshots__/test_copilot.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
'''
---
description: Precision Aligner. Makes targeted code changes.
model: gpt-4o
tools:
- edit
- read
- search
---
# Aligner
'''
Expand All @@ -11,9 +16,30 @@
'''
---
description: Code Explorer. Reads and analyzes source code.
model: claude-sonnet-4-5
tools:
- execute
- read
- search
- web
---
# Explorer

Read-only code exploration agent.
'''
# ---
# name: test_cast_orchestrator_with_delegates
'''
---
description: Orchestrator. Coordinates sub-agents.
model: claude-sonnet-4-5
tools:
- agent
- edit
- execute
- read
- search
---
# Orchestrator
'''
# ---
109 changes: 108 additions & 1 deletion tests/test_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
COPILOT_CONFIG = TargetConfig(
name="copilot",
enabled=True,
model_map={},
model_map={"reasoning": "claude-sonnet-4-5", "coding": "gpt-4o"},
capability_map={},
)

Expand Down Expand Up @@ -47,8 +47,115 @@ def test_cast_explorer(snapshot):
assert outputs[0].content == snapshot


def test_cast_orchestrator_with_delegates(snapshot):
orchestrator = AgentDef(
name="orchestrator",
description="Orchestrator. Coordinates sub-agents.",
role="primary",
model=ModelConfig(tier="reasoning"),
capabilities=[
"read",
"write",
{"bash": ["ls*", "cat*", "git status*"]},
{"delegate": ["explorer", "aligner"]},
],
prompt_content="# Orchestrator",
)
adapter = CopilotAdapter()
outputs = adapter.cast(
[
orchestrator,
AgentDef(name="explorer", description="Explorer"),
AgentDef(name="aligner", description="Aligner"),
],
COPILOT_CONFIG,
)
assert outputs[0].content == snapshot


def test_output_path_uses_agent_name():
agent = AgentDef(name="my-agent", description="Test")
adapter = CopilotAdapter()
outputs = adapter.cast([agent], COPILOT_CONFIG)
assert outputs[0].path == ".github/agents/my-agent.md"


def test_read_group_maps_to_copilot_tools():
agent = AgentDef(name="test", description="Test", capabilities=["read"])
adapter = CopilotAdapter()
spec = adapter._expand_capabilities(agent.capabilities, COPILOT_CONFIG.capability_map)
tools = adapter._map_tool_ids(spec)
assert set(tools) == {"read", "search"}


def test_basic_group_maps_to_copilot_tools():
agent = AgentDef(name="test", description="Test", capabilities=["basic"])
adapter = CopilotAdapter()
spec = adapter._expand_capabilities(agent.capabilities, COPILOT_CONFIG.capability_map)
tools = adapter._map_tool_ids(spec)
assert set(tools) == {"edit", "read", "search", "web"}


def test_empty_capabilities_default_to_basic():
agent = AgentDef(name="test", description="Test", capabilities=[])
adapter = CopilotAdapter()
spec = adapter._expand_capabilities(agent.capabilities, COPILOT_CONFIG.capability_map)
tools = adapter._map_tool_ids(spec)
assert set(tools) == {"edit", "read", "search", "web"}


def test_all_capability_maps_to_all_copilot_tools():
agent = AgentDef(name="test", description="Test", capabilities=["all"])
adapter = CopilotAdapter()
spec = adapter._expand_capabilities(agent.capabilities, COPILOT_CONFIG.capability_map)
tools = adapter._map_tool_ids(spec)
assert set(tools) == {"agent", "edit", "execute", "read", "search", "web"}


def test_web_access_group_maps_to_web():
agent = AgentDef(name="test", description="Test", capabilities=["web-access"])
adapter = CopilotAdapter()
spec = adapter._expand_capabilities(agent.capabilities, COPILOT_CONFIG.capability_map)
tools = adapter._map_tool_ids(spec)
assert set(tools) == {"web"}


def test_delegate_group_maps_to_agent():
agent = AgentDef(name="test", description="Test", capabilities=["delegate"])
adapter = CopilotAdapter()
spec = adapter._expand_capabilities(agent.capabilities, COPILOT_CONFIG.capability_map)
tools = adapter._map_tool_ids(spec)
assert set(tools) == {"agent"}


def test_model_resolved_from_model_map():
agent = AgentDef(
name="test",
description="Test",
model=ModelConfig(tier="coding"),
prompt_content="prompt",
)
adapter = CopilotAdapter()
outputs = adapter.cast([agent], COPILOT_CONFIG)
assert "model: gpt-4o" in outputs[0].content


def test_omits_empty_description():
agent = AgentDef(name="test", description="", prompt_content="# Prompt")
adapter = CopilotAdapter()
outputs = adapter.cast([agent], COPILOT_CONFIG)
assert "description:" not in outputs[0].content


def test_frontmatter_only_without_prompt():
agent = AgentDef(name="minimal", description="Minimal.", prompt_content="")
adapter = CopilotAdapter()
outputs = adapter.cast([agent], COPILOT_CONFIG)
assert outputs[0].content.endswith("---")


def test_default_model_map_used_when_config_empty():
"""Adapter provides default model_map so render still works without roles.toml."""
adapter = CopilotAdapter()
assert adapter.default_model_map
assert "reasoning" in adapter.default_model_map
2 changes: 0 additions & 2 deletions tests/test_model_less_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@

import pytest

from role_forge.adapters.copilot import CopilotAdapter
from role_forge.adapters.cursor import CursorAdapter
from role_forge.adapters.windsurf import WindsurfAdapter
from role_forge.models import AgentDef, TargetConfig

ADAPTER_CASES = [
pytest.param(CopilotAdapter, "copilot", ".github/agents", ".md", id="copilot"),
pytest.param(CursorAdapter, "cursor", ".cursor/agents", ".mdc", id="cursor"),
pytest.param(WindsurfAdapter, "windsurf", ".windsurf/rules", ".md", id="windsurf"),
]
Expand Down
Loading