feat(cpm): agent_ref - compose APM-deployed agents into teams

Empirically verified: oh-my-opencode-slim preset entries keyed to an
APM-deployed native OpenCode agent (.opencode/agents/*.md) reconfigure
it in place - model override works, no duplicate agents are created.

- new optional role field 'agent_ref: <name>': persona (prompt,
  identity) comes from the APM primitive; cpm only resolves model_class
  locally and emits a preset entry under the deployed agent's name
- validation: warns when agent_ref target is missing from
  .opencode/agents/ (apm install not run yet) and when 'purpose' is
  redundantly set on agent_ref roles
- collision warning: inline roles shadowing deployed native agents
  suggest using agent_ref instead
- microsoft-design-review example: reviewer role now uses
  agent_ref: design-reviewer, showcasing both role forms
- README: new section on the relationship to APM agent primitives
- blog-review.md: verified findings as material for a blog addendum
This commit is contained in:
Tobias J. Endres 2026-08-23 19:37:21 +02:00
parent dd72fc4520
commit 9a3b8d62f3
4 changed files with 98 additions and 12 deletions

View file

@ -579,3 +579,20 @@ Direkt vor dem ersten team-profile-Codeblock (Abschnitt „Die APM-OMOS-Idee"):
Ersatzfassung: Ersatzfassung:
> „Spawnfile v0.1 besitzt derzeit keinen gebündelten OpenCode-Adapter. OpenCode wird als explorativer Runtime-Kandidat geführt; ein natives OpenCode-Artefakt wird daher aktuell nicht erzeugt." > „Spawnfile v0.1 besitzt derzeit keinen gebündelten OpenCode-Adapter. OpenCode wird als explorativer Runtime-Kandidat geführt; ein natives OpenCode-Artefakt wird daher aktuell nicht erzeugt."
Zusätzlich (aus unserer Code-Analyse): In v0.1 implementiert kein Adapter `compileTeam()` Teams degradieren überall zu Kontext-Dokumenten. Zusätzlich (aus unserer Code-Analyse): In v0.1 implementiert kein Adapter `compileTeam()` Teams degradieren überall zu Kontext-Dokumenten.
---
## Blog-Nachtrag-Material: APM-Agent-Primitives als Persona-Quelle (empirisch verifiziert)
Aus dem Praxistest mit dem offiziellen microsoft/apm-sample-package (August 2026):
**Befund:** Ein Preset-Eintrag, der auf den Namen eines APM-deployten nativen OpenCode-Agenten zielt, konfiguriert diesen Agenten inklusive Modell-Override. Kein Duplikat, die Persona aus der deployten Datei bleibt erhalten. Verifiziert am design-reviewer-Agenten: injiziertes `ollama/qwen3.5:9b` wurde zur Laufzeit gemeldet („Design-Reviewer · Qwen 3.5 9B").
**Konsequenz für das CPM-Format:** Die Rollen-Definition hat zwei Formen:
1. **Inline-Persona** (`purpose` + `capabilities`) für Packages ohne eigene Agent-Primitives
2. **`agent_ref`** für Packages mit `.apm/agents/`: Die Rolle bindet den deployten Agenten ins Team, `model_class` löst das Modell lokal auf
Damit schrumpft die CPM-Rolle bei Form B auf ihr Minimum: Team-Slot + Modellklasse (+ optionale Einschränkung der Whitelist). Prompt und Tools kommen aus dem Primitive eine Quelle der Wahrheit, „tighten only".
**Für den Blogpost:** Der Abschnitt „Was OMOS tatsächlich begrenzen kann" kann um diesen Befund ergänzt werden; außerdem ist der Design-Review-Beispiel-Flow jetzt vollständig end-to-end gelaufen (cpm setup → apm install → /preset → Delegation → Modell-Verifikation).

View file

@ -114,10 +114,35 @@ roles:
capabilities: capabilities:
mcps: [websearch, openviking] # MCP allowlist ([] = none) mcps: [websearch, openviking] # MCP allowlist ([] = none)
skills: [job-application] # skill allowlist ([] = none) skills: [job-application] # skill allowlist ([] = none)
- id: reviewer # Form B: bind an APM-deployed agent
agent_ref: design-reviewer # persona comes from .opencode/agents/
model_class: high-reasoning # resolved locally, injected via preset
``` ```
The `runtime_agent` field is deliberately harness-neutral: the adapter decides how the role is represented in the target harness. Builtins recognized by the oh-my-opencode-slim adapter: `orchestrator`, `oracle`, `librarian`, `explorer`, `fixer`, `designer`, `council`, `observer`. Everything else (or `runtime_agent: custom`) creates a custom agent including `prompt` and `orchestratorPrompt` derived from the `purpose` field. The `runtime_agent` field is deliberately harness-neutral: the adapter decides how the role is represented in the target harness. Builtins recognized by the oh-my-opencode-slim adapter: `orchestrator`, `oracle`, `librarian`, `explorer`, `fixer`, `designer`, `council`, `observer`. Everything else (or `runtime_agent: custom`) creates a custom agent including `prompt` and `orchestratorPrompt` derived from the `purpose` field.
### Relationship to APM agent primitives
APM packages can carry their own agent definitions (`.apm/agents/*.agent.md`, deployed to `.opencode/agents/`). A CPM team profile composes these deployed agents instead of duplicating them:
```yaml
roles:
- id: reviewer
agent_ref: design-reviewer # persona comes from the APM-deployed primitive
model_class: high-reasoning # model class resolved locally, injected via preset
```
With `agent_ref`:
- **Persona** (prompt, identity) comes from the APM primitive `purpose` is ignored. One source of truth.
- **Model** is resolved by the CPM from your local mapping and injected via the preset entry keyed to the agent's name. Verified empirically: oh-my-opencode-slim presets reconfigure APM-deployed native agents (model override works, no duplicate agents are created).
- The team may only **restrict** what the primitive declares, never redefine it same tighten-only philosophy as APM policies.
Roles without `agent_ref` use the inline form (`purpose` + `capabilities`) for packages that ship no agent primitives. `cpm check` warns when an inline role would shadow a deployed native agent consider `agent_ref` instead.
Note: if an APM agent primitive freezes a concrete model you do not have, prefer upstream primitives that omit `model:` (like [microsoft/apm-sample-package](https://github.com/microsoft/apm-sample-package) does) and let the CPM resolve it locally via `model_class`.
### Mapping formats ### Mapping formats
```yaml ```yaml
@ -143,7 +168,7 @@ All extra fields are passed through 1:1 into the agent entry of the preset.
| Example | Based on | Team | | Example | Based on | Team |
|---|---|---| |---|---|---|
| `examples/package/` | Own `job-application` package (OpenViking + ShareLaTeX) | `acme.job-applications` Researcher, Writer, Checker, Notifier | | `examples/package/` | Own `job-application` package (OpenViking + ShareLaTeX) | `acme.job-applications` Researcher, Writer, Checker, Notifier |
| `examples/microsoft-design-review/` | Official [microsoft/apm-sample-package](https://github.com/microsoft/apm-sample-package) | `acme.design-review` Reviewer (Oracle), Style-Checker (Fixer), Accessibility-Auditor | | `examples/microsoft-design-review/` | Official [microsoft/apm-sample-package](https://github.com/microsoft/apm-sample-package) | `acme.design-review` Reviewer (`agent_ref` → deployed design-reviewer), Style-Checker (Fixer), Accessibility-Auditor |
| `examples/microsoft-issue-autopilot/` | Official [`apm-issue-autopilot`](https://github.com/microsoft/apm/tree/main/packages/apm-issue-autopilot) from the microsoft/apm repo | `acme.issue-autopilot` Triager, Shepherd, PR-Writer, Reviewer (+ GitHub MCP) | | `examples/microsoft-issue-autopilot/` | Official [`apm-issue-autopilot`](https://github.com/microsoft/apm/tree/main/packages/apm-issue-autopilot) from the microsoft/apm repo | `acme.issue-autopilot` Triager, Shepherd, PR-Writer, Reviewer (+ GitHub MCP) |
The two Microsoft examples are thin CPM wrappers: their own `apm.yml` pulls the official package as a versioned APM dependency (`microsoft/apm-sample-package#v1.0.0` or monorepo subpath `microsoft/apm/packages/apm-issue-autopilot`) and only adds the team recommendation. The two Microsoft examples are thin CPM wrappers: their own `apm.yml` pulls the official package as a versioned APM dependency (`microsoft/apm-sample-package#v1.0.0` or monorepo subpath `microsoft/apm/packages/apm-issue-autopilot`) and only adds the team recommendation.

View file

@ -142,12 +142,23 @@ def sanitize_preset_name(team_id: str) -> str:
# Translation # Translation
def build_preset(profile: dict, mapping: dict) -> tuple[dict, dict, list[str]]: def deployed_native_agents(package_dir: Path) -> set[str]:
"""Names of APM-deployed native agents (.opencode/agents/*.md)."""
agents_dir = package_dir / ".opencode" / "agents"
if not agents_dir.exists():
return set()
return {p.stem for p in agents_dir.glob("*.md")}
def build_preset(profile: dict, mapping: dict,
package_dir: Path | None = None) -> tuple[dict, dict, list[str]]:
"""Returns (preset, custom_agents, warnings).""" """Returns (preset, custom_agents, warnings)."""
preset: dict = {} preset: dict = {}
custom_agents: dict = {} custom_agents: dict = {}
warnings: list[str] = [] warnings: list[str] = []
native = deployed_native_agents(package_dir) if package_dir else set()
for role in profile["roles"]: for role in profile["roles"]:
role_id = role.get("id") role_id = role.get("id")
if not role_id: if not role_id:
@ -170,6 +181,30 @@ def build_preset(profile: dict, mapping: dict) -> tuple[dict, dict, list[str]]:
skills = list(capabilities.get("skills", []) or []) skills = list(capabilities.get("skills", []) or [])
purpose = str(role.get("purpose", "")).strip() purpose = str(role.get("purpose", "")).strip()
# Form B: bind the role to an APM-deployed native agent.
agent_ref = role.get("agent_ref")
if agent_ref:
if native and agent_ref not in native:
warnings.append(
f"Role '{role_id}': agent_ref '{agent_ref}' not found in "
f".opencode/agents/ ({', '.join(sorted(native)) or 'empty'}). "
"Run 'apm install' first?"
)
preset[agent_ref] = {
**agent_fields,
"mcps": mcps,
"skills": skills,
}
if not purpose:
pass # persona comes from the APM primitive; nothing to do
else:
warnings.append(
f"Role '{role_id}': 'purpose' ignored - with agent_ref the "
"persona (prompt) comes from the APM agent primitive."
)
continue
runtime_agent = role.get("runtime_agent", role_id) runtime_agent = role.get("runtime_agent", role_id)
if runtime_agent == "custom": if runtime_agent == "custom":
# Explicitly marked as custom agent -> use the role's own name. # Explicitly marked as custom agent -> use the role's own name.
@ -222,6 +257,20 @@ def build_preset(profile: dict, mapping: dict) -> tuple[dict, dict, list[str]]:
"entry its model stays unchanged." "entry its model stays unchanged."
) )
# Inline (non-agent_ref) roles colliding with deployed native agents
# would create OMOS custom agents that shadow the APM-deployed ones.
for role in profile["roles"]:
if role.get("agent_ref"):
continue
role_id = role.get("id")
runtime_agent = role.get("runtime_agent", role_id)
if runtime_agent == "custom" and native and role_id in native:
warnings.append(
f"Role '{role_id}' would create a custom agent shadowing the "
f"APM-deployed native agent '{role_id}'. Consider "
f"'agent_ref: {role_id}' instead."
)
return preset, custom_agents, warnings return preset, custom_agents, warnings
@ -393,7 +442,7 @@ def cmd_render(args: argparse.Namespace) -> int:
profile, profile_path = load_team_profile(package_dir, args.team_file) profile, profile_path = load_team_profile(package_dir, args.team_file)
mapping = load_mapping(Path(args.mapping) if args.mapping else None) mapping = load_mapping(Path(args.mapping) if args.mapping else None)
preset, custom_agents, warnings = build_preset(profile, mapping) preset, custom_agents, warnings = build_preset(profile, mapping, package_dir)
output = Path(args.output) if args.output else package_dir / DEFAULT_OUTPUT output = Path(args.output) if args.output else package_dir / DEFAULT_OUTPUT
if output.exists(): if output.exists():
@ -430,6 +479,7 @@ def cmd_render(args: argparse.Namespace) -> int:
"roles": { "roles": {
r.get("id"): { r.get("id"): {
"runtimeAgent": r.get("runtime_agent", r.get("id")), "runtimeAgent": r.get("runtime_agent", r.get("id")),
"agentRef": r.get("agent_ref"),
"modelClass": r.get("model_class"), "modelClass": r.get("model_class"),
"resolvedModel": mapping[r["model_class"]], "resolvedModel": mapping[r["model_class"]],
"mcps": (r.get("capabilities", {}) or {}).get("mcps", []), "mcps": (r.get("capabilities", {}) or {}).get("mcps", []),

View file

@ -20,16 +20,10 @@ roles:
skills: [] skills: []
- id: reviewer - id: reviewer
purpose: >- # persona (prompt, identity) comes from the APM-deployed primitive
Performs the design review following the design-review.prompt.md # .opencode/agents/design-reviewer.md - created by 'apm install'
template. Evaluates UI consistency against the agent_ref: design-reviewer
design-standards.instructions.md. Reports findings with severity,
no unauthorized code changes.
runtime_agent: oracle # deep review = oracle profile
model_class: high-reasoning model_class: high-reasoning
capabilities:
mcps: []
skills: [style-checker]
- id: style-checker - id: style-checker
purpose: >- purpose: >-