Fit every model request to its context window
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 55s

Every turn re-sent the whole transcript with nothing checking it against
the model's window. On a local model that fails silently: Ollama drops
the start of an over-long prompt, which is where the system prompt and
the plan are. Measured on the Jetson's llama3.2, cut at 4096 tokens with
no error.

- The model catalog reads max_input_tokens and max_output_tokens from
  LiteLLM's model_info, and startup refuses a role on a model with no
  declared window
- BudgetedClient wraps both routes. It fits the canonical events before
  rendering: older tool results cut to head and tail, then the oldest
  turns dropped with a note, keeping calls with their results; a request
  that still cannot fit fails with ContextOverflow and is never sent.
  The output ceiling is the smaller of config and the model's limit
- Tokens are estimated from characters, pessimistically, and the
  estimate is corrected from reported prompt sizes, only ever upward
- The executor takes its scope from the log: the latest plan, which is
  the approved milestone plan, goes in its system prompt, and it sees
  only events after it
- Tool output keeps its head and, mostly, its tail, so a test runner's
  failure summary survives truncation
- tuning.context.keep_recent_turns and safety_margin are new knobs
- scripts/probe_context.py checks a declared window against what the
  server does; it confirmed llama3.2 cuts at the declared 4096
- The tuning guide covers the budget, declaring a local model's limits,
  and the probe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-09-24 00:26:30 +00:00
1 parent 31851b7396
commit 494715efa9
17 files changed
+1082 -45

No files matched your search

+28 -4
View File
@@ -15,6 +15,7 @@ from nisse_dev.approvals import PlanBoard
from nisse_dev.cancel import Cancel
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig
from nisse_dev.context import BudgetedClient
from nisse_dev.control import Orchestrator
from nisse_dev.delegation import Caps, Delegation
from nisse_dev.executor import LLMExecutor
@@ -50,6 +51,10 @@ def discover_models(config: AgentConfig, oplog: OpLog) -> ModelCatalog:
"""
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
catalog.validate(config.roles.pinned())
# Every role's context window must be known before a request is built
# against it (A10): a guess that is too large reaches a server that may
# silently cut the prompt.
catalog.require_limits(config.roles.pinned())
for role in TOOL_DRIVING_ROLES:
facts = catalog.facts(config.roles.resolve(role))
if facts.supports_tools is False:
@@ -156,14 +161,33 @@ def build_agent(
max_output_chars=tuning.tools.max_output_chars,
)
normalized = LiteLLMClient(
config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens
def limits_for(model: str) -> tuple[int, int | None]:
facts = catalog.facts(model)
assert facts.max_input_tokens is not None # require_limits checked
return facts.max_input_tokens, facts.max_output_tokens
def budgeted(client: object) -> BudgetedClient:
# Every request, on either route, is fitted to its model's window.
return BudgetedClient(
client,
limits_for,
max_output_tokens=config.max_output_tokens,
keep_recent_turns=tuning.context.keep_recent_turns,
safety_margin=tuning.context.safety_margin,
)
normalized = budgeted(
LiteLLMClient(
config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens
)
)
# Delegations prefer the vendor-native route; fidelity is cheap
# elsewhere (A5). Which route a given model gets is decided by
# select_client, not by where the caller happens to sit.
passthrough = AnthropicPassthroughClient(
config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens
passthrough = budgeted(
AnthropicPassthroughClient(
config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens
)
)
transport = MatrixTransport(config.matrix)
+39
View File
@@ -35,12 +35,25 @@ class UnknownModel(LLMError):
stale alias, or a key without access. Always a misconfiguration."""
class UnknownLimits(LLMError):
"""A model in use has no declared context window. LiteLLM knows the
limits of commercial models; for a local one they are whatever its server
allocates, and have to be declared in LiteLLM's ``model_info``. Guessing
is not safe: a server that silently truncates a prompt (Ollama does)
drops its start, which is where the system prompt and the plan are."""
@dataclass(frozen=True)
class ModelFacts:
alias: str # what we ask for, e.g. "remote-architecture"
backing: str # what LiteLLM calls, e.g. "anthropic/claude-opus-5"
provider: str # "anthropic", "openai", "ollama", ...
supports_tools: bool | None # None when LiteLLM does not say
# The whole context window, prompt plus response, as LiteLLM declares it
# (model_info.max_input_tokens); None when it does not say.
max_input_tokens: int | None = None
# The most one response may use (model_info.max_output_tokens).
max_output_tokens: int | None = None
@property
def native_anthropic(self) -> bool:
@@ -72,6 +85,8 @@ class ModelCatalog:
backing=backing,
provider=str(provider),
supports_tools=info.get("supports_function_calling"),
max_input_tokens=_positive_int(info.get("max_input_tokens")),
max_output_tokens=_positive_int(info.get("max_output_tokens")),
)
return cls(facts)
@@ -136,6 +151,24 @@ class ModelCatalog:
f"available: {', '.join(self.aliases()) or '(none)'}"
)
def require_limits(self, roles: Mapping[str, str]) -> None:
"""Refuse a role mapping that uses a model with no declared context
window, naming every such model at once."""
missing = {
role: alias
for role, alias in roles.items()
if self.facts(alias).max_input_tokens is None
}
if missing:
named = ", ".join(
f"{role}={alias!r}" for role, alias in sorted(missing.items())
)
raise UnknownLimits(
f"no context window is declared for: {named}. Set "
"model_info.max_input_tokens (and max_output_tokens) for these "
"in LiteLLM; docs/Tuning-Guide.md says how to find the values"
)
def pinned(self, roles: Mapping[str, str]) -> dict[str, dict[str, Any]]:
"""The resolved mapping to record at task start (A11, A16).
@@ -154,6 +187,12 @@ class ModelCatalog:
}
def _positive_int(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
return int(value) if value > 0 else None
def select_client(
model: str,
normalized: LLMClient,
+10
View File
@@ -86,12 +86,19 @@ class ToolsTuning:
max_output_chars: int = 4000
@dataclass(frozen=True)
class ContextTuning:
keep_recent_turns: int = 6
safety_margin: float = 0.1
@dataclass(frozen=True)
class Tuning:
executor: ExecutorTuning = field(default_factory=ExecutorTuning)
expert: ExpertTuning = field(default_factory=ExpertTuning)
delegation: DelegationTuning = field(default_factory=DelegationTuning)
tools: ToolsTuning = field(default_factory=ToolsTuning)
context: ContextTuning = field(default_factory=ContextTuning)
_TUNING_SECTIONS: dict[str, type] = {
@@ -128,6 +135,9 @@ def _parse_tuning(raw: Any, errors: list[str]) -> Tuning:
continue
values[key] = number
sections[name] = cls(**values)
margin = sections.get("context", ContextTuning()).safety_margin
if margin >= 1:
errors.append("tuning.context.safety_margin: must be less than 1")
return Tuning(**sections)
+262
View File
@@ -0,0 +1,262 @@
"""The context budget (spec A10: token budgeting is a render-time concern).
Every model call re-sends a transcript that only grows, and a model server
does not always refuse one that no longer fits: Ollama silently drops the
start of the prompt, which is where the system prompt and the plan are. So a
request is made to fit the model's declared window *before* it is sent, and
one that cannot be made to fit is never sent.
The log itself is never changed. Fitting works on the events about to be
rendered, in three steps, stopping at the first that fits:
1. Tool results older than the most recent turns are cut to their first and
last lines, with a note saying how much was left out.
2. The oldest events are dropped, keeping tool calls with their results, and
a note at the start says how many.
3. If even the recent turns and the system prompt do not fit, the call
fails with ContextOverflow. The caller treats it like any failed call:
the executor stops and asks the operator.
Token counts are estimated from characters, conservatively, and the estimate
is corrected from the input-token counts the server reports back. The
correction only ever makes the estimate larger: underestimating is the
dangerous direction.
"""
import json
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from typing import Any
from nisse_dev.canonical import (
AssistantText,
CanonicalEvent,
DelegationStarted,
PlanSubmitted,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
)
from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, LLMError
# English and code run at about four characters a token. Three is deliberately
# pessimistic, before any correction from what the server reports.
CHARS_PER_TOKEN = 3.0
# Role markers and formatting each rendered message costs beyond its text.
MESSAGE_OVERHEAD_TOKENS = 8
# How much of an older tool result survives step 1.
ELIDED_RESULT_CHARS = 600
# Events the model produced. A turn starts where these start.
_MODEL_SIDE = (AssistantText, ToolCall, PlanSubmitted, DelegationStarted)
class ContextOverflow(LLMError):
"""The request cannot be made to fit the model's context window."""
def head_and_tail(text: str, limit: int) -> str:
"""Keep the start and, mostly, the end of a long text.
The end matters more: test runners and compilers put their summary of
what failed last. The start keeps the exit code and the first lines.
"""
if len(text) <= limit:
return text
head = limit // 4
tail = limit - head
dropped = len(text) - limit
return f"{text[:head]}\n[... {dropped} characters omitted ...]\n{text[-tail:]}"
def _event_chars(event: CanonicalEvent) -> int:
match event:
case ToolCall(tool=tool, arguments=arguments):
return len(tool) + len(json.dumps(arguments))
case ToolResult(result=result):
return len(result)
case _:
text = getattr(event, "text", None) or getattr(event, "plan", None)
if text is None:
text = json.dumps(getattr(event, "__dict__", {}), default=str)
return len(str(text))
def estimate_tokens(
events: Sequence[StoredEvent],
system: str | None,
tools: list[dict[str, Any]] | None,
factor: float = 1.0,
) -> int:
chars = sum(_event_chars(e.event) for e in events)
chars += len(system or "") + len(json.dumps(tools or []))
raw = chars / CHARS_PER_TOKEN + MESSAGE_OVERHEAD_TOKENS * (len(events) + 1)
return int(raw * factor) + 1
def _turn_starts(events: Sequence[StoredEvent]) -> list[int]:
starts = []
previous_model_side = False
for index, stored in enumerate(events):
model_side = isinstance(stored.event, _MODEL_SIDE)
if model_side and not previous_model_side:
starts.append(index)
previous_model_side = model_side
return starts
def _consistent(events: list[StoredEvent]) -> list[StoredEvent]:
"""Drop results whose call is gone: a provider rejects a tool result
with nothing to answer."""
calls = {e.event.call_id for e in events if isinstance(e.event, ToolCall)}
return [
e
for e in events
if not isinstance(e.event, ToolResult) or e.event.call_id in calls
]
@dataclass(frozen=True)
class Budget:
window: int # prompt plus response, in tokens
max_output: int # the ceiling sent with the request
keep_recent_turns: int
safety_margin: float # fraction of the window held back
@property
def for_prompt(self) -> int:
return int(self.window * (1 - self.safety_margin)) - self.max_output
def fit(
events: Sequence[StoredEvent],
system: str | None,
tools: list[dict[str, Any]] | None,
budget: Budget,
factor: float = 1.0,
) -> list[StoredEvent]:
"""The events to render: all of them if they fit, otherwise the result of
the three steps in the module docstring."""
limit = budget.for_prompt
def fits(candidate: Sequence[StoredEvent]) -> bool:
return estimate_tokens(candidate, system, tools, factor) <= limit
kept = list(events)
if fits(kept):
return kept
starts = _turn_starts(kept)
recent_from = (
starts[-budget.keep_recent_turns]
if len(starts) >= budget.keep_recent_turns
else 0
)
# Step 1: cut older tool results down.
kept = [
replace(
stored,
event=replace(
stored.event,
result=head_and_tail(stored.event.result, ELIDED_RESULT_CHARS),
),
)
if index < recent_from and isinstance(stored.event, ToolResult)
else stored
for index, stored in enumerate(kept)
]
if fits(kept):
return kept
# Step 2: drop the oldest events, one turn at a time, never the recent
# ones. The note keeps the transcript starting with a user message, which
# some chat templates require.
cuts = sorted({s for s in starts if 0 < s < recent_from} | {recent_from} - {0})
for cut in cuts:
remaining = _consistent(kept[cut:])
note = StoredEvent(
id=0,
ts="",
event=UserMessage(
text=(
f"[{cut} earlier events omitted to fit the context window; "
"the log still has them]"
),
source="context-budget",
),
)
candidate = [note, *remaining]
if fits(candidate):
return candidate
# Step 3: nothing more can go.
needed = estimate_tokens(kept[recent_from:], system, tools, factor)
raise ContextOverflow(
f"the system prompt and the last {budget.keep_recent_turns} turns need "
f"about {needed} tokens, but only {limit} fit a {budget.window}-token "
"window after the output ceiling and safety margin"
)
class BudgetedClient:
"""An LLM client that fits every request to the model's window.
Wraps a real client (either route) and takes each model's limits from
``limits_for``, which the agent fills from LiteLLM's model catalog.
"""
def __init__(
self,
inner: Any,
limits_for: Callable[[str], tuple[int, int | None]],
*,
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
keep_recent_turns: int = 6,
safety_margin: float = 0.1,
) -> None:
self._inner = inner
self._limits_for = limits_for
self._max_output_tokens = max_output_tokens
self._keep_recent_turns = keep_recent_turns
self._safety_margin = safety_margin
# Per model: how much the character estimate undercounted, as learned
# from reported usage. Starts at 1 and only grows.
self._factors: dict[str, float] = {}
@property
def last_usage(self) -> dict[str, int]:
return getattr(self._inner, "last_usage", {}) or {}
def budget_for(self, model: str) -> Budget:
window, model_max_output = self._limits_for(model)
ceiling = self._max_output_tokens
if model_max_output is not None:
ceiling = min(ceiling, model_max_output)
return Budget(
window=window,
max_output=ceiling,
keep_recent_turns=self._keep_recent_turns,
safety_margin=self._safety_margin,
)
def complete(
self,
events: list[StoredEvent],
tools: list[dict[str, Any]] | None,
model: str,
system: str | None = None,
) -> list[CanonicalEvent]:
budget = self.budget_for(model)
factor = self._factors.get(model, 1.0)
fitted = fit(events, system, tools, budget, factor)
produced = self._inner.complete(
fitted, tools, model, system=system, max_tokens=budget.max_output
)
reported = int(self.last_usage.get("input", 0))
if reported > 0:
estimated = estimate_tokens(fitted, system, tools)
observed = reported / max(estimated, 1)
self._factors[model] = max(factor, observed)
return produced
+25 -3
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from typing import Any, Literal
from nisse_dev.cancel import Cancel, skip_calls
from nisse_dev.canonical import ToolCall, ToolResult
from nisse_dev.canonical import PlanSubmitted, StoredEvent, ToolCall, ToolResult
from nisse_dev.delegation import Delegation
from nisse_dev.expert import ASK_EXPERT_TOOL, BudgetExhausted, ExpertDesk
from nisse_dev.llm import LLMClient, LLMError, RoleMap
@@ -24,7 +24,7 @@ from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, ToolOutcome, Workspa
EXECUTOR_SYSTEM = """You are nisse-dev's executor, doing mechanical work.
Follow the approved plan above 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.
Work by calling tools. Verify your work by running the project's tests.
@@ -145,12 +145,34 @@ class LLMExecutor:
except LLMError as exc:
return ToolOutcome(f"the consultant did not answer: {exc}", success=False)
def _scope(self) -> tuple[str | None, int]:
"""The approved plan, and where this milestone's events begin.
The most recent plan in the log is the approved milestone plan: no
plan is submitted between approval and execution, or during review
responses. The executor sees that plan and what came after it, and
not the planning conversation or earlier milestones, which a small
context window cannot afford and the plan already distils.
"""
latest: StoredEvent | None = None
for stored in self._store.read_events():
if isinstance(stored.event, PlanSubmitted):
latest = stored
if latest is None:
return None, 0
assert isinstance(latest.event, PlanSubmitted)
return latest.event.plan, latest.id
def run(self, milestone: int) -> ExecutorOutcome:
model = self._roles.resolve("executor")
plan, after = self._scope()
system = (
f"{EXECUTOR_SYSTEM}\n\n{self._workspace.describe()}"
f"\n\nYou are executing milestone {milestone}."
)
if plan is not None:
# In the system prompt, so the context budget never drops it.
system += f"\n\nThe approved plan:\n{plan}"
consecutive_failures = 0
did_work = False
challenged = False
@@ -162,7 +184,7 @@ class LLMExecutor:
return ExecutorOutcome("stopped", f"before turn {turn + 1}")
try:
produced = self._client.complete(
self._store.read_events(),
self._store.read_events(after=after),
tools=self._tools(),
model=model,
system=system,
+16 -7
View File
@@ -149,12 +149,15 @@ class LiteLLMClient:
tools: list[dict[str, Any]] | None,
model: str,
system: str | None = None,
*,
max_tokens: int | None = None,
) -> list[CanonicalEvent]:
body = self._renderer.render(events, tools, model, system=system)
# 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
# nothing to check against.
body["max_tokens"] = self._max_tokens
# nothing to check against. A caller that knows the model's limits
# (the context budget) passes a tighter one.
body["max_tokens"] = max_tokens or self._max_tokens
response = _post_with_rate_limit_retry(
self._http, "/chat/completions", body, self._sleep
)
@@ -175,8 +178,8 @@ class LiteLLMClient:
raise LLMError(f"malformed completion response: {exc}") from exc
if choice.get("finish_reason") == "length":
raise TruncatedResponse(
f"{requested_model} hit its output limit mid-answer; "
"raise llm.max_output_tokens"
f"{requested_model} hit its output limit mid-answer; raise "
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
)
produced_by = data.get("model") or requested_model
usage = data.get("usage") or {}
@@ -258,9 +261,15 @@ class AnthropicPassthroughClient:
tools: list[dict[str, Any]] | None,
model: str,
system: str | None = None,
*,
max_tokens: int | None = None,
) -> list[CanonicalEvent]:
body = self._renderer.render(
events, tools, model, system=system, max_tokens=self._max_tokens
events,
tools,
model,
system=system,
max_tokens=max_tokens or self._max_tokens,
)
response = _post_with_rate_limit_retry(
self._http, "/v1/messages", body, self._sleep
@@ -277,8 +286,8 @@ class AnthropicPassthroughClient:
) -> list[CanonicalEvent]:
if data.get("stop_reason") == "max_tokens":
raise TruncatedResponse(
f"{requested_model} hit the output limit of {self._max_tokens} "
f"tokens mid-answer; raise llm.max_output_tokens"
f"{requested_model} hit its output limit mid-answer; raise "
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
)
content = data.get("content")
if not isinstance(content, list):
+114
View File
@@ -0,0 +1,114 @@
"""Check a model's declared context window against what its server does.
LiteLLM's ``max_input_tokens`` for a local model is only a declaration; the
real limit is whatever the model server allocates. This sends one prompt
half again as long as the declared window and reads back what the server did
with it:
- **It refused.** Over-long prompts fail loudly, which is the safe behaviour.
- **It cut the prompt.** The reported prompt size is where it cut, which is
the server's real window. Ollama does this, silently.
- **It took the whole prompt.** The server allows more than is declared, so
the declaration is conservative.
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
otherwise: commercial models' limits are already known to LiteLLM.
"""
from dataclasses import dataclass
import httpx
# 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.
_UNCUT = 0.9
# How close the server's cut must be to the declaration to count as a match.
_MATCH = 0.02
@dataclass(frozen=True)
class ProbeResult:
verdict: str # "matches", "declared_too_high", "declared_conservative", ...
message: str
server_window: int | None = None
def interpret(
declared: int, sent: int, reported: int | None, error: str | None
) -> ProbeResult:
"""What one over-long probe says about the server's window.
``sent`` is roughly how many tokens the probe held; ``reported`` is the
prompt size the server reported, if it answered; ``error`` its error
text, if it refused.
"""
if error is not None:
return ProbeResult(
"refuses",
f"the server refused a ~{sent}-token prompt ({error[:200]}). Over-long "
"prompts fail loudly here, which is safe; the declared window of "
f"{declared} was not contradicted.",
)
assert reported is not None
if reported >= sent * _UNCUT:
return ProbeResult(
"declared_conservative",
f"the server took the whole ~{sent}-token prompt ({reported} reported), "
f"more than the declared {declared}. The declaration is safe; raise it "
"if you want the room.",
server_window=None,
)
if abs(reported - declared) <= declared * _MATCH:
return ProbeResult(
"matches",
f"the server cut the prompt at {reported} tokens, matching the declared "
f"{declared}.",
server_window=reported,
)
if reported < declared:
return ProbeResult(
"declared_too_high",
f"the server cut the prompt at {reported} tokens, but LiteLLM declares "
f"{declared}. Prompts between the two would be silently cut: set "
f"max_input_tokens to {reported}.",
server_window=reported,
)
return ProbeResult(
"declared_conservative",
f"the server cut the prompt at {reported} tokens, above the declared "
f"{declared}. The declaration is safe; it could be raised to {reported}.",
server_window=reported,
)
def _prompt(words: int) -> str:
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]:
response = http.post(
"/chat/completions",
json={
"model": model,
"messages": [{"role": "user", "content": _prompt(words)}],
"max_tokens": 5,
},
)
if response.status_code != 200:
return None, f"{response.status_code}: {response.text}"
usage = response.json().get("usage") or {}
return int(usage.get("prompt_tokens") or 0), None
def probe(http: httpx.Client, model: str, declared: int) -> ProbeResult:
"""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."""
calibration_words = 200
tokens, error = _send(http, model, calibration_words)
if error is not None or not tokens:
return ProbeResult("failed", f"a short calibration request failed: {error}")
per_word = tokens / calibration_words
sent = int(declared * 1.5)
reported, error = _send(http, model, int(sent / per_word))
return interpret(declared, sent, reported, error)
+3 -5
View File
@@ -20,6 +20,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nisse_dev.context import head_and_tail
# Tools that change or verify something. Reading a file or listing a
# directory is a model looking around, not doing the work, so neither counts
# as evidence that work was carried out.
@@ -112,11 +114,7 @@ class Workspace:
self._max_output_chars = max_output_chars
def _truncate(self, text: str) -> str:
limit = self._max_output_chars
if len(text) <= limit:
return text
dropped = len(text) - limit
return f"{text[:limit]}\n[... {dropped} characters truncated]"
return head_and_tail(text, self._max_output_chars)
def _resolve(self, path: str) -> Path:
candidate = Path(path)
+10 -6
View File
@@ -26,6 +26,8 @@ TEST_CATALOG = ModelCatalog.from_payload(
"model_info": {
"litellm_provider": "anthropic",
"supports_function_calling": True,
"max_input_tokens": 200000,
"max_output_tokens": 64000,
},
}
for alias in ("claude-opus-5", "claude-haiku-4-5")
@@ -185,8 +187,8 @@ def test_delegation_uses_the_passthrough_route(
agent = build_offline(tmp_path, origin, monkeypatch)
delegation = agent.orchestrator._delegate
assert isinstance(delegation._client, AnthropicPassthroughClient)
assert isinstance(agent.orchestrator._executor._client, LiteLLMClient)
assert isinstance(delegation._client._inner, AnthropicPassthroughClient)
assert isinstance(agent.orchestrator._executor._client._inner, LiteLLMClient)
agent.stop()
@@ -194,8 +196,8 @@ def test_httpx_clients_are_not_shared_between_routes(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
agent = build_offline(tmp_path, origin, monkeypatch)
normalized = agent.orchestrator._executor._client._http
passthrough = agent.orchestrator._delegate._client._http
normalized = agent.orchestrator._executor._client._inner._http
passthrough = agent.orchestrator._delegate._client._inner._http
assert isinstance(normalized, httpx.Client)
assert normalized is not passthrough # different base URLs and headers
agent.stop()
@@ -244,6 +246,7 @@ def test_a_tool_less_model_warns_but_still_boots(tmp_path: Path, monkeypatch) ->
"model_info": {
"litellm_provider": "openai",
"supports_function_calling": False,
"max_input_tokens": 32768,
},
}
for alias in ("claude-opus-5", "claude-haiku-4-5")
@@ -338,6 +341,7 @@ def test_tuning_reaches_every_component(
assert delegation._caps.wall_clock_s == 60
assert executor._workspace._timeout_s == 300
assert executor._workspace._max_output_chars == 2000
assert executor._client._max_tokens == 8192
assert delegation._client._max_tokens == 8192
assert executor._client._inner._max_tokens == 8192
assert delegation._client._inner._max_tokens == 8192
assert executor._client._max_output_tokens == 8192
agent.stop()
+9
View File
@@ -297,3 +297,12 @@ def test_sibling_paths_are_fine(tmp_path: Path) -> None:
{**SECRETS, "NISSE_ENV": str(tmp_path / ".env")},
)
assert config.workspace.name == "project"
def test_context_tuning_is_read_and_checked() -> None:
config = build(tuning={"context": {"keep_recent_turns": 4, "safety_margin": 0.2}})
assert config.tuning.context.keep_recent_turns == 4
assert config.tuning.context.safety_margin == 0.2
assert build().tuning.context.keep_recent_turns == 6
with pytest.raises(ConfigError, match="safety_margin: must be less than 1"):
build(tuning={"context": {"safety_margin": 1}})
+294
View File
@@ -0,0 +1,294 @@
"""The context budget (spec A10)."""
import io
from pathlib import Path
from typing import Any
import pytest
from nisse_dev.canonical import (
AssistantText,
CanonicalEvent,
GateOpened,
PlanSubmitted,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
)
from nisse_dev.catalog import ModelCatalog, UnknownLimits
from nisse_dev.context import (
Budget,
BudgetedClient,
ContextOverflow,
estimate_tokens,
fit,
head_and_tail,
)
from nisse_dev.executor import LLMExecutor
from nisse_dev.llm import RoleMap
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore
def stored(events: list[CanonicalEvent]) -> list[StoredEvent]:
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
def turn(n: int, output: str) -> list[CanonicalEvent]:
"""One executor turn: a command and its (possibly long) output."""
return [
ToolCall(call_id=f"c{n}", tool="run_command", arguments={"command": "make"}),
ToolResult(call_id=f"c{n}", result=output, success=True),
]
def transcript(turns: int, output_chars: int) -> list[StoredEvent]:
events: list[CanonicalEvent] = [UserMessage(text="start milestone 1", source="t")]
for n in range(turns):
events += turn(n, f"output {n} " + "x" * output_chars)
return stored(events)
def budget(window: int, keep: int = 2) -> Budget:
return Budget(
window=window, max_output=500, keep_recent_turns=keep, safety_margin=0.1
)
def results(events: list[StoredEvent]) -> dict[str, str]:
return {
e.event.call_id: e.event.result
for e in events
if isinstance(e.event, ToolResult)
}
# --- keeping the end of long output ---------------------------------------
def test_head_and_tail_keeps_the_failure_summary_at_the_end() -> None:
output = (
"exit code: 1\nstdout:\n"
+ "collected 400 items\n" * 500
+ "FAILED tests/test_app.py::test_greet - AssertionError\n"
+ "==== 1 failed, 399 passed ====\n"
)
kept = head_and_tail(output, 1000)
assert len(kept) < 1100
assert kept.startswith("exit code: 1") # the head keeps the exit code
assert "FAILED tests/test_app.py::test_greet" in kept
assert "1 failed, 399 passed" in kept
assert "characters omitted" in kept
def test_short_text_is_untouched() -> None:
assert head_and_tail("fine", 1000) == "fine"
# --- fitting, step by step -------------------------------------------------
def test_a_transcript_that_fits_is_sent_unchanged() -> None:
events = transcript(turns=3, output_chars=100)
assert fit(events, "system", None, budget(100_000)) == events
def test_step_one_cuts_older_tool_results_and_keeps_recent_ones_whole() -> None:
events = transcript(turns=6, output_chars=3000)
fitted = fit(events, "system", None, budget(4000, keep=2))
kept = results(fitted)
assert len(kept) == 6 # nothing dropped yet
for older in ("c0", "c1", "c2", "c3"):
assert "characters omitted" in kept[older]
for recent in ("c4", "c5"):
assert "characters omitted" not in kept[recent]
def test_step_two_drops_the_oldest_turns_and_says_so() -> None:
events = transcript(turns=20, output_chars=3000)
fitted = fit(events, "system", None, budget(4000, keep=2))
first = fitted[0].event
assert isinstance(first, UserMessage)
assert "omitted to fit the context window" in first.text
kept = results(fitted)
assert "c0" not in kept # the oldest went
assert {"c18", "c19"} <= kept.keys() # the recent turns stayed
assert "characters omitted" not in kept["c19"]
# Every surviving result still has its call: a provider rejects an
# answer to a call it never saw.
calls = {e.event.call_id for e in fitted if isinstance(e.event, ToolCall)}
assert kept.keys() <= calls
assert estimate_tokens(fitted, "system", None) <= budget(4000).for_prompt
def test_step_three_refuses_what_cannot_fit() -> None:
# Even the two most recent turns are bigger than the whole window.
events = transcript(turns=3, output_chars=20_000)
with pytest.raises(ContextOverflow, match="last 2 turns"):
fit(events, "system", None, budget(2000, keep=2))
def test_a_huge_system_prompt_alone_overflows() -> None:
events = transcript(turns=1, output_chars=10)
with pytest.raises(ContextOverflow):
fit(events, "s" * 50_000, None, budget(4096))
def test_the_output_ceiling_and_margin_come_out_of_the_window() -> None:
b = Budget(window=4096, max_output=1024, keep_recent_turns=6, safety_margin=0.1)
assert b.for_prompt == int(4096 * 0.9) - 1024
# --- the client wrapper ----------------------------------------------------
class RecordingClient:
def __init__(self, reported_input: int = 0) -> None:
self.calls: list[dict[str, Any]] = []
self.last_usage = {"input": reported_input, "output": 5}
def complete(self, events, tools, model, system=None, *, max_tokens=None):
self.calls.append({"events": events, "max_tokens": max_tokens})
return [AssistantText(text="ok", model=model)]
def test_the_ceiling_is_the_smaller_of_config_and_model() -> None:
inner = RecordingClient()
client = BudgetedClient(inner, lambda m: (4096, 1024), max_output_tokens=16384)
client.complete(transcript(1, 10), None, "llama3.2")
assert inner.calls[0]["max_tokens"] == 1024
client = BudgetedClient(inner, lambda m: (200_000, 64_000), max_output_tokens=16384)
client.complete(transcript(1, 10), None, "claude-haiku-4-5")
assert inner.calls[1]["max_tokens"] == 16384
def test_the_wrapper_sends_the_fitted_events() -> None:
inner = RecordingClient()
client = BudgetedClient(
inner, lambda m: (4000, 500), keep_recent_turns=2, safety_margin=0.1
)
client.complete(transcript(20, 3000), None, "m", system="system")
sent = inner.calls[0]["events"]
assert "omitted to fit" in sent[0].event.text
def test_reported_usage_only_ever_raises_the_estimate() -> None:
events = transcript(1, 10)
estimate = estimate_tokens(events, None, None)
under = RecordingClient(reported_input=estimate * 2) # we undercounted
client = BudgetedClient(under, lambda m: (100_000, None))
client.complete(events, None, "m")
assert client._factors["m"] == pytest.approx(2.0, rel=0.05)
over = RecordingClient(reported_input=estimate // 2) # we overcounted
under_then_over = BudgetedClient(over, lambda m: (100_000, None))
under_then_over._factors["m"] = 2.0
under_then_over.complete(events, None, "m")
assert under_then_over._factors["m"] == 2.0 # never loosened
def test_last_usage_passes_through() -> None:
client = BudgetedClient(RecordingClient(reported_input=7), lambda m: (100, None))
assert client.last_usage["input"] == 7
# --- the limits come from LiteLLM's catalog --------------------------------
CATALOG = ModelCatalog.from_payload(
{
"data": [
{
"model_name": "claude-haiku-4-5",
"litellm_params": {"model": "anthropic/claude-haiku-4-5"},
"model_info": {"max_input_tokens": 200000, "max_output_tokens": 64000},
},
{
"model_name": "llama3.2",
"litellm_params": {"model": "ollama/llama3.2"},
"model_info": {"max_input_tokens": 4096, "max_output_tokens": 1024},
},
{
"model_name": "undeclared",
"litellm_params": {"model": "ollama/qwen3"},
"model_info": {"max_input_tokens": None},
},
]
}
)
def test_the_catalog_reads_each_models_limits() -> None:
facts = CATALOG.facts("llama3.2")
assert (facts.max_input_tokens, facts.max_output_tokens) == (4096, 1024)
assert CATALOG.facts("undeclared").max_input_tokens is None
def test_a_role_on_a_model_without_a_window_is_refused() -> None:
CATALOG.require_limits({"executor": "llama3.2", "planner": "claude-haiku-4-5"})
with pytest.raises(UnknownLimits, match="executor='undeclared'"):
CATALOG.require_limits({"executor": "undeclared", "planner": "llama3.2"})
# --- the executor sees the approved plan and what came after ---------------
def test_the_executor_sees_only_its_milestone(tmp_path: Path) -> None:
store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
for event in (
UserMessage(text="use greet: a long planning conversation", source="t"),
PlanSubmitted(plan="arch plan", model="m"),
UserMessage(text="approve", source="t"),
PlanSubmitted(plan="milestone 1: write greet.py", model="m"),
GateOpened(gate="milestone-1", reason="plan submitted"),
UserMessage(text="start milestone 1", source="t"),
):
store.append_event(event)
class Workspace:
def describe(self) -> str:
return "workspace"
seen: dict[str, Any] = {}
class Client:
def complete(self, events, tools, model, system=None):
seen["events"] = [e.event for e in events]
seen["system"] = system
return [AssistantText(text="done", model=model)]
roles = RoleMap(executor="m", planner="m", delegate="m", consultant="m")
LLMExecutor(Client(), store, roles, Workspace()).run(1)
texts = [getattr(e, "text", None) for e in seen["events"]]
assert "start milestone 1" in texts
assert "use greet: a long planning conversation" not in texts
assert not any(isinstance(e, PlanSubmitted) for e in seen["events"])
# The plan rides in the system prompt, which the budget never drops.
assert "The approved plan:\nmilestone 1: write greet.py" in seen["system"]
assert "arch plan" not in seen["system"]
def test_a_request_that_cannot_fit_stops_the_milestone(tmp_path: Path) -> None:
# Never sent: the executor stops and asks the operator, like any failed
# call, rather than handing a server a prompt it would silently cut.
store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
store.append_event(UserMessage(text="start milestone 1", source="t"))
class Workspace:
def describe(self) -> str:
return "workspace " + "w" * 20_000 # a system prompt too big to fit
inner = RecordingClient()
client = BudgetedClient(inner, lambda m: (4096, 1024))
roles = RoleMap(executor="m", planner="m", delegate="m", consultant="m")
outcome = LLMExecutor(client, store, roles, Workspace()).run(1)
assert outcome.status == "escalate"
assert "fit" in outcome.note
assert inner.calls == []
+53
View File
@@ -0,0 +1,53 @@
import json
import httpx
from nisse_dev.probe import interpret, probe
def test_a_cut_at_the_declared_window_matches() -> None:
result = interpret(declared=4096, sent=6144, reported=4096, error=None)
assert result.verdict == "matches"
assert result.server_window == 4096
def test_a_cut_below_the_declaration_says_what_to_set() -> None:
# The dangerous case: prompts between the two would be silently cut.
result = interpret(declared=8192, sent=12288, reported=4096, error=None)
assert result.verdict == "declared_too_high"
assert "set max_input_tokens to 4096" in result.message
def test_a_cut_above_the_declaration_is_conservative() -> None:
result = interpret(declared=4096, sent=6144, reported=5000, error=None)
assert result.verdict == "declared_conservative"
def test_an_uncut_prompt_means_the_server_allows_more() -> None:
result = interpret(declared=4096, sent=6144, reported=6100, error=None)
assert result.verdict == "declared_conservative"
assert result.server_window is None
def test_a_refusal_is_the_safe_behaviour() -> None:
result = interpret(declared=4096, sent=6144, reported=None, error="400: too long")
assert result.verdict == "refuses"
def test_probe_calibrates_then_sends_half_again_the_window() -> None:
"""Against a fake Ollama: 2 tokens a word, silently cut at 4096."""
sizes: list[int] = []
def handler(request: httpx.Request) -> httpx.Response:
words = len(json.loads(request.content)["messages"][0]["content"].split())
sizes.append(words * 2)
reported = min(words * 2, 4096)
return httpx.Response(200, json={"usage": {"prompt_tokens": reported}})
with httpx.Client(
base_url="http://litellm", transport=httpx.MockTransport(handler)
) as http:
result = probe(http, "llama3.2", declared=4096)
assert result.verdict == "matches"
assert sizes[1] >= 4096 * 1.4 # the probe really exceeded the window
+1 -1
View File
@@ -126,7 +126,7 @@ def test_stdin_is_closed_so_prompts_fail_fast(ws: Workspace) -> None:
def test_long_output_is_truncated(ws: Workspace) -> None:
outcome = ws.run_command("printf 'x%.0s' $(seq 1 20000)")
assert len(outcome.result) < MAX_OUTPUT_CHARS + 200
assert "truncated" in outcome.result
assert "characters omitted" in outcome.result
def test_dispatch_unknown_tool_and_missing_argument(ws: Workspace) -> None: