Before each driver call, over compact_at (0.6) of the prompt budget, the same model is asked for notes over the very request it was about to get, plus one message, so the server's cache covers all but the notes. The notes, with code's list of the calls they replace, become a compaction event that renders in place of its range, keeping word for word what started the work and the last few turns. Rare, large steps keep the prompt's start unchanged between them. A later compaction folds in the earlier; one from an earlier run's view is hidden. Failed notes are logged and fitting takes over. Fitting now applies compactions before it measures, records what it cuts (Trim) and logs each cut as context.trimmed. Wired into both agents' drivers and the benchmark; nisse-dev's helper loop is a follow-up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
166 lines
6.3 KiB
Python
166 lines
6.3 KiB
Python
import json
|
|
from itertools import pairwise
|
|
|
|
from nisse_core.log.canonical import (
|
|
AssistantText,
|
|
Compaction,
|
|
GateOpened,
|
|
StoredEvent,
|
|
ToolCall,
|
|
ToolResult,
|
|
UserMessage,
|
|
)
|
|
from nisse_core.models.render import OpenAIChatRenderer, effective_events
|
|
|
|
|
|
def stored(events: list) -> list[StoredEvent]:
|
|
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
|
|
|
|
|
def test_renders_mixed_transcript_in_order() -> None:
|
|
events = stored(
|
|
[
|
|
UserMessage(text="run the tests", source="m:1"),
|
|
ToolCall(call_id="call-9", tool="run_tests", arguments={"path": "x"}),
|
|
ToolResult(call_id="call-9", result="1 passed", success=True),
|
|
AssistantText(text="all green", model="local/qwen"),
|
|
]
|
|
)
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="local/qwen")
|
|
assert body["model"] == "local/qwen"
|
|
roles = [m["role"] for m in body["messages"]]
|
|
assert roles == ["user", "assistant", "tool", "assistant"]
|
|
call = body["messages"][1]["tool_calls"][0]
|
|
assert call["id"] == "call-9"
|
|
assert call["function"]["name"] == "run_tests"
|
|
assert json.loads(call["function"]["arguments"]) == {"path": "x"}
|
|
assert body["messages"][2]["tool_call_id"] == "call-9"
|
|
assert "tools" not in body
|
|
|
|
|
|
def test_system_prompt_and_tools_pass_through() -> None:
|
|
tools = [{"type": "function", "function": {"name": "run_tests"}}]
|
|
body = OpenAIChatRenderer().render(
|
|
stored([UserMessage(text="hi", source="m:1")]),
|
|
tools=tools,
|
|
model="local/qwen",
|
|
system="you are nisse-dev",
|
|
)
|
|
assert body["messages"][0] == {"role": "system", "content": "you are nisse-dev"}
|
|
assert body["tools"] == tools
|
|
|
|
|
|
def test_compaction_replaces_its_range_only() -> None:
|
|
events = stored(
|
|
[
|
|
UserMessage(text="old-1", source="m:1"),
|
|
AssistantText(text="old-2", model="q"),
|
|
UserMessage(text="kept", source="m:3"),
|
|
]
|
|
)
|
|
events.append(
|
|
StoredEvent(
|
|
id=4,
|
|
ts="t",
|
|
event=Compaction(summary="the old stuff", model="q", replaces=[1, 2]),
|
|
)
|
|
)
|
|
remaining = effective_events(events)
|
|
# The summary takes the place of what it replaces.
|
|
assert [e.id for e in remaining] == [4, 3]
|
|
assert effective_events(remaining) == remaining # applying again: no change
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
|
contents = [m["content"] for m in body["messages"]]
|
|
# Both render as user messages, so they share one: roles must alternate.
|
|
assert contents == ["[summary of earlier conversation]\nthe old stuff\n\nkept"]
|
|
|
|
|
|
def test_a_later_compaction_folds_in_an_earlier_one() -> None:
|
|
events = stored([UserMessage(text=f"e{i}", source="m") for i in range(1, 7)])
|
|
events.append(
|
|
StoredEvent(id=7, ts="t", event=Compaction("first", "q", replaces=[2, 3]))
|
|
)
|
|
events.append(
|
|
StoredEvent(id=8, ts="t", event=Compaction("second", "q", replaces=[2, 5]))
|
|
)
|
|
remaining = effective_events(events)
|
|
assert [e.id for e in remaining] == [1, 8, 6]
|
|
|
|
|
|
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
|
|
# Providers reject a dangling tool_use; this happens after a crash (L8)
|
|
# or with turn-ending gate tools like submit_plan.
|
|
events = stored(
|
|
[
|
|
UserMessage(text="go", source="m:1"),
|
|
ToolCall(call_id="call-x", tool="submit_plan", arguments={}),
|
|
]
|
|
)
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
|
assert [m["role"] for m in body["messages"]] == ["user", "assistant", "tool"]
|
|
assert body["messages"][2]["tool_call_id"] == "call-x"
|
|
assert body["messages"][2]["content"] == "[no result recorded]"
|
|
|
|
|
|
def test_answered_tool_call_gets_no_placeholder() -> None:
|
|
events = stored(
|
|
[
|
|
ToolCall(call_id="call-x", tool="run_command", arguments={}),
|
|
ToolResult(call_id="call-x", result="ok", success=True),
|
|
]
|
|
)
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
|
assert [m["role"] for m in body["messages"]] == ["assistant", "tool"]
|
|
assert body["messages"][1]["content"] == "ok"
|
|
|
|
|
|
def test_gate_event_renders_as_context() -> None:
|
|
events = stored([GateOpened(gate="arch", reason="plan submitted (rev-1)")])
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
|
assert body["messages"][0]["role"] == "user"
|
|
assert "arch" in body["messages"][0]["content"]
|
|
|
|
|
|
def _roles(body: dict) -> list[str]:
|
|
return [m["role"] for m in body["messages"]]
|
|
|
|
|
|
def test_one_turn_is_one_assistant_message() -> None:
|
|
# Text and two parallel tool calls from one model turn: the OpenAI format
|
|
# carries them as a single assistant message with a tool_calls list.
|
|
events = stored(
|
|
[
|
|
UserMessage(text="go", source="m:1"),
|
|
AssistantText(text="Looking around first.", model="q"),
|
|
ToolCall(call_id="a", tool="list_dir", arguments={"path": "."}),
|
|
ToolCall(call_id="b", tool="read_file", arguments={"path": "README.md"}),
|
|
ToolResult(call_id="a", result="README.md", success=True),
|
|
ToolResult(call_id="b", result="hello", success=True),
|
|
]
|
|
)
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
|
assert _roles(body) == ["user", "assistant", "tool", "tool"]
|
|
turn = body["messages"][1]
|
|
assert turn["content"] == "Looking around first."
|
|
assert [c["id"] for c in turn["tool_calls"]] == ["a", "b"]
|
|
|
|
|
|
def test_roles_never_repeat_for_strict_templates() -> None:
|
|
"""Mistral's chat template raises on two user or two assistant messages
|
|
in a row; every mix of events must render without one."""
|
|
events = stored(
|
|
[
|
|
UserMessage(text="one", source="m:1"),
|
|
UserMessage(text="two", source="m:2"),
|
|
AssistantText(text="thinking", model="q"),
|
|
ToolCall(call_id="a", tool="list_dir", arguments={}),
|
|
ToolResult(call_id="a", result="x", success=True),
|
|
AssistantText(text="more", model="q"),
|
|
AssistantText(text="and more", model="q"),
|
|
ToolCall(call_id="b", tool="finish", arguments={}),
|
|
]
|
|
)
|
|
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
|
|
for first, second in pairwise(roles):
|
|
assert not (first == second and first in ("user", "assistant")), roles
|