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>
677 lines
30 KiB
Python
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()
|