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
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
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.agents.models import BuiltinAgentName
|
||||
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.types import (
|
||||
ApprovalResponse,
|
||||
|
|
@ -176,7 +176,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
||||
**kwargs: Any,
|
||||
) -> NewSessionResponse:
|
||||
load_api_keys_from_env()
|
||||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
|
||||
from acp.schema import (
|
||||
EnvVariable,
|
||||
TerminalToolCallContent,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
|
|
@ -40,14 +38,11 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
|
||||
timeout = args.timeout or self.config.default_timeout
|
||||
max_bytes = self.config.max_output_bytes
|
||||
env, command, cmd_args = self._parse_command(args.command)
|
||||
|
||||
try:
|
||||
terminal_handle = await client.create_terminal(
|
||||
session_id=session_id,
|
||||
command=command,
|
||||
args=cmd_args,
|
||||
env=env,
|
||||
command=args.command,
|
||||
cwd=str(Path.cwd()),
|
||||
output_byte_limit=max_bytes,
|
||||
)
|
||||
|
|
@ -84,25 +79,6 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
except Exception as e:
|
||||
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
|
||||
def get_summary(cls, args: BashArgs) -> str:
|
||||
summary = f"{args.command}"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from vibe.core.config import (
|
|||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
VibeConfig,
|
||||
load_api_keys_from_env,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
|
|
@ -122,7 +122,7 @@ def _load_messages_from_previous_session(
|
|||
|
||||
|
||||
def run_cli(args: argparse.Namespace) -> None:
|
||||
load_api_keys_from_env()
|
||||
load_dotenv_values()
|
||||
bootstrap_config_files()
|
||||
|
||||
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(
|
||||
api_key: str | None, gateway: WhoAmIGateway
|
||||
) -> PlanOfferAction:
|
||||
) -> tuple[PlanOfferAction, PlanType]:
|
||||
if not api_key:
|
||||
return PlanOfferAction.UPGRADE
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
try:
|
||||
response = await gateway.whoami(api_key)
|
||||
except WhoAmIGatewayUnauthorized:
|
||||
return PlanOfferAction.UPGRADE
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
except WhoAmIGatewayError:
|
||||
logger.warning("Failed to fetch plan status.", exc_info=True)
|
||||
return PlanOfferAction.NONE
|
||||
return _action_from_response(response)
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
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:
|
||||
case WhoAmIResponse(is_pro_plan=True):
|
||||
return PlanOfferAction.NONE
|
||||
return PlanOfferAction.NONE, PlanType.PRO
|
||||
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):
|
||||
return PlanOfferAction.UPGRADE
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
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 (
|
||||
ACTION_TO_URL,
|
||||
PlanOfferAction,
|
||||
PlanType,
|
||||
decide_plan_offer,
|
||||
)
|
||||
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.config import VibeConfig
|
||||
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.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
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 (
|
||||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
|
|
@ -594,8 +602,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
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(
|
||||
ErrorMessage(str(e), collapsed=self._tools_collapsed)
|
||||
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
finally:
|
||||
self._agent_running = False
|
||||
|
|
@ -773,6 +789,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return None
|
||||
if not self.agent_loop.session_logger.session_id:
|
||||
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]
|
||||
|
||||
async def _exit_app(self) -> None:
|
||||
|
|
@ -1023,7 +1045,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
async def _maybe_show_plan_offer(self) -> None:
|
||||
if self._plan_offer_shown:
|
||||
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:
|
||||
return
|
||||
url = ACTION_TO_URL[action]
|
||||
|
|
@ -1035,12 +1058,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(PlanOfferMessage(text))
|
||||
self._plan_offer_shown = True
|
||||
|
||||
async def _resolve_plan_offer_action(self) -> PlanOfferAction:
|
||||
async def _resolve_plan_offer_action(self) -> tuple[PlanOfferAction, PlanType]:
|
||||
try:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
except ValueError:
|
||||
return PlanOfferAction.NONE
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
|
||||
api_key_env = provider.api_key_env_var
|
||||
api_key = getenv(api_key_env) if api_key_env else None
|
||||
|
|
@ -1051,7 +1074,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
logger.warning(
|
||||
"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:
|
||||
if self._current_streaming_reasoning is not None:
|
||||
|
|
|
|||
|
|
@ -949,21 +949,24 @@ WelcomeBanner {
|
|||
.whats-new-message {
|
||||
background: $surface;
|
||||
border-left: solid $warning;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.plan-offer-message {
|
||||
background: $surface;
|
||||
border-left: solid $warning;
|
||||
background: transparent;
|
||||
border: none;
|
||||
height: auto;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
padding: 1 2;
|
||||
margin-top: 1;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
color: $foreground-muted;
|
||||
text-style: dim;
|
||||
|
||||
Markdown > *:last-child {
|
||||
margin-bottom: 0;
|
||||
link-style: bold underline;
|
||||
link-style: none;
|
||||
link-color: $foreground-muted;
|
||||
link-background-hover: transparent;
|
||||
link-style-hover: underline;
|
||||
link-color-hover: $warning;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
from threading import Thread
|
||||
import time
|
||||
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.config import VibeConfig
|
||||
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.types import BackendLike
|
||||
from vibe.core.middleware import (
|
||||
|
|
@ -54,6 +56,7 @@ from vibe.core.types import (
|
|||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
RateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
SyncApprovalCallback,
|
||||
|
|
@ -95,6 +98,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
|
|||
"""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:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -193,7 +200,18 @@ class AgentLoop:
|
|||
def add_message(self, message: LLMMessage) -> None:
|
||||
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:
|
||||
return
|
||||
|
||||
|
|
@ -308,12 +326,11 @@ class AgentLoop:
|
|||
if is_user_cancellation_event(event):
|
||||
user_cancelled = True
|
||||
yield event
|
||||
await self._flush_new_messages()
|
||||
|
||||
last_message = self.messages[-1]
|
||||
should_break_loop = last_message.role != Role.tool
|
||||
|
||||
self._flush_new_messages()
|
||||
|
||||
if user_cancelled:
|
||||
return
|
||||
|
||||
|
|
@ -327,14 +344,7 @@ class AgentLoop:
|
|||
return
|
||||
|
||||
finally:
|
||||
self._flush_new_messages()
|
||||
await self.session_logger.save_interaction(
|
||||
self.messages,
|
||||
self.stats,
|
||||
self._base_config,
|
||||
self.tool_manager,
|
||||
self.agent_profile,
|
||||
)
|
||||
await self._flush_new_messages()
|
||||
|
||||
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
|
||||
if self.enable_streaming:
|
||||
|
|
@ -593,6 +603,9 @@ class AgentLoop:
|
|||
return LLMChunk(message=processed_message, usage=result.usage)
|
||||
|
||||
except Exception as e:
|
||||
if _should_raise_rate_limit_error(e):
|
||||
raise RateLimitError(provider.name, active_model.name) from e
|
||||
|
||||
raise RuntimeError(
|
||||
f"API error from {provider.name} (model: {active_model.name}): {e}"
|
||||
) from e
|
||||
|
|
@ -642,6 +655,9 @@ class AgentLoop:
|
|||
self.messages.append(chunk_agg.message)
|
||||
|
||||
except Exception as e:
|
||||
if _should_raise_rate_limit_error(e):
|
||||
raise RateLimitError(provider.name, active_model.name) from e
|
||||
|
||||
raise RuntimeError(
|
||||
f"API error from {provider.name} (model: {active_model.name}): {e}"
|
||||
) from e
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from enum import StrEnum, auto
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
|
@ -19,18 +20,28 @@ from pydantic_settings import (
|
|||
)
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||
from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPTS_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.tools.base import BaseToolConfig
|
||||
|
||||
|
||||
def load_api_keys_from_env() -> None:
|
||||
if GLOBAL_ENV_FILE.path.is_file():
|
||||
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
|
||||
for key, value in env_vars.items():
|
||||
if value:
|
||||
os.environ.setdefault(key, value)
|
||||
def load_dotenv_values(
|
||||
env_path: Path = GLOBAL_ENV_FILE.path,
|
||||
environ: MutableMapping[str, str] = os.environ,
|
||||
) -> None:
|
||||
if not env_path.is_file():
|
||||
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):
|
||||
|
|
@ -43,11 +54,17 @@ class MissingAPIKeyError(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__(
|
||||
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"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.prompt_dir = prompt_dir
|
||||
|
|
@ -392,10 +409,16 @@ class VibeConfig(BaseSettings):
|
|||
except KeyError:
|
||||
pass
|
||||
|
||||
custom_sp_path = (PROMPT_DIR.path / self.system_prompt_id).with_suffix(".md")
|
||||
if not custom_sp_path.is_file():
|
||||
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR.path))
|
||||
return custom_sp_path.read_text()
|
||||
for current_prompt_dir in [PROMPTS_DIR.path, GLOBAL_PROMPTS_DIR.path]:
|
||||
custom_sp_path = (current_prompt_dir / self.system_prompt_id).with_suffix(
|
||||
".md"
|
||||
)
|
||||
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:
|
||||
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_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"))
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
|
|||
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
|
||||
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
|
||||
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")
|
||||
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
||||
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
||||
|
|
|
|||
|
|
@ -22,40 +22,48 @@ class SessionLoader:
|
|||
return False
|
||||
|
||||
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)
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
|
||||
with open(messages_path, encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()
|
||||
if not lines:
|
||||
return False
|
||||
messages = [json.loads(line) for line in lines]
|
||||
if not isinstance(messages, list) or not all(
|
||||
isinstance(msg, dict) for msg in messages
|
||||
):
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
with messages_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
has_messages = False
|
||||
for line in f:
|
||||
has_messages = True
|
||||
message = json.loads(line)
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if not has_messages:
|
||||
return False
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def latest_session(session_dirs: list[Path]) -> Path | None:
|
||||
latest_dir = None
|
||||
latest_mtime = 0
|
||||
sessions_with_mtime: list[tuple[Path, float]] = []
|
||||
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
|
||||
|
||||
messages_path = session / MESSAGES_FILENAME
|
||||
mtime = messages_path.stat().st_mtime
|
||||
if mtime > latest_mtime:
|
||||
latest_mtime = mtime
|
||||
latest_dir = session
|
||||
if not sessions_with_mtime:
|
||||
return None
|
||||
|
||||
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
|
||||
def find_latest_session(config: SessionLoggingConfig) -> Path | None:
|
||||
|
|
@ -72,15 +80,32 @@ class SessionLoader:
|
|||
def find_session_by_id(
|
||||
session_id: str, config: SessionLoggingConfig
|
||||
) -> Path | None:
|
||||
save_dir = Path(config.save_dir)
|
||||
if not save_dir.exists():
|
||||
return None
|
||||
|
||||
short_id = session_id[:8]
|
||||
matches = list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
|
||||
matches = SessionLoader._find_session_dirs_by_short_id(session_id, config)
|
||||
|
||||
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
|
||||
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
|
||||
# Load session messages from MESSAGES_FILENAME
|
||||
|
|
@ -94,7 +119,7 @@ class SessionLoader:
|
|||
f"Error reading session messages at {filepath}: {e}"
|
||||
) from e
|
||||
|
||||
if not len(content):
|
||||
if not content:
|
||||
raise ValueError(
|
||||
f"Session messages file is empty (may have been corrupted by interruption): "
|
||||
f"{filepath}"
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ class SessionLogger:
|
|||
@staticmethod
|
||||
async def persist_metadata(metadata: Any, session_dir: Path) -> None:
|
||||
temp_metadata_filepath = None
|
||||
metadata_filepath = session_dir / "meta.json"
|
||||
metadata_filepath = session_dir / METADATA_FILENAME
|
||||
try:
|
||||
async with NamedTemporaryFile(
|
||||
mode="w",
|
||||
|
|
@ -201,12 +201,12 @@ class SessionLogger:
|
|||
base_config: VibeConfig,
|
||||
tool_manager: ToolManager,
|
||||
agent_profile: AgentProfile,
|
||||
) -> str | None:
|
||||
) -> None:
|
||||
if not self.enabled or self.session_dir is None:
|
||||
return None
|
||||
return
|
||||
|
||||
if self.session_metadata is None:
|
||||
return None
|
||||
return
|
||||
|
||||
# If the session directory does not exist, create it
|
||||
try:
|
||||
|
|
@ -232,14 +232,14 @@ class SessionLogger:
|
|||
) from e
|
||||
|
||||
try:
|
||||
non_system_messages = [m for m in messages if m.role != Role.system]
|
||||
# Append new messages
|
||||
new_messages = messages[old_total_messages:]
|
||||
new_messages = non_system_messages[old_total_messages:]
|
||||
|
||||
messages_data = [
|
||||
m.model_dump(exclude_none=True)
|
||||
for m in new_messages
|
||||
if m.role != Role.system
|
||||
]
|
||||
if len(new_messages) == 0:
|
||||
return
|
||||
|
||||
messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
|
||||
await SessionLogger.persist_messages(messages_data, self.session_dir)
|
||||
|
||||
# If message update succeeded, write metadata
|
||||
|
|
@ -256,12 +256,12 @@ class SessionLogger:
|
|||
]
|
||||
|
||||
title = self._get_title(messages)
|
||||
if messages[0].role == Role.system:
|
||||
system_prompt = messages[0].model_dump()
|
||||
total_messages = len(messages[1:])
|
||||
else:
|
||||
system_prompt = None
|
||||
total_messages = len(messages)
|
||||
system_prompt = (
|
||||
messages[0].model_dump()
|
||||
if len(messages) > 0 and messages[0].role == Role.system
|
||||
else None
|
||||
)
|
||||
total_messages = len(non_system_messages)
|
||||
|
||||
metadata_dump = {
|
||||
**self.session_metadata.model_dump(),
|
||||
|
|
@ -286,8 +286,6 @@ class SessionLogger:
|
|||
finally:
|
||||
self.cleanup_tmp_files()
|
||||
|
||||
return str(self.session_dir)
|
||||
|
||||
def reset_session(self, session_id: str) -> None:
|
||||
"""Clear existing session info and setup a new session"""
|
||||
if not self.enabled:
|
||||
|
|
|
|||
|
|
@ -382,3 +382,12 @@ type SyncApprovalCallback = Callable[
|
|||
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
||||
|
||||
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