Files
nisse/tests/integration/test_model_capability.py
T
dongarandClaude Opus 5.5 b934c46714
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 56s
Enforce the tool-call contract, asking servers only where it works
The executor ended a milestone on any reply without a tool call, and
nothing held a model to the offered tools. Measured on the 4070:
tool_choice "required" is enforced by vLLM (local-fast) and Anthropic,
but qwen3-14b (local-coder) under it cannot stop and writes until the
output ceiling, a known model bug. So enforcement is asked for per
model, and the loop holds the contract itself everywhere.

- The catalog reads supports_tool_choice from LiteLLM's model_info;
  BudgetedClient sends tool_choice only to models declared true, as
  "required" on the normalized route and {"type": "any"} on the
  Anthropic passthrough, and only when tools are offered
- The executor gains an explicit finish tool, the only way to end a
  milestone. Finishing with nothing changed is refused once; a prose
  reply gets one nudge, then escalates; a call to a tool not offered
  counts toward failure_limit, like a failed command; calls after
  finish are recorded as not run
- Clients record the model that actually answered. ModelFacts.answered
  recognises a proxy fallback, which once credited Claude's answers to
  a local model that could not call tools at all
- The context probe reports a fallback instead of trusting it; rerun,
  both local models now refuse over-long prompts, as they should
- The capability matrix checks every answer came from the model asked,
  that models call only offered tools, and that a declared
  supports_tool_choice is true
- Spec A15 and the tuning guide say how enforcement is requested,
  declared and backed up

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:54:49 +00:00

259 lines
9.9 KiB
Python

"""Capability matrix across models (spec Part H, A15).
Part H says the thresholds that depend on local capability — A12's failure
count, A14's consult budget, A15's manifest size — must be tuned "at
build-order step 5 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.executor import FINISH_TOOL
from nisse_dev.expert import parse_answer
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError
from nisse_dev.planner import PLANNER_TOOLS
from nisse_dev.tools import EXECUTOR_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_planner_contract(
model: str, catalog: ModelCatalog, routes
) -> None:
"""A4's gating is structural at the tool layer: a model that answers in
prose cannot be gated, because there is no plan to approve."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
produced = client.complete(
user("Draft an architecture plan for a command-line slug generator."),
PLANNER_TOOLS,
model,
system=(
"You must answer by calling exactly one tool: submit_plan 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 {"submit_plan", "ask_human"}
if calls[0].tool == "submit_plan":
content = calls[0].arguments.get("content", "")
# 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} submitted a degenerate plan: {content!r}"
)
def test_model_round_trips_a_tool_result(
model: str, catalog: ModelCatalog, routes
) -> None:
"""The executor loop 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, EXECUTOR_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, EXECUTOR_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:
"""A14 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 = [*EXECUTOR_TOOLS, FINISH_TOOL]
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 executor counts 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