evalstone/evalscope/tests/agent/test_agent_loop.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

677 lines
30 KiB
Python

# Copyright (c) Alibaba, Inc. and its affiliates.
"""T1 骨架 - AgentLoop 核心循环 + FunctionCallingStrategy.
Plan 覆盖点:
- async AgentLoop: generate → parse → tool_call → observe → terminate
- 每步 trace 打点 (MODEL_GENERATE / TOOL_CALL / TOOL_RESULT / SUBMIT / ERROR)
- max_steps 强制终止
- 未知工具 → ToolCallError 而非中断循环
- FunctionCallingStrategy.parse_output / is_done / tool_schema_mode
"""
import asyncio
import itertools
import unittest
from typing import List, Optional
from unittest.mock import AsyncMock, MagicMock
import evalscope # noqa: F401 - trigger strategy registration
from evalscope.api.agent import (
AgentContext,
AgentLoop,
AgentStrategy,
AgentTrace,
EventType,
ParsedAction,
ToolExecutionOutput,
ToolExecutor,
)
from evalscope.api.messages import ChatMessage, ChatMessageAssistant, ChatMessageTool, ChatMessageUser, ContentImage
from evalscope.api.model.model_output import ChatCompletionChoice, ModelOutput
from evalscope.api.registry import get_strategy
from evalscope.api.tool import ToolCall, ToolCallError
from evalscope.api.tool.tool_call import ToolFunction
def _make_output(
content: str = '',
tool_calls: Optional[List[ToolCall]] = None,
stop_reason: str = 'stop',
) -> ModelOutput:
msg = ChatMessageAssistant(content=content, tool_calls=tool_calls)
return ModelOutput(
model='mock',
choices=[ChatCompletionChoice(message=msg, stop_reason=stop_reason)],
)
def _tool_call(name: str = 'echo', args: Optional[dict] = None, call_id: str = 'c1') -> ToolCall:
return ToolCall(id=call_id, function=ToolFunction(name=name, arguments=args or {'x': 1}))
class TestFunctionCallingStrategy(unittest.TestCase):
"""FC 策略: 无 tool_calls 即停, 有 tool_calls 则继续."""
def setUp(self):
self.strategy = get_strategy('function_calling')()
self.ctx = AgentContext(
sample_id='s',
messages=[ChatMessageUser(content='hi')],
)
def test_parse_output_without_tool_calls(self):
"""No tool calls → no final_answer; model must call submit to finish."""
parsed = self.strategy.parse_output(_make_output(content='final!'), self.ctx)
self.assertIsNone(parsed.final_answer)
self.assertEqual(parsed.tool_calls, [])
self.assertFalse(self.strategy.is_done(parsed, self.ctx))
def test_parse_output_with_tool_calls(self):
parsed = self.strategy.parse_output(
_make_output(content='', tool_calls=[_tool_call()]),
self.ctx,
)
self.assertEqual(len(parsed.tool_calls), 1)
self.assertIsNone(parsed.final_answer)
self.assertFalse(self.strategy.is_done(parsed, self.ctx))
def test_tool_schema_mode_is_function_calling(self):
self.assertEqual(self.strategy.tool_schema_mode(), 'function_calling')
def test_optional_submit_tool(self):
strategy = get_strategy('function_calling')(include_submit_tool=False)
self.assertEqual(strategy.tools(self.ctx), [])
parsed = strategy.parse_output(
_make_output(tool_calls=[_tool_call(name='submit', args={'answer': 'done'})]),
self.ctx,
)
self.assertIsNone(parsed.final_answer)
self.assertEqual(parsed.tool_calls[0].function.name, 'submit')
def test_max_tool_calls_per_turn(self):
strategy = get_strategy('function_calling')(max_tool_calls_per_turn=1)
parsed = strategy.parse_output(
_make_output(tool_calls=[_tool_call(call_id='one'), _tool_call(call_id='two')]),
self.ctx,
)
self.assertEqual(parsed.tool_calls, [])
self.assertEqual(parsed.error, 'Call at most 1 tool call per turn.')
class TestAgentLoopCore(unittest.TestCase):
"""AgentLoop 主循环语义."""
def _build_loop(self, model, *, handlers=None, max_steps=5, trace=None):
strategy = get_strategy('function_calling')()
executor = ToolExecutor(handlers=handlers or {}, environment=None)
return AgentLoop(
model=model,
strategy=strategy,
tool_executor=executor,
max_steps=max_steps,
trace=trace,
)
def test_submit_tool_terminates_loop(self):
"""Model must call submit to finish; nudge injected when no tool used."""
submit_call = ToolCall(id='sc1', function=ToolFunction(name='submit', arguments={'answer': '42'}))
model = MagicMock()
model.generate_async = AsyncMock(side_effect=[
_make_output(content='the answer is 42'), # no tool call → nudge
_make_output(tool_calls=[submit_call]), # submit → done
])
loop = self._build_loop(model)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
self.assertEqual(model.generate_async.call_count, 2)
# user + assistant(text) + nudge(user) + assistant(submit)
self.assertEqual(len(result.messages), 4)
types = [e.type for e in result.trace.events]
self.assertIn(EventType.MODEL_GENERATE, types)
self.assertIn(EventType.SUBMIT, types)
# Nudge event present
nudge_events = [e for e in result.trace.events if e.payload and e.payload.get('source') == 'nudge']
self.assertEqual(len(nudge_events), 1)
def test_tool_call_then_submit(self):
model = MagicMock()
# 第 1 轮发起 tool_call; 第 2 轮调用 submit
submit_call = ToolCall(id='sc1', function=ToolFunction(name='submit', arguments={'answer': 'done'}))
model.generate_async = AsyncMock(side_effect=[
_make_output(tool_calls=[_tool_call(name='echo', args={'x': 7})]),
_make_output(tool_calls=[submit_call]),
])
async def echo_handler(call, env):
return f"echoed:{call.function.arguments['x']}"
loop = self._build_loop(model, handlers={'echo': echo_handler})
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='run echo')])
result = asyncio.run(loop.run(ctx))
self.assertEqual(model.generate_async.call_count, 2)
# user + assistant(tool_call) + tool + assistant(submit)
self.assertEqual(len(result.messages), 4)
tool_msg = result.messages[2]
self.assertIsInstance(tool_msg, ChatMessageTool)
self.assertEqual(tool_msg.content, 'echoed:7')
self.assertIsNone(tool_msg.error)
types = [e.type for e in result.trace.events]
self.assertEqual(
types,
[
EventType.MODEL_GENERATE,
EventType.TOOL_CALL,
EventType.TOOL_RESULT,
EventType.MODEL_GENERATE,
EventType.SUBMIT,
],
)
def test_rich_tool_output_appends_attachment_and_terminates(self):
model = MagicMock()
model.generate_async = AsyncMock(
return_value=_make_output(tool_calls=[_tool_call(name='browser_action', args={'action': 'click("1")'})])
)
async def browser_handler(call, env):
return ToolExecutionOutput(
text='reward=1',
attachments=[ContentImage(image='/tmp/step-001.png')],
metadata={'reward': 1.0},
terminate=True,
final_answer='1',
)
loop = self._build_loop(model, handlers={'browser_action': browser_handler})
format_observation = loop.strategy.format_observation
def format_with_strategy_metadata(*args, **kwargs):
message = format_observation(*args, **kwargs)
message.metadata = {'strategy': 'preserved'}
return message
loop.strategy.format_observation = MagicMock(side_effect=format_with_strategy_metadata)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='click')])
result = asyncio.run(loop.run(ctx))
self.assertEqual(model.generate_async.call_count, 1)
self.assertIsInstance(result.messages[2], ChatMessageTool)
self.assertEqual(result.messages[2].content, 'reward=1')
self.assertEqual(result.messages[2].metadata, {'strategy': 'preserved', 'reward': 1.0})
self.assertIsInstance(result.messages[3], ChatMessageUser)
self.assertEqual(result.messages[3].content[0].image, '/tmp/step-001.png')
self.assertEqual(result.messages[3].tool_call_id, ['c1'])
tool_event = next(event for event in result.trace.events if event.type == EventType.TOOL_RESULT)
self.assertEqual(tool_event.payload['metadata']['reward'], 1.0)
self.assertEqual(tool_event.payload['attachments'], ['/tmp/step-001.png'])
def test_rich_tool_attachments_follow_all_tool_results(self):
model = MagicMock()
submit_call = ToolCall(id='submit', function=ToolFunction(name='submit', arguments={'answer': 'done'}))
model.generate_async = AsyncMock(side_effect=[
_make_output(tool_calls=[
_tool_call(name='echo', call_id='one'),
_tool_call(name='echo', call_id='two'),
]),
_make_output(tool_calls=[submit_call]),
])
async def rich_handler(call, env):
return ToolExecutionOutput(
text=f'output:{call.id}',
attachments=[ContentImage(image=f'/tmp/{call.id}.png')],
)
loop = self._build_loop(model, handlers={'echo': rich_handler})
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='run both')])
result = asyncio.run(loop.run(ctx))
self.assertEqual([message.role for message in result.messages[:6]], [
'user',
'assistant',
'tool',
'tool',
'user',
'user',
])
self.assertEqual(result.messages[2].tool_call_id, 'one')
self.assertEqual(result.messages[3].tool_call_id, 'two')
self.assertEqual(result.messages[4].tool_call_id, ['one'])
self.assertEqual(result.messages[5].tool_call_id, ['two'])
def test_unknown_tool_yields_error_observation_without_aborting(self):
model = MagicMock()
submit_call = ToolCall(id='sc1', function=ToolFunction(name='submit', arguments={'answer': 'recovered'}))
model.generate_async = AsyncMock(side_effect=[
_make_output(tool_calls=[_tool_call(name='missing')]),
_make_output(tool_calls=[submit_call]),
])
loop = self._build_loop(model, handlers={})
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='go')])
result = asyncio.run(loop.run(ctx))
# 第三条消息是 tool 观察, 含 error
tool_msg = result.messages[2]
self.assertIsInstance(tool_msg, ChatMessageTool)
self.assertIsInstance(tool_msg.error, ToolCallError)
self.assertEqual(tool_msg.error.type, 'unknown')
# Loop 没被打断, 第二轮成功 submit
def test_max_steps_exhaustion_emits_error_event(self):
model = MagicMock()
# 每轮都返回 tool_call → 循环永远不收敛 → 触发 max_steps
model.generate_async = AsyncMock(return_value=_make_output(tool_calls=[_tool_call(name='echo')]))
async def echo_handler(call, env):
return 'obs'
loop = self._build_loop(model, handlers={'echo': echo_handler}, max_steps=2)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')], max_steps=2)
result = asyncio.run(loop.run(ctx))
self.assertEqual(model.generate_async.call_count, 2)
# 最末事件应为 ERROR + max_steps_exceeded
last = result.trace.events[-1]
self.assertEqual(last.type, EventType.ERROR)
self.assertEqual(last.payload.get('message'), 'max_steps_exceeded')
def test_system_prompt_injected_once(self):
# 使用自定义策略返回 system prompt
class _SysStrategy:
name = 'sys'
def build_system_prompt(self, ctx):
return 'SYSTEM_PROMPT_X'
def prepare_messages(self, ctx):
return ctx.messages
def parse_output(self, output, ctx):
return ParsedAction(final_answer=output.choices[0].message.content)
def is_done(self, parsed, ctx):
return True
def tool_schema_mode(self):
return 'none'
def tools(self, ctx):
return []
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='ok'))
executor = ToolExecutor(handlers={}, environment=None)
loop = AgentLoop(
model=model,
strategy=_SysStrategy(),
tool_executor=executor,
max_steps=1,
)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
self.assertEqual(result.messages[0].role, 'system')
self.assertEqual(result.messages[0].content, 'SYSTEM_PROMPT_X')
class _AlwaysTextStrategy(AgentStrategy):
"""Minimal strategy stub that never yields tool calls.
Inherits ``should_nudge`` / ``nudge_message`` from :class:`AgentStrategy`
rather than restating them, so these tests exercise the real defaults
instead of a copy that could drift from them.
"""
name: str = 'always_text'
def __init__(self, max_nudges: int) -> None:
self.max_nudges = max_nudges
def build_system_prompt(self, ctx: AgentContext) -> None:
return None
def prepare_messages(self, ctx: AgentContext) -> List[ChatMessage]:
return ctx.messages
def parse_output(self, output: ModelOutput, ctx: AgentContext) -> ParsedAction:
return ParsedAction(raw_text=output.choices[0].message.content)
def is_done(self, parsed: ParsedAction, ctx: AgentContext) -> bool:
return parsed.final_answer is not None
def tool_schema_mode(self) -> str:
return 'none'
def tools(self, ctx: AgentContext) -> list:
return []
def format_observation(self, call, observation, error, parsed, ctx) -> ChatMessageTool:
return ChatMessageTool(content=str(observation), tool_call_id=call.id, function=call.function.name)
#: Per-strategy nudge budgets: (idle, malformed). Pinned here rather than read
#: back from the strategy, so widening a budget fails a test.
EXPECTED_NUDGE_BUDGETS = {
'function_calling': (2, 3),
'react': (2, 3),
'swe_bench_toolcall': (1, 3),
'swe_bench_backticks': (2, 3),
}
class TestAgentLoopNudgeBudget(unittest.TestCase):
"""The loop owns the nudge count; a stuck model stops after max_nudges.
Regression for #1577: the SWE-bench strategy counted a reminder string that
the loop never injected, so ``should_nudge`` always saw 0 and nudged on
every no-tool turn up to ``max_steps`` (250 for SWE-bench).
"""
def _run_all_text(self, strategy, *, max_steps=20):
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='thinking, no tool'))
executor = ToolExecutor(handlers={}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=max_steps)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
return model, ctx, result
def test_every_registered_strategy_has_a_reviewed_budget(self):
from evalscope.api.registry import STRATEGY_REGISTRY
self.assertEqual(set(STRATEGY_REGISTRY.list_keys()), set(EXPECTED_NUDGE_BUDGETS))
def test_registered_strategies_stop_after_their_budget(self):
# Parametrized over the live registry so a new strategy is covered
# automatically. On the pre-fix code swe_bench_* would reach max_steps.
from evalscope.api.registry import STRATEGY_REGISTRY
for name in STRATEGY_REGISTRY.list_keys():
with self.subTest(strategy=name):
strategy = get_strategy(name)()
budget, error_budget = EXPECTED_NUDGE_BUDGETS[name]
self.assertEqual(strategy.max_nudges, budget)
self.assertEqual(strategy.max_parse_error_nudges, error_budget)
model, ctx, result = self._run_all_text(strategy)
# One initial generate plus exactly ``budget`` retries.
self.assertEqual(model.generate_async.call_count, budget + 1)
self.assertEqual(ctx.nudge_count, budget)
# An all-text run is IDLE throughout, so the malformed budget
# must be untouched.
self.assertEqual(ctx.parse_error_nudge_count, 0)
nudge_events = [e for e in result.trace.events
if e.payload and e.payload.get('source') == 'nudge']
# Invariant: the loop-owned counter equals the observable nudges.
self.assertEqual(len(nudge_events), budget)
# An all-text turn carries no parse error, so the trace must
# tag these as the no-tool-call variant.
self.assertEqual(
{e.payload.get('message') for e in nudge_events},
{'no_tool_call_reminder'},
)
submit = [e for e in result.trace.events if e.type == EventType.SUBMIT]
self.assertEqual(len(submit), 1)
self.assertEqual(submit[0].payload.get('source'), 'implicit_no_nudge')
def test_tool_call_resets_the_nudge_streak(self):
# P3 semantics: nudges bound a *consecutive* silent streak. A tool call
# mid-episode resets the counter, so a later stray text turn does not
# inherit earlier nudges. Sequence: text, echo, text, text, text.
strategy = _AlwaysTextStrategy(max_nudges=2)
strategy.parse_output = MagicMock(side_effect=[
ParsedAction(raw_text='t1'),
ParsedAction(tool_calls=[_tool_call(name='echo')]),
ParsedAction(raw_text='t3'),
ParsedAction(raw_text='t4'),
ParsedAction(raw_text='t5'),
])
async def echo_handler(call, env):
return 'echoed'
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='x'))
executor = ToolExecutor(handlers={'echo': echo_handler}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=20)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
# nudge(t1) → tool(reset) → nudge(t3) → nudge(t4) → implicit submit(t5).
# Without the reset the budget would be spent by t4 (call_count 4).
self.assertEqual(model.generate_async.call_count, 5)
nudge_events = [e for e in result.trace.events
if e.payload and e.payload.get('source') == 'nudge']
self.assertEqual(len(nudge_events), 3)
self.assertEqual(ctx.nudge_count, 2)
class TestAgentLoopNudgeContent(unittest.TestCase):
"""The reminder injected on a nudge reflects what the model did wrong."""
def _nudge_messages(self, result):
# Identify nudges by the trace's NUDGE message_ids rather than by
# position: a strategy that injects a system prompt shifts the initial
# user message, and positional filtering would report the task
# description as a nudge. Same principle as the fix under test — read
# the authoritative record instead of inferring from the transcript.
nudge_ids = {
e.message_id
for e in result.trace.events if e.type == EventType.NUDGE and e.message_id is not None
}
return [m for m in result.messages if m.id in nudge_ids]
def test_parse_error_reaches_the_model(self):
# function_calling with a per-turn cap: two tool calls -> ParsedAction
# with an error and no tool_calls. The model must see that error, not
# the generic "no tool was called" text (it did call tools).
strategy = get_strategy('function_calling')(max_tool_calls_per_turn=1)
submit_call = ToolCall(id='s', function=ToolFunction(name='submit', arguments={'answer': 'done'}))
model = MagicMock()
model.generate_async = AsyncMock(side_effect=[
_make_output(tool_calls=[_tool_call(call_id='a'), _tool_call(call_id='b')]),
_make_output(tool_calls=[submit_call]),
])
executor = ToolExecutor(handlers={}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=10)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
nudges = self._nudge_messages(result)
self.assertEqual(len(nudges), 1)
self.assertEqual(str(nudges[0].content), 'Call at most 1 tool call per turn.')
nudge_events = [e for e in result.trace.events
if e.payload and e.payload.get('source') == 'nudge']
self.assertEqual(nudge_events[0].payload.get('message'), 'parse_error_reminder')
def test_no_submit_tool_reminder_omits_submit(self):
# BrowserGym-style config: no submit tool. The reminder must not tell
# the model to call a submit tool that is not exposed.
strategy = get_strategy('function_calling')(include_submit_tool=False)
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='thinking'))
executor = ToolExecutor(handlers={}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=6)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
nudges = self._nudge_messages(result)
self.assertTrue(nudges)
self.assertNotIn('submit', str(nudges[0].content).lower())
def test_reminder_identified_correctly_with_system_prompt(self):
# swe_bench_toolcall always injects a system prompt, so messages[0] is
# the system message and the task description sits at index 1. The
# reminder must still be identified correctly (and must not be the task
# description) — positional filtering would get this wrong.
strategy = get_strategy('swe_bench_toolcall')()
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='thinking about the repo'))
executor = ToolExecutor(handlers={}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=6)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='Fix the failing test.')])
result = asyncio.run(loop.run(ctx))
self.assertEqual(result.messages[0].role, 'system')
nudges = self._nudge_messages(result)
self.assertEqual(len(nudges), 1)
self.assertIn('bash', str(nudges[0].content).lower())
self.assertNotIn('Fix the failing test.', str(nudges[0].content))
class TestSplitNudgeBudgets(unittest.TestCase):
"""Malformed and idle turns are budgeted independently.
``tool_calls`` being empty is a symptom shared by two different failures,
so one shared counter let either of them consume the other's retries.
"""
def _loop(self, strategy, parse_results, *, handlers=None, max_steps=30):
strategy.parse_output = MagicMock(side_effect=parse_results)
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='x'))
executor = ToolExecutor(handlers=handlers or {}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=max_steps)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
return model, ctx, result
def test_malformed_streak_uses_its_own_budget(self):
# 3 malformed retries (not 2) then the loop gives up: 1 + 3 generates.
strategy = _AlwaysTextStrategy(max_nudges=2)
model, ctx, _ = self._loop(strategy, [ParsedAction(error='bad format', raw_text='prose')] * 6)
self.assertEqual(model.generate_async.call_count, 4)
self.assertEqual(ctx.parse_error_nudge_count, 3)
self.assertEqual(ctx.nudge_count, 0)
def test_idle_streak_does_not_consume_the_malformed_budget(self):
# Two malformed turns, then idle: the idle budget is still full, so the
# run continues. A shared counter would already be exhausted (2+2 > 3).
strategy = _AlwaysTextStrategy(max_nudges=2)
model, ctx, _ = self._loop(
strategy,
[
ParsedAction(error='bad format', raw_text='p1'),
ParsedAction(error='bad format', raw_text='p2'),
ParsedAction(raw_text='t3'),
ParsedAction(raw_text='t4'),
ParsedAction(raw_text='t5'),
],
)
# malformed, malformed, idle, idle → nudged 4 times, 5th turn is final.
self.assertEqual(model.generate_async.call_count, 5)
self.assertEqual(ctx.parse_error_nudge_count, 2)
self.assertEqual(ctx.nudge_count, 2)
def test_act_turn_resets_both_counters(self):
async def echo_handler(call, env):
return 'echoed'
strategy = _AlwaysTextStrategy(max_nudges=2)
model, ctx, _ = self._loop(
strategy,
[
ParsedAction(error='bad format', raw_text='p1'),
ParsedAction(raw_text='t2'),
ParsedAction(tool_calls=[_tool_call(name='echo')]),
ParsedAction(raw_text='t4'),
ParsedAction(raw_text='t5'),
ParsedAction(raw_text='t6'),
],
handlers={'echo': echo_handler},
)
# After the ACT turn both streaks restart, so the idle budget of 2 is
# available again: t4 and t5 are nudged, t6 is final.
self.assertEqual(model.generate_async.call_count, 6)
self.assertEqual(ctx.nudge_count, 2)
self.assertEqual(ctx.parse_error_nudge_count, 0)
class TestTerminalSource(unittest.TestCase):
"""The SUBMIT event must say *why* the episode ended."""
def _run(self, parse_result, *, strategy=None):
strategy = strategy or _AlwaysTextStrategy(max_nudges=1)
strategy.parse_output = MagicMock(return_value=parse_result)
model = MagicMock()
model.generate_async = AsyncMock(return_value=_make_output(content='x'))
executor = ToolExecutor(handlers={}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=30)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='q')])
result = asyncio.run(loop.run(ctx))
submits = [e for e in result.trace.events if e.type == EventType.SUBMIT]
self.assertEqual(len(submits), 1)
return submits[0]
def test_malformed_exhausted_gets_its_own_source(self):
submit = self._run(ParsedAction(error='Call at most 1 tool call per turn.', raw_text='here you go'))
self.assertEqual(submit.payload.get('source'), 'parse_error_exhausted')
self.assertEqual(submit.payload.get('outcome'), 'malformed')
# The error must travel with it: "kept breaking the protocol" is a
# different diagnosis from "stopped calling tools".
self.assertEqual(submit.payload.get('error'), 'Call at most 1 tool call per turn.')
def test_idle_exhausted_keeps_implicit_no_nudge(self):
submit = self._run(ParsedAction(raw_text='the answer is 42'))
self.assertEqual(submit.payload.get('source'), 'implicit_no_nudge')
self.assertEqual(submit.payload.get('outcome'), 'idle')
self.assertIsNone(submit.payload.get('error'))
def test_loop_does_not_publish_a_final_answer_it_does_not_own(self):
# The reported prediction is resolved after the loop returns, by the
# adapter hook; a competing ``final_answer`` here already disagreed
# with it for swe_bench_backticks.
submit = self._run(ParsedAction(raw_text='THOUGHT: the patch would be trivial'))
self.assertNotIn('final_answer', submit.payload)
self.assertIn('THOUGHT:', submit.payload.get('raw_text_preview', ''))
class TestNudgeIsNotMistakenForASubmission(unittest.TestCase):
"""A reminder must never be reported as the model's submission.
``swe_bench_backticks`` archives observations as ``ChatMessageUser``, and so
is a nudge — a run ending on one would return the reminder as the patch.
"""
def test_backticks_extract_ignores_a_trailing_nudge(self):
# Alternate a valid fenced block with prose so the nudge streak never
# exhausts the budget; max_steps then runs out on a nudge turn.
strategy = get_strategy('swe_bench_backticks')()
outputs = itertools.cycle([
_make_output(content='```mswea_bash_command\nls\n```'),
_make_output(content='THOUGHT: let me think without emitting a command'),
])
async def bash_handler(call, env):
return 'file1\nfile2'
model = MagicMock()
model.generate_async = AsyncMock(side_effect=lambda *a, **kw: next(outputs))
executor = ToolExecutor(handlers={'bash': bash_handler}, environment=None)
loop = AgentLoop(model=model, strategy=strategy, tool_executor=executor, max_steps=10)
ctx = AgentContext(sample_id='s', messages=[ChatMessageUser(content='fix the bug')])
result = asyncio.run(loop.run(ctx))
# Precondition: the run really did end on a nudge, otherwise this test
# would pass without exercising the guard.
nudge_ids = {e.message_id for e in result.trace.events if e.type == EventType.NUDGE}
self.assertIn(result.messages[-1].id, nudge_ids)
# The sentinel never fired, so there is no submission to recover.
answer = strategy.extract_final_answer(result)
self.assertEqual(answer, '')
self.assertNotIn('mswea_bash_command', answer)
if __name__ == '__main__':
unittest.main()