v2.0.2
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>
This commit is contained in:
parent
bd3497b1c0
commit
9809cfc831
32 changed files with 622 additions and 305 deletions
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "ACP Server",
|
"name": "ACP Server",
|
||||||
|
|
|
||||||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -5,11 +5,32 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-01-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow environment variables to be overridden by dotenv files
|
||||||
|
- Display custom rate limit messages depending on plan type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Made plan offer message more discreet in UI
|
||||||
|
- Speed up latest session scan and harden validation
|
||||||
|
- Updated pytest-xdist configuration to schedule single test chunks
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevent duplicate messages in persisted sessions
|
||||||
|
- Fix ACP bash tool to pass full command string for chained commands
|
||||||
|
- Fix global agent prompt not being loaded correctly
|
||||||
|
- Do not propose to "resume" when there is nothing to resume
|
||||||
|
|
||||||
|
|
||||||
## [2.0.1] - 2026-01-28
|
## [2.0.1] - 2026-01-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Encoding issues in Windows
|
- Fix encoding issues in Windows
|
||||||
|
|
||||||
|
|
||||||
## [2.0.0] - 2026-01-27
|
## [2.0.0] - 2026-01-27
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.0.1"
|
version = "2.0.2"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.1/vibe-acp-darwin-aarch64-2.0.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-darwin-aarch64-2.0.2.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.1/vibe-acp-darwin-x86_64-2.0.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-darwin-x86_64-2.0.2.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.1/vibe-acp-linux-aarch64-2.0.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-linux-aarch64-2.0.2.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.1/vibe-acp-linux-x86_64-2.0.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-linux-x86_64-2.0.2.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.1/vibe-acp-windows-aarch64-2.0.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-windows-aarch64-2.0.2.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.1/vibe-acp-windows-x86_64-2.0.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-windows-x86_64-2.0.2.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.0.1"
|
version = "2.0.2"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -159,5 +159,5 @@ max-nested-blocks = 4
|
||||||
ignore_decorators = ["@*"]
|
ignore_decorators = ["@*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-vvvv -q -n auto --durations=5 --import-mode=importlib"
|
addopts = "-vvvv -q -n auto --durations=10 --import-mode=importlib --maxschedchunk=1"
|
||||||
timeout = 10
|
timeout = 10
|
||||||
|
|
|
||||||
|
|
@ -125,42 +125,6 @@ class TestAcpBashBasic:
|
||||||
display = Bash.get_summary(args)
|
display = Bash.get_summary(args)
|
||||||
assert display == "ls (timeout 10s)"
|
assert display == "ls (timeout 10s)"
|
||||||
|
|
||||||
def test_parse_command_simple(self) -> None:
|
|
||||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
|
||||||
env, command, args = tool._parse_command("ls")
|
|
||||||
assert env == []
|
|
||||||
assert command == "ls"
|
|
||||||
assert args == []
|
|
||||||
|
|
||||||
def test_parse_command_with_args(self) -> None:
|
|
||||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
|
||||||
env, command, args = tool._parse_command("ls -la src")
|
|
||||||
assert env == []
|
|
||||||
assert command == "ls"
|
|
||||||
assert args == ["-la", "src"]
|
|
||||||
|
|
||||||
def test_parse_command_with_env(self) -> None:
|
|
||||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
|
||||||
env, command, args = tool._parse_command("NODE_ENV=test DEBUG=1 npm test")
|
|
||||||
assert len(env) == 2
|
|
||||||
assert env[0].name == "NODE_ENV"
|
|
||||||
assert env[0].value == "test"
|
|
||||||
assert env[1].name == "DEBUG"
|
|
||||||
assert env[1].value == "1"
|
|
||||||
assert command == "npm"
|
|
||||||
assert args == ["test"]
|
|
||||||
|
|
||||||
def test_parse_command_with_env_value_contains_equals(self) -> None:
|
|
||||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
|
||||||
env, command, args = tool._parse_command(
|
|
||||||
"PATH=/usr/bin:/usr/local/bin echo hello"
|
|
||||||
)
|
|
||||||
assert len(env) == 1
|
|
||||||
assert env[0].name == "PATH"
|
|
||||||
assert env[0].value == "/usr/bin:/usr/local/bin"
|
|
||||||
assert command == "echo"
|
|
||||||
assert args == ["hello"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestAcpBashExecution:
|
class TestAcpBashExecution:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -181,8 +145,7 @@ class TestAcpBashExecution:
|
||||||
# Verify create_terminal was called correctly
|
# Verify create_terminal was called correctly
|
||||||
params = mock_client._last_create_params
|
params = mock_client._last_create_params
|
||||||
assert params["session_id"] == "test_session_123"
|
assert params["session_id"] == "test_session_123"
|
||||||
assert params["command"] == "echo"
|
assert params["command"] == "echo hello"
|
||||||
assert params["args"] == ["hello"]
|
|
||||||
assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd
|
assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -200,16 +163,7 @@ class TestAcpBashExecution:
|
||||||
await collect_result(tool.run(args))
|
await collect_result(tool.run(args))
|
||||||
|
|
||||||
params = mock_client._last_create_params
|
params = mock_client._last_create_params
|
||||||
env = params["env"]
|
assert params["command"] == "NODE_ENV=test npm run build"
|
||||||
assert env is not None
|
|
||||||
assert (
|
|
||||||
isinstance(env, list) and len(env) > 0 and isinstance(env[0], EnvVariable)
|
|
||||||
)
|
|
||||||
assert len(env) == 1
|
|
||||||
assert env[0].name == "NODE_ENV"
|
|
||||||
assert env[0].value == "test"
|
|
||||||
assert params["command"] == "npm"
|
|
||||||
assert params["args"] == ["run", "build"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:
|
async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
assert response.agent_info == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods == []
|
assert response.auth_methods == []
|
||||||
|
|
@ -48,7 +48,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
assert response.agent_info == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods is not None
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import logging
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||||
from vibe.cli.plan_offer.decide_plan_offer import PlanOfferAction, decide_plan_offer
|
from vibe.cli.plan_offer.decide_plan_offer import (
|
||||||
|
PlanOfferAction,
|
||||||
|
PlanType,
|
||||||
|
decide_plan_offer,
|
||||||
|
)
|
||||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,14 +22,15 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
|
||||||
prompt_switching_to_pro_plan=False,
|
prompt_switching_to_pro_plan=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
action = await decide_plan_offer("", gateway)
|
action, plan_type = await decide_plan_offer("", gateway)
|
||||||
|
|
||||||
assert action is PlanOfferAction.UPGRADE
|
assert action is PlanOfferAction.UPGRADE
|
||||||
|
assert plan_type is PlanType.FREE
|
||||||
assert gateway.calls == []
|
assert gateway.calls == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("response", "expected"),
|
("response", "expected_action", "expected_plan_type"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
WhoAmIResponse(
|
WhoAmIResponse(
|
||||||
|
|
@ -34,6 +39,7 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
|
||||||
prompt_switching_to_pro_plan=False,
|
prompt_switching_to_pro_plan=False,
|
||||||
),
|
),
|
||||||
PlanOfferAction.NONE,
|
PlanOfferAction.NONE,
|
||||||
|
PlanType.PRO,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
WhoAmIResponse(
|
WhoAmIResponse(
|
||||||
|
|
@ -42,6 +48,7 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
|
||||||
prompt_switching_to_pro_plan=False,
|
prompt_switching_to_pro_plan=False,
|
||||||
),
|
),
|
||||||
PlanOfferAction.UPGRADE,
|
PlanOfferAction.UPGRADE,
|
||||||
|
PlanType.FREE,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
WhoAmIResponse(
|
WhoAmIResponse(
|
||||||
|
|
@ -50,18 +57,22 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
|
||||||
prompt_switching_to_pro_plan=True,
|
prompt_switching_to_pro_plan=True,
|
||||||
),
|
),
|
||||||
PlanOfferAction.SWITCH_TO_PRO_KEY,
|
PlanOfferAction.SWITCH_TO_PRO_KEY,
|
||||||
|
PlanType.PRO,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
|
ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proposes_an_action_based_on_current_plan_status(
|
async def test_proposes_an_action_based_on_current_plan_status(
|
||||||
response: WhoAmIResponse, expected: PlanOfferAction
|
response: WhoAmIResponse,
|
||||||
|
expected_action: PlanOfferAction,
|
||||||
|
expected_plan_type: PlanType,
|
||||||
) -> None:
|
) -> None:
|
||||||
gateway = FakeWhoAmIGateway(response)
|
gateway = FakeWhoAmIGateway(response)
|
||||||
action = await decide_plan_offer("api-key", gateway)
|
action, plan_type = await decide_plan_offer("api-key", gateway)
|
||||||
|
|
||||||
assert action is expected
|
assert action is expected_action
|
||||||
|
assert plan_type is expected_plan_type
|
||||||
assert gateway.calls == ["api-key"]
|
assert gateway.calls == ["api-key"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,18 +86,20 @@ async def test_proposes_nothing_when_nothing_is_suggested() -> None:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
action = await decide_plan_offer("api-key", gateway)
|
action, plan_type = await decide_plan_offer("api-key", gateway)
|
||||||
|
|
||||||
assert action is PlanOfferAction.NONE
|
assert action is PlanOfferAction.NONE
|
||||||
|
assert plan_type is PlanType.UNKNOWN
|
||||||
assert gateway.calls == ["api-key"]
|
assert gateway.calls == ["api-key"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
|
async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
|
||||||
gateway = FakeWhoAmIGateway(unauthorized=True)
|
gateway = FakeWhoAmIGateway(unauthorized=True)
|
||||||
action = await decide_plan_offer("bad-key", gateway)
|
action, plan_type = await decide_plan_offer("bad-key", gateway)
|
||||||
|
|
||||||
assert action is PlanOfferAction.UPGRADE
|
assert action is PlanOfferAction.UPGRADE
|
||||||
|
assert plan_type is PlanType.FREE
|
||||||
assert gateway.calls == ["bad-key"]
|
assert gateway.calls == ["bad-key"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,8 +109,9 @@ async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
|
||||||
) -> None:
|
) -> None:
|
||||||
gateway = FakeWhoAmIGateway(error=True)
|
gateway = FakeWhoAmIGateway(error=True)
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
action = await decide_plan_offer("api-key", gateway)
|
action, plan_type = await decide_plan_offer("api-key", gateway)
|
||||||
|
|
||||||
assert action is PlanOfferAction.NONE
|
assert action is PlanOfferAction.NONE
|
||||||
|
assert plan_type is PlanType.UNKNOWN
|
||||||
assert gateway.calls == ["api-key"]
|
assert gateway.calls == ["api-key"]
|
||||||
assert "Failed to fetch plan status." in caplog.text
|
assert "Failed to fetch plan status." in caplog.text
|
||||||
|
|
|
||||||
61
tests/core/test_config_load_dotenv.py
Normal file
61
tests/core/test_config_load_dotenv.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from vibe.core.config import load_dotenv_values
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env_file(path: Path, content: str) -> None:
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_missing_file(tmp_path: Path) -> None:
|
||||||
|
environ = {"EXISTING": "1"}
|
||||||
|
missing_path = tmp_path / "missing.env"
|
||||||
|
|
||||||
|
load_dotenv_values(env_path=missing_path, environ=environ)
|
||||||
|
|
||||||
|
assert environ == {"EXISTING": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sets_and_overrides_values(tmp_path: Path) -> None:
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
_write_env_file(
|
||||||
|
env_path,
|
||||||
|
"\n".join([
|
||||||
|
"MISTRAL_API_KEY=new-key",
|
||||||
|
"HTTPS_PROXY=https://local-proxy:8080",
|
||||||
|
"OTHER=from-env",
|
||||||
|
"NEW_KEY=added",
|
||||||
|
"FOO=replace",
|
||||||
|
])
|
||||||
|
+ "\n",
|
||||||
|
)
|
||||||
|
environ = {
|
||||||
|
"MISTRAL_API_KEY": "old-key",
|
||||||
|
"HTTPS_PROXY": "old-https",
|
||||||
|
"OTHER": "keep",
|
||||||
|
"FOO": "keep",
|
||||||
|
}
|
||||||
|
|
||||||
|
load_dotenv_values(env_path=env_path, environ=environ)
|
||||||
|
|
||||||
|
assert environ["MISTRAL_API_KEY"] == "new-key"
|
||||||
|
assert environ["HTTPS_PROXY"] == "https://local-proxy:8080"
|
||||||
|
assert environ["OTHER"] == "from-env"
|
||||||
|
assert environ["NEW_KEY"] == "added"
|
||||||
|
assert environ["FOO"] == "replace"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_empty_values(tmp_path: Path) -> None:
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
_write_env_file(
|
||||||
|
env_path, "\n".join(["EMPTY=", "MISTRAL_API_KEY=", "NO_VALUE"]) + "\n"
|
||||||
|
)
|
||||||
|
environ: dict[str, str] = {}
|
||||||
|
|
||||||
|
load_dotenv_values(env_path=env_path, environ=environ)
|
||||||
|
|
||||||
|
assert "EMPTY" not in environ
|
||||||
|
assert "MISTRAL_API_KEY" not in environ
|
||||||
|
assert "NO_VALUE" not in environ
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
import tomli_w
|
|
||||||
|
|
||||||
from vibe.core import config
|
|
||||||
from vibe.core.config import VibeConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_dump_config(config_file: Path):
|
|
||||||
original_dump_config = VibeConfig.dump_config
|
|
||||||
|
|
||||||
def real_dump_config(cls, config_dict: dict) -> None:
|
|
||||||
try:
|
|
||||||
with config_file.open("wb") as f:
|
|
||||||
tomli_w.dump(config_dict, f)
|
|
||||||
except OSError:
|
|
||||||
config_file.write_text(
|
|
||||||
"\n".join(
|
|
||||||
f"{k} = {v!r}" for k, v in config_dict.items() if v is not None
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
VibeConfig.dump_config = classmethod(real_dump_config) # type: ignore[assignment]
|
|
||||||
return original_dump_config
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _migrate_config_file(tmp_path: Path, content: str):
|
|
||||||
config_file = tmp_path / "config.toml"
|
|
||||||
config_file.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
original_config_file = config.CONFIG_FILE
|
|
||||||
original_dump_config = _restore_dump_config(config_file)
|
|
||||||
|
|
||||||
try:
|
|
||||||
config.CONFIG_FILE = config_file
|
|
||||||
VibeConfig._migrate()
|
|
||||||
yield config_file
|
|
||||||
finally:
|
|
||||||
config.CONFIG_FILE = original_config_file
|
|
||||||
VibeConfig.dump_config = original_dump_config
|
|
||||||
|
|
||||||
|
|
||||||
def _load_migrated_config(config_file: Path) -> dict:
|
|
||||||
with config_file.open("rb") as f:
|
|
||||||
return tomllib.load(f)
|
|
||||||
|
|
@ -206,6 +206,83 @@ class TestSessionLoaderFindLatestSession:
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result == valid_session
|
assert result == valid_session
|
||||||
|
|
||||||
|
def test_find_latest_session_skips_empty_messages_file(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
|
||||||
|
valid_session = create_test_session(session_dir, "valid123-session")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
empty_session = create_test_session(session_dir, "emptymss-session")
|
||||||
|
(empty_session / "messages.jsonl").write_text("")
|
||||||
|
|
||||||
|
result = SessionLoader.find_latest_session(session_config)
|
||||||
|
assert result is not None
|
||||||
|
assert result == valid_session
|
||||||
|
|
||||||
|
def test_find_latest_session_skips_messages_json_not_dict(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
|
||||||
|
valid_session = create_test_session(session_dir, "valid123-session")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
invalid_session = create_test_session(session_dir, "msglist-session")
|
||||||
|
(invalid_session / "messages.jsonl").write_text("[]\n")
|
||||||
|
|
||||||
|
result = SessionLoader.find_latest_session(session_config)
|
||||||
|
assert result is not None
|
||||||
|
assert result == valid_session
|
||||||
|
|
||||||
|
def test_find_latest_session_skips_metadata_json_not_dict(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
|
||||||
|
valid_session = create_test_session(session_dir, "valid123-session")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
invalid_session = create_test_session(session_dir, "metalist-session")
|
||||||
|
(invalid_session / "meta.json").write_text("[]")
|
||||||
|
|
||||||
|
result = SessionLoader.find_latest_session(session_config)
|
||||||
|
assert result is not None
|
||||||
|
assert result == valid_session
|
||||||
|
|
||||||
|
def test_find_latest_session_skips_unreadable_messages_file(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
|
||||||
|
valid_session = create_test_session(session_dir, "valid123-session")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
unreadable_session = create_test_session(session_dir, "unreadab-session")
|
||||||
|
unreadable_messages = unreadable_session / "messages.jsonl"
|
||||||
|
unreadable_messages.chmod(0)
|
||||||
|
|
||||||
|
result = SessionLoader.find_latest_session(session_config)
|
||||||
|
assert result is not None
|
||||||
|
assert result == valid_session
|
||||||
|
|
||||||
|
def test_find_latest_session_skips_unreadable_metadata_file(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
|
||||||
|
valid_session = create_test_session(session_dir, "valid123-session")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
unreadable_session = create_test_session(session_dir, "unreadab-session")
|
||||||
|
unreadable_metadata = unreadable_session / "meta.json"
|
||||||
|
unreadable_metadata.chmod(0)
|
||||||
|
|
||||||
|
result = SessionLoader.find_latest_session(session_config)
|
||||||
|
assert result is not None
|
||||||
|
assert result == valid_session
|
||||||
|
|
||||||
|
|
||||||
class TestSessionLoaderFindSessionById:
|
class TestSessionLoaderFindSessionById:
|
||||||
def test_find_session_by_id_exact_match(
|
def test_find_session_by_id_exact_match(
|
||||||
|
|
@ -285,6 +362,27 @@ class TestSessionLoaderFindSessionById:
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionLoaderDoesSessionExist:
|
||||||
|
def test_does_session_exist_no_messages(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
session_folder = create_test_session(session_dir, "test-session-123")
|
||||||
|
(session_folder / "messages.jsonl").unlink()
|
||||||
|
|
||||||
|
result = SessionLoader.does_session_exist("test-session-123", session_config)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_does_session_exist_success(
|
||||||
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
) -> None:
|
||||||
|
session_dir = Path(session_config.save_dir)
|
||||||
|
session_folder = create_test_session(session_dir, "test-session-123")
|
||||||
|
|
||||||
|
result = SessionLoader.does_session_exist("test-session-123", session_config)
|
||||||
|
assert result == session_folder
|
||||||
|
|
||||||
|
|
||||||
class TestSessionLoaderLoadSession:
|
class TestSessionLoaderLoadSession:
|
||||||
def test_load_session_success(
|
def test_load_session_success(
|
||||||
self, session_config: SessionLoggingConfig, create_test_session
|
self, session_config: SessionLoggingConfig, create_test_session
|
||||||
|
|
|
||||||
|
|
@ -208,8 +208,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test that save_interaction returns a path when enabled
|
await logger.save_interaction(
|
||||||
result = await logger.save_interaction(
|
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
base_config=mock_vibe_config,
|
base_config=mock_vibe_config,
|
||||||
|
|
@ -217,11 +216,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
agent_profile=mock_agent_profile,
|
agent_profile=mock_agent_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify the result
|
# Verify behavior via file system
|
||||||
assert result is not None
|
|
||||||
assert str(logger.session_dir) in result
|
|
||||||
|
|
||||||
# Verify that files were created
|
|
||||||
assert logger.session_dir is not None
|
assert logger.session_dir is not None
|
||||||
messages_file = logger.session_dir / "messages.jsonl"
|
messages_file = logger.session_dir / "messages.jsonl"
|
||||||
metadata_file = logger.session_dir / "meta.json"
|
metadata_file = logger.session_dir / "meta.json"
|
||||||
|
|
@ -229,7 +224,6 @@ class TestSessionLoggerSaveInteraction:
|
||||||
assert messages_file.exists()
|
assert messages_file.exists()
|
||||||
assert metadata_file.exists()
|
assert metadata_file.exists()
|
||||||
|
|
||||||
# Verify that metadata contains expected data
|
|
||||||
with open(metadata_file) as f:
|
with open(metadata_file) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
assert metadata["session_id"] == session_id
|
assert metadata["session_id"] == session_id
|
||||||
|
|
@ -261,7 +255,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await logger.save_interaction(
|
await logger.save_interaction(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
base_config=mock_vibe_config,
|
base_config=mock_vibe_config,
|
||||||
|
|
@ -269,10 +263,9 @@ class TestSessionLoggerSaveInteraction:
|
||||||
agent_profile=mock_agent_profile,
|
agent_profile=mock_agent_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert logger.session_dir is not None
|
assert logger.session_dir is not None
|
||||||
|
|
||||||
metadata_file = logger.session_dir / "meta.json"
|
metadata_file = logger.session_dir / "meta.json"
|
||||||
|
assert metadata_file.exists()
|
||||||
with open(metadata_file) as f:
|
with open(metadata_file) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
assert "system_prompt" in metadata
|
assert "system_prompt" in metadata
|
||||||
|
|
@ -280,6 +273,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
assert metadata["system_prompt"]["role"] == "system"
|
assert metadata["system_prompt"]["role"] == "system"
|
||||||
|
|
||||||
messages_file = logger.session_dir / "messages.jsonl"
|
messages_file = logger.session_dir / "messages.jsonl"
|
||||||
|
assert messages_file.exists()
|
||||||
with open(messages_file) as f:
|
with open(messages_file) as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
messages_data = [json.loads(line) for line in lines]
|
messages_data = [json.loads(line) for line in lines]
|
||||||
|
|
@ -330,7 +324,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
steps=2, session_prompt_tokens=20, session_completion_tokens=40
|
steps=2, session_prompt_tokens=20, session_completion_tokens=40
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await logger.save_interaction(
|
await logger.save_interaction(
|
||||||
messages=all_messages,
|
messages=all_messages,
|
||||||
stats=updated_stats,
|
stats=updated_stats,
|
||||||
base_config=mock_vibe_config,
|
base_config=mock_vibe_config,
|
||||||
|
|
@ -338,17 +332,78 @@ class TestSessionLoggerSaveInteraction:
|
||||||
agent_profile=mock_agent_profile,
|
agent_profile=mock_agent_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify that the result is not None
|
# Verify behavior via file system: metadata was updated
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
# Verify that metadata was updated
|
|
||||||
assert logger.session_dir is not None
|
assert logger.session_dir is not None
|
||||||
metadata_file = logger.session_dir / "meta.json"
|
metadata_file = logger.session_dir / "meta.json"
|
||||||
|
assert metadata_file.exists()
|
||||||
with open(metadata_file) as f:
|
with open(metadata_file) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
assert metadata["total_messages"] == 4
|
assert metadata["total_messages"] == 4
|
||||||
assert metadata["stats"]["steps"] == updated_stats.steps
|
assert metadata["stats"]["steps"] == updated_stats.steps
|
||||||
|
|
||||||
|
messages_file = logger.session_dir / "messages.jsonl"
|
||||||
|
assert messages_file.exists()
|
||||||
|
with open(messages_file) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
assert len(lines) == 4
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_interaction_no_new_messages_is_noop(
|
||||||
|
self,
|
||||||
|
session_config: SessionLoggingConfig,
|
||||||
|
mock_vibe_config: VibeConfig,
|
||||||
|
mock_tool_manager: ToolManager,
|
||||||
|
mock_agent_profile: AgentProfile,
|
||||||
|
) -> None:
|
||||||
|
"""Test that save_interaction does nothing when there are no new messages."""
|
||||||
|
session_id = "test-session-123"
|
||||||
|
logger = SessionLogger(session_config, session_id)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
LLMMessage(role=Role.system, content="System prompt"),
|
||||||
|
LLMMessage(role=Role.user, content="Hello"),
|
||||||
|
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||||
|
]
|
||||||
|
stats = AgentStats(
|
||||||
|
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||||
|
)
|
||||||
|
|
||||||
|
await logger.save_interaction(
|
||||||
|
messages=messages,
|
||||||
|
stats=stats,
|
||||||
|
base_config=mock_vibe_config,
|
||||||
|
tool_manager=mock_tool_manager,
|
||||||
|
agent_profile=mock_agent_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert logger.session_dir is not None
|
||||||
|
metadata_file = logger.session_dir / "meta.json"
|
||||||
|
messages_file = logger.session_dir / "messages.jsonl"
|
||||||
|
|
||||||
|
with open(metadata_file) as f:
|
||||||
|
meta_before = json.load(f)
|
||||||
|
with open(messages_file) as f:
|
||||||
|
lines_before = f.readlines()
|
||||||
|
|
||||||
|
# Call again with same messages: no new messages, should be no-op
|
||||||
|
await logger.save_interaction(
|
||||||
|
messages=messages,
|
||||||
|
stats=stats,
|
||||||
|
base_config=mock_vibe_config,
|
||||||
|
tool_manager=mock_tool_manager,
|
||||||
|
agent_profile=mock_agent_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(metadata_file) as f:
|
||||||
|
meta_after = json.load(f)
|
||||||
|
with open(messages_file) as f:
|
||||||
|
lines_after = f.readlines()
|
||||||
|
|
||||||
|
assert len(lines_after) == len(lines_before) == 2
|
||||||
|
assert lines_after == lines_before
|
||||||
|
assert meta_after["total_messages"] == meta_before["total_messages"] == 2
|
||||||
|
assert meta_after == meta_before
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_interaction_no_user_messages(
|
async def test_save_interaction_no_user_messages(
|
||||||
self,
|
self,
|
||||||
|
|
@ -371,7 +426,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await logger.save_interaction(
|
await logger.save_interaction(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
base_config=mock_vibe_config,
|
base_config=mock_vibe_config,
|
||||||
|
|
@ -379,13 +434,10 @@ class TestSessionLoggerSaveInteraction:
|
||||||
agent_profile=mock_agent_profile,
|
agent_profile=mock_agent_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify the result
|
# Verify behavior via file system
|
||||||
assert result is not None
|
|
||||||
assert str(logger.session_dir) in result
|
|
||||||
|
|
||||||
# Verify that metadata contains expected data
|
|
||||||
assert logger.session_dir is not None
|
assert logger.session_dir is not None
|
||||||
metadata_file = logger.session_dir / "meta.json"
|
metadata_file = logger.session_dir / "meta.json"
|
||||||
|
assert metadata_file.exists()
|
||||||
with open(metadata_file) as f:
|
with open(metadata_file) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
assert metadata["session_id"] == session_id
|
assert metadata["session_id"] == session_id
|
||||||
|
|
@ -393,6 +445,11 @@ class TestSessionLoggerSaveInteraction:
|
||||||
assert metadata["stats"]["steps"] == stats.steps
|
assert metadata["stats"]["steps"] == stats.steps
|
||||||
assert metadata["title"] == "Untitled session"
|
assert metadata["title"] == "Untitled session"
|
||||||
|
|
||||||
|
messages_file = logger.session_dir / "messages.jsonl"
|
||||||
|
assert messages_file.exists()
|
||||||
|
with open(messages_file) as f:
|
||||||
|
assert len(f.readlines()) == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_interaction_long_user_message(
|
async def test_save_interaction_long_user_message(
|
||||||
self,
|
self,
|
||||||
|
|
@ -417,7 +474,7 @@ class TestSessionLoggerSaveInteraction:
|
||||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await logger.save_interaction(
|
await logger.save_interaction(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
base_config=mock_vibe_config,
|
base_config=mock_vibe_config,
|
||||||
|
|
@ -425,13 +482,10 @@ class TestSessionLoggerSaveInteraction:
|
||||||
agent_profile=mock_agent_profile,
|
agent_profile=mock_agent_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify the result
|
# Verify behavior via file system
|
||||||
assert result is not None
|
|
||||||
assert str(logger.session_dir) in result
|
|
||||||
|
|
||||||
# Verify that metadata contains expected data
|
|
||||||
assert logger.session_dir is not None
|
assert logger.session_dir is not None
|
||||||
metadata_file = logger.session_dir / "meta.json"
|
metadata_file = logger.session_dir / "meta.json"
|
||||||
|
assert metadata_file.exists()
|
||||||
with open(metadata_file) as f:
|
with open(metadata_file) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
assert metadata["session_id"] == session_id
|
assert metadata["session_id"] == session_id
|
||||||
|
|
@ -440,6 +494,11 @@ class TestSessionLoggerSaveInteraction:
|
||||||
expected_title = long_message[:50] + "…"
|
expected_title = long_message[:50] + "…"
|
||||||
assert metadata["title"] == expected_title
|
assert metadata["title"] == expected_title
|
||||||
|
|
||||||
|
messages_file = logger.session_dir / "messages.jsonl"
|
||||||
|
assert messages_file.exists()
|
||||||
|
with open(messages_file) as f:
|
||||||
|
assert len(f.readlines()) == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSessionLoggerResetSession:
|
class TestSessionLoggerResetSession:
|
||||||
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
|
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
|
@ -1,9 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from http import HTTPStatus
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.mock.utils import mock_llm_chunk
|
from tests.mock.utils import mock_llm_chunk
|
||||||
|
|
@ -11,6 +13,7 @@ from tests.stubs.fake_backend import FakeBackend
|
||||||
from vibe.core.agent_loop import AgentLoop
|
from vibe.core.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||||
|
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||||
from vibe.core.middleware import (
|
from vibe.core.middleware import (
|
||||||
ConversationContext,
|
ConversationContext,
|
||||||
MiddlewareAction,
|
MiddlewareAction,
|
||||||
|
|
@ -25,6 +28,7 @@ from vibe.core.types import (
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
FunctionCall,
|
FunctionCall,
|
||||||
LLMMessage,
|
LLMMessage,
|
||||||
|
RateLimitError,
|
||||||
ReasoningEvent,
|
ReasoningEvent,
|
||||||
Role,
|
Role,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
|
|
@ -369,7 +373,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||||
assert events[-1].skipped is True
|
assert events[-1].skipped is True
|
||||||
assert events[-1].skip_reason is not None
|
assert events[-1].skip_reason is not None
|
||||||
assert "<user_cancellation>" in events[-1].skip_reason
|
assert "<user_cancellation>" in events[-1].skip_reason
|
||||||
assert agent.session_logger.save_interaction.await_count == 1
|
assert agent.session_logger.save_interaction.await_count >= 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -388,6 +392,34 @@ async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> N
|
||||||
assert agent.session_logger.save_interaction.await_count == 1
|
assert agent.session_logger.save_interaction.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit(observer_capture) -> None:
|
||||||
|
observed, observer = observer_capture
|
||||||
|
response = httpx.Response(HTTPStatus.TOO_MANY_REQUESTS)
|
||||||
|
backend_error = BackendErrorBuilder.build_http_error(
|
||||||
|
provider="mistral",
|
||||||
|
endpoint="test",
|
||||||
|
response=response,
|
||||||
|
headers=None,
|
||||||
|
model="test-model",
|
||||||
|
messages=[],
|
||||||
|
temperature=0.0,
|
||||||
|
has_tools=False,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
backend = FakeBackend(exception_to_raise=backend_error)
|
||||||
|
agent = AgentLoop(
|
||||||
|
make_config(), backend=backend, message_observer=observer, enable_streaming=True
|
||||||
|
)
|
||||||
|
agent.session_logger.save_interaction = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with pytest.raises(RateLimitError):
|
||||||
|
[_ async for _ in agent.act("Trigger rate limit failure while streaming")]
|
||||||
|
|
||||||
|
assert [role for role, _ in observed] == [Role.system, Role.user]
|
||||||
|
assert agent.session_logger.save_interaction.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_events(events: list) -> list[tuple[str, str]]:
|
def _snapshot_events(events: list) -> list[tuple[str, str]]:
|
||||||
return [
|
return [
|
||||||
(type(e).__name__, e.content)
|
(type(e).__name__, e.content)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.mock.utils import mock_llm_chunk
|
from tests.mock.utils import mock_llm_chunk
|
||||||
|
|
@ -16,6 +18,8 @@ from vibe.core.agents.models import (
|
||||||
_deep_merge,
|
_deep_merge,
|
||||||
)
|
)
|
||||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||||
|
from vibe.core.paths.config_paths import ConfigPath
|
||||||
|
from vibe.core.paths.global_paths import GlobalPath
|
||||||
from vibe.core.tools.base import ToolPermission
|
from vibe.core.tools.base import ToolPermission
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
FunctionCall,
|
FunctionCall,
|
||||||
|
|
@ -164,6 +168,43 @@ class TestAgentProfile:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentApplyToConfig:
|
||||||
|
def test_custom_prompt_found_in_global_when_missing_from_project(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Regression test for https://github.com/mistralai/mistral-vibe/issues/288
|
||||||
|
|
||||||
|
When a custom prompt .md file is absent from the project-local prompts
|
||||||
|
directory, the system_prompt property should fall back to the global
|
||||||
|
~/.vibe/prompts/ directory and load the file from there.
|
||||||
|
"""
|
||||||
|
project_prompts = tmp_path / "project" / ".vibe" / "prompts"
|
||||||
|
project_prompts.mkdir(parents=True)
|
||||||
|
|
||||||
|
global_prompts = tmp_path / "home" / ".vibe" / "prompts"
|
||||||
|
global_prompts.mkdir(parents=True)
|
||||||
|
(global_prompts / "cc.md").write_text("Global custom prompt")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"vibe.core.config.PROMPTS_DIR", ConfigPath(lambda: project_prompts)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"vibe.core.config.GLOBAL_PROMPTS_DIR", GlobalPath(lambda: global_prompts)
|
||||||
|
)
|
||||||
|
|
||||||
|
base = VibeConfig(include_project_context=False, include_prompt_detail=False)
|
||||||
|
agent = AgentProfile(
|
||||||
|
name="cc",
|
||||||
|
display_name="Cc",
|
||||||
|
description="",
|
||||||
|
safety=AgentSafety.NEUTRAL,
|
||||||
|
overrides={"system_prompt_id": "cc"},
|
||||||
|
)
|
||||||
|
result = agent.apply_to_config(base)
|
||||||
|
assert result.system_prompt_id == "cc"
|
||||||
|
assert result.system_prompt == "Global custom prompt"
|
||||||
|
|
||||||
|
|
||||||
class TestAgentProfileOverrides:
|
class TestAgentProfileOverrides:
|
||||||
def test_default_agent_has_no_overrides(self) -> None:
|
def test_default_agent_has_no_overrides(self) -> None:
|
||||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
|
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -673,7 +673,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.0.1"
|
version = "2.0.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "2.0.1"
|
__version__ = "2.0.2"
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ from vibe.acp.utils import (
|
||||||
from vibe.core.agent_loop import AgentLoop
|
from vibe.core.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
|
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
||||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
ApprovalResponse,
|
ApprovalResponse,
|
||||||
|
|
@ -176,7 +176,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> NewSessionResponse:
|
) -> NewSessionResponse:
|
||||||
load_api_keys_from_env()
|
load_dotenv_values()
|
||||||
os.chdir(cwd)
|
os.chdir(cwd)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shlex
|
|
||||||
|
|
||||||
from acp.schema import (
|
from acp.schema import (
|
||||||
EnvVariable,
|
|
||||||
TerminalToolCallContent,
|
TerminalToolCallContent,
|
||||||
ToolCallProgress,
|
ToolCallProgress,
|
||||||
ToolCallStart,
|
ToolCallStart,
|
||||||
|
|
@ -40,14 +38,11 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
||||||
|
|
||||||
timeout = args.timeout or self.config.default_timeout
|
timeout = args.timeout or self.config.default_timeout
|
||||||
max_bytes = self.config.max_output_bytes
|
max_bytes = self.config.max_output_bytes
|
||||||
env, command, cmd_args = self._parse_command(args.command)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
terminal_handle = await client.create_terminal(
|
terminal_handle = await client.create_terminal(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
command=command,
|
command=args.command,
|
||||||
args=cmd_args,
|
|
||||||
env=env,
|
|
||||||
cwd=str(Path.cwd()),
|
cwd=str(Path.cwd()),
|
||||||
output_byte_limit=max_bytes,
|
output_byte_limit=max_bytes,
|
||||||
)
|
)
|
||||||
|
|
@ -84,25 +79,6 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to release terminal: {e!r}")
|
logger.error(f"Failed to release terminal: {e!r}")
|
||||||
|
|
||||||
def _parse_command(
|
|
||||||
self, command_str: str
|
|
||||||
) -> tuple[list[EnvVariable], str, list[str]]:
|
|
||||||
parts = shlex.split(command_str)
|
|
||||||
env: list[EnvVariable] = []
|
|
||||||
command: str = ""
|
|
||||||
args: list[str] = []
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
if "=" in part and not command:
|
|
||||||
key, value = part.split("=", 1)
|
|
||||||
env.append(EnvVariable(name=key, value=value))
|
|
||||||
elif not command:
|
|
||||||
command = part
|
|
||||||
else:
|
|
||||||
args.append(part)
|
|
||||||
|
|
||||||
return env, command, args
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_summary(cls, args: BashArgs) -> str:
|
def get_summary(cls, args: BashArgs) -> str:
|
||||||
summary = f"{args.command}"
|
summary = f"{args.command}"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from vibe.core.config import (
|
||||||
MissingAPIKeyError,
|
MissingAPIKeyError,
|
||||||
MissingPromptFileError,
|
MissingPromptFileError,
|
||||||
VibeConfig,
|
VibeConfig,
|
||||||
load_api_keys_from_env,
|
load_dotenv_values,
|
||||||
)
|
)
|
||||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
|
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
|
||||||
from vibe.core.programmatic import run_programmatic
|
from vibe.core.programmatic import run_programmatic
|
||||||
|
|
@ -122,7 +122,7 @@ def _load_messages_from_previous_session(
|
||||||
|
|
||||||
|
|
||||||
def run_cli(args: argparse.Namespace) -> None:
|
def run_cli(args: argparse.Namespace) -> None:
|
||||||
load_api_keys_from_env()
|
load_dotenv_values()
|
||||||
bootstrap_config_files()
|
bootstrap_config_files()
|
||||||
|
|
||||||
if args.setup:
|
if args.setup:
|
||||||
|
|
|
||||||
|
|
@ -29,28 +29,36 @@ ACTION_TO_URL: dict[PlanOfferAction, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlanType(StrEnum):
|
||||||
|
FREE = "free"
|
||||||
|
PRO = "pro"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
async def decide_plan_offer(
|
async def decide_plan_offer(
|
||||||
api_key: str | None, gateway: WhoAmIGateway
|
api_key: str | None, gateway: WhoAmIGateway
|
||||||
) -> PlanOfferAction:
|
) -> tuple[PlanOfferAction, PlanType]:
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return PlanOfferAction.UPGRADE
|
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||||
try:
|
try:
|
||||||
response = await gateway.whoami(api_key)
|
response = await gateway.whoami(api_key)
|
||||||
except WhoAmIGatewayUnauthorized:
|
except WhoAmIGatewayUnauthorized:
|
||||||
return PlanOfferAction.UPGRADE
|
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||||
except WhoAmIGatewayError:
|
except WhoAmIGatewayError:
|
||||||
logger.warning("Failed to fetch plan status.", exc_info=True)
|
logger.warning("Failed to fetch plan status.", exc_info=True)
|
||||||
return PlanOfferAction.NONE
|
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||||
return _action_from_response(response)
|
return _action_and_plan_from_response(response)
|
||||||
|
|
||||||
|
|
||||||
def _action_from_response(response: WhoAmIResponse) -> PlanOfferAction:
|
def _action_and_plan_from_response(
|
||||||
|
response: WhoAmIResponse,
|
||||||
|
) -> tuple[PlanOfferAction, PlanType]:
|
||||||
match response:
|
match response:
|
||||||
case WhoAmIResponse(is_pro_plan=True):
|
case WhoAmIResponse(is_pro_plan=True):
|
||||||
return PlanOfferAction.NONE
|
return PlanOfferAction.NONE, PlanType.PRO
|
||||||
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
|
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
|
||||||
return PlanOfferAction.SWITCH_TO_PRO_KEY
|
return PlanOfferAction.SWITCH_TO_PRO_KEY, PlanType.PRO
|
||||||
case WhoAmIResponse(advertise_pro_plan=True):
|
case WhoAmIResponse(advertise_pro_plan=True):
|
||||||
return PlanOfferAction.UPGRADE
|
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||||
case _:
|
case _:
|
||||||
return PlanOfferAction.NONE
|
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
|
||||||
from vibe.cli.plan_offer.decide_plan_offer import (
|
from vibe.cli.plan_offer.decide_plan_offer import (
|
||||||
ACTION_TO_URL,
|
ACTION_TO_URL,
|
||||||
PlanOfferAction,
|
PlanOfferAction,
|
||||||
|
PlanType,
|
||||||
decide_plan_offer,
|
decide_plan_offer,
|
||||||
)
|
)
|
||||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||||
|
|
@ -74,12 +75,19 @@ from vibe.core.agents import AgentProfile
|
||||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||||
|
from vibe.core.session.session_loader import SessionLoader
|
||||||
from vibe.core.tools.base import ToolPermission
|
from vibe.core.tools.base import ToolPermission
|
||||||
from vibe.core.tools.builtins.ask_user_question import (
|
from vibe.core.tools.builtins.ask_user_question import (
|
||||||
AskUserQuestionArgs,
|
AskUserQuestionArgs,
|
||||||
AskUserQuestionResult,
|
AskUserQuestionResult,
|
||||||
)
|
)
|
||||||
from vibe.core.types import AgentStats, ApprovalResponse, LLMMessage, Role
|
from vibe.core.types import (
|
||||||
|
AgentStats,
|
||||||
|
ApprovalResponse,
|
||||||
|
LLMMessage,
|
||||||
|
RateLimitError,
|
||||||
|
Role,
|
||||||
|
)
|
||||||
from vibe.core.utils import (
|
from vibe.core.utils import (
|
||||||
CancellationReason,
|
CancellationReason,
|
||||||
get_user_cancellation_message,
|
get_user_cancellation_message,
|
||||||
|
|
@ -594,8 +602,16 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
await self._loading_widget.remove()
|
await self._loading_widget.remove()
|
||||||
if self.event_handler:
|
if self.event_handler:
|
||||||
self.event_handler.stop_current_tool_call()
|
self.event_handler.stop_current_tool_call()
|
||||||
|
|
||||||
|
message = str(e)
|
||||||
|
if isinstance(e, RateLimitError):
|
||||||
|
if self.plan_type == PlanType.FREE:
|
||||||
|
message = "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||||
|
else:
|
||||||
|
message = "Rate limits exceeded. Please wait a moment before trying again."
|
||||||
|
|
||||||
await self._mount_and_scroll(
|
await self._mount_and_scroll(
|
||||||
ErrorMessage(str(e), collapsed=self._tools_collapsed)
|
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._agent_running = False
|
self._agent_running = False
|
||||||
|
|
@ -773,6 +789,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
return None
|
return None
|
||||||
if not self.agent_loop.session_logger.session_id:
|
if not self.agent_loop.session_logger.session_id:
|
||||||
return None
|
return None
|
||||||
|
session_config = self.agent_loop.session_logger.session_config
|
||||||
|
session_path = SessionLoader.does_session_exist(
|
||||||
|
self.agent_loop.session_logger.session_id, session_config
|
||||||
|
)
|
||||||
|
if session_path is None:
|
||||||
|
return None
|
||||||
return self.agent_loop.session_logger.session_id[:8]
|
return self.agent_loop.session_logger.session_id[:8]
|
||||||
|
|
||||||
async def _exit_app(self) -> None:
|
async def _exit_app(self) -> None:
|
||||||
|
|
@ -1023,7 +1045,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
async def _maybe_show_plan_offer(self) -> None:
|
async def _maybe_show_plan_offer(self) -> None:
|
||||||
if self._plan_offer_shown:
|
if self._plan_offer_shown:
|
||||||
return
|
return
|
||||||
action = await self._resolve_plan_offer_action()
|
action, plan_type = await self._resolve_plan_offer_action()
|
||||||
|
self.plan_type = plan_type
|
||||||
if action is PlanOfferAction.NONE:
|
if action is PlanOfferAction.NONE:
|
||||||
return
|
return
|
||||||
url = ACTION_TO_URL[action]
|
url = ACTION_TO_URL[action]
|
||||||
|
|
@ -1035,12 +1058,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
await self._mount_and_scroll(PlanOfferMessage(text))
|
await self._mount_and_scroll(PlanOfferMessage(text))
|
||||||
self._plan_offer_shown = True
|
self._plan_offer_shown = True
|
||||||
|
|
||||||
async def _resolve_plan_offer_action(self) -> PlanOfferAction:
|
async def _resolve_plan_offer_action(self) -> tuple[PlanOfferAction, PlanType]:
|
||||||
try:
|
try:
|
||||||
active_model = self.config.get_active_model()
|
active_model = self.config.get_active_model()
|
||||||
provider = self.config.get_provider_for_model(active_model)
|
provider = self.config.get_provider_for_model(active_model)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return PlanOfferAction.NONE
|
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||||
|
|
||||||
api_key_env = provider.api_key_env_var
|
api_key_env = provider.api_key_env_var
|
||||||
api_key = getenv(api_key_env) if api_key_env else None
|
api_key = getenv(api_key_env) if api_key_env else None
|
||||||
|
|
@ -1051,7 +1074,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
|
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
|
||||||
)
|
)
|
||||||
return PlanOfferAction.NONE
|
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||||
|
|
||||||
async def _finalize_current_streaming_message(self) -> None:
|
async def _finalize_current_streaming_message(self) -> None:
|
||||||
if self._current_streaming_reasoning is not None:
|
if self._current_streaming_reasoning is not None:
|
||||||
|
|
|
||||||
|
|
@ -949,21 +949,24 @@ WelcomeBanner {
|
||||||
.whats-new-message {
|
.whats-new-message {
|
||||||
background: $surface;
|
background: $surface;
|
||||||
border-left: solid $warning;
|
border-left: solid $warning;
|
||||||
|
margin-bottom: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-offer-message {
|
.plan-offer-message {
|
||||||
background: $surface;
|
background: transparent;
|
||||||
border-left: solid $warning;
|
border: none;
|
||||||
height: auto;
|
height: auto;
|
||||||
text-align: center;
|
padding: 0;
|
||||||
content-align: center middle;
|
margin-top: 0;
|
||||||
padding: 1 2;
|
color: $foreground-muted;
|
||||||
margin-top: 1;
|
text-style: dim;
|
||||||
|
|
||||||
Markdown > *:last-child {
|
Markdown > *:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
link-style: bold underline;
|
link-style: none;
|
||||||
|
link-color: $foreground-muted;
|
||||||
link-background-hover: transparent;
|
link-background-hover: transparent;
|
||||||
|
link-style-hover: underline;
|
||||||
link-color-hover: $warning;
|
link-color-hover: $warning;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
|
from http import HTTPStatus
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
import time
|
import time
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
@ -14,6 +15,7 @@ from vibe.core.agents.manager import AgentManager
|
||||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||||
|
from vibe.core.llm.exceptions import BackendError
|
||||||
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
|
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
|
||||||
from vibe.core.llm.types import BackendLike
|
from vibe.core.llm.types import BackendLike
|
||||||
from vibe.core.middleware import (
|
from vibe.core.middleware import (
|
||||||
|
|
@ -54,6 +56,7 @@ from vibe.core.types import (
|
||||||
LLMChunk,
|
LLMChunk,
|
||||||
LLMMessage,
|
LLMMessage,
|
||||||
LLMUsage,
|
LLMUsage,
|
||||||
|
RateLimitError,
|
||||||
ReasoningEvent,
|
ReasoningEvent,
|
||||||
Role,
|
Role,
|
||||||
SyncApprovalCallback,
|
SyncApprovalCallback,
|
||||||
|
|
@ -95,6 +98,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
|
||||||
"""Raised when LLM response is malformed or missing expected data."""
|
"""Raised when LLM response is malformed or missing expected data."""
|
||||||
|
|
||||||
|
|
||||||
|
def _should_raise_rate_limit_error(e: Exception) -> bool:
|
||||||
|
return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS
|
||||||
|
|
||||||
|
|
||||||
class AgentLoop:
|
class AgentLoop:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -193,7 +200,18 @@ class AgentLoop:
|
||||||
def add_message(self, message: LLMMessage) -> None:
|
def add_message(self, message: LLMMessage) -> None:
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
|
|
||||||
def _flush_new_messages(self) -> None:
|
async def _save_messages(self) -> None:
|
||||||
|
await self.session_logger.save_interaction(
|
||||||
|
self.messages,
|
||||||
|
self.stats,
|
||||||
|
self._base_config,
|
||||||
|
self.tool_manager,
|
||||||
|
self.agent_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _flush_new_messages(self) -> None:
|
||||||
|
await self._save_messages()
|
||||||
|
|
||||||
if not self.message_observer:
|
if not self.message_observer:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -308,12 +326,11 @@ class AgentLoop:
|
||||||
if is_user_cancellation_event(event):
|
if is_user_cancellation_event(event):
|
||||||
user_cancelled = True
|
user_cancelled = True
|
||||||
yield event
|
yield event
|
||||||
|
await self._flush_new_messages()
|
||||||
|
|
||||||
last_message = self.messages[-1]
|
last_message = self.messages[-1]
|
||||||
should_break_loop = last_message.role != Role.tool
|
should_break_loop = last_message.role != Role.tool
|
||||||
|
|
||||||
self._flush_new_messages()
|
|
||||||
|
|
||||||
if user_cancelled:
|
if user_cancelled:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -327,14 +344,7 @@ class AgentLoop:
|
||||||
return
|
return
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._flush_new_messages()
|
await self._flush_new_messages()
|
||||||
await self.session_logger.save_interaction(
|
|
||||||
self.messages,
|
|
||||||
self.stats,
|
|
||||||
self._base_config,
|
|
||||||
self.tool_manager,
|
|
||||||
self.agent_profile,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
|
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
|
||||||
if self.enable_streaming:
|
if self.enable_streaming:
|
||||||
|
|
@ -593,6 +603,9 @@ class AgentLoop:
|
||||||
return LLMChunk(message=processed_message, usage=result.usage)
|
return LLMChunk(message=processed_message, usage=result.usage)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if _should_raise_rate_limit_error(e):
|
||||||
|
raise RateLimitError(provider.name, active_model.name) from e
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"API error from {provider.name} (model: {active_model.name}): {e}"
|
f"API error from {provider.name} (model: {active_model.name}): {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
@ -642,6 +655,9 @@ class AgentLoop:
|
||||||
self.messages.append(chunk_agg.message)
|
self.messages.append(chunk_agg.message)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if _should_raise_rate_limit_error(e):
|
||||||
|
raise RateLimitError(provider.name, active_model.name) from e
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"API error from {provider.name} (model: {active_model.name}): {e}"
|
f"API error from {provider.name} (model: {active_model.name}): {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import MutableMapping
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -19,18 +20,28 @@ from pydantic_settings import (
|
||||||
)
|
)
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
|
from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPTS_DIR
|
||||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
from vibe.core.paths.global_paths import (
|
||||||
|
GLOBAL_ENV_FILE,
|
||||||
|
GLOBAL_PROMPTS_DIR,
|
||||||
|
SESSION_LOG_DIR,
|
||||||
|
)
|
||||||
from vibe.core.prompts import SystemPrompt
|
from vibe.core.prompts import SystemPrompt
|
||||||
from vibe.core.tools.base import BaseToolConfig
|
from vibe.core.tools.base import BaseToolConfig
|
||||||
|
|
||||||
|
|
||||||
def load_api_keys_from_env() -> None:
|
def load_dotenv_values(
|
||||||
if GLOBAL_ENV_FILE.path.is_file():
|
env_path: Path = GLOBAL_ENV_FILE.path,
|
||||||
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
|
environ: MutableMapping[str, str] = os.environ,
|
||||||
for key, value in env_vars.items():
|
) -> None:
|
||||||
if value:
|
if not env_path.is_file():
|
||||||
os.environ.setdefault(key, value)
|
return
|
||||||
|
|
||||||
|
env_vars = dotenv_values(env_path)
|
||||||
|
for key, value in env_vars.items():
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
environ.update({key: value})
|
||||||
|
|
||||||
|
|
||||||
class MissingAPIKeyError(RuntimeError):
|
class MissingAPIKeyError(RuntimeError):
|
||||||
|
|
@ -43,11 +54,17 @@ class MissingAPIKeyError(RuntimeError):
|
||||||
|
|
||||||
|
|
||||||
class MissingPromptFileError(RuntimeError):
|
class MissingPromptFileError(RuntimeError):
|
||||||
def __init__(self, system_prompt_id: str, prompt_dir: str) -> None:
|
def __init__(
|
||||||
|
self, system_prompt_id: str, prompt_dir: str, global_prompt_dir: str
|
||||||
|
) -> None:
|
||||||
|
extra_global_prompt_dir = (
|
||||||
|
f" or {global_prompt_dir}" if global_prompt_dir != prompt_dir else ""
|
||||||
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"Invalid system_prompt_id value: '{system_prompt_id}'. "
|
f"Invalid system_prompt_id value: '{system_prompt_id}'. "
|
||||||
f"Must be one of the available prompts ({', '.join(f'{p.name.lower()}' for p in SystemPrompt)}), "
|
f"Must be one of the available prompts ({', '.join(f'{p.name.lower()}' for p in SystemPrompt)}), "
|
||||||
f"or correspond to a .md file in {prompt_dir}"
|
f"or correspond to a .md file in {prompt_dir}{extra_global_prompt_dir}"
|
||||||
)
|
)
|
||||||
self.system_prompt_id = system_prompt_id
|
self.system_prompt_id = system_prompt_id
|
||||||
self.prompt_dir = prompt_dir
|
self.prompt_dir = prompt_dir
|
||||||
|
|
@ -392,10 +409,16 @@ class VibeConfig(BaseSettings):
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
custom_sp_path = (PROMPT_DIR.path / self.system_prompt_id).with_suffix(".md")
|
for current_prompt_dir in [PROMPTS_DIR.path, GLOBAL_PROMPTS_DIR.path]:
|
||||||
if not custom_sp_path.is_file():
|
custom_sp_path = (current_prompt_dir / self.system_prompt_id).with_suffix(
|
||||||
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR.path))
|
".md"
|
||||||
return custom_sp_path.read_text()
|
)
|
||||||
|
if custom_sp_path.is_file():
|
||||||
|
return custom_sp_path.read_text()
|
||||||
|
|
||||||
|
raise MissingPromptFileError(
|
||||||
|
self.system_prompt_id, str(PROMPTS_DIR.path), str(GLOBAL_PROMPTS_DIR.path)
|
||||||
|
)
|
||||||
|
|
||||||
def get_active_model(self) -> ModelConfig:
|
def get_active_model(self) -> ModelConfig:
|
||||||
for model in self.models:
|
for model in self.models:
|
||||||
|
|
|
||||||
|
|
@ -62,5 +62,5 @@ def unlock_config_paths() -> None:
|
||||||
|
|
||||||
CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file"))
|
CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file"))
|
||||||
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
|
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
|
||||||
PROMPT_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir"))
|
PROMPTS_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir"))
|
||||||
HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file"))
|
HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file"))
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
|
||||||
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
|
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
|
||||||
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
|
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
|
||||||
GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
|
GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
|
||||||
|
GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
|
||||||
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
|
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
|
||||||
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
||||||
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
||||||
|
|
|
||||||
|
|
@ -22,40 +22,48 @@ class SessionLoader:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, encoding="utf-8", errors="ignore") as f:
|
with metadata_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
if not isinstance(metadata, dict):
|
if not isinstance(metadata, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with open(messages_path, encoding="utf-8", errors="ignore") as f:
|
with messages_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||||
lines = f.readlines()
|
has_messages = False
|
||||||
if not lines:
|
for line in f:
|
||||||
return False
|
has_messages = True
|
||||||
messages = [json.loads(line) for line in lines]
|
message = json.loads(line)
|
||||||
if not isinstance(messages, list) or not all(
|
if not isinstance(message, dict):
|
||||||
isinstance(msg, dict) for msg in messages
|
return False
|
||||||
):
|
if not has_messages:
|
||||||
return False
|
return False
|
||||||
except json.JSONDecodeError:
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def latest_session(session_dirs: list[Path]) -> Path | None:
|
def latest_session(session_dirs: list[Path]) -> Path | None:
|
||||||
latest_dir = None
|
sessions_with_mtime: list[tuple[Path, float]] = []
|
||||||
latest_mtime = 0
|
|
||||||
for session in session_dirs:
|
for session in session_dirs:
|
||||||
if not SessionLoader._is_valid_session(session):
|
messages_path = session / MESSAGES_FILENAME
|
||||||
|
if not messages_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = messages_path.stat().st_mtime
|
||||||
|
sessions_with_mtime.append((session, mtime))
|
||||||
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
messages_path = session / MESSAGES_FILENAME
|
if not sessions_with_mtime:
|
||||||
mtime = messages_path.stat().st_mtime
|
return None
|
||||||
if mtime > latest_mtime:
|
|
||||||
latest_mtime = mtime
|
|
||||||
latest_dir = session
|
|
||||||
|
|
||||||
return latest_dir
|
sessions_with_mtime.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
for session, _mtime in sessions_with_mtime:
|
||||||
|
if SessionLoader._is_valid_session(session):
|
||||||
|
return session
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_latest_session(config: SessionLoggingConfig) -> Path | None:
|
def find_latest_session(config: SessionLoggingConfig) -> Path | None:
|
||||||
|
|
@ -72,15 +80,32 @@ class SessionLoader:
|
||||||
def find_session_by_id(
|
def find_session_by_id(
|
||||||
session_id: str, config: SessionLoggingConfig
|
session_id: str, config: SessionLoggingConfig
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
save_dir = Path(config.save_dir)
|
matches = SessionLoader._find_session_dirs_by_short_id(session_id, config)
|
||||||
if not save_dir.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
short_id = session_id[:8]
|
|
||||||
matches = list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
|
|
||||||
|
|
||||||
return SessionLoader.latest_session(matches)
|
return SessionLoader.latest_session(matches)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def does_session_exist(
|
||||||
|
session_id: str, config: SessionLoggingConfig
|
||||||
|
) -> Path | None:
|
||||||
|
for session_dir in SessionLoader._find_session_dirs_by_short_id(
|
||||||
|
session_id, config
|
||||||
|
):
|
||||||
|
if (session_dir / MESSAGES_FILENAME).is_file():
|
||||||
|
return session_dir
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_session_dirs_by_short_id(
|
||||||
|
session_id: str, config: SessionLoggingConfig
|
||||||
|
) -> list[Path]:
|
||||||
|
save_dir = Path(config.save_dir)
|
||||||
|
if not save_dir.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
short_id = session_id[:8]
|
||||||
|
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
|
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
|
||||||
# Load session messages from MESSAGES_FILENAME
|
# Load session messages from MESSAGES_FILENAME
|
||||||
|
|
@ -94,7 +119,7 @@ class SessionLoader:
|
||||||
f"Error reading session messages at {filepath}: {e}"
|
f"Error reading session messages at {filepath}: {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if not len(content):
|
if not content:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Session messages file is empty (may have been corrupted by interruption): "
|
f"Session messages file is empty (may have been corrupted by interruption): "
|
||||||
f"{filepath}"
|
f"{filepath}"
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ class SessionLogger:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def persist_metadata(metadata: Any, session_dir: Path) -> None:
|
async def persist_metadata(metadata: Any, session_dir: Path) -> None:
|
||||||
temp_metadata_filepath = None
|
temp_metadata_filepath = None
|
||||||
metadata_filepath = session_dir / "meta.json"
|
metadata_filepath = session_dir / METADATA_FILENAME
|
||||||
try:
|
try:
|
||||||
async with NamedTemporaryFile(
|
async with NamedTemporaryFile(
|
||||||
mode="w",
|
mode="w",
|
||||||
|
|
@ -201,12 +201,12 @@ class SessionLogger:
|
||||||
base_config: VibeConfig,
|
base_config: VibeConfig,
|
||||||
tool_manager: ToolManager,
|
tool_manager: ToolManager,
|
||||||
agent_profile: AgentProfile,
|
agent_profile: AgentProfile,
|
||||||
) -> str | None:
|
) -> None:
|
||||||
if not self.enabled or self.session_dir is None:
|
if not self.enabled or self.session_dir is None:
|
||||||
return None
|
return
|
||||||
|
|
||||||
if self.session_metadata is None:
|
if self.session_metadata is None:
|
||||||
return None
|
return
|
||||||
|
|
||||||
# If the session directory does not exist, create it
|
# If the session directory does not exist, create it
|
||||||
try:
|
try:
|
||||||
|
|
@ -232,14 +232,14 @@ class SessionLogger:
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
non_system_messages = [m for m in messages if m.role != Role.system]
|
||||||
# Append new messages
|
# Append new messages
|
||||||
new_messages = messages[old_total_messages:]
|
new_messages = non_system_messages[old_total_messages:]
|
||||||
|
|
||||||
messages_data = [
|
if len(new_messages) == 0:
|
||||||
m.model_dump(exclude_none=True)
|
return
|
||||||
for m in new_messages
|
|
||||||
if m.role != Role.system
|
messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
|
||||||
]
|
|
||||||
await SessionLogger.persist_messages(messages_data, self.session_dir)
|
await SessionLogger.persist_messages(messages_data, self.session_dir)
|
||||||
|
|
||||||
# If message update succeeded, write metadata
|
# If message update succeeded, write metadata
|
||||||
|
|
@ -256,12 +256,12 @@ class SessionLogger:
|
||||||
]
|
]
|
||||||
|
|
||||||
title = self._get_title(messages)
|
title = self._get_title(messages)
|
||||||
if messages[0].role == Role.system:
|
system_prompt = (
|
||||||
system_prompt = messages[0].model_dump()
|
messages[0].model_dump()
|
||||||
total_messages = len(messages[1:])
|
if len(messages) > 0 and messages[0].role == Role.system
|
||||||
else:
|
else None
|
||||||
system_prompt = None
|
)
|
||||||
total_messages = len(messages)
|
total_messages = len(non_system_messages)
|
||||||
|
|
||||||
metadata_dump = {
|
metadata_dump = {
|
||||||
**self.session_metadata.model_dump(),
|
**self.session_metadata.model_dump(),
|
||||||
|
|
@ -286,8 +286,6 @@ class SessionLogger:
|
||||||
finally:
|
finally:
|
||||||
self.cleanup_tmp_files()
|
self.cleanup_tmp_files()
|
||||||
|
|
||||||
return str(self.session_dir)
|
|
||||||
|
|
||||||
def reset_session(self, session_id: str) -> None:
|
def reset_session(self, session_id: str) -> None:
|
||||||
"""Clear existing session info and setup a new session"""
|
"""Clear existing session info and setup a new session"""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
|
|
|
||||||
|
|
@ -382,3 +382,12 @@ type SyncApprovalCallback = Callable[
|
||||||
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
||||||
|
|
||||||
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
|
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(Exception):
|
||||||
|
def __init__(self, provider: str, model: str) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.model = model
|
||||||
|
super().__init__(
|
||||||
|
"Rate limits exceeded. Please wait a moment before trying again."
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# What's New
|
||||||
|
|
||||||
|
- **Config:** Variables defined in the `.env` file in your global `.vibe` folder now override environment variables
|
||||||
|
- **Sessions:** Fixed message duplication in persisted sessions
|
||||||
|
- **Resume:** Only shown when a session is available to resume
|
||||||
Loading…
Add table
Add a link
Reference in a new issue