Merge pull request 'Enforce the tool-call contract, asking servers only where it works' (#45) from enforce-tool-calls into main
CI / checks (push) Successful in 53s
CI / checks (push) Successful in 53s
Reviewed-on: dongar/nisse#45 Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #45.
This commit is contained in:
commit
b2bbf70b7b
16 files changed
+568
-86
No files matched your search
@@ -668,7 +668,13 @@ subsystem.
|
|||||||
|
|
||||||
### A15. Local Model Accommodations
|
### A15. Local Model Accommodations
|
||||||
|
|
||||||
- **Constrained decoding for tool-call JSON.**
|
- **Constrained decoding for tool-call JSON.** Requested with `tool_choice`
|
||||||
|
(`required`), and only of models whose LiteLLM entry declares
|
||||||
|
`supports_tool_choice`: enforcement is the model server's, and asking one that
|
||||||
|
can't honour it can be worse than not asking. Where it isn't available, the
|
||||||
|
loop enforces the contract itself: an explicit `finish` tool ends the work, a
|
||||||
|
prose reply is a violation, and a call to a tool not offered counts as a
|
||||||
|
failure (A12).
|
||||||
- **Reduced tool schema for the executor role:** fewer tools, flatter arguments, no
|
- **Reduced tool schema for the executor role:** fewer tools, flatter arguments, no
|
||||||
deeply nested optionals. Maintain **two tool manifests keyed on role**, rendered at
|
deeply nested optionals. Maintain **two tool manifests keyed on role**, rendered at
|
||||||
request time like everything else.
|
request time like everything else.
|
||||||
|
|||||||
+55
-6
@@ -282,6 +282,48 @@ log.
|
|||||||
- **Deciding:** if milestones stop with "cannot fit", look first at
|
- **Deciding:** if milestones stop with "cannot fit", look first at
|
||||||
`tools.max_output_chars` and the model's window, not at these.
|
`tools.max_output_chars` and the model's window, not at these.
|
||||||
|
|
||||||
|
### Enforced tool calls — `supports_tool_choice`, declared in LiteLLM
|
||||||
|
|
||||||
|
The agent's loops depend on every turn being a well-formed call to a real
|
||||||
|
tool. A model only *writes text*: a "tool call" is text in a format the model
|
||||||
|
was trained to produce, which the server recognises and turns into a
|
||||||
|
structured call. By default (`tool_choice: auto`) the server just parses
|
||||||
|
whatever comes out, so prose, broken JSON and calls to invented tools are all
|
||||||
|
possible.
|
||||||
|
|
||||||
|
With `tool_choice: required`, a server that supports it **constrains
|
||||||
|
decoding**: at every step it rules out any token that couldn't lead to a
|
||||||
|
valid call to one of the offered tools, with arguments matching its schema.
|
||||||
|
The model still chooses the tool and the arguments; it can't produce anything
|
||||||
|
else. That makes it a property of the **model server** (and sometimes of the
|
||||||
|
model), not something a prompt can buy.
|
||||||
|
|
||||||
|
So the agent asks for it only where it is known to work: models whose LiteLLM
|
||||||
|
entry declares `supports_tool_choice: true`. Measured on the 4070:
|
||||||
|
|
||||||
|
| Alias | Server | `required` on a chat prompt | Declare |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `local-fast` (Qwen3-8B-AWQ) | vLLM 0.29 | calls `finish` every time | `true` |
|
||||||
|
| `local-coder` (Qwen3-14B) | llama.cpp | can't stop; writes until the output ceiling (a known Qwen3-14B bug) | `false` |
|
||||||
|
| `claude-haiku-4-5` | Anthropic | calls `finish` every time | `true` (LiteLLM already says so) |
|
||||||
|
|
||||||
|
Asking a model that can't honour it can be worse than not asking: the
|
||||||
|
Qwen3-14B turn above costs a full output ceiling of generation, where a plain
|
||||||
|
prose reply would have cost a few hundred tokens.
|
||||||
|
|
||||||
|
Where it isn't enforced, the executor holds the contract itself:
|
||||||
|
|
||||||
|
- **`finish` is the only way to end a milestone.** It is refused once if
|
||||||
|
nothing has been changed yet.
|
||||||
|
- **A prose reply is a violation.** The model is told once; a second escalates.
|
||||||
|
- **A call to a tool that wasn't offered counts as a failure** toward
|
||||||
|
`failure_limit`, like a failed command, so an inventing model escalates in a
|
||||||
|
couple of turns instead of using up `max_turns`.
|
||||||
|
|
||||||
|
The capability matrix checks the declaration: a model declared `true` that
|
||||||
|
answers a required call in prose fails
|
||||||
|
`test_supports_tool_choice_is_declared_truthfully`.
|
||||||
|
|
||||||
### Declaring a local model's limits
|
### Declaring a local model's limits
|
||||||
|
|
||||||
LiteLLM knows commercial models' limits. For a local model, declare them in
|
LiteLLM knows commercial models' limits. For a local model, declare them in
|
||||||
@@ -292,8 +334,10 @@ model_list:
|
|||||||
- model_name: local-fast
|
- model_name: local-fast
|
||||||
litellm_params: {model: openai/qwen3-8b-awq, api_base: http://inference-host:8080/v1}
|
litellm_params: {model: openai/qwen3-8b-awq, api_base: http://inference-host:8080/v1}
|
||||||
model_info:
|
model_info:
|
||||||
max_input_tokens: 32768 # the server's per-request context
|
max_input_tokens: 32768 # the server's per-request context
|
||||||
max_output_tokens: 4096 # the most one response may use
|
max_output_tokens: 4096 # the most one response may use
|
||||||
|
supports_function_calling: true
|
||||||
|
supports_tool_choice: true # only once measured; see above
|
||||||
```
|
```
|
||||||
|
|
||||||
`max_input_tokens` is the *whole* window, prompt plus response. The agent
|
`max_input_tokens` is the *whole* window, prompt plus response. The agent
|
||||||
@@ -332,7 +376,7 @@ Upstream choices change what's right downstream, so tune in this order and
|
|||||||
change one thing at a time.
|
change one thing at a time.
|
||||||
|
|
||||||
1. **Model and quantization.** Start with whether a candidate holds the
|
1. **Model and quantization.** Start with whether a candidate holds the
|
||||||
tool-call contract at all:
|
tool-call contract at all, and whether its server enforces `tool_choice`:
|
||||||
|
|
||||||
uv run pytest -m integration tests/integration/test_model_capability.py \
|
uv run pytest -m integration tests/integration/test_model_capability.py \
|
||||||
--capability-models=alias-a,alias-b
|
--capability-models=alias-a,alias-b
|
||||||
@@ -342,6 +386,14 @@ change one thing at a time.
|
|||||||
each one actually points at in LiteLLM (`/model/info`, the
|
each one actually points at in LiteLLM (`/model/info`, the
|
||||||
`litellm_params.model` field). Two aliases believed to be the same model
|
`litellm_params.model` field). Two aliases believed to be the same model
|
||||||
once weren't, and that cost a lot of debugging.
|
once weren't, and that cost a lot of debugging.
|
||||||
|
|
||||||
|
**Make sure LiteLLM has no fallback for a model you are measuring.** A
|
||||||
|
fallback answers a failed request with another model, under that model's
|
||||||
|
name, in a response that otherwise looks normal. One made a local model
|
||||||
|
that couldn't call tools at all look like it "held the tool contract 20 of
|
||||||
|
20": every answer was Claude's. The capability matrix and the probe now
|
||||||
|
check which model answered and fail on a mismatch, but the fallback also
|
||||||
|
quietly turns local work into frontier spending in production.
|
||||||
2. **Context length and KV-cache precision** on the server. Take the largest
|
2. **Context length and KV-cache precision** on the server. Take the largest
|
||||||
context that fits beside the weights with 1–2 GB of headroom. Try an 8-bit
|
context that fits beside the weights with 1–2 GB of headroom. Try an 8-bit
|
||||||
KV cache before giving up context.
|
KV cache before giving up context.
|
||||||
@@ -369,8 +421,5 @@ section for each:
|
|||||||
- **Compaction.** A model-written summary replacing old turns, instead of
|
- **Compaction.** A model-written summary replacing old turns, instead of
|
||||||
dropping them. The log already has an event for it; it needs a summarizing
|
dropping them. The log already has an event for it; it needs a summarizing
|
||||||
model and a policy for what is safe to summarize.
|
model and a policy for what is safe to summarize.
|
||||||
- **Enforced tool calls.** An explicit `finish` tool, so the server can be told
|
|
||||||
a tool call is required and hold the model to the contract, where today it
|
|
||||||
can fall back to prose.
|
|
||||||
- **A benchmark script** that runs the capability matrix and representative
|
- **A benchmark script** that runs the capability matrix and representative
|
||||||
milestones across candidate models and prints a comparison table.
|
milestones across candidate models and prints a comparison table.
|
||||||
@@ -174,6 +174,9 @@ def build_agent(
|
|||||||
max_output_tokens=config.max_output_tokens,
|
max_output_tokens=config.max_output_tokens,
|
||||||
keep_recent_turns=tuning.context.keep_recent_turns,
|
keep_recent_turns=tuning.context.keep_recent_turns,
|
||||||
safety_margin=tuning.context.safety_margin,
|
safety_margin=tuning.context.safety_margin,
|
||||||
|
enforces_tool_choice=lambda m: (
|
||||||
|
catalog.facts(m).supports_tool_choice is True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
normalized = budgeted(
|
normalized = budgeted(
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ class Cancel:
|
|||||||
return self._event.is_set()
|
return self._event.is_set()
|
||||||
|
|
||||||
|
|
||||||
def skip_calls(store: StateStore, calls: list[ToolCall]) -> None:
|
def skip_calls(
|
||||||
"""Record the calls a stop left unrun. Every tool call needs a result for
|
store: StateStore, calls: list[ToolCall], reason: str = STOPPED_RESULT
|
||||||
the transcript to render again, and the result says what happened."""
|
) -> None:
|
||||||
|
"""Record calls left unrun. Every tool call needs a result for the
|
||||||
|
transcript to render again, and the result says what happened."""
|
||||||
for call in calls:
|
for call in calls:
|
||||||
store.append_event(
|
store.append_event(
|
||||||
ToolResult(call_id=call.call_id, result=STOPPED_RESULT, success=False)
|
ToolResult(call_id=call.call_id, result=reason, success=False)
|
||||||
)
|
)
|
||||||
@@ -54,11 +54,26 @@ class ModelFacts:
|
|||||||
max_input_tokens: int | None = None
|
max_input_tokens: int | None = None
|
||||||
# The most one response may use (model_info.max_output_tokens).
|
# The most one response may use (model_info.max_output_tokens).
|
||||||
max_output_tokens: int | None = None
|
max_output_tokens: int | None = None
|
||||||
|
# Whether its server enforces tool_choice (model_info.supports_tool_choice).
|
||||||
|
# Only a declared True counts: a model that says nothing is not asked.
|
||||||
|
supports_tool_choice: bool | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_anthropic(self) -> bool:
|
def native_anthropic(self) -> bool:
|
||||||
return self.provider == ANTHROPIC_PROVIDER
|
return self.provider == ANTHROPIC_PROVIDER
|
||||||
|
|
||||||
|
def answered(self, reported: str | None) -> bool:
|
||||||
|
"""Whether a response reporting this model name came from this model.
|
||||||
|
|
||||||
|
A proxy fallback answers under another model's name. LiteLLM reports
|
||||||
|
the alias; a provider may report its own name for the backing model,
|
||||||
|
sometimes with a date suffix ("claude-haiku-4-5-20251001").
|
||||||
|
"""
|
||||||
|
if not reported:
|
||||||
|
return True # nothing reported, nothing contradicted
|
||||||
|
own_name = self.backing.partition("/")[2] or self.backing
|
||||||
|
return reported in (self.alias, self.backing) or reported.startswith(own_name)
|
||||||
|
|
||||||
|
|
||||||
class ModelCatalog:
|
class ModelCatalog:
|
||||||
"""What one LiteLLM key can see, as of the moment it was asked."""
|
"""What one LiteLLM key can see, as of the moment it was asked."""
|
||||||
@@ -87,6 +102,7 @@ class ModelCatalog:
|
|||||||
supports_tools=info.get("supports_function_calling"),
|
supports_tools=info.get("supports_function_calling"),
|
||||||
max_input_tokens=_positive_int(info.get("max_input_tokens")),
|
max_input_tokens=_positive_int(info.get("max_input_tokens")),
|
||||||
max_output_tokens=_positive_int(info.get("max_output_tokens")),
|
max_output_tokens=_positive_int(info.get("max_output_tokens")),
|
||||||
|
supports_tool_choice=info.get("supports_tool_choice"),
|
||||||
)
|
)
|
||||||
return cls(facts)
|
return cls(facts)
|
||||||
|
|
||||||
|
|||||||
@@ -215,9 +215,15 @@ class BudgetedClient:
|
|||||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
||||||
keep_recent_turns: int = 6,
|
keep_recent_turns: int = 6,
|
||||||
safety_margin: float = 0.1,
|
safety_margin: float = 0.1,
|
||||||
|
enforces_tool_choice: Callable[[str], bool] = lambda model: False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._inner = inner
|
self._inner = inner
|
||||||
self._limits_for = limits_for
|
self._limits_for = limits_for
|
||||||
|
# Whether a model's server holds it to the offered tools when asked
|
||||||
|
# (LiteLLM's supports_tool_choice). Asking one that doesn't can be
|
||||||
|
# worse than not asking: qwen3-14b can't stop, and rambles until the
|
||||||
|
# output ceiling.
|
||||||
|
self._enforces_tool_choice = enforces_tool_choice
|
||||||
self._max_output_tokens = max_output_tokens
|
self._max_output_tokens = max_output_tokens
|
||||||
self._keep_recent_turns = keep_recent_turns
|
self._keep_recent_turns = keep_recent_turns
|
||||||
self._safety_margin = safety_margin
|
self._safety_margin = safety_margin
|
||||||
@@ -229,6 +235,10 @@ class BudgetedClient:
|
|||||||
def last_usage(self) -> dict[str, int]:
|
def last_usage(self) -> dict[str, int]:
|
||||||
return getattr(self._inner, "last_usage", {}) or {}
|
return getattr(self._inner, "last_usage", {}) or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_model(self) -> str | None:
|
||||||
|
return getattr(self._inner, "last_model", None)
|
||||||
|
|
||||||
def budget_for(self, model: str) -> Budget:
|
def budget_for(self, model: str) -> Budget:
|
||||||
window, model_max_output = self._limits_for(model)
|
window, model_max_output = self._limits_for(model)
|
||||||
ceiling = self._max_output_tokens
|
ceiling = self._max_output_tokens
|
||||||
@@ -252,7 +262,15 @@ class BudgetedClient:
|
|||||||
factor = self._factors.get(model, 1.0)
|
factor = self._factors.get(model, 1.0)
|
||||||
fitted = fit(events, system, tools, budget, factor)
|
fitted = fit(events, system, tools, budget, factor)
|
||||||
produced = self._inner.complete(
|
produced = self._inner.complete(
|
||||||
fitted, tools, model, system=system, max_tokens=budget.max_output
|
fitted,
|
||||||
|
tools,
|
||||||
|
model,
|
||||||
|
system=system,
|
||||||
|
max_tokens=budget.max_output,
|
||||||
|
# Every caller that offers tools needs one called: the executor
|
||||||
|
# finishes with finish, the planner with submit_plan or
|
||||||
|
# ask_human, a delegation with hand_back.
|
||||||
|
require_tool_call=bool(tools) and self._enforces_tool_choice(model),
|
||||||
)
|
)
|
||||||
reported = int(self.last_usage.get("input", 0))
|
reported = int(self.last_usage.get("input", 0))
|
||||||
if reported > 0:
|
if reported > 0:
|
||||||
|
|||||||
@@ -27,20 +27,42 @@ EXECUTOR_SYSTEM = """You are nisse-dev's executor, doing mechanical work.
|
|||||||
Follow the approved plan below exactly. It was written to be executed
|
Follow the approved plan below exactly. It was written to be executed
|
||||||
verbatim; do not redesign it, expand its scope, or skip steps.
|
verbatim; do not redesign it, expand its scope, or skip steps.
|
||||||
|
|
||||||
Work by calling tools. Verify your work by running the project's tests.
|
Every reply must be a tool call. Verify your work by running the project's
|
||||||
When every step of the plan is done and the tests pass, reply with a short
|
tests. When every step of the plan is done and the tests pass, call finish
|
||||||
plain-text summary and no tool call — that ends the milestone."""
|
with a short summary — that, and nothing else, ends the milestone."""
|
||||||
|
|
||||||
DEFAULT_MAX_TURNS = 24
|
DEFAULT_MAX_TURNS = 24
|
||||||
DEFAULT_FAILURE_LIMIT = 2
|
DEFAULT_FAILURE_LIMIT = 2
|
||||||
|
|
||||||
# A turn with no tool call means "the plan is done". Believing that before
|
FINISH_TOOL: dict[str, Any] = {
|
||||||
# any real work has happened would record an empty milestone as a success, so
|
"type": "function",
|
||||||
# the claim is challenged once first.
|
"function": {
|
||||||
_NUDGE = (
|
"name": "finish",
|
||||||
"\n\nYou have not made any change yet, so the plan cannot be complete. "
|
"description": (
|
||||||
"Carry out the next step of the plan with a tool call, or run the tests "
|
"End the milestone: every step of the plan is done and the tests pass."
|
||||||
"to show the work is already done."
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": {"type": "string", "description": "What was done."}
|
||||||
|
},
|
||||||
|
"required": ["summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Finishing before any real work has happened would record an empty
|
||||||
|
# milestone as a success, so the claim is refused once first.
|
||||||
|
_NOT_YET = (
|
||||||
|
"You have not made any change yet, so the plan cannot be complete. Carry "
|
||||||
|
"out the next step of the plan, or run the tests to show the work is "
|
||||||
|
"already done, before calling finish."
|
||||||
|
)
|
||||||
|
# A reply in prose breaks the tool contract. A server that enforces
|
||||||
|
# tool_choice never allows one; on one that doesn't, the model is told once.
|
||||||
|
_PROSE_NUDGE = (
|
||||||
|
"\n\nYour last reply was prose. Every reply must be a tool call: carry "
|
||||||
|
"out the next step, or call finish if the plan is complete."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,8 +150,8 @@ class LLMExecutor:
|
|||||||
# A12: ask_expert exists in addition to the observable escalation
|
# A12: ask_expert exists in addition to the observable escalation
|
||||||
# triggers, never as a replacement for them.
|
# triggers, never as a replacement for them.
|
||||||
if self._expert is None:
|
if self._expert is None:
|
||||||
return EXECUTOR_TOOLS
|
return [*EXECUTOR_TOOLS, FINISH_TOOL]
|
||||||
return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL]
|
return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL, FINISH_TOOL]
|
||||||
|
|
||||||
def _consult(self, arguments: dict[str, Any]) -> ToolOutcome:
|
def _consult(self, arguments: dict[str, Any]) -> ToolOutcome:
|
||||||
assert self._expert is not None
|
assert self._expert is not None
|
||||||
@@ -173,9 +195,12 @@ class LLMExecutor:
|
|||||||
if plan is not None:
|
if plan is not None:
|
||||||
# In the system prompt, so the context budget never drops it.
|
# In the system prompt, so the context budget never drops it.
|
||||||
system += f"\n\nThe approved plan:\n{plan}"
|
system += f"\n\nThe approved plan:\n{plan}"
|
||||||
|
tools = self._tools()
|
||||||
|
offered = {tool["function"]["name"] for tool in tools}
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
did_work = False
|
did_work = False
|
||||||
challenged = False
|
finish_refused = False
|
||||||
|
prose_nudged = False
|
||||||
|
|
||||||
for turn in range(self._max_turns):
|
for turn in range(self._max_turns):
|
||||||
# A4: `stop` takes effect at the next tool boundary. The start of
|
# A4: `stop` takes effect at the next tool boundary. The start of
|
||||||
@@ -185,7 +210,7 @@ class LLMExecutor:
|
|||||||
try:
|
try:
|
||||||
produced = self._client.complete(
|
produced = self._client.complete(
|
||||||
self._store.read_events(after=after),
|
self._store.read_events(after=after),
|
||||||
tools=self._tools(),
|
tools=tools,
|
||||||
model=model,
|
model=model,
|
||||||
system=system,
|
system=system,
|
||||||
)
|
)
|
||||||
@@ -199,19 +224,12 @@ class LLMExecutor:
|
|||||||
calls.append(event)
|
calls.append(event)
|
||||||
|
|
||||||
if not calls:
|
if not calls:
|
||||||
if did_work:
|
if not prose_nudged:
|
||||||
return ExecutorOutcome(
|
prose_nudged = True
|
||||||
"completed", "executor reported the plan done"
|
system += _PROSE_NUDGE
|
||||||
)
|
|
||||||
if not challenged:
|
|
||||||
# Claiming completion without having touched anything is
|
|
||||||
# not evidence of completion; ask once before believing it.
|
|
||||||
challenged = True
|
|
||||||
system += _NUDGE
|
|
||||||
continue
|
continue
|
||||||
return ExecutorOutcome(
|
return ExecutorOutcome(
|
||||||
"escalate",
|
"escalate", "executor kept replying in prose instead of tools"
|
||||||
"executor claimed the milestone was done without making any change",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._report(turn, calls)
|
self._report(turn, calls)
|
||||||
@@ -219,11 +237,47 @@ class LLMExecutor:
|
|||||||
if self._cancel.requested:
|
if self._cancel.requested:
|
||||||
skip_calls(self._store, calls[index:])
|
skip_calls(self._store, calls[index:])
|
||||||
return ExecutorOutcome("stopped", f"during turn {turn + 1}")
|
return ExecutorOutcome("stopped", f"during turn {turn + 1}")
|
||||||
if call.tool in MUTATING_TOOLS:
|
if call.tool == "finish":
|
||||||
did_work = True
|
if not did_work and not finish_refused:
|
||||||
if call.tool == "ask_expert":
|
finish_refused = True
|
||||||
|
outcome = ToolOutcome(_NOT_YET, success=False)
|
||||||
|
elif not did_work:
|
||||||
|
return ExecutorOutcome(
|
||||||
|
"escalate",
|
||||||
|
"executor claimed the milestone was done without "
|
||||||
|
"making any change",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._store.append_event(
|
||||||
|
ToolResult(
|
||||||
|
call_id=call.call_id,
|
||||||
|
result="milestone finished",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
skip_calls(
|
||||||
|
self._store,
|
||||||
|
calls[index + 1 :],
|
||||||
|
"not run: finish had already ended the milestone",
|
||||||
|
)
|
||||||
|
summary = str(call.arguments.get("summary", "")).strip()
|
||||||
|
return ExecutorOutcome(
|
||||||
|
"completed", summary or "executor reported the plan done"
|
||||||
|
)
|
||||||
|
elif call.tool not in offered:
|
||||||
|
# A model inventing tools is not doing the work, and with
|
||||||
|
# no server enforcement nothing else would stop it, so it
|
||||||
|
# counts toward escalation like a failed command (A12).
|
||||||
|
outcome = ToolOutcome(
|
||||||
|
f"no such tool: {call.tool}; the tools are "
|
||||||
|
+ ", ".join(sorted(offered)),
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
elif call.tool == "ask_expert":
|
||||||
outcome = self._consult(call.arguments)
|
outcome = self._consult(call.arguments)
|
||||||
else:
|
else:
|
||||||
|
if call.tool in MUTATING_TOOLS:
|
||||||
|
did_work = True
|
||||||
outcome = self._workspace.dispatch(call.tool, call.arguments)
|
outcome = self._workspace.dispatch(call.tool, call.arguments)
|
||||||
self._store.append_event(
|
self._store.append_event(
|
||||||
ToolResult(
|
ToolResult(
|
||||||
@@ -232,9 +286,10 @@ class LLMExecutor:
|
|||||||
success=outcome.success,
|
success=outcome.success,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Only commands count toward escalation: a failed read is the
|
# Commands and invented tools count toward escalation: a
|
||||||
# model looking around, a failed command is work not working.
|
# failed read is the model looking around, a failed command
|
||||||
if call.tool == "run_command":
|
# is work not working.
|
||||||
|
if call.tool == "run_command" or call.tool not in offered:
|
||||||
consecutive_failures = (
|
consecutive_failures = (
|
||||||
0 if outcome.success else consecutive_failures + 1
|
0 if outcome.success else consecutive_failures + 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class LiteLLMClient:
|
|||||||
# a side channel rather than a return value, so the LLMClient
|
# a side channel rather than a return value, so the LLMClient
|
||||||
# protocol stays the same for every implementation.
|
# protocol stays the same for every implementation.
|
||||||
self.last_usage: dict[str, int] = {}
|
self.last_usage: dict[str, int] = {}
|
||||||
|
self.last_model: str | None = None
|
||||||
|
|
||||||
def complete(
|
def complete(
|
||||||
self,
|
self,
|
||||||
@@ -151,8 +152,12 @@ class LiteLLMClient:
|
|||||||
system: str | None = None,
|
system: str | None = None,
|
||||||
*,
|
*,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
|
require_tool_call: bool = False,
|
||||||
) -> list[CanonicalEvent]:
|
) -> list[CanonicalEvent]:
|
||||||
body = self._renderer.render(events, tools, model, system=system)
|
body = self._renderer.render(events, tools, model, system=system)
|
||||||
|
if require_tool_call and tools:
|
||||||
|
# Only for models whose server enforces it; see BudgetedClient.
|
||||||
|
body["tool_choice"] = "required"
|
||||||
# A ceiling on this route too: without one a runaway turn is bounded
|
# A ceiling on this route too: without one a runaway turn is bounded
|
||||||
# only by the provider's default, and the same truncation check has
|
# only by the provider's default, and the same truncation check has
|
||||||
# nothing to check against. A caller that knows the model's limits
|
# nothing to check against. A caller that knows the model's limits
|
||||||
@@ -182,6 +187,9 @@ class LiteLLMClient:
|
|||||||
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
|
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
|
||||||
)
|
)
|
||||||
produced_by = data.get("model") or requested_model
|
produced_by = data.get("model") or requested_model
|
||||||
|
# What actually answered, which is not always what was asked for: a
|
||||||
|
# proxy fallback answers under another model's name.
|
||||||
|
self.last_model = produced_by
|
||||||
usage = data.get("usage") or {}
|
usage = data.get("usage") or {}
|
||||||
self.last_usage = {
|
self.last_usage = {
|
||||||
"input": int(usage.get("prompt_tokens") or 0),
|
"input": int(usage.get("prompt_tokens") or 0),
|
||||||
@@ -254,6 +262,7 @@ class AnthropicPassthroughClient:
|
|||||||
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
|
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
|
||||||
self._sleep = sleep
|
self._sleep = sleep
|
||||||
self.last_usage: dict[str, int] = {}
|
self.last_usage: dict[str, int] = {}
|
||||||
|
self.last_model: str | None = None
|
||||||
|
|
||||||
def complete(
|
def complete(
|
||||||
self,
|
self,
|
||||||
@@ -263,6 +272,7 @@ class AnthropicPassthroughClient:
|
|||||||
system: str | None = None,
|
system: str | None = None,
|
||||||
*,
|
*,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
|
require_tool_call: bool = False,
|
||||||
) -> list[CanonicalEvent]:
|
) -> list[CanonicalEvent]:
|
||||||
body = self._renderer.render(
|
body = self._renderer.render(
|
||||||
events,
|
events,
|
||||||
@@ -271,6 +281,8 @@ class AnthropicPassthroughClient:
|
|||||||
system=system,
|
system=system,
|
||||||
max_tokens=max_tokens or self._max_tokens,
|
max_tokens=max_tokens or self._max_tokens,
|
||||||
)
|
)
|
||||||
|
if require_tool_call and tools:
|
||||||
|
body["tool_choice"] = {"type": "any"} # Anthropic's name for it
|
||||||
response = _post_with_rate_limit_retry(
|
response = _post_with_rate_limit_retry(
|
||||||
self._http, "/v1/messages", body, self._sleep
|
self._http, "/v1/messages", body, self._sleep
|
||||||
)
|
)
|
||||||
@@ -293,6 +305,9 @@ class AnthropicPassthroughClient:
|
|||||||
if not isinstance(content, list):
|
if not isinstance(content, list):
|
||||||
raise LLMError(f"malformed passthrough response: {str(data)[:300]}")
|
raise LLMError(f"malformed passthrough response: {str(data)[:300]}")
|
||||||
produced_by = data.get("model") or requested_model
|
produced_by = data.get("model") or requested_model
|
||||||
|
# What actually answered, which is not always what was asked for: a
|
||||||
|
# proxy fallback answers under another model's name.
|
||||||
|
self.last_model = produced_by
|
||||||
usage = data.get("usage") or {}
|
usage = data.get("usage") or {}
|
||||||
self.last_usage = {
|
self.last_usage = {
|
||||||
"input": int(usage.get("input_tokens") or 0),
|
"input": int(usage.get("input_tokens") or 0),
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ with it:
|
|||||||
- **It took the whole prompt.** The server allows more than is declared, so
|
- **It took the whole prompt.** The server allows more than is declared, so
|
||||||
the declaration is conservative.
|
the declaration is conservative.
|
||||||
|
|
||||||
|
Every answer is checked for which model gave it. A proxy that falls back to
|
||||||
|
another model on an error returns a normal-looking answer under that model's
|
||||||
|
name, and once made this probe report a refusing server as roomy.
|
||||||
|
|
||||||
Run it with ``scripts/probe_context.py``. It spends one small and one large
|
Run it with ``scripts/probe_context.py``. It spends one small and one large
|
||||||
request on the model, so it refuses windows above a ceiling unless told
|
request on the model, so it refuses windows above a ceiling unless told
|
||||||
otherwise: commercial models' limits are already known to LiteLLM.
|
otherwise: commercial models' limits are already known to LiteLLM.
|
||||||
@@ -20,6 +24,8 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from nisse_dev.catalog import ModelFacts
|
||||||
|
|
||||||
# Of a probe's size, how much must come back for it to count as uncut. A
|
# Of a probe's size, how much must come back for it to count as uncut. A
|
||||||
# chat template adds a little, and a tokenizer counts a little differently.
|
# chat template adds a little, and a tokenizer counts a little differently.
|
||||||
_UNCUT = 0.9
|
_UNCUT = 0.9
|
||||||
@@ -86,7 +92,14 @@ def _prompt(words: int) -> str:
|
|||||||
return " ".join(f"item{i}" for i in range(words)) + "\n\nReply with just: ok"
|
return " ".join(f"item{i}" for i in range(words)) + "\n\nReply with just: ok"
|
||||||
|
|
||||||
|
|
||||||
def _send(http: httpx.Client, model: str, words: int) -> tuple[int | None, str | None]:
|
class Fallback(Exception):
|
||||||
|
def __init__(self, answered_by: str) -> None:
|
||||||
|
self.answered_by = answered_by
|
||||||
|
|
||||||
|
|
||||||
|
def _send(
|
||||||
|
http: httpx.Client, model: str, words: int, backing: str
|
||||||
|
) -> tuple[int | None, str | None]:
|
||||||
response = http.post(
|
response = http.post(
|
||||||
"/chat/completions",
|
"/chat/completions",
|
||||||
json={
|
json={
|
||||||
@@ -97,18 +110,33 @@ def _send(http: httpx.Client, model: str, words: int) -> tuple[int | None, str |
|
|||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
return None, f"{response.status_code}: {response.text}"
|
return None, f"{response.status_code}: {response.text}"
|
||||||
usage = response.json().get("usage") or {}
|
data = response.json()
|
||||||
|
answered = str(data.get("model") or "")
|
||||||
|
facts = ModelFacts(alias=model, backing=backing, provider="", supports_tools=None)
|
||||||
|
if not facts.answered(answered):
|
||||||
|
raise Fallback(answered)
|
||||||
|
usage = data.get("usage") or {}
|
||||||
return int(usage.get("prompt_tokens") or 0), None
|
return int(usage.get("prompt_tokens") or 0), None
|
||||||
|
|
||||||
|
|
||||||
def probe(http: httpx.Client, model: str, declared: int) -> ProbeResult:
|
def probe(
|
||||||
|
http: httpx.Client, model: str, declared: int, backing: str = ""
|
||||||
|
) -> ProbeResult:
|
||||||
"""Measure how many tokens a word of the probe costs on this model, then
|
"""Measure how many tokens a word of the probe costs on this model, then
|
||||||
send a prompt half again as long as the declared window."""
|
send a prompt half again as long as the declared window."""
|
||||||
calibration_words = 200
|
calibration_words = 200
|
||||||
tokens, error = _send(http, model, calibration_words)
|
try:
|
||||||
if error is not None or not tokens:
|
tokens, error = _send(http, model, calibration_words, backing)
|
||||||
return ProbeResult("failed", f"a short calibration request failed: {error}")
|
if error is not None or not tokens:
|
||||||
per_word = tokens / calibration_words
|
return ProbeResult("failed", f"a short calibration request failed: {error}")
|
||||||
sent = int(declared * 1.5)
|
per_word = tokens / calibration_words
|
||||||
reported, error = _send(http, model, int(sent / per_word))
|
sent = int(declared * 1.5)
|
||||||
|
reported, error = _send(http, model, int(sent / per_word), backing)
|
||||||
|
except Fallback as fallback:
|
||||||
|
return ProbeResult(
|
||||||
|
"fallback",
|
||||||
|
f"{fallback.answered_by} answered instead of {model}: the proxy fell "
|
||||||
|
"back to another model, so nothing here describes this one. Remove "
|
||||||
|
"the fallback, or probe the server directly.",
|
||||||
|
)
|
||||||
return interpret(declared, sent, reported, error)
|
return interpret(declared, sent, reported, error)
|
||||||
@@ -344,4 +344,6 @@ def test_tuning_reaches_every_component(
|
|||||||
assert executor._client._inner._max_tokens == 8192
|
assert executor._client._inner._max_tokens == 8192
|
||||||
assert delegation._client._inner._max_tokens == 8192
|
assert delegation._client._inner._max_tokens == 8192
|
||||||
assert executor._client._max_output_tokens == 8192
|
assert executor._client._max_output_tokens == 8192
|
||||||
|
# tool_choice is asked of exactly the models LiteLLM declares enforce it.
|
||||||
|
assert executor._client._enforces_tool_choice("claude-opus-5") is False
|
||||||
agent.stop()
|
agent.stop()
|
||||||
@@ -150,8 +150,8 @@ class RecordingClient:
|
|||||||
self.calls: list[dict[str, Any]] = []
|
self.calls: list[dict[str, Any]] = []
|
||||||
self.last_usage = {"input": reported_input, "output": 5}
|
self.last_usage = {"input": reported_input, "output": 5}
|
||||||
|
|
||||||
def complete(self, events, tools, model, system=None, *, max_tokens=None):
|
def complete(self, events, tools, model, system=None, *, max_tokens=None, **kwargs):
|
||||||
self.calls.append({"events": events, "max_tokens": max_tokens})
|
self.calls.append({"events": events, "max_tokens": max_tokens, **kwargs})
|
||||||
return [AssistantText(text="ok", model=model)]
|
return [AssistantText(text="ok", model=model)]
|
||||||
|
|
||||||
|
|
||||||
@@ -292,3 +292,51 @@ def test_a_request_that_cannot_fit_stops_the_milestone(tmp_path: Path) -> None:
|
|||||||
assert outcome.status == "escalate"
|
assert outcome.status == "escalate"
|
||||||
assert "fit" in outcome.note
|
assert "fit" in outcome.note
|
||||||
assert inner.calls == []
|
assert inner.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- tool_choice is only asked of servers that enforce it -------------------
|
||||||
|
|
||||||
|
FINISH = [{"type": "function", "function": {"name": "finish", "parameters": {}}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_is_asked_only_of_models_declared_to_enforce_it() -> None:
|
||||||
|
# Measured: qwen3-14b under "required" cannot stop and rambles to the
|
||||||
|
# output ceiling, so a model that is not declared is never asked.
|
||||||
|
inner = RecordingClient()
|
||||||
|
client = BudgetedClient(
|
||||||
|
inner, lambda m: (32768, 8192), enforces_tool_choice=lambda m: m == "local-fast"
|
||||||
|
)
|
||||||
|
client.complete(transcript(1, 10), FINISH, "local-fast")
|
||||||
|
client.complete(transcript(1, 10), FINISH, "local-coder")
|
||||||
|
client.complete(transcript(1, 10), None, "local-fast")
|
||||||
|
assert [c["require_tool_call"] for c in inner.calls] == [True, False, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalog_reads_supports_tool_choice() -> None:
|
||||||
|
catalog = ModelCatalog.from_payload(
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{"model_name": "a", "model_info": {"supports_tool_choice": True}},
|
||||||
|
{"model_name": "b", "model_info": {"supports_tool_choice": False}},
|
||||||
|
{"model_name": "c", "model_info": {}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert [catalog.facts(m).supports_tool_choice for m in "abc"] == [True, False, None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fallback_answer_is_recognised() -> None:
|
||||||
|
facts = ModelCatalog.from_payload(
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"model_name": "claude-haiku-4-5",
|
||||||
|
"litellm_params": {"model": "anthropic/claude-haiku-4-5"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
).facts("claude-haiku-4-5")
|
||||||
|
assert facts.answered("claude-haiku-4-5") # LiteLLM's alias
|
||||||
|
assert facts.answered("claude-haiku-4-5-20251001") # the provider's own name
|
||||||
|
assert facts.answered(None) # nothing reported
|
||||||
|
assert not facts.answered("claude-sonnet-5") # somebody else answered
|
||||||
@@ -4,7 +4,13 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nisse_dev.canonical import AssistantText, CanonicalEvent, StoredEvent, ToolCall
|
from nisse_dev.canonical import (
|
||||||
|
AssistantText,
|
||||||
|
CanonicalEvent,
|
||||||
|
StoredEvent,
|
||||||
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
|
)
|
||||||
from nisse_dev.executor import LLMExecutor
|
from nisse_dev.executor import LLMExecutor
|
||||||
from nisse_dev.llm import LLMError, RoleMap
|
from nisse_dev.llm import LLMError, RoleMap
|
||||||
from nisse_dev.oplog import OpLog
|
from nisse_dev.oplog import OpLog
|
||||||
@@ -33,7 +39,7 @@ class ScriptedClient:
|
|||||||
) -> list[CanonicalEvent]:
|
) -> list[CanonicalEvent]:
|
||||||
self.calls.append({"tools": tools, "model": model, "system": system})
|
self.calls.append({"tools": tools, "model": model, "system": system})
|
||||||
if not self._turns:
|
if not self._turns:
|
||||||
return [AssistantText(text="done", model=model)]
|
return [finish(call_id=f"fin-{len(self.calls)}")]
|
||||||
return self._turns.pop(0)
|
return self._turns.pop(0)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +52,10 @@ def call(tool: str, call_id: str = "c1", **arguments: Any) -> ToolCall:
|
|||||||
return ToolCall(call_id=call_id, tool=tool, arguments=arguments)
|
return ToolCall(call_id=call_id, tool=tool, arguments=arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def finish(summary: str = "done", call_id: str = "fin") -> ToolCall:
|
||||||
|
return ToolCall(call_id=call_id, tool="finish", arguments={"summary": summary})
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def store(tmp_path: Path) -> StateStore:
|
def store(tmp_path: Path) -> StateStore:
|
||||||
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||||
@@ -66,15 +76,16 @@ def test_tool_calls_are_executed_and_results_logged(
|
|||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
[
|
[
|
||||||
[call("write_file", path="app.py", content="print(1)\n")],
|
[call("write_file", path="app.py", content="print(1)\n")],
|
||||||
[AssistantText(text="wrote the file", model="llama3.2")],
|
[finish("wrote the file")],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
outcome = build(client, store, ws).run(milestone=1)
|
outcome = build(client, store, ws).run(milestone=1)
|
||||||
|
|
||||||
assert outcome.status == "completed"
|
assert outcome.status == "completed"
|
||||||
|
assert outcome.note == "wrote the file"
|
||||||
assert (ws.root / "app.py").read_text() == "print(1)\n"
|
assert (ws.root / "app.py").read_text() == "print(1)\n"
|
||||||
kinds = [type(e.event).__name__ for e in store.read_events()]
|
kinds = [type(e.event).__name__ for e in store.read_events()]
|
||||||
assert kinds == ["ToolCall", "ToolResult", "AssistantText"]
|
assert kinds == ["ToolCall", "ToolResult", "ToolCall", "ToolResult"]
|
||||||
|
|
||||||
|
|
||||||
def test_uses_executor_role_and_reduced_manifest(
|
def test_uses_executor_role_and_reduced_manifest(
|
||||||
@@ -84,50 +95,99 @@ def test_uses_executor_role_and_reduced_manifest(
|
|||||||
build(client, store, ws).run(milestone=2)
|
build(client, store, ws).run(milestone=2)
|
||||||
assert client.calls[0]["model"] == "llama3.2" # never the planner
|
assert client.calls[0]["model"] == "llama3.2" # never the planner
|
||||||
names = [t["function"]["name"] for t in client.calls[0]["tools"]]
|
names = [t["function"]["name"] for t in client.calls[0]["tools"]]
|
||||||
assert names == ["read_file", "write_file", "list_dir", "run_command"]
|
assert names == ["read_file", "write_file", "list_dir", "run_command", "finish"]
|
||||||
assert "milestone 2" in client.calls[0]["system"]
|
assert "milestone 2" in client.calls[0]["system"]
|
||||||
|
|
||||||
|
|
||||||
def test_no_tool_call_after_work_ends_the_milestone(
|
def test_prose_no_longer_ends_the_milestone(store: StateStore, ws: Workspace) -> None:
|
||||||
store: StateStore, ws: Workspace
|
# Only finish ends a milestone. Prose breaks the tool contract: the model
|
||||||
) -> None:
|
# is told once, and here it gets back to work.
|
||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
[
|
[
|
||||||
[call("write_file", path="a.py", content="x")],
|
[call("write_file", path="a.py", content="x")],
|
||||||
[AssistantText(text="all steps done", model="m")],
|
[AssistantText(text="all steps done", model="m")],
|
||||||
|
[finish()],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert build(client, store, ws).run(milestone=1).status == "completed"
|
assert build(client, store, ws).run(milestone=1).status == "completed"
|
||||||
|
assert "Every reply must be a tool call" in client.calls[2]["system"]
|
||||||
|
|
||||||
|
|
||||||
def test_completion_claimed_without_any_work_is_challenged(
|
def test_persisting_in_prose_escalates(store: StateStore, ws: Workspace) -> None:
|
||||||
|
prose = [AssistantText(text="let me just chat", model="m")]
|
||||||
|
client = ScriptedClient([prose, prose])
|
||||||
|
outcome = build(client, store, ws).run(milestone=1)
|
||||||
|
assert outcome.status == "escalate"
|
||||||
|
assert "prose" in outcome.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_finishing_without_any_work_is_refused_once(
|
||||||
store: StateStore, ws: Workspace
|
store: StateStore, ws: Workspace
|
||||||
) -> None:
|
) -> None:
|
||||||
# A model that answers with prose on turn one has done nothing; believing
|
# A model that finishes on turn one has done nothing; believing it would
|
||||||
# it would record an empty milestone as a success.
|
# record an empty milestone as a success.
|
||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
[
|
[
|
||||||
[AssistantText(text="looks done to me", model="m")],
|
[finish("looks done to me", call_id="f1")],
|
||||||
[call("write_file", path="a.py", content="x")],
|
[call("write_file", path="a.py", content="x")],
|
||||||
[AssistantText(text="now it is done", model="m")],
|
[finish("now it is done", call_id="f2")],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
outcome = build(client, store, ws).run(milestone=1)
|
outcome = build(client, store, ws).run(milestone=1)
|
||||||
assert outcome.status == "completed"
|
assert outcome.status == "completed"
|
||||||
assert (ws.root / "a.py").exists() # the nudge produced actual work
|
assert (ws.root / "a.py").exists() # the refusal produced actual work
|
||||||
assert "not made any change yet" in client.calls[1]["system"]
|
[refusal] = [
|
||||||
|
e.event
|
||||||
|
for e in store.read_events()
|
||||||
|
if isinstance(e.event, ToolResult) and e.event.call_id == "f1"
|
||||||
|
]
|
||||||
|
assert not refusal.success
|
||||||
|
assert "not made any change yet" in refusal.result
|
||||||
|
|
||||||
|
|
||||||
def test_persisting_in_an_empty_claim_escalates(
|
def test_persisting_in_an_empty_finish_escalates(
|
||||||
store: StateStore, ws: Workspace
|
store: StateStore, ws: Workspace
|
||||||
) -> None:
|
) -> None:
|
||||||
prose = [AssistantText(text="nothing to do", model="m")]
|
client = ScriptedClient([[finish(call_id="f1")], [finish(call_id="f2")]])
|
||||||
client = ScriptedClient([prose, prose])
|
|
||||||
outcome = build(client, store, ws).run(milestone=1)
|
outcome = build(client, store, ws).run(milestone=1)
|
||||||
assert outcome.status == "escalate"
|
assert outcome.status == "escalate"
|
||||||
assert "without making any change" in outcome.note
|
assert "without making any change" in outcome.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_calls_after_finish_are_not_run(store: StateStore, ws: Workspace) -> None:
|
||||||
|
client = ScriptedClient(
|
||||||
|
[
|
||||||
|
[call("write_file", path="a.py", content="x")],
|
||||||
|
[finish(), call("write_file", call_id="late", path="b.py", content="y")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert build(client, store, ws).run(milestone=1).status == "completed"
|
||||||
|
assert not (ws.root / "b.py").exists()
|
||||||
|
[late] = [
|
||||||
|
e.event
|
||||||
|
for e in store.read_events()
|
||||||
|
if isinstance(e.event, ToolResult) and e.event.call_id == "late"
|
||||||
|
]
|
||||||
|
assert "finish had already ended the milestone" in late.result
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_invented_tool_counts_toward_escalation(
|
||||||
|
store: StateStore, ws: Workspace
|
||||||
|
) -> None:
|
||||||
|
# Measured: a model on a server that doesn't enforce tool_choice will
|
||||||
|
# call tools that were never offered ("chat", "respond"). Without this it
|
||||||
|
# would burn every turn up to the cap.
|
||||||
|
client = ScriptedClient(
|
||||||
|
[[call("chat", call_id="x1")], [call("respond_user", call_id="x2")]]
|
||||||
|
)
|
||||||
|
outcome = build(client, store, ws, failure_limit=2).run(milestone=1)
|
||||||
|
assert outcome.status == "escalate"
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
results = [e.event for e in store.read_events() if isinstance(e.event, ToolResult)]
|
||||||
|
assert "no such tool: chat" in results[0].result
|
||||||
|
assert "finish" in results[0].result # it is told what does exist
|
||||||
|
|
||||||
|
|
||||||
def test_two_consecutive_command_failures_escalate(
|
def test_two_consecutive_command_failures_escalate(
|
||||||
store: StateStore, ws: Workspace
|
store: StateStore, ws: Workspace
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -150,7 +210,7 @@ def test_a_success_resets_the_failure_count(store: StateStore, ws: Workspace) ->
|
|||||||
[call("run_command", command="exit 1")],
|
[call("run_command", command="exit 1")],
|
||||||
[call("run_command", command="true")],
|
[call("run_command", command="true")],
|
||||||
[call("run_command", command="exit 1")],
|
[call("run_command", command="exit 1")],
|
||||||
[AssistantText(text="done", model="m")],
|
[finish()],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
|
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
|
||||||
@@ -168,7 +228,7 @@ def test_failed_reads_do_not_count_toward_escalation(
|
|||||||
[call("read_file", path="missing-a.txt")],
|
[call("read_file", path="missing-a.txt")],
|
||||||
[call("read_file", path="missing-b.txt")],
|
[call("read_file", path="missing-b.txt")],
|
||||||
[call("write_file", path="a.py", content="x")],
|
[call("write_file", path="a.py", content="x")],
|
||||||
[AssistantText(text="done", model="m")],
|
[finish()],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
|
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
|
||||||
@@ -182,8 +242,8 @@ def test_reading_alone_is_not_doing_the_work(store: StateStore, ws: Workspace) -
|
|||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
[
|
[
|
||||||
[call("list_dir", path=".")],
|
[call("list_dir", path=".")],
|
||||||
[AssistantText(text="looks done to me", model="m")],
|
[finish("looks done to me", call_id="f1")],
|
||||||
[AssistantText(text="really, done", model="m")],
|
[finish("really, done", call_id="f2")],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
outcome = build(client, store, ws).run(milestone=1)
|
outcome = build(client, store, ws).run(milestone=1)
|
||||||
@@ -214,7 +274,7 @@ def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) ->
|
|||||||
call("write_file", call_id="a", path="one.txt", content="1"),
|
call("write_file", call_id="a", path="one.txt", content="1"),
|
||||||
call("write_file", call_id="b", path="two.txt", content="2"),
|
call("write_file", call_id="b", path="two.txt", content="2"),
|
||||||
],
|
],
|
||||||
[AssistantText(text="done", model="m")],
|
[finish()],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
build(client, store, ws).run(milestone=1)
|
build(client, store, ws).run(milestone=1)
|
||||||
@@ -222,7 +282,7 @@ def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) ->
|
|||||||
results = [
|
results = [
|
||||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||||
]
|
]
|
||||||
assert {r.call_id for r in results} == {"a", "b"}
|
assert {r.call_id for r in results} == {"a", "b", "fin"}
|
||||||
|
|
||||||
|
|
||||||
class StubDesk:
|
class StubDesk:
|
||||||
@@ -255,7 +315,8 @@ def test_ask_expert_is_offered_only_when_a_desk_exists(
|
|||||||
client2 = ScriptedClient([])
|
client2 = ScriptedClient([])
|
||||||
build(client2, store, ws, expert=StubDesk()).run(milestone=1)
|
build(client2, store, ws, expert=StubDesk()).run(milestone=1)
|
||||||
names = [t["function"]["name"] for t in client2.calls[0]["tools"]]
|
names = [t["function"]["name"] for t in client2.calls[0]["tools"]]
|
||||||
assert names[-1] == "ask_expert"
|
assert "ask_expert" in names
|
||||||
|
assert names[-1] == "finish"
|
||||||
# A12: in addition to the observable triggers, not instead of them.
|
# A12: in addition to the observable triggers, not instead of them.
|
||||||
assert "run_command" in names
|
assert "run_command" in names
|
||||||
|
|
||||||
@@ -268,7 +329,7 @@ def test_ask_expert_answer_comes_back_as_a_tool_result(
|
|||||||
[
|
[
|
||||||
[call("ask_expert", question="sqlite or postgres?")],
|
[call("ask_expert", question="sqlite or postgres?")],
|
||||||
[call("write_file", path="a.py", content="x")],
|
[call("write_file", path="a.py", content="x")],
|
||||||
[AssistantText(text="done", model="m")],
|
[finish()],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
build(client, store, ws, expert=desk).run(milestone=1)
|
build(client, store, ws, expert=desk).run(milestone=1)
|
||||||
@@ -286,8 +347,8 @@ def test_consulting_is_not_doing_the_work(store: StateStore, ws: Workspace) -> N
|
|||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
[
|
[
|
||||||
[call("ask_expert", question="what should I do?")],
|
[call("ask_expert", question="what should I do?")],
|
||||||
[AssistantText(text="I think it is done", model="m")],
|
[finish("I think it is done", call_id="f1")],
|
||||||
[AssistantText(text="really done", model="m")],
|
[finish("really done", call_id="f2")],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
outcome = build(client, store, ws, expert=StubDesk()).run(milestone=1)
|
outcome = build(client, store, ws, expert=StubDesk()).run(milestone=1)
|
||||||
@@ -304,7 +365,7 @@ def test_exhausted_budget_is_reported_to_the_model_not_raised(
|
|||||||
[
|
[
|
||||||
[call("ask_expert", question="again?")],
|
[call("ask_expert", question="again?")],
|
||||||
[call("write_file", path="a.py", content="x")],
|
[call("write_file", path="a.py", content="x")],
|
||||||
[AssistantText(text="done", model="m")],
|
[finish()],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
outcome = build(client, store, ws, expert=desk).run(milestone=1)
|
outcome = build(client, store, ws, expert=desk).run(milestone=1)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -341,3 +342,73 @@ def test_budget_refusal_is_a_kind_of_upstream_trouble() -> None:
|
|||||||
# So one catch covers the family, but it can still be told apart where
|
# So one catch covers the family, but it can still be told apart where
|
||||||
# the difference matters — it will not clear within the hour.
|
# the difference matters — it will not clear within the hour.
|
||||||
assert issubclass(BudgetExhausted, UpstreamUnavailable)
|
assert issubclass(BudgetExhausted, UpstreamUnavailable)
|
||||||
|
|
||||||
|
|
||||||
|
# --- tool_choice (A15: constrained decoding) --------------------------------
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "finish",
|
||||||
|
"description": "done",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def capture_bodies() -> tuple[list[dict], Any]:
|
||||||
|
bodies: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
bodies.append(json.loads(request.content))
|
||||||
|
return completion({"role": "assistant", "content": "ok"})
|
||||||
|
|
||||||
|
return bodies, handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_is_sent_only_when_asked_and_tools_are_offered() -> None:
|
||||||
|
bodies, handler = capture_bodies()
|
||||||
|
client = make_client(handler)
|
||||||
|
client.complete(EVENTS, tools=TOOLS, model="m", require_tool_call=True)
|
||||||
|
client.complete(EVENTS, tools=TOOLS, model="m")
|
||||||
|
client.complete(EVENTS, tools=None, model="m", require_tool_call=True)
|
||||||
|
assert bodies[0]["tool_choice"] == "required"
|
||||||
|
assert "tool_choice" not in bodies[1] # the server's default, auto
|
||||||
|
assert "tool_choice" not in bodies[2] # nothing to choose from
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_passthrough_asks_in_anthropics_terms() -> None:
|
||||||
|
bodies: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
bodies.append(json.loads(request.content))
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"model": "claude-haiku-4-5",
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": "t", "name": "finish", "input": {}}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = AnthropicPassthroughClient(
|
||||||
|
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
client.complete(
|
||||||
|
EVENTS, tools=TOOLS, model="claude-haiku-4-5", require_tool_call=True
|
||||||
|
)
|
||||||
|
assert bodies[0]["tool_choice"] == {"type": "any"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_answering_model_is_recorded() -> None:
|
||||||
|
# A proxy fallback answers under another model's name; this is how a
|
||||||
|
# caller can tell.
|
||||||
|
client = make_client(
|
||||||
|
lambda request: completion({"role": "assistant", "content": "hi"})
|
||||||
|
)
|
||||||
|
client.complete(EVENTS, tools=None, model="local-fast")
|
||||||
|
assert client.last_model == "gpt-test-1"
|
||||||
@@ -42,7 +42,9 @@ def test_probe_calibrates_then_sends_half_again_the_window() -> None:
|
|||||||
words = len(json.loads(request.content)["messages"][0]["content"].split())
|
words = len(json.loads(request.content)["messages"][0]["content"].split())
|
||||||
sizes.append(words * 2)
|
sizes.append(words * 2)
|
||||||
reported = min(words * 2, 4096)
|
reported = min(words * 2, 4096)
|
||||||
return httpx.Response(200, json={"usage": {"prompt_tokens": reported}})
|
return httpx.Response(
|
||||||
|
200, json={"model": "llama3.2", "usage": {"prompt_tokens": reported}}
|
||||||
|
)
|
||||||
|
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
||||||
@@ -51,3 +53,37 @@ def test_probe_calibrates_then_sends_half_again_the_window() -> None:
|
|||||||
|
|
||||||
assert result.verdict == "matches"
|
assert result.verdict == "matches"
|
||||||
assert sizes[1] >= 4096 * 1.4 # the probe really exceeded the window
|
assert sizes[1] >= 4096 * 1.4 # the probe really exceeded the window
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fallback_answer_is_never_credited_to_the_model() -> None:
|
||||||
|
"""Measured: LiteLLM answered an oversized prompt to local-coder with
|
||||||
|
claude-sonnet-5, and the probe called the refusing server roomy."""
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
words = len(json.loads(request.content)["messages"][0]["content"].split())
|
||||||
|
model = "local-coder" if words < 1000 else "claude-sonnet-5"
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"model": model, "usage": {"prompt_tokens": words * 2}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
||||||
|
) as http:
|
||||||
|
result = probe(http, "local-coder", declared=32768, backing="openai/qwen3-14b")
|
||||||
|
|
||||||
|
assert result.verdict == "fallback"
|
||||||
|
assert "claude-sonnet-5 answered instead of local-coder" in result.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_provider_reporting_its_own_model_name_is_not_a_fallback() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"model": "qwen3-14b", "usage": {"prompt_tokens": 400}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
||||||
|
) as http:
|
||||||
|
result = probe(http, "local-coder", declared=32768, backing="openai/qwen3-14b")
|
||||||
|
|
||||||
|
assert result.verdict != "fallback"
|
||||||
@@ -45,7 +45,8 @@ def main() -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
|
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
|
||||||
declared = catalog.facts(args.alias).max_input_tokens
|
facts = catalog.facts(args.alias)
|
||||||
|
declared = facts.max_input_tokens
|
||||||
if declared is None:
|
if declared is None:
|
||||||
print(
|
print(
|
||||||
f"LiteLLM declares no max_input_tokens for {args.alias}; set one first",
|
f"LiteLLM declares no max_input_tokens for {args.alias}; set one first",
|
||||||
@@ -65,7 +66,7 @@ def main() -> int:
|
|||||||
headers={"Authorization": f"Bearer {config.llm_token}"},
|
headers={"Authorization": f"Bearer {config.llm_token}"},
|
||||||
timeout=600,
|
timeout=600,
|
||||||
) as http:
|
) as http:
|
||||||
result = probe(http, args.alias, declared)
|
result = probe(http, args.alias, declared, facts.backing)
|
||||||
print(f"{args.alias}: {result.message}")
|
print(f"{args.alias}: {result.message}")
|
||||||
return 0 if result.verdict in ("matches", "refuses", "declared_conservative") else 1
|
return 0 if result.verdict in ("matches", "refuses", "declared_conservative") else 1
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from nisse_dev.canonical import (
|
|||||||
)
|
)
|
||||||
from nisse_dev.catalog import ModelCatalog, select_client
|
from nisse_dev.catalog import ModelCatalog, select_client
|
||||||
from nisse_dev.config import AgentConfig
|
from nisse_dev.config import AgentConfig
|
||||||
|
from nisse_dev.executor import FINISH_TOOL
|
||||||
from nisse_dev.expert import parse_answer
|
from nisse_dev.expert import parse_answer
|
||||||
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError
|
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError
|
||||||
from nisse_dev.planner import PLANNER_TOOLS
|
from nisse_dev.planner import PLANNER_TOOLS
|
||||||
@@ -73,6 +74,16 @@ def catalog(live_config: AgentConfig) -> ModelCatalog:
|
|||||||
return ModelCatalog.discover(live_config.llm_base_url, live_config.llm_token)
|
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]:
|
def user(text: str) -> list[StoredEvent]:
|
||||||
return [StoredEvent(id=1, ts="t", event=UserMessage(text=text, source="probe"))]
|
return [StoredEvent(id=1, ts="t", event=UserMessage(text=text, source="probe"))]
|
||||||
|
|
||||||
@@ -83,6 +94,7 @@ def test_model_answers_at_all(model: str, catalog: ModelCatalog, routes) -> None
|
|||||||
produced = client.complete(
|
produced = client.complete(
|
||||||
user("Reply with exactly one word: pong"), None, model, system="Be terse."
|
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)]
|
texts = [e.text for e in produced if isinstance(e, AssistantText)]
|
||||||
assert texts, f"{model} produced no text: {produced!r}"
|
assert texts, f"{model} produced no text: {produced!r}"
|
||||||
|
|
||||||
@@ -103,6 +115,7 @@ def test_model_holds_the_planner_contract(
|
|||||||
"ask_human. Plain prose is not accepted."
|
"ask_human. Plain prose is not accepted."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
assert_answered(client, model, catalog)
|
||||||
calls = [e for e in produced if isinstance(e, ToolCall)]
|
calls = [e for e in produced if isinstance(e, ToolCall)]
|
||||||
assert calls, f"{model} answered in prose instead of calling a tool"
|
assert calls, f"{model} answered in prose instead of calling a tool"
|
||||||
assert calls[0].tool in {"submit_plan", "ask_human"}
|
assert calls[0].tool in {"submit_plan", "ask_human"}
|
||||||
@@ -140,6 +153,7 @@ def test_model_round_trips_a_tool_result(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
assert_answered(client, model, catalog)
|
||||||
follow_up = client.complete(events, EXECUTOR_TOOLS, model, system="Use a tool.")
|
follow_up = client.complete(events, EXECUTOR_TOOLS, model, system="Use a tool.")
|
||||||
assert follow_up, f"{model} produced nothing after its tool result"
|
assert follow_up, f"{model} produced nothing after its tool result"
|
||||||
|
|
||||||
@@ -161,6 +175,7 @@ def test_model_holds_the_prompt_level_json_contract(
|
|||||||
'"caveats": "..."}'
|
'"caveats": "..."}'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
assert_answered(client, model, catalog)
|
||||||
text = "\n".join(e.text for e in produced if isinstance(e, AssistantText))
|
text = "\n".join(e.text for e in produced if isinstance(e, AssistantText))
|
||||||
assert parse_answer(text) is not None, (
|
assert parse_answer(text) is not None, (
|
||||||
f"{model} did not produce parseable JSON: {text[:200]!r}"
|
f"{model} did not produce parseable JSON: {text[:200]!r}"
|
||||||
@@ -185,3 +200,59 @@ def test_route_selection_matches_the_model(
|
|||||||
chosen.complete(user("say pong"), None, model, system="Be terse.")
|
chosen.complete(user("say pong"), None, model, system="Be terse.")
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
pytest.fail(f"{model} failed on its selected route: {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
|
||||||
Reference in new issue
Block a user