Files
nisse/tests/integration/test_model_capability.py
T
dongarandClaude Opus 5.5 6ae2375194
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m16s
CI / e2e (pull_request) Successful in 10m51s
M2.8: switch over to the new loop
nisse-dev now runs only the conversational loop. The gated state machine
it was built alongside is gone:

- Removed control.py, planner.py, executor.py, approvals.py, gates.py
  (command_form moves to loop/reader.py), publish.py and poller.py, the
  old assembly in agent.py (its shared helpers move to loop/build.py),
  the store's state-machine view (get_state, transition), their tests,
  the old end-to-end and live planning/milestone/gate tests, and
  specs/orchestrator.qnt with its scenarios.
- Roles are driver, helper, expert and classifier; driver is required.
  loop:, roles.planner, roles.executor and tuning.executor are refused
  with what to use instead; tuning.driver takes the turn cap and failure
  limit.
- The benchmark and the capability matrix measure the driver: the
  loop's own system prompt and tools, the task's plan as the agreed
  restatement. Task plans end with finish_milestone, so earlier runs
  show as (old); stored runs' executor tuning reads as the driver's.
- A delegation on the loop records what its helper's alias resolved to
  (L9), as the old path's did.
- The dump shows a work item's revision and what it waits for.
- Behaviour the old tests covered and the loop shares stays covered:
  driver runs that fail on a model call, stop mid-turn, reset the
  failure count; go-aheads ignoring case and punctuation; a request too
  big to fit is never sent; the assembly tests, now on build_loop_agent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 03:12:58 +00:00

256 lines
9.8 KiB
Python

"""Capability matrix across models (architecture L9, Q).
The thresholds that depend on local capability (L9: the failure count, the
consult budget, the manifest size) are tuned on the real hardware. This is the
measurement that makes that possible, rather than reasoning about model
strength from reputation.
What it measures is the thing that actually breaks: whether a model can hold
the tool-call contract our loops depend on. llama3.2 could not — it returned
`{` as an entire plan — and no amount of prompt work fixed it.
Opt in by naming the models to measure, for one run:
uv run pytest -m integration tests/integration/test_model_capability.py \
--capability-models=local-fast,local-coder
Skipped otherwise, because it spends real tokens across every model listed.
Which models to measure is a question for a particular run, so it is an
argument, not configuration.
"""
import pytest
from nisse_dev.canonical import (
AssistantText,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
)
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig
from nisse_dev.expert import parse_answer
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError
from nisse_dev.loop.tools import ASK_HUMAN, FINISH_MILESTONE, RESTATE
from nisse_dev.tools import EXECUTION_TOOLS
pytestmark = pytest.mark.integration
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "model" not in metafunc.fixturenames:
return
raw = metafunc.config.getoption("--capability-models")
models = [m.strip() for m in raw.split(",") if m.strip()]
if models:
metafunc.parametrize("model", models)
else:
metafunc.parametrize(
"model",
[
pytest.param(
None,
marks=pytest.mark.skip(
reason="no models to measure; pass --capability-models"
),
)
],
)
@pytest.fixture
def routes(
litellm: LiteLLMClient, passthrough: AnthropicPassthroughClient
) -> tuple[LiteLLMClient, AnthropicPassthroughClient]:
return litellm, passthrough
@pytest.fixture(scope="module")
def catalog(live_config: AgentConfig) -> ModelCatalog:
"""The live catalog this key can see. /model/info is scoped per key, so
the probe list and the key have to agree."""
return ModelCatalog.discover(live_config.llm_base_url, live_config.llm_token)
def assert_answered(client: object, model: str, catalog: ModelCatalog) -> None:
"""A proxy fallback answers under another model's name. Crediting its
answer to this model is how a local model once 'held the tool contract
20/20' while never answering at all."""
reported = getattr(client, "last_model", None)
assert catalog.facts(model).answered(reported), (
f"{reported} answered instead of {model}: a fallback, not a measurement"
)
def user(text: str) -> list[StoredEvent]:
return [StoredEvent(id=1, ts="t", event=UserMessage(text=text, source="probe"))]
def test_model_answers_at_all(model: str, catalog: ModelCatalog, routes) -> None:
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Reply with exactly one word: pong"), None, model, system="Be terse."
)
assert_answered(client, model, catalog)
texts = [e.text for e in produced if isinstance(e, AssistantText)]
assert texts, f"{model} produced no text: {produced!r}"
def test_model_holds_the_restatement_contract(
model: str, catalog: ModelCatalog, routes
) -> None:
"""L3's go-ahead is structural at the tool layer: a driver that answers
in prose cannot be gated, because there is no restatement to agree to."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Issue #1: add a command-line slug generator."),
[RESTATE, ASK_HUMAN],
model,
system=(
"You must answer by calling exactly one tool: restate or "
"ask_human. Plain prose is not accepted."
),
)
assert_answered(client, model, catalog)
calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, f"{model} answered in prose instead of calling a tool"
assert calls[0].tool in {"restate", "ask_human"}
if calls[0].tool == "restate":
content = calls[0].arguments.get("text", "")
# llama3.2 emitted "{" here — structurally a tool call, substantively
# nothing. Length is a crude proxy, but it separates the two.
assert len(str(content)) > 80, f"{model} restated degenerately: {content!r}"
def test_model_round_trips_a_tool_result(
model: str, catalog: ModelCatalog, routes
) -> None:
"""The driver's run feeds results back; a model that cannot consume its
own tool result cannot run a milestone."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
events = user("List the files in the current directory using your tools.")
produced = client.complete(events, EXECUTION_TOOLS, model, system="Use a tool.")
calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, f"{model} made no tool call"
next_id = 2
for event in produced:
events.append(StoredEvent(id=next_id, ts="t", event=event))
next_id += 1
events.append(
StoredEvent(
id=next_id,
ts="t",
event=ToolResult(
call_id=calls[0].call_id, result="app.py\ntests/", success=True
),
)
)
assert_answered(client, model, catalog)
follow_up = client.complete(events, EXECUTION_TOOLS, model, system="Use a tool.")
assert follow_up, f"{model} produced nothing after its tool result"
def test_model_holds_the_prompt_level_json_contract(
model: str, catalog: ModelCatalog, routes
) -> None:
"""L9 contracts structured output at the prompt level because vendors
differ on JSON enforcement. This measures whether that holds per model."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Is SQLite sufficient for a single-process agent's task state?"),
None,
model,
system=(
"Reply with a single JSON object and nothing else: "
'{"answer": "...", "confidence": "high"|"medium"|"low", '
'"caveats": "..."}'
),
)
assert_answered(client, model, catalog)
text = "\n".join(e.text for e in produced if isinstance(e, AssistantText))
assert parse_answer(text) is not None, (
f"{model} did not produce parseable JSON: {text[:200]!r}"
)
def test_route_selection_matches_the_model(
model: str, catalog: ModelCatalog, routes
) -> None:
"""A non-Anthropic model must not be sent Anthropic-shaped bodies. This
was hardcoded until gpt-6-astra made it observable, and name-sniffed until
semantic aliases made the name stop meaning anything."""
normalized, passthrough = routes
facts = catalog.facts(model)
chosen = select_client(model, normalized, passthrough, catalog)
if facts.provider == "anthropic":
assert chosen is passthrough
else:
assert chosen is normalized
# And the chosen route must actually work for this model.
try:
chosen.complete(user("say pong"), None, model, system="Be terse.")
except LLMError as exc:
pytest.fail(f"{model} failed on its selected route: {exc}")
assert_answered(chosen, model, catalog)
FINISH_TOOLS = [*EXECUTION_TOOLS, FINISH_MILESTONE]
OFFERED = {tool["function"]["name"] for tool in FINISH_TOOLS}
def test_model_calls_only_the_tools_it_was_offered(
model: str, catalog: ModelCatalog, routes
) -> None:
"""Measured on a server that did not enforce tool_choice: a model asked
to chat called "chat", "say_hello" and "respond", none of them offered.
The driver answers those as failures; this measures how often."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Hi! How are you today? Just chat with me for a moment."),
FINISH_TOOLS,
model,
system="You are an agent. Every reply must be a tool call.",
)
assert_answered(client, model, catalog)
called = {e.tool for e in produced if isinstance(e, ToolCall)}
assert called <= OFFERED, (
f"{model} called tools it was never offered: {called - OFFERED}"
)
def test_supports_tool_choice_is_declared_truthfully(
model: str, catalog: ModelCatalog, routes
) -> None:
"""The agent asks for tool_choice only of models LiteLLM declares
supports_tool_choice for, so the declaration must be true. Asked of a
model that can't honour it, "required" can be worse than not asking:
qwen3-14b can't stop, and writes until the output ceiling."""
facts = catalog.facts(model)
if facts.supports_tool_choice is not True:
pytest.skip(
f"{model} is not declared to support tool_choice, so is never asked"
)
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Hi! How are you today? Just chat with me for a moment."),
FINISH_TOOLS,
model,
system="You are a helpful assistant.",
require_tool_call=True,
)
assert_answered(client, model, catalog)
calls = [e for e in produced if isinstance(e, ToolCall)]
assert calls, (
f"{model} is declared supports_tool_choice but answered a required call "
"in prose; set it to false in LiteLLM"
)
assert {c.tool for c in calls} <= OFFERED