313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""Opt-in levers: blocked-call rejection feedback + identical-request breaker.
|
|
|
|
Covers OPENSQUILLA_PROVIDER_CONTEXT_BLOCK_FEEDBACK and
|
|
OPENSQUILLA_IDENTICAL_REQUEST_LOOP_BREAK (both off by default). Motivation:
|
|
when blocked compacted-placeholder tool calls are stripped from the provider
|
|
projection together with their error tool_results, the model never sees the
|
|
rejection and can re-emit byte-identical requests until the iteration cap.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
from opensquilla.engine import Agent, AgentConfig
|
|
from opensquilla.engine.agent import (
|
|
_IDENTICAL_REQUEST_LOOP_NUDGE,
|
|
_INVALID_PROVIDER_CONTEXT_ARGUMENTS_KEY,
|
|
_PROVIDER_CONTEXT_REPAIR_PROMPT,
|
|
)
|
|
from opensquilla.provider import (
|
|
ChatConfig,
|
|
ContentBlockText,
|
|
ContentBlockToolResult,
|
|
ContentBlockToolUse,
|
|
Message,
|
|
)
|
|
|
|
|
|
class CapturingProvider:
|
|
provider_name = "fake"
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def chat(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[Any] | None = None,
|
|
config: ChatConfig | None = None,
|
|
) -> AsyncIterator[Any]:
|
|
self.calls.append({"messages": messages, "tools": tools, "config": config})
|
|
return self._stream()
|
|
|
|
async def _stream(self) -> AsyncIterator[Any]:
|
|
return
|
|
yield
|
|
|
|
async def list_models(self) -> list[Any]:
|
|
return []
|
|
|
|
|
|
REJECTION_TEXT = (
|
|
"The apply_patch arguments were compacted for provider context and are not "
|
|
"executable. The tool was not run."
|
|
)
|
|
|
|
|
|
def _blocked_history(*, blocked_last: bool) -> list[Message]:
|
|
"""History whose blocked pair sits at the tail (loop case) or mid-history."""
|
|
messages = [
|
|
Message(role="user", content="fix the bug"),
|
|
Message(
|
|
role="assistant",
|
|
content=[
|
|
ContentBlockToolUse(
|
|
id="blocked-1",
|
|
name="apply_patch",
|
|
input={_INVALID_PROVIDER_CONTEXT_ARGUMENTS_KEY: True},
|
|
),
|
|
],
|
|
),
|
|
Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockToolResult(
|
|
tool_use_id="blocked-1",
|
|
content=REJECTION_TEXT,
|
|
is_error=True,
|
|
)
|
|
],
|
|
),
|
|
]
|
|
if not blocked_last:
|
|
messages.extend(
|
|
[
|
|
Message(
|
|
role="assistant",
|
|
content=[
|
|
ContentBlockToolUse(
|
|
id="ok-1", name="read_file", input={"path": "a.py"}
|
|
),
|
|
],
|
|
),
|
|
Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockToolResult(
|
|
tool_use_id="ok-1",
|
|
content="file body",
|
|
is_error=False,
|
|
)
|
|
],
|
|
),
|
|
]
|
|
)
|
|
return messages
|
|
|
|
|
|
def _tool_use_ids(messages: list[Message]) -> list[str]:
|
|
ids: list[str] = []
|
|
for message in messages:
|
|
if not isinstance(message.content, list):
|
|
continue
|
|
for block in message.content:
|
|
if isinstance(block, ContentBlockToolUse):
|
|
ids.append(block.id)
|
|
return ids
|
|
|
|
|
|
def test_default_off_strips_blocked_pair_and_rejection() -> None:
|
|
agent = Agent(provider=CapturingProvider(), config=AgentConfig())
|
|
projected = agent._strip_provider_context_marker_replay_for_provider(
|
|
_blocked_history(blocked_last=True)
|
|
)
|
|
assert "blocked-1" not in _tool_use_ids(projected)
|
|
flattened = " ".join(
|
|
block.content
|
|
for message in projected
|
|
if isinstance(message.content, list)
|
|
for block in message.content
|
|
if isinstance(block, ContentBlockToolResult) and isinstance(block.content, str)
|
|
)
|
|
assert REJECTION_TEXT not in flattened
|
|
# The historical defect: last surviving message is the user task, so the
|
|
# repair prompt is not appended either - the model gets zero feedback.
|
|
assert projected[-1].content == "fix the bug"
|
|
|
|
|
|
def test_feedback_keeps_pair_and_appends_repair_prompt() -> None:
|
|
agent = Agent(
|
|
provider=CapturingProvider(),
|
|
config=AgentConfig(provider_context_block_feedback=True),
|
|
)
|
|
projected = agent._strip_provider_context_marker_replay_for_provider(
|
|
_blocked_history(blocked_last=True)
|
|
)
|
|
assert "blocked-1" in _tool_use_ids(projected)
|
|
blocked_use = projected[1].content[0]
|
|
assert isinstance(blocked_use, ContentBlockToolUse)
|
|
assert blocked_use.input[_INVALID_PROVIDER_CONTEXT_ARGUMENTS_KEY] is True
|
|
assert blocked_use.input["reason"] == "provider_context_omitted"
|
|
blocked_result = projected[2].content[0]
|
|
assert isinstance(blocked_result, ContentBlockToolResult)
|
|
assert blocked_result.content == REJECTION_TEXT
|
|
assert blocked_result.is_error is True
|
|
assert projected[-1].role == "user"
|
|
assert projected[-1].content == _PROVIDER_CONTEXT_REPAIR_PROMPT
|
|
assert (
|
|
agent.config.metadata["tool_argument_projection_replay_feedback"] == 1
|
|
)
|
|
|
|
|
|
def test_feedback_skips_repair_prompt_when_model_recovered() -> None:
|
|
agent = Agent(
|
|
provider=CapturingProvider(),
|
|
config=AgentConfig(provider_context_block_feedback=True),
|
|
)
|
|
history = _blocked_history(blocked_last=False)
|
|
projected = agent._strip_provider_context_marker_replay_for_provider(history)
|
|
assert "blocked-1" in _tool_use_ids(projected)
|
|
# The rejection is stale (model moved on) - no trailing nudge, and the
|
|
# projection keeps the same number of messages as the input history.
|
|
assert len(projected) == len(history)
|
|
assert projected[-1].content[0].tool_use_id == "ok-1"
|
|
|
|
|
|
def test_feedback_does_not_mutate_input_history() -> None:
|
|
agent = Agent(
|
|
provider=CapturingProvider(),
|
|
config=AgentConfig(provider_context_block_feedback=True),
|
|
)
|
|
history = _blocked_history(blocked_last=True)
|
|
original_input = dict(history[1].content[0].input)
|
|
agent._strip_provider_context_marker_replay_for_provider(history)
|
|
assert history[1].content[0].input == original_input
|
|
assert len(history) == 3
|
|
|
|
|
|
def _request(text: str) -> list[Message]:
|
|
return [Message(role="user", content=text)]
|
|
|
|
|
|
def test_loop_break_off_by_default() -> None:
|
|
agent = Agent(provider=CapturingProvider(), config=AgentConfig())
|
|
assert AgentConfig().identical_request_loop_break_threshold == 0
|
|
for _ in range(10):
|
|
action = agent._identical_request_loop_break_action(
|
|
_request("same"), first_attempt=True
|
|
)
|
|
assert action is None
|
|
|
|
|
|
def test_loop_break_perturbs_at_threshold_and_aborts_at_double() -> None:
|
|
agent = Agent(
|
|
provider=CapturingProvider(),
|
|
config=AgentConfig(identical_request_loop_break_threshold=3),
|
|
)
|
|
actions = [
|
|
agent._identical_request_loop_break_action(_request("same"), first_attempt=True)
|
|
for _ in range(6)
|
|
]
|
|
assert actions == [None, None, "perturb", "perturb", "perturb", "abort"]
|
|
|
|
|
|
def test_loop_break_resets_on_new_payload() -> None:
|
|
agent = Agent(
|
|
provider=CapturingProvider(),
|
|
config=AgentConfig(identical_request_loop_break_threshold=3),
|
|
)
|
|
for _ in range(2):
|
|
agent._identical_request_loop_break_action(_request("same"), first_attempt=True)
|
|
assert (
|
|
agent._identical_request_loop_break_action(
|
|
_request("different"), first_attempt=True
|
|
)
|
|
is None
|
|
)
|
|
assert agent._identical_request_streak == 1
|
|
|
|
|
|
def test_loop_break_retry_attempts_do_not_advance_streak() -> None:
|
|
agent = Agent(
|
|
provider=CapturingProvider(),
|
|
config=AgentConfig(identical_request_loop_break_threshold=3),
|
|
)
|
|
agent._identical_request_loop_break_action(_request("same"), first_attempt=True)
|
|
for _ in range(10):
|
|
action = agent._identical_request_loop_break_action(
|
|
_request("same"), first_attempt=False
|
|
)
|
|
assert action is None
|
|
assert agent._identical_request_streak == 1
|
|
|
|
|
|
def test_loop_break_nudge_names_recovery_actions() -> None:
|
|
assert "Do not repeat the previous tool call" in _IDENTICAL_REQUEST_LOOP_NUDGE
|
|
|
|
|
|
def test_append_nudge_merges_into_trailing_string_user_message() -> None:
|
|
agent = Agent(provider=CapturingProvider(), config=AgentConfig())
|
|
request_messages = [
|
|
Message(role="assistant", content="thinking"),
|
|
Message(role="user", content="please retry"),
|
|
]
|
|
result = agent._append_identical_request_loop_nudge(request_messages)
|
|
assert len(result) == len(request_messages)
|
|
assert result[-1].role == "user"
|
|
assert "please retry" in result[-1].content
|
|
assert _IDENTICAL_REQUEST_LOOP_NUDGE in result[-1].content
|
|
|
|
|
|
def test_append_nudge_merges_into_trailing_list_user_message() -> None:
|
|
agent = Agent(provider=CapturingProvider(), config=AgentConfig())
|
|
tool_result = ContentBlockToolResult(
|
|
tool_use_id="t1", content="result body", is_error=False
|
|
)
|
|
request_messages = [
|
|
Message(role="assistant", content=[ContentBlockToolUse(id="t1", name="grep", input={})]),
|
|
Message(role="user", content=[tool_result]),
|
|
]
|
|
result = agent._append_identical_request_loop_nudge(request_messages)
|
|
assert len(result) == len(request_messages)
|
|
assert result[-1].role == "user"
|
|
assert isinstance(result[-1].content, list)
|
|
assert result[-1].content[0] is tool_result
|
|
assert len(result[-1].content) == 2
|
|
nudge_block = result[-1].content[1]
|
|
assert isinstance(nudge_block, ContentBlockText)
|
|
assert nudge_block.text == _IDENTICAL_REQUEST_LOOP_NUDGE
|
|
# Original message is untouched (no in-place mutation).
|
|
assert len(request_messages[-1].content) == 1
|
|
|
|
|
|
def test_append_nudge_appends_new_message_when_trailing_is_assistant() -> None:
|
|
agent = Agent(provider=CapturingProvider(), config=AgentConfig())
|
|
request_messages = [
|
|
Message(role="user", content="do it"),
|
|
Message(role="assistant", content="ok, doing it"),
|
|
]
|
|
result = agent._append_identical_request_loop_nudge(request_messages)
|
|
assert len(result) == len(request_messages) + 1
|
|
assert result[-1].role == "user"
|
|
assert result[-1].content == _IDENTICAL_REQUEST_LOOP_NUDGE
|
|
|
|
|
|
def test_env_plumbing_for_both_levers(monkeypatch) -> None:
|
|
from opensquilla.engine.turn_runner.agent_bootstrap_stage import (
|
|
_bool_from_env,
|
|
_nonnegative_int_from_env,
|
|
)
|
|
|
|
assert _bool_from_env("OPENSQUILLA_PROVIDER_CONTEXT_BLOCK_FEEDBACK", False) is False
|
|
assert (
|
|
_nonnegative_int_from_env("OPENSQUILLA_IDENTICAL_REQUEST_LOOP_BREAK", 0) == 0
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_PROVIDER_CONTEXT_BLOCK_FEEDBACK", "1")
|
|
monkeypatch.setenv("OPENSQUILLA_IDENTICAL_REQUEST_LOOP_BREAK", "3")
|
|
assert _bool_from_env("OPENSQUILLA_PROVIDER_CONTEXT_BLOCK_FEEDBACK", False) is True
|
|
assert (
|
|
_nonnegative_int_from_env("OPENSQUILLA_IDENTICAL_REQUEST_LOOP_BREAK", 0) == 3
|
|
)
|