Skip to content

Commit 54a830d

Browse files
authored
feat(v1.5-C): per-agent LLM proof point — intake on Ollama Cloud (#9)
* feat(v1.5-C): per-agent LLM proof point — intake on ollama_cloud, downstream on llm.default Activates the M8 milestone's per-agent provider story. The framework already resolved ``skill.model`` per-skill via ``graph.py:_build_agent_nodes -> get_llm(cfg.llm, skill.model, role=...)``; v1.5-C uncomments the override on the example incident_management intake skill so a default deployment (with both OLLAMA_API_KEY + OPENROUTER_API_KEY set, or wherever ``llm.default`` resolves) shows intake hitting Ollama Cloud's gpt-oss while the rest of the agents follow the runtime default. Changes: * ``examples/incident_management/skills/intake/config.yaml`` — declare ``model: gpt_oss_cheap`` (was a documented but commented-out hint). Comment block updated to reference v1.5-C and explain the resolver. * ``src/runtime/config.py`` — extend the ``LLMConfig.stub()`` default models map with stub aliases for ``gpt_oss``, ``gpt_oss_cheap``, and ``workhorse``. The skill-validator (``Orchestrator.create``) checks every ``skill.model`` against ``llm.models``; without these aliases the existing test suite would explode the moment intake declares ``model: gpt_oss_cheap`` (because tests build ``LLMConfig.stub()`` which previously only knew ``stub_default``). The aliases route to the same stub provider so behaviour is unchanged for stub-mode callers. * ``tests/test_per_agent_model_dispatch.py`` (new, 2 tests) — pin the dispatch contract: - ``test_build_agent_nodes_passes_skill_model_to_get_llm`` mocks ``runtime.graph.get_llm`` and asserts the framework calls it with ``model_name=skill.model`` per skill (intake gets ``"gpt_oss_cheap"``, triage with ``model=None`` gets ``None`` so ``get_llm`` falls back to ``llm.default`` downstream). - ``test_intake_skill_yaml_has_per_agent_override_uncommented`` pins the YAML edit so a future refactor can't silently drop the override. Live verification (``tests/test_integration_driver_s1.py`` family) continues to require ``OLLAMA_API_KEY`` + ``OPENROUTER_API_KEY`` + ``OLLAMA_BASE_URL`` and remains skipped without them — the human verification gate documented at ``.planning/phases/15-real-llm-tool-loop-termination/15-VERIFICATION.md``. Suite: 1260 passed (was 1258 — added 2), ruff clean, coverage 87.08%. * build: regenerate dist bundles for v1.5-C per-agent override Bundles dist/app.py + dist/apps/{code-review,incident-management}.py in line with the LLMConfig stub-aliases extension from the preceding commit. No bundle-only edits.
1 parent 25e363c commit 54a830d

6 files changed

Lines changed: 172 additions & 6 deletions

File tree

dist/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,6 +1638,15 @@ class LLMConfig(BaseModel):
16381638
models: dict[str, ModelConfig] = Field(
16391639
default_factory=lambda: {
16401640
"stub_default": ModelConfig(provider="stub", model="stub-1"),
1641+
# Aliases for the example apps' per-agent model overrides
1642+
# (e.g. incident_management's intake skill carries
1643+
# ``model: gpt_oss_cheap`` for the v1.5-C / M8 proof point).
1644+
# Tests + ``LLMConfig.stub()`` callers route them to the
1645+
# same stub provider so the skill validator passes without
1646+
# forcing every test to re-declare the registry.
1647+
"gpt_oss": ModelConfig(provider="stub", model="stub-1"),
1648+
"gpt_oss_cheap": ModelConfig(provider="stub", model="stub-1"),
1649+
"workhorse": ModelConfig(provider="stub", model="stub-1"),
16411650
}
16421651
)
16431652
embedding: EmbeddingConfig | None = None

dist/apps/code-review.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1691,6 +1691,15 @@ class LLMConfig(BaseModel):
16911691
models: dict[str, ModelConfig] = Field(
16921692
default_factory=lambda: {
16931693
"stub_default": ModelConfig(provider="stub", model="stub-1"),
1694+
# Aliases for the example apps' per-agent model overrides
1695+
# (e.g. incident_management's intake skill carries
1696+
# ``model: gpt_oss_cheap`` for the v1.5-C / M8 proof point).
1697+
# Tests + ``LLMConfig.stub()`` callers route them to the
1698+
# same stub provider so the skill validator passes without
1699+
# forcing every test to re-declare the registry.
1700+
"gpt_oss": ModelConfig(provider="stub", model="stub-1"),
1701+
"gpt_oss_cheap": ModelConfig(provider="stub", model="stub-1"),
1702+
"workhorse": ModelConfig(provider="stub", model="stub-1"),
16941703
}
16951704
)
16961705
embedding: EmbeddingConfig | None = None

dist/apps/incident-management.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,6 +1703,15 @@ class LLMConfig(BaseModel):
17031703
models: dict[str, ModelConfig] = Field(
17041704
default_factory=lambda: {
17051705
"stub_default": ModelConfig(provider="stub", model="stub-1"),
1706+
# Aliases for the example apps' per-agent model overrides
1707+
# (e.g. incident_management's intake skill carries
1708+
# ``model: gpt_oss_cheap`` for the v1.5-C / M8 proof point).
1709+
# Tests + ``LLMConfig.stub()`` callers route them to the
1710+
# same stub provider so the skill validator passes without
1711+
# forcing every test to re-declare the registry.
1712+
"gpt_oss": ModelConfig(provider="stub", model="stub-1"),
1713+
"gpt_oss_cheap": ModelConfig(provider="stub", model="stub-1"),
1714+
"workhorse": ModelConfig(provider="stub", model="stub-1"),
17061715
}
17071716
)
17081717
embedding: EmbeddingConfig | None = None

examples/incident_management/skills/intake/config.yaml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
description: Intake supervisor — framework similarity retrieval + ASR memory hydration; dispatches to triage
22
kind: supervisor
3-
# M8: per-agent provider swap. Uncomment the line below to route this
4-
# agent through the Ollama Cloud gpt-oss model defined in
5-
# ``config/config.yaml``; the rest of the agents stay on the default
6-
# ``workhorse`` model. graph.py:_build_agent_nodes reads skill.model
7-
# and resolves it via the per-app LLMConfig.models registry.
8-
# model: gpt_oss_cheap
3+
# v1.5-C (M8 proof point): per-agent provider swap. Intake runs through
4+
# the Ollama Cloud gpt-oss model defined in ``config/config.yaml``;
5+
# downstream agents follow the runtime ``llm.default``. The framework
6+
# resolves this via ``graph.py:_build_agent_nodes`` -> ``get_llm(cfg.llm,
7+
# skill.model, ...)`` which falls back to ``cfg.default`` when ``model``
8+
# is None. Comment this back out to force every agent onto the default.
9+
model: gpt_oss_cheap
910
subordinates:
1011
- triage
1112
dispatch_strategy: rule

src/runtime/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ class LLMConfig(BaseModel):
8888
models: dict[str, ModelConfig] = Field(
8989
default_factory=lambda: {
9090
"stub_default": ModelConfig(provider="stub", model="stub-1"),
91+
# Aliases for the example apps' per-agent model overrides
92+
# (e.g. incident_management's intake skill carries
93+
# ``model: gpt_oss_cheap`` for the v1.5-C / M8 proof point).
94+
# Tests + ``LLMConfig.stub()`` callers route them to the
95+
# same stub provider so the skill validator passes without
96+
# forcing every test to re-declare the registry.
97+
"gpt_oss": ModelConfig(provider="stub", model="stub-1"),
98+
"gpt_oss_cheap": ModelConfig(provider="stub", model="stub-1"),
99+
"workhorse": ModelConfig(provider="stub", model="stub-1"),
91100
}
92101
)
93102
embedding: EmbeddingConfig | None = None
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""v1.5-C (M8 proof point): per-agent LLM dispatch contract.
2+
3+
Pins the contract that ``runtime.graph._build_agent_nodes`` resolves
4+
``skill.model`` per-skill, so apps can route different agents through
5+
different providers without touching the framework.
6+
7+
The live demonstration of this contract — intake on Ollama Cloud
8+
gpt-oss while downstream agents follow ``llm.default`` — lives in
9+
``examples/incident_management/skills/intake/config.yaml`` (the
10+
``model: gpt_oss_cheap`` line) and is exercised by
11+
``tests/test_integration_driver_s1.py`` when the appropriate API
12+
keys are set.
13+
14+
These tests run without keys: they intercept ``runtime.graph.get_llm``
15+
and assert the model name passed for each skill matches the skill's
16+
``model`` field (or the ``LLMConfig.default`` fallback when ``model``
17+
is None).
18+
"""
19+
from __future__ import annotations
20+
21+
from unittest.mock import patch
22+
23+
from runtime.config import (
24+
AppConfig,
25+
LLMConfig,
26+
MCPConfig,
27+
OrchestratorConfig,
28+
Paths,
29+
RuntimeConfig,
30+
)
31+
from runtime.mcp_loader import ToolRegistry
32+
from runtime.skill import RouteRule, Skill
33+
34+
35+
def _stub_app_cfg() -> AppConfig:
36+
"""Minimal AppConfig with two named models — the framework picks
37+
between them by ``skill.model`` only."""
38+
llm_cfg = LLMConfig.stub()
39+
return AppConfig(
40+
llm=llm_cfg,
41+
mcp=MCPConfig(servers=[]),
42+
paths=Paths(skills_dir="config/skills", incidents_dir="/tmp"),
43+
runtime=RuntimeConfig(state_class=None),
44+
orchestrator=OrchestratorConfig(),
45+
)
46+
47+
48+
def test_build_agent_nodes_passes_skill_model_to_get_llm():
49+
"""The framework must call ``get_llm(cfg.llm, skill.model, ...)`` for
50+
every responsive skill. Without this, per-agent provider swaps
51+
silently collapse to the default model.
52+
53+
We fully mock ``get_llm`` to capture the (role, model_name) tuple
54+
per skill — this isolates the test from the LLMConfig.models
55+
registry shape, which is what the production code resolves the
56+
name through downstream.
57+
"""
58+
from runtime.graph import _build_agent_nodes
59+
from runtime.llm import StubChatModel
60+
61+
skills = {
62+
"intake": Skill(
63+
name="intake",
64+
description="d",
65+
kind="responsive",
66+
model="gpt_oss_cheap",
67+
routes=[RouteRule(when="default", next="triage")],
68+
system_prompt="x",
69+
),
70+
"triage": Skill(
71+
name="triage",
72+
description="d",
73+
kind="responsive",
74+
model=None, # falls back to llm.default downstream
75+
routes=[RouteRule(when="default", next="__end__")],
76+
system_prompt="x",
77+
),
78+
}
79+
80+
captured: list[tuple[str, str | None]] = []
81+
82+
def _fake_get_llm(cfg, model_name, *, role, **kwargs):
83+
captured.append((role, model_name))
84+
return StubChatModel(role=role)
85+
86+
cfg = _stub_app_cfg()
87+
with patch("runtime.graph.get_llm", side_effect=_fake_get_llm):
88+
nodes = _build_agent_nodes(
89+
cfg=cfg,
90+
skills=skills,
91+
store=None, # type: ignore[arg-type] — _build_agent_nodes
92+
# only forwards ``store`` to make_agent_node, never reads it
93+
# itself; tests of the dispatch contract leave it None.
94+
registry=ToolRegistry(entries={}),
95+
)
96+
97+
# Both skills produced a node.
98+
assert set(nodes.keys()) == {"intake", "triage"}
99+
100+
# Per-skill model resolution: intake got its override, triage got
101+
# None (which get_llm resolves to llm.default downstream).
102+
by_role = dict(captured)
103+
assert by_role.get("intake") == "gpt_oss_cheap", (
104+
f"intake should resolve to its skill.model override; got {by_role!r}"
105+
)
106+
assert by_role.get("triage") is None, (
107+
f"triage skill.model was None; should pass None through so "
108+
f"get_llm falls back to llm.default; got {by_role!r}"
109+
)
110+
111+
112+
def test_intake_skill_yaml_has_per_agent_override_uncommented():
113+
"""The intake skill config must carry ``model: gpt_oss_cheap`` — the
114+
v1.5-C deliverable. A human flipping this back to a comment is
115+
intentional (e.g. forcing all-default for a benchmark run); the
116+
test fails if that happens silently in a refactor.
117+
"""
118+
import yaml
119+
from pathlib import Path
120+
121+
cfg_path = Path(
122+
"examples/incident_management/skills/intake/config.yaml"
123+
)
124+
parsed = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
125+
assert parsed.get("model") == "gpt_oss_cheap", (
126+
f"intake skill must declare ``model: gpt_oss_cheap`` per the "
127+
f"v1.5-C M8 proof point; got {parsed.get('model')!r}. If "
128+
f"intentionally rolling back, remove this test guard too."
129+
)

0 commit comments

Comments
 (0)