Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -9,9 +9,9 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tool import FakeTool
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import (
@ -25,11 +25,12 @@ from vibe.core.types import (
ToolCall,
ToolCallEvent,
ToolResultEvent,
UserMessageEvent,
)
async def act_and_collect_events(agent: Agent, prompt: str) -> list[BaseEvent]:
return [ev async for ev in agent.act(prompt)]
async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[BaseEvent]:
return [ev async for ev in agent_loop.act(prompt)]
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
@ -53,20 +54,24 @@ def make_todo_tool_call(
)
def make_agent(
def make_agent_loop(
*,
auto_approve: bool = True,
todo_permission: ToolPermission = ToolPermission.ALWAYS,
backend: FakeBackend,
approval_callback: SyncApprovalCallback | None = None,
) -> Agent:
mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
agent = Agent(
make_config(todo_permission=todo_permission), mode=mode, backend=backend
) -> AgentLoop:
agent_name = (
BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT
)
agent_loop = AgentLoop(
make_config(todo_permission=todo_permission),
agent_name=agent_name,
backend=backend,
)
if approval_callback:
agent.set_approval_callback(approval_callback)
return agent
agent_loop.set_approval_callback(approval_callback)
return agent_loop
@pytest.mark.asyncio
@ -77,28 +82,30 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
[mock_llm_chunk(content="Let me check your todos.", tool_calls=[tool_call])],
[mock_llm_chunk(content="I retrieved 0 todos.")],
])
agent = make_agent(auto_approve=True, backend=backend)
agent_loop = make_agent_loop(auto_approve=True, backend=backend)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert [type(e) for e in events] == [
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
AssistantEvent,
]
assert isinstance(events[0], AssistantEvent)
assert events[0].content == "Let me check your todos."
assert isinstance(events[1], ToolCallEvent)
assert events[1].tool_name == "todo"
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is None
assert events[2].skipped is False
assert events[2].result is not None
assert isinstance(events[3], AssistantEvent)
assert events[3].content == "I retrieved 0 todos."
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], AssistantEvent)
assert events[1].content == "Let me check your todos."
assert isinstance(events[2], ToolCallEvent)
assert events[2].tool_name == "todo"
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is None
assert events[3].skipped is False
assert events[3].result is not None
assert isinstance(events[4], AssistantEvent)
assert events[4].content == "I retrieved 0 todos."
# check conversation history
tool_msgs = [m for m in agent.messages if m.role == Role.tool]
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert len(tool_msgs) == 1
assert tool_msgs[-1].tool_call_id == mocked_tool_call_id
assert "total_count" in (tool_msgs[-1].content or "")
@ -106,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
@pytest.mark.asyncio
async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
backend=FakeBackend([
@ -120,21 +127,23 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[1], ToolCallEvent)
assert events[1].tool_name == "todo"
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is True
assert events[2].error is None
assert events[2].result is None
assert events[2].skip_reason is not None
assert "not permitted" in events[2].skip_reason.lower()
assert isinstance(events[3], AssistantEvent)
assert events[3].content == "I cannot execute the tool without approval."
assert agent.stats.tool_calls_rejected == 1
assert agent.stats.tool_calls_agreed == 0
assert agent.stats.tool_calls_succeeded == 0
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], AssistantEvent)
assert isinstance(events[2], ToolCallEvent)
assert events[2].tool_name == "todo"
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is True
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason is not None
assert "not permitted" in events[3].skip_reason.lower()
assert isinstance(events[4], AssistantEvent)
assert events[4].content == "I cannot execute the tool without approval."
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
@ -144,7 +153,7 @@ async def test_tool_call_approved_by_callback() -> None:
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@ -159,15 +168,16 @@ async def test_tool_call_approved_by_callback() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is False
assert events[2].error is None
assert events[2].result is not None
assert agent.stats.tool_calls_agreed == 1
assert agent.stats.tool_calls_rejected == 0
assert agent.stats.tool_calls_succeeded == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is False
assert events[3].error is None
assert events[3].result is not None
assert agent_loop.stats.tool_calls_agreed == 1
assert agent_loop.stats.tool_calls_rejected == 0
assert agent_loop.stats.tool_calls_succeeded == 1
@pytest.mark.asyncio
@ -181,7 +191,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@ -196,21 +206,22 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is True
assert events[2].error is None
assert events[2].result is None
assert events[2].skip_reason == custom_feedback
assert agent.stats.tool_calls_rejected == 1
assert agent.stats.tool_calls_agreed == 0
assert agent.stats.tool_calls_succeeded == 0
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is True
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason == custom_feedback
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_tool_call_skipped_when_permission_is_never() -> None:
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.NEVER,
backend=FakeBackend([
@ -224,27 +235,30 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is True
assert events[2].error is None
assert events[2].result is None
assert events[2].skip_reason is not None
assert "permanently disabled" in events[2].skip_reason.lower()
tool_msgs = [m for m in agent.messages if m.role == Role.tool and m.name == "todo"]
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is True
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason is not None
assert "permanently disabled" in events[3].skip_reason.lower()
tool_msgs = [
m for m in agent_loop.messages if m.role == Role.tool and m.name == "todo"
]
assert len(tool_msgs) == 1
assert tool_msgs[0].name == "todo"
assert events[2].skip_reason in (tool_msgs[-1].content or "")
assert agent.stats.tool_calls_rejected == 1
assert agent.stats.tool_calls_agreed == 0
assert agent.stats.tool_calls_succeeded == 0
assert events[3].skip_reason in (tool_msgs[-1].content or "")
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:
callback_invocations = []
agent_ref: Agent | None = None
agent_ref: AgentLoop | None = None
def approval_callback(
tool_name: str, _args: BaseModel, _tool_call_id: str
@ -257,7 +271,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
return (ApprovalResponse.YES, None)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@ -278,32 +292,34 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
[mock_llm_chunk(content="Second done.")],
]),
)
agent_ref = agent
agent_ref = agent_loop
events1 = await act_and_collect_events(agent, "First request")
events2 = await act_and_collect_events(agent, "Second request")
events1 = await act_and_collect_events(agent_loop, "First request")
events2 = await act_and_collect_events(agent_loop, "Second request")
tool_config_todo = agent.tool_manager.get_tool_config("todo")
tool_config_todo = agent_loop.tool_manager.get_tool_config("todo")
assert tool_config_todo.permission is ToolPermission.ALWAYS
tool_config_help = agent.tool_manager.get_tool_config("bash")
tool_config_help = agent_loop.tool_manager.get_tool_config("bash")
assert tool_config_help.permission is not ToolPermission.ALWAYS
assert agent.auto_approve is False
assert agent_loop.auto_approve is False
assert len(callback_invocations) == 1
assert callback_invocations[0] == "todo"
assert isinstance(events1[2], ToolResultEvent)
assert events1[2].skipped is False
assert events1[2].result is not None
assert isinstance(events2[2], ToolResultEvent)
assert events2[2].skipped is False
assert events2[2].result is not None
assert agent.stats.tool_calls_rejected == 0
assert agent.stats.tool_calls_succeeded == 2
assert isinstance(events1[0], UserMessageEvent)
assert isinstance(events1[3], ToolResultEvent)
assert events1[3].skipped is False
assert events1[3].result is not None
assert isinstance(events2[0], UserMessageEvent)
assert isinstance(events2[3], ToolResultEvent)
assert events2[3].skipped is False
assert events2[3].result is not None
assert agent_loop.stats.tool_calls_rejected == 0
assert agent_loop.stats.tool_calls_succeeded == 2
@pytest.mark.asyncio
async def test_tool_call_with_invalid_action() -> None:
tool_call = make_todo_tool_call("call_5", arguments='{"action": "invalid_action"}')
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[
@ -315,13 +331,14 @@ async def test_tool_call_with_invalid_action() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is not None
assert events[2].result is None
assert "tool_error" in events[2].error.lower()
assert agent.stats.tool_calls_failed == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is not None
assert events[3].result is None
assert "tool_error" in events[3].error.lower()
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@ -337,7 +354,7 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
"todos": [t.model_dump() for t in duplicate_todos],
}),
)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
@ -345,13 +362,14 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
]),
)
events = await act_and_collect_events(agent, "Add todos")
events = await act_and_collect_events(agent_loop, "Add todos")
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is not None
assert events[2].result is None
assert "unique" in events[2].error.lower()
assert agent.stats.tool_calls_failed == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is not None
assert events[3].result is None
assert "unique" in events[3].error.lower()
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@ -364,7 +382,7 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
"todos": [t.model_dump() for t in many_todos],
}),
)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
@ -372,26 +390,23 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
]),
)
events = await act_and_collect_events(agent, "Add todos")
events = await act_and_collect_events(agent_loop, "Add todos")
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is not None
assert events[2].result is None
assert "100" in events[2].error
assert agent.stats.tool_calls_failed == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is not None
assert events[3].result is None
assert "100" in events[3].error
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception_class",
[
pytest.param(KeyboardInterrupt, id="keyboard_interrupt"),
pytest.param(asyncio.CancelledError, id="asyncio_cancelled"),
],
)
async def test_tool_call_can_be_interrupted(
exception_class: type[BaseException],
) -> None:
async def test_tool_call_can_be_interrupted() -> None:
"""Test that tool calls can be interrupted via asyncio.CancelledError.
Note: KeyboardInterrupt is no longer handled here as ctrl+C now quits the app directly.
"""
exception_class = asyncio.CancelledError
tool_call = ToolCall(
id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}")
)
@ -400,23 +415,23 @@ async def test_tool_call_can_be_interrupted(
auto_compact_threshold=0,
enabled_tools=["stub_tool"],
)
agent = Agent(
agent_loop = AgentLoop(
config,
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend([
[mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Tool execution completed.")],
]),
)
# no dependency injection available => monkey patch
agent.tool_manager._available["stub_tool"] = FakeTool
stub_tool_instance = agent.tool_manager.get("stub_tool")
agent_loop.tool_manager._available["stub_tool"] = FakeTool
stub_tool_instance = agent_loop.tool_manager.get("stub_tool")
assert isinstance(stub_tool_instance, FakeTool)
stub_tool_instance._exception_to_raise = exception_class()
events: list[BaseEvent] = []
with pytest.raises(exception_class):
async for ev in agent.act("Execute tool"):
async for ev in agent_loop.act("Execute tool"):
events.append(ev)
tool_result_event = next(
@ -429,9 +444,9 @@ async def test_tool_call_can_be_interrupted(
@pytest.mark.asyncio
async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
agent = Agent(
agent_loop = AgentLoop(
make_config(),
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend(mock_llm_chunk(content="ok")),
)
tool_calls_messages = [
@ -441,8 +456,8 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
assistant_msg = LLMMessage(
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
)
agent.messages = [
agent.messages[0],
agent_loop.messages = [
agent_loop.messages[0],
assistant_msg,
# only one tool responded: the second is missing
LLMMessage(
@ -450,9 +465,9 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
),
]
await act_and_collect_events(agent, "Proceed")
await act_and_collect_events(agent_loop, "Proceed")
tool_msgs = [m for m in agent.messages if m.role == Role.tool]
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert any(m.tool_call_id == "tc2" for m in tool_msgs)
# find placeholder message for tc2
placeholder = next(m for m in tool_msgs if m.tool_call_id == "tc2")
@ -465,19 +480,19 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
@pytest.mark.asyncio
async def test_ensure_assistant_after_tool_appends_understood() -> None:
agent = Agent(
agent_loop = AgentLoop(
make_config(),
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend(mock_llm_chunk(content="ok")),
)
tool_msg = LLMMessage(
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
)
agent.messages = [agent.messages[0], tool_msg]
agent_loop.messages = [agent_loop.messages[0], tool_msg]
await act_and_collect_events(agent, "Next")
await act_and_collect_events(agent_loop, "Next")
# find the seeded tool message and ensure the next message is "Understood."
idx = next(i for i, m in enumerate(agent.messages) if m.role == Role.tool)
assert agent.messages[idx + 1].role == Role.assistant
assert agent.messages[idx + 1].content == "Understood."
idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool)
assert agent_loop.messages[idx + 1].role == Role.assistant
assert agent_loop.messages[idx + 1].content == "Understood."