v2.9.5 (#676)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
4972dd5694
commit
b23f49e5f4
74 changed files with 2342 additions and 240 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.9.4"
|
||||
__version__ = "2.9.5"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import inspect
|
|||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
from typing import Any, cast, override
|
||||
from uuid import uuid4
|
||||
|
|
@ -994,11 +995,22 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session = self._get_session(session_id)
|
||||
self.sessions.pop(session_id, None)
|
||||
|
||||
session.agent_loop.emit_session_closed_telemetry()
|
||||
await session.close()
|
||||
await self._close_agent_loop(session.agent_loop)
|
||||
|
||||
return CloseSessionResponse()
|
||||
|
||||
async def emit_session_closed_for_active_sessions(self) -> None:
|
||||
agent_loops = [session.agent_loop for session in self.sessions.values()]
|
||||
for agent_loop in agent_loops:
|
||||
agent_loop.telemetry_client._client = None
|
||||
agent_loop.emit_session_closed_telemetry()
|
||||
await asyncio.gather(
|
||||
*(agent_loop.telemetry_client.aclose() for agent_loop in agent_loops),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _close_agent_loop(self, agent_loop: AgentLoop) -> None:
|
||||
deferred_init_thread = agent_loop._deferred_init_thread
|
||||
if deferred_init_thread is not None and deferred_init_thread.is_alive():
|
||||
|
|
@ -1248,6 +1260,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
tool_call_id = str(uuid4())
|
||||
old_tokens = session.agent_loop.stats.context_tokens
|
||||
old_session_id = session.agent_loop.session_id
|
||||
parts = text_prompt.strip().split(None, 1)
|
||||
cmd_args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
|
|
@ -1268,6 +1281,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
old_context_tokens=old_tokens or 0,
|
||||
new_context_tokens=new_tokens or 0,
|
||||
summary_length=0,
|
||||
old_session_id=old_session_id,
|
||||
new_session_id=session.agent_loop.session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
await self.client.session_update(
|
||||
|
|
@ -1419,19 +1434,48 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id)
|
||||
|
||||
|
||||
SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
def run_acp_server() -> None:
|
||||
agent = VibeAcpAgentLoop()
|
||||
install_sigterm_flush = TelemetryClient(config_getter=VibeConfig.load).is_active()
|
||||
received_sigterm = False
|
||||
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
|
||||
|
||||
def _handle_sigterm(_signum: int, _frame: Any) -> None:
|
||||
nonlocal received_sigterm
|
||||
received_sigterm = True
|
||||
raise KeyboardInterrupt
|
||||
|
||||
if install_sigterm_flush:
|
||||
signal.signal(signal.SIGTERM, _handle_sigterm)
|
||||
try:
|
||||
asyncio.run(
|
||||
run_agent(
|
||||
agent=VibeAcpAgentLoop(),
|
||||
agent=agent,
|
||||
use_unstable_protocol=True,
|
||||
observers=[acp_message_observer],
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
if received_sigterm:
|
||||
signal.signal(signal.SIGTERM, previous_sigterm_handler)
|
||||
try:
|
||||
asyncio.run(
|
||||
asyncio.wait_for(
|
||||
agent.emit_session_closed_for_active_sessions(),
|
||||
timeout=SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
except (TimeoutError, Exception):
|
||||
pass
|
||||
# This is expected when the server is terminated
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log any unexpected errors
|
||||
print(f"ACP Agent Server error: {e}", file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
if install_sigterm_flush:
|
||||
signal.signal(signal.SIGTERM, previous_sigterm_handler)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ def bootstrap_config_files() -> None:
|
|||
raise
|
||||
|
||||
|
||||
# When DEBUG_MODE=true, attaches debugpy on localhost:5678.
|
||||
def handle_debug_mode() -> None:
|
||||
if os.environ.get("DEBUG_MODE") != "true":
|
||||
return
|
||||
|
|
|
|||
|
|
@ -231,7 +231,10 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
|
|||
type="text",
|
||||
text=(
|
||||
compact_reduction_display(
|
||||
event.old_context_tokens, event.new_context_tokens
|
||||
event.old_context_tokens,
|
||||
event.new_context_tokens,
|
||||
old_session_id=event.old_session_id,
|
||||
new_session_id=event.new_session_id,
|
||||
)
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class SlashCommandController:
|
|||
return
|
||||
|
||||
suggestions = self._completer.get_completion_items(text, cursor_index)
|
||||
if suggestions and all(alias != text for alias, _ in suggestions):
|
||||
if suggestions:
|
||||
self._suggestions = suggestions
|
||||
self._selected_index = 0
|
||||
self._view.render_completion_suggestions(
|
||||
|
|
|
|||
|
|
@ -42,10 +42,10 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
|
|||
)
|
||||
|
||||
|
||||
def get_initial_agent_name(args: argparse.Namespace) -> str:
|
||||
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
|
||||
def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str:
|
||||
if args.prompt is not None and args.agent is None:
|
||||
return BuiltinAgentName.AUTO_APPROVE
|
||||
return args.agent
|
||||
return args.agent or config.default_agent
|
||||
|
||||
|
||||
def get_prompt_from_stdin() -> str | None:
|
||||
|
|
@ -196,9 +196,9 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
sys.exit(0)
|
||||
|
||||
try:
|
||||
initial_agent_name = get_initial_agent_name(args)
|
||||
is_interactive = args.prompt is None
|
||||
config = load_config_or_exit(interactive=is_interactive)
|
||||
initial_agent_name = get_initial_agent_name(args, config)
|
||||
hook_config_result = load_hooks_from_fs(config)
|
||||
setup_tracing(config)
|
||||
|
||||
|
|
@ -243,18 +243,22 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
except TeleportError as e:
|
||||
print(f"Teleport error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
except (RuntimeError, ValueError) as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
agent_loop = AgentLoop(
|
||||
config,
|
||||
agent_name=initial_agent_name,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=_build_cli_entrypoint_metadata(),
|
||||
defer_heavy_init=True,
|
||||
hook_config_result=hook_config_result,
|
||||
)
|
||||
try:
|
||||
agent_loop = AgentLoop(
|
||||
config,
|
||||
agent_name=initial_agent_name,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=_build_cli_entrypoint_metadata(),
|
||||
defer_heavy_init=True,
|
||||
hook_config_result=hook_config_result,
|
||||
)
|
||||
except ValueError as e:
|
||||
rprint(f"[red]Error:[/] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if loaded_session:
|
||||
_resume_previous_session(agent_loop, *loaded_session)
|
||||
|
|
|
|||
|
|
@ -161,6 +161,14 @@ class CommandRegistry:
|
|||
description="Rewind to a previous message",
|
||||
handler="_start_rewind_mode",
|
||||
),
|
||||
"loop": Command(
|
||||
aliases=frozenset(["/loop"]),
|
||||
description=(
|
||||
"Schedule a recurring prompt. "
|
||||
"Use `/loop <interval> <prompt>`, `/loop list`, or `/loop cancel <id|all>`"
|
||||
),
|
||||
handler="_loop_command",
|
||||
),
|
||||
"data-retention": Command(
|
||||
aliases=frozenset(["/data-retention"]),
|
||||
description="Show data retention information",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import sys
|
|||
from rich import print as rprint
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config.harness_files import init_harness_files_manager
|
||||
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
|
|
@ -18,7 +17,18 @@ from vibe.setup.trusted_folders.trust_folder_dialog import (
|
|||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the Mistral Vibe interactive CLI",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Environment variables:\n"
|
||||
" VIBE_HOME Override the Vibe home directory (default: ~/.vibe)\n"
|
||||
" LOG_LEVEL Logging level: DEBUG, INFO, WARNING (default), ERROR, CRITICAL.\n"
|
||||
" Logs are written to $VIBE_HOME/logs/vibe.log.\n"
|
||||
" LOG_MAX_BYTES Max size of vibe.log before rotation (default: 10485760).\n"
|
||||
" VIBE_* Override any config field (e.g. VIBE_ACTIVE_MODEL=local)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
|
||||
)
|
||||
|
|
@ -72,9 +82,12 @@ def parse_arguments() -> argparse.Namespace:
|
|||
parser.add_argument(
|
||||
"--agent",
|
||||
metavar="NAME",
|
||||
default=BuiltinAgentName.DEFAULT,
|
||||
default=None,
|
||||
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
|
||||
"or custom from ~/.vibe/agents/NAME.toml)",
|
||||
"or custom from ~/.vibe/agents/NAME.toml). In interactive mode, "
|
||||
"defaults to the 'default_agent' config setting. In programmatic "
|
||||
"mode (-p/--prompt), defaults to auto-approve and 'default_agent' "
|
||||
"is ignored.",
|
||||
)
|
||||
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
|
||||
parser.add_argument(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
|
@ -22,12 +24,10 @@ class HistoryManager:
|
|||
try:
|
||||
text = read_safe(self.history_file).text
|
||||
except OSError:
|
||||
self._entries = []
|
||||
return
|
||||
|
||||
entries = []
|
||||
for raw_line in text.splitlines():
|
||||
raw_line = raw_line.rstrip("\n\r")
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
|
|
@ -36,13 +36,24 @@ class HistoryManager:
|
|||
entry = raw_line
|
||||
entries.append(entry if isinstance(entry, str) else str(entry))
|
||||
self._entries = entries[-self.max_entries :]
|
||||
self.reset_navigation()
|
||||
|
||||
def _save_history(self) -> None:
|
||||
try:
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.history_file.open("w", encoding="utf-8") as f:
|
||||
for entry in self._entries:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=f".{self.history_file.name}.",
|
||||
suffix=".tmp",
|
||||
dir=self.history_file.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
for entry in self._entries:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
os.replace(tmp_path, self.history_file)
|
||||
except OSError:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
raise
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
|
@ -51,6 +62,8 @@ class HistoryManager:
|
|||
if not text:
|
||||
return
|
||||
|
||||
self._load_history()
|
||||
|
||||
if self._entries and self._entries[-1] == text:
|
||||
return
|
||||
|
||||
|
|
@ -60,7 +73,6 @@ class HistoryManager:
|
|||
self._entries = self._entries[-self.max_entries :]
|
||||
|
||||
self._save_history()
|
||||
self.reset_navigation()
|
||||
|
||||
def get_previous(self, current_input: str) -> str | None:
|
||||
if not self._entries:
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from vibe.cli.textual_ui.notifications import (
|
|||
)
|
||||
from vibe.cli.textual_ui.quit_manager import QuitManager
|
||||
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
|
||||
from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner
|
||||
from vibe.cli.textual_ui.session_exit import print_session_resume_message
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
|
|
@ -140,6 +141,7 @@ from vibe.core.session.resume_sessions import (
|
|||
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportAuthCompleteEvent,
|
||||
TeleportAuthRequiredEvent,
|
||||
|
|
@ -339,7 +341,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.scroll_sensitivity_y = 1.0
|
||||
self.agent_loop = agent_loop
|
||||
self._plan_info: PlanInfo | None = None
|
||||
self._voice_manager: VoiceManagerPort = (
|
||||
|
|
@ -397,7 +398,17 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._rewind_mode = False
|
||||
self._rewind_highlighted_widget: UserMessage | None = None
|
||||
self._fatal_init_error = False
|
||||
self._force_quit_task: asyncio.Task[None] | None = None
|
||||
self.commands = self._build_command_registry()
|
||||
self._loop_runner = ScheduledLoopRunner(
|
||||
self.agent_loop.session_logger,
|
||||
can_fire=lambda: (
|
||||
not self._agent_running and self._current_bottom_app == BottomApp.Input
|
||||
),
|
||||
fire=self._handle_user_message,
|
||||
mount=self._mount_and_scroll,
|
||||
tools_collapsed=lambda: self._tools_collapsed,
|
||||
)
|
||||
|
||||
def _configure_startup_options(self, startup: StartupOptions | None) -> None:
|
||||
opts = startup or StartupOptions()
|
||||
|
|
@ -504,6 +515,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._resolve_plan()
|
||||
await self._show_dangerous_directory_warning()
|
||||
await self._resume_history_from_messages()
|
||||
self._loop_runner.restore_from_session()
|
||||
self._loop_runner.start()
|
||||
await self._check_and_show_whats_new()
|
||||
self._schedule_update_notification()
|
||||
if not self._is_resuming_session:
|
||||
|
|
@ -1407,6 +1420,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if show_message:
|
||||
await self._mount_and_scroll(UserMessage("/teleport"))
|
||||
if not has_history:
|
||||
send_teleport_early_failure_telemetry(
|
||||
self.agent_loop.telemetry_client,
|
||||
stage="no_history",
|
||||
error_class="TeleportNoHistoryError",
|
||||
nb_session_messages=len(self.agent_loop.messages[1:]),
|
||||
)
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"No conversation history to teleport.",
|
||||
|
|
@ -1429,6 +1448,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(teleport_msg)
|
||||
|
||||
if self._remote_manager.is_active:
|
||||
send_teleport_early_failure_telemetry(
|
||||
self.agent_loop.telemetry_client,
|
||||
stage="remote_session",
|
||||
error_class="TeleportRemoteSessionError",
|
||||
nb_session_messages=len(self.agent_loop.messages[1:]),
|
||||
)
|
||||
await loading.remove()
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1818,6 +1843,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
f"Session `{short_session_id(session.session_id)}` not found."
|
||||
)
|
||||
|
||||
self._emit_session_closed_for_active_session()
|
||||
|
||||
loaded_messages, metadata = SessionLoader.load_session(session_path)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.set_custom_border(None)
|
||||
|
|
@ -1846,6 +1873,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self.event_handler:
|
||||
self.event_handler.is_remote = False
|
||||
await self._resume_history_from_messages()
|
||||
self._loop_runner.restore_from_session()
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
f"Resumed session `{short_session_id(session.session_id)}`"
|
||||
|
|
@ -1856,6 +1884,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._remote_manager.attach(
|
||||
session_id=session.session_id, config=self.config
|
||||
)
|
||||
self._emit_session_closed_for_active_session()
|
||||
self.agent_loop.session_id = session.session_id
|
||||
self._refresh_profile_widgets()
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.set_custom_border(None)
|
||||
|
|
@ -2018,6 +2048,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _loop_command(self, cmd_args: str = "", **kwargs: Any) -> None:
|
||||
widget = await self._loop_runner.handle_command(cmd_args)
|
||||
await self._mount_and_scroll(widget)
|
||||
|
||||
async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None:
|
||||
if self._agent_running:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -2041,22 +2075,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
|
||||
old_tokens = self.agent_loop.stats.context_tokens
|
||||
old_session_id = self.agent_loop.session_id
|
||||
compact_msg = CompactMessage()
|
||||
self.event_handler.current_compact = compact_msg
|
||||
await self._mount_and_scroll(compact_msg)
|
||||
|
||||
self._agent_task = asyncio.create_task(
|
||||
self._run_compact(compact_msg, old_tokens, cmd_args.strip())
|
||||
self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
|
||||
)
|
||||
|
||||
async def _run_compact(
|
||||
self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = ""
|
||||
self,
|
||||
compact_msg: CompactMessage,
|
||||
old_tokens: int,
|
||||
old_session_id: str,
|
||||
extra_instructions: str = "",
|
||||
) -> None:
|
||||
self._agent_running = True
|
||||
try:
|
||||
await self.agent_loop.compact(extra_instructions=extra_instructions)
|
||||
new_tokens = self.agent_loop.stats.context_tokens
|
||||
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
|
||||
compact_msg.set_complete(
|
||||
old_tokens=old_tokens,
|
||||
new_tokens=new_tokens,
|
||||
old_session_id=old_session_id,
|
||||
new_session_id=self.agent_loop.session_id,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
compact_msg.set_error("Compaction interrupted")
|
||||
|
|
@ -2085,9 +2129,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return short_session_id(self.agent_loop.session_logger.session_id)
|
||||
|
||||
async def _exit_app(self, **kwargs: Any) -> None:
|
||||
self._emit_session_closed_for_active_session()
|
||||
await self._loop_runner.stop()
|
||||
self._log_reader.shutdown()
|
||||
await self._narrator_manager.close()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
try:
|
||||
await self.agent_loop.telemetry_client.aclose()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to close telemetry client during exit", exc_info=exc)
|
||||
finally:
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
def _make_default_voice_manager(self) -> VoiceManager:
|
||||
try:
|
||||
|
|
@ -2746,7 +2797,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
self._quit_manager.request_confirmation("Ctrl+D")
|
||||
|
||||
def _emit_session_closed_for_active_session(self) -> None:
|
||||
self.agent_loop.emit_session_closed_telemetry()
|
||||
|
||||
def _force_quit(self) -> None:
|
||||
if self._force_quit_task is not None and not self._force_quit_task.done():
|
||||
return
|
||||
self._force_quit_task = asyncio.create_task(self._force_quit_async())
|
||||
|
||||
async def _force_quit_async(self) -> None:
|
||||
self._emit_session_closed_for_active_session()
|
||||
if self._agent_task and not self._agent_task.done():
|
||||
self._agent_task.cancel()
|
||||
if self._bash_task and not self._bash_task.done():
|
||||
|
|
@ -2755,7 +2815,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self._log_reader.shutdown()
|
||||
self._narrator_manager.cancel()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
try:
|
||||
await self.agent_loop.telemetry_client.aclose()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to close telemetry client during force quit", exc_info=exc
|
||||
)
|
||||
finally:
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
def action_scroll_chat_up(self) -> None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -980,7 +980,7 @@ NarratorStatus {
|
|||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 0 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#banner-container {
|
||||
|
|
|
|||
|
|
@ -217,7 +217,10 @@ class EventHandler:
|
|||
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
|
||||
if self.current_compact:
|
||||
self.current_compact.set_complete(
|
||||
old_tokens=event.old_context_tokens, new_tokens=event.new_context_tokens
|
||||
old_tokens=event.old_context_tokens,
|
||||
new_tokens=event.new_context_tokens,
|
||||
old_session_id=event.old_session_id,
|
||||
new_session_id=event.new_session_id,
|
||||
)
|
||||
self.current_compact = None
|
||||
|
||||
|
|
|
|||
99
vibe/cli/textual_ui/scheduled_loop_runner.py
Normal file
99
vibe/cli/textual_ui/scheduled_loop_runner.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import time
|
||||
|
||||
from textual.widget import Widget
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.loop import (
|
||||
USAGE_HINT,
|
||||
LoopErrorResult,
|
||||
LoopListResult,
|
||||
LoopManager,
|
||||
LoopOkResult,
|
||||
ScheduledLoop,
|
||||
format_duration,
|
||||
)
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
|
||||
|
||||
def _format_loop_list(loops: list[ScheduledLoop]) -> str:
|
||||
if not loops:
|
||||
return "No scheduled loops."
|
||||
now = time.time()
|
||||
rows = ["| Prompt | Next in | Every | ID |", "|--------|------|-------|----|"]
|
||||
for loop in loops:
|
||||
remaining = format_duration(max(0, int(loop.next_fire_at - now)), short=True)
|
||||
interval = format_duration(loop.interval_seconds)
|
||||
prompt = loop.prompt.replace("|", "\\|").replace("\n", " ")
|
||||
rows.append(f"| {prompt} | {remaining} | {interval} | `{loop.id}` |")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
class ScheduledLoopRunner:
|
||||
def __init__(
|
||||
self,
|
||||
session_logger: SessionLogger,
|
||||
*,
|
||||
can_fire: Callable[[], bool],
|
||||
fire: Callable[[str], Awaitable[None]],
|
||||
mount: Callable[[Widget], Awaitable[None]],
|
||||
tools_collapsed: Callable[[], bool],
|
||||
) -> None:
|
||||
self._session_logger = session_logger
|
||||
self._manager = LoopManager(session_logger)
|
||||
self._can_fire = can_fire
|
||||
self._fire = fire
|
||||
self._mount = mount
|
||||
self._tools_collapsed = tools_collapsed
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
def restore_from_session(self) -> None:
|
||||
metadata = self._session_logger.session_metadata
|
||||
self._manager.restore(list(metadata.loops) if metadata is not None else [])
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._poll())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def handle_command(self, cmd_args: str) -> Widget:
|
||||
result = await self._manager.handle_command(cmd_args)
|
||||
match result:
|
||||
case LoopListResult(loops=loops):
|
||||
return UserCommandMessage(_format_loop_list(loops))
|
||||
case LoopErrorResult(message=message):
|
||||
return ErrorMessage(
|
||||
f"{message}\n{USAGE_HINT}", collapsed=self._tools_collapsed()
|
||||
)
|
||||
case LoopOkResult(message=message):
|
||||
return UserCommandMessage(message)
|
||||
|
||||
async def _poll(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
sleep_for = min(self._manager.next_due_in(), 1.0)
|
||||
await asyncio.sleep(max(0.05, sleep_for))
|
||||
if not self._can_fire():
|
||||
continue
|
||||
due = await self._manager.pop_due()
|
||||
if due is None:
|
||||
continue
|
||||
await self._fire(due.prompt)
|
||||
await self._mount(UserCommandMessage(f"Loop `{due.id}` fired"))
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Error polling scheduled loops", exc_info=e)
|
||||
|
|
@ -17,6 +17,8 @@ class CompactMessage(StatusMessage):
|
|||
self.add_class("compact-message")
|
||||
self.old_tokens: int | None = None
|
||||
self.new_tokens: int | None = None
|
||||
self.old_session_id: str | None = None
|
||||
self.new_session_id: str | None = None
|
||||
self.error_message: str | None = None
|
||||
|
||||
def get_content(self) -> str:
|
||||
|
|
@ -26,13 +28,25 @@ class CompactMessage(StatusMessage):
|
|||
if self.error_message:
|
||||
return f"Error: {self.error_message}"
|
||||
|
||||
return compact_reduction_display(self.old_tokens, self.new_tokens)
|
||||
return compact_reduction_display(
|
||||
self.old_tokens,
|
||||
self.new_tokens,
|
||||
old_session_id=self.old_session_id,
|
||||
new_session_id=self.new_session_id,
|
||||
)
|
||||
|
||||
def set_complete(
|
||||
self, old_tokens: int | None = None, new_tokens: int | None = None
|
||||
self,
|
||||
old_tokens: int | None = None,
|
||||
new_tokens: int | None = None,
|
||||
*,
|
||||
old_session_id: str | None = None,
|
||||
new_session_id: str | None = None,
|
||||
) -> None:
|
||||
self.old_tokens = old_tokens
|
||||
self.new_tokens = new_tokens
|
||||
self.old_session_id = old_session_id
|
||||
self.new_session_id = new_session_id
|
||||
self.stop_spinning(success=True)
|
||||
self.post_message(self.Completed(self))
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ from vibe.core.telemetry.types import (
|
|||
TelemetryCallType,
|
||||
TelemetryRequestMetadata,
|
||||
)
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.telemetry import TeleportTelemetryTracker
|
||||
from vibe.core.teleport.types import TeleportCompleteEvent
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
InvokeContext,
|
||||
|
|
@ -174,9 +177,18 @@ def _is_context_too_long_error(e: Exception) -> bool:
|
|||
|
||||
def _is_non_retryable_error(e: BaseException) -> bool:
|
||||
# Detect Temporal-style ``non_retryable`` flag without importing temporalio.
|
||||
# Wrapping such an exception in a plain RuntimeError strips the flag, so
|
||||
# Temporal's activity retry policy will retry the call until exhaustion.
|
||||
return bool(getattr(e, "non_retryable", False))
|
||||
# Walks ``__cause__`` so an ``ActivityError`` whose cause is a non-retryable
|
||||
# ``ApplicationError`` is detected too — that's what callers driving the
|
||||
# agent loop from a Temporal activity will see when a sub-activity has
|
||||
# already failed terminally.
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = e
|
||||
while current is not None and id(current) not in seen:
|
||||
if getattr(current, "non_retryable", False):
|
||||
return True
|
||||
seen.add(id(current))
|
||||
current = current.__cause__
|
||||
return False
|
||||
|
||||
|
||||
def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
|
@ -504,6 +516,9 @@ class AgentLoop:
|
|||
def emit_ready_telemetry(self, init_duration_ms: int) -> None:
|
||||
self.telemetry_client.send_ready(init_duration_ms=init_duration_ms)
|
||||
|
||||
def emit_session_closed_telemetry(self) -> None:
|
||||
self.telemetry_client.send_session_closed()
|
||||
|
||||
def _create_connector_registry(self) -> ConnectorRegistry | None:
|
||||
if not connectors_enabled():
|
||||
return None
|
||||
|
|
@ -592,16 +607,25 @@ class AgentLoop:
|
|||
async def teleport_to_vibe_code(
|
||||
self, prompt: str | None
|
||||
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.nuage import TeleportSession
|
||||
|
||||
session_messages = [
|
||||
msg.model_dump(exclude_none=True) for msg in self.messages[1:]
|
||||
]
|
||||
telemetry_tracker = TeleportTelemetryTracker(
|
||||
telemetry_client=self.telemetry_client,
|
||||
nb_session_messages=len(session_messages),
|
||||
stage="no_history"
|
||||
if prompt is None and not session_messages
|
||||
else "git_check",
|
||||
)
|
||||
session = TeleportSession(
|
||||
metadata={
|
||||
"agent": self.agent_profile.name,
|
||||
"model": self.config.active_model,
|
||||
"stats": self.stats.model_dump(),
|
||||
},
|
||||
messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]],
|
||||
messages=session_messages,
|
||||
)
|
||||
try:
|
||||
async with self.teleport_service:
|
||||
|
|
@ -610,12 +634,23 @@ class AgentLoop:
|
|||
while True:
|
||||
try:
|
||||
event = await gen.asend(response)
|
||||
telemetry_tracker.record_event(event)
|
||||
if isinstance(event, TeleportCompleteEvent):
|
||||
telemetry_tracker.send_success()
|
||||
response = yield event
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except ServiceTeleportError as e:
|
||||
telemetry_tracker.record_service_error(e)
|
||||
raise TeleportError(str(e)) from e
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
telemetry_tracker.record_cancelled()
|
||||
raise
|
||||
except Exception as e:
|
||||
telemetry_tracker.record_unexpected_error(e)
|
||||
raise
|
||||
finally:
|
||||
telemetry_tracker.send_failure_if_needed()
|
||||
self._teleport_service = None
|
||||
|
||||
def _setup_middleware(self) -> None:
|
||||
|
|
@ -709,6 +744,8 @@ class AgentLoop:
|
|||
old_context_tokens=old_tokens,
|
||||
new_context_tokens=new_tokens,
|
||||
summary_length=len(summary),
|
||||
old_session_id=old_session_id,
|
||||
new_session_id=self.session_id,
|
||||
)
|
||||
|
||||
case MiddlewareAction.CONTINUE:
|
||||
|
|
@ -1395,8 +1432,9 @@ class AgentLoop:
|
|||
responded_ids: set[str] = set()
|
||||
j = i + 1
|
||||
while j < len(self.messages) and self.messages[j].role == "tool":
|
||||
if self.messages[j].tool_call_id:
|
||||
responded_ids.add(self.messages[j].tool_call_id)
|
||||
tool_call_id = self.messages[j].tool_call_id
|
||||
if tool_call_id is not None:
|
||||
responded_ids.add(tool_call_id)
|
||||
j += 1
|
||||
|
||||
if len(responded_ids) < expected_responses:
|
||||
|
|
@ -1431,6 +1469,7 @@ class AgentLoop:
|
|||
|
||||
def _reset_session(self, keep_parent: bool = True) -> None:
|
||||
old_session_id = self.session_id
|
||||
self.emit_session_closed_telemetry()
|
||||
suffix = extract_suffix(self.session_id)
|
||||
self.session_id = generate_session_id(suffix=suffix)
|
||||
parent_session_id = old_session_id if keep_parent else None
|
||||
|
|
|
|||
|
|
@ -40,18 +40,22 @@ class AgentManager:
|
|||
" ".join(str(p) for p in self._search_paths),
|
||||
)
|
||||
|
||||
profile = self._available.get(initial_agent)
|
||||
if (
|
||||
not allow_subagent
|
||||
and profile is not None
|
||||
and profile.agent_type != AgentType.AGENT
|
||||
):
|
||||
available = self.available_agents
|
||||
profile = available.get(initial_agent)
|
||||
if profile is None:
|
||||
if initial_agent in self._available:
|
||||
raise ValueError(
|
||||
f"Agent '{initial_agent}' is not available. "
|
||||
f"It may be disabled, not installed, or excluded by your config."
|
||||
)
|
||||
raise ValueError(f"Agent '{initial_agent}' not found.")
|
||||
if not allow_subagent and profile.agent_type != AgentType.AGENT:
|
||||
raise ValueError(
|
||||
f"Agent '{initial_agent}' is a {profile.agent_type} and cannot be used"
|
||||
f" as the primary agent. Only agents of type 'agent' can be selected"
|
||||
f" with --agent."
|
||||
)
|
||||
self.active_profile = profile or self._available[BuiltinAgentName.DEFAULT]
|
||||
self.active_profile = profile
|
||||
self._cached_config: VibeConfig | None = None
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -64,13 +64,16 @@ class CommandCompleter(Completer):
|
|||
descriptions[alias] = description
|
||||
return list(descriptions.keys()), descriptions
|
||||
|
||||
def _head_word(self, text: str, cursor_pos: int) -> str:
|
||||
head = text.split(" ", 1)[0]
|
||||
return head[1 : min(cursor_pos, len(head))].lower()
|
||||
|
||||
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
|
||||
if not text.startswith("/"):
|
||||
return []
|
||||
|
||||
aliases, _ = self._build_lookup()
|
||||
word = text[1:cursor_pos].lower()
|
||||
search_str = "/" + word
|
||||
search_str = "/" + self._head_word(text, cursor_pos)
|
||||
return [alias for alias in aliases if alias.lower().startswith(search_str)]
|
||||
|
||||
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
|
||||
|
|
@ -78,8 +81,7 @@ class CommandCompleter(Completer):
|
|||
return []
|
||||
|
||||
aliases, descriptions = self._build_lookup()
|
||||
word = text[1:cursor_pos].lower()
|
||||
search_str = "/" + word
|
||||
search_str = "/" + self._head_word(text, cursor_pos)
|
||||
items = [
|
||||
(alias, descriptions.get(alias, ""))
|
||||
for alias in aliases
|
||||
|
|
@ -90,9 +92,11 @@ class CommandCompleter(Completer):
|
|||
def get_replacement_range(
|
||||
self, text: str, cursor_pos: int
|
||||
) -> tuple[int, int] | None:
|
||||
if text.startswith("/"):
|
||||
return (0, cursor_pos)
|
||||
return None
|
||||
if not text.startswith("/"):
|
||||
return None
|
||||
first_space = text.find(" ")
|
||||
end = cursor_pos if first_space == -1 else min(cursor_pos, first_space)
|
||||
return (0, end)
|
||||
|
||||
|
||||
class PathCompleter(Completer):
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from pydantic_settings import (
|
|||
)
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||
|
|
@ -610,6 +611,15 @@ class VibeConfig(BaseSettings):
|
|||
"A list of opt-in builtin agent names that have been explicitly installed."
|
||||
),
|
||||
)
|
||||
default_agent: str = Field(
|
||||
default=BuiltinAgentName.DEFAULT,
|
||||
description=(
|
||||
"Agent profile to use when no --agent flag is passed in interactive "
|
||||
"mode. Builtin: default, plan, accept-edits, auto-approve. "
|
||||
"Ignored in programmatic mode (-p/--prompt), which falls back to "
|
||||
"auto-approve when --agent is not provided."
|
||||
),
|
||||
)
|
||||
skill_paths: list[Path] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ def apply_logging_config(target_logger: logging.Logger) -> None:
|
|||
|
||||
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
|
||||
|
||||
# DEBUG_MODE is the debugpy switch (see vibe/acp/entrypoint.py);
|
||||
# it also forces DEBUG-level logging here.
|
||||
if os.environ.get("DEBUG_MODE") == "true":
|
||||
log_level_str = "DEBUG"
|
||||
else:
|
||||
|
|
|
|||
250
vibe/core/loop.py
Normal file
250
vibe/core/loop.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.types import ScheduledLoop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
|
||||
__all__ = [
|
||||
"MAX_LOOPS_PER_SESSION",
|
||||
"MIN_INTERVAL_SECONDS",
|
||||
"USAGE_HINT",
|
||||
"IntervalUnit",
|
||||
"LoopCommandResult",
|
||||
"LoopError",
|
||||
"LoopErrorResult",
|
||||
"LoopListResult",
|
||||
"LoopManager",
|
||||
"LoopOkResult",
|
||||
"format_duration",
|
||||
"parse_interval",
|
||||
]
|
||||
|
||||
|
||||
MIN_INTERVAL_SECONDS = 30
|
||||
MAX_LOOPS_PER_SESSION = 50
|
||||
USAGE_HINT = """\
|
||||
Usage:
|
||||
/loop <interval> <prompt>
|
||||
/loop list
|
||||
/loop cancel <id|all>
|
||||
"""
|
||||
|
||||
_INTERVAL_RE = re.compile(r"^(\d+)([smhd])$")
|
||||
_CANCEL_VERBS = frozenset({"cancel", "rm", "stop", "delete"})
|
||||
_LIST_VERBS = frozenset({"list", "ls"})
|
||||
|
||||
|
||||
class LoopError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class IntervalUnit(StrEnum):
|
||||
SECOND = auto()
|
||||
MINUTE = auto()
|
||||
HOUR = auto()
|
||||
DAY = auto()
|
||||
|
||||
@property
|
||||
def seconds(self) -> int:
|
||||
match self:
|
||||
case IntervalUnit.SECOND:
|
||||
return 1
|
||||
case IntervalUnit.MINUTE:
|
||||
return 60
|
||||
case IntervalUnit.HOUR:
|
||||
return 3600
|
||||
case IntervalUnit.DAY:
|
||||
return 86400
|
||||
|
||||
@property
|
||||
def suffix(self) -> str:
|
||||
match self:
|
||||
case IntervalUnit.SECOND:
|
||||
return "s"
|
||||
case IntervalUnit.MINUTE:
|
||||
return "m"
|
||||
case IntervalUnit.HOUR:
|
||||
return "h"
|
||||
case IntervalUnit.DAY:
|
||||
return "d"
|
||||
|
||||
@classmethod
|
||||
def from_suffix(cls, ch: str) -> IntervalUnit:
|
||||
match ch:
|
||||
case "s":
|
||||
return cls.SECOND
|
||||
case "m":
|
||||
return cls.MINUTE
|
||||
case "h":
|
||||
return cls.HOUR
|
||||
case "d":
|
||||
return cls.DAY
|
||||
case _:
|
||||
raise LoopError(f"Unknown interval unit `{ch}`.")
|
||||
|
||||
|
||||
def parse_interval(text: str) -> int:
|
||||
if not text:
|
||||
raise LoopError("Missing interval.")
|
||||
match = _INTERVAL_RE.match(text.strip().lower())
|
||||
if match is None:
|
||||
raise LoopError(
|
||||
f"Invalid interval `{text}`. "
|
||||
"Expected: <number><unit> (e.g., 30s, 5m, 2h, 1d)."
|
||||
)
|
||||
value = int(match.group(1))
|
||||
seconds = value * IntervalUnit.from_suffix(match.group(2)).seconds
|
||||
if seconds < MIN_INTERVAL_SECONDS:
|
||||
raise LoopError(f"Interval must be at least {MIN_INTERVAL_SECONDS}s.")
|
||||
return seconds
|
||||
|
||||
|
||||
def format_duration(seconds: int, short: bool = False) -> str:
|
||||
parts = []
|
||||
for unit in (
|
||||
IntervalUnit.DAY,
|
||||
IntervalUnit.HOUR,
|
||||
IntervalUnit.MINUTE,
|
||||
IntervalUnit.SECOND,
|
||||
):
|
||||
value = seconds // unit.seconds
|
||||
if value > 0:
|
||||
parts.append(f"{value}{unit.suffix}")
|
||||
seconds %= unit.seconds
|
||||
if not parts:
|
||||
parts = ["0s"]
|
||||
if short:
|
||||
return parts[0]
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class _LoopResultBase(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
|
||||
class LoopListResult(_LoopResultBase):
|
||||
loops: list[ScheduledLoop]
|
||||
|
||||
|
||||
class LoopOkResult(_LoopResultBase):
|
||||
message: str
|
||||
|
||||
|
||||
class LoopErrorResult(_LoopResultBase):
|
||||
message: str
|
||||
|
||||
|
||||
LoopCommandResult = LoopListResult | LoopOkResult | LoopErrorResult
|
||||
|
||||
|
||||
class LoopManager:
|
||||
def __init__(self, session_logger: SessionLogger) -> None:
|
||||
self._session_logger = session_logger
|
||||
self._loops: list[ScheduledLoop] = []
|
||||
|
||||
@property
|
||||
def loops(self) -> list[ScheduledLoop]:
|
||||
return list(self._loops)
|
||||
|
||||
def restore(self, loops: list[ScheduledLoop]) -> None:
|
||||
self._loops = list(loops)
|
||||
|
||||
def next_due_in(self, now: float | None = None) -> float:
|
||||
if not self._loops:
|
||||
return math.inf
|
||||
ts = now if now is not None else time.time()
|
||||
return max(0.0, min(loop.next_fire_at for loop in self._loops) - ts)
|
||||
|
||||
async def pop_due(self, now: float | None = None) -> ScheduledLoop | None:
|
||||
if not self._loops:
|
||||
return None
|
||||
ts = now if now is not None else time.time()
|
||||
due_loops = [loop for loop in self._loops if loop.next_fire_at <= ts]
|
||||
if not due_loops:
|
||||
return None
|
||||
due = min(due_loops, key=lambda loop: loop.next_fire_at)
|
||||
due.next_fire_at = ts + due.interval_seconds
|
||||
await self._persist()
|
||||
return due
|
||||
|
||||
async def handle_command(self, args: str) -> LoopCommandResult:
|
||||
text = args.strip()
|
||||
if not text:
|
||||
return self._list_result()
|
||||
verb, _, rest = text.partition(" ")
|
||||
verb_lower = verb.lower()
|
||||
if verb_lower in _LIST_VERBS:
|
||||
return self._list_result()
|
||||
if verb_lower in _CANCEL_VERBS:
|
||||
return await self._cancel(rest.strip())
|
||||
return await self._add(verb, rest)
|
||||
|
||||
def _list_result(self) -> LoopListResult:
|
||||
return LoopListResult(loops=list(self._loops))
|
||||
|
||||
async def _add(
|
||||
self, interval_text: str, prompt: str
|
||||
) -> LoopOkResult | LoopErrorResult:
|
||||
try:
|
||||
seconds = parse_interval(interval_text)
|
||||
except LoopError as e:
|
||||
return LoopErrorResult(message=str(e))
|
||||
prompt = prompt.strip()
|
||||
if not prompt:
|
||||
return LoopErrorResult(message="Missing prompt.")
|
||||
if prompt.startswith("/"):
|
||||
return LoopErrorResult(message="Prompt cannot start with '/'.")
|
||||
if len(self._loops) >= MAX_LOOPS_PER_SESSION:
|
||||
return LoopErrorResult(
|
||||
message=f"Loop limit reached ({MAX_LOOPS_PER_SESSION} per session)."
|
||||
)
|
||||
now = time.time()
|
||||
loop = ScheduledLoop(
|
||||
id=secrets.token_hex(4),
|
||||
interval_seconds=seconds,
|
||||
prompt=prompt,
|
||||
next_fire_at=now + seconds,
|
||||
created_at=now,
|
||||
)
|
||||
self._loops.append(loop)
|
||||
await self._persist()
|
||||
return LoopOkResult(
|
||||
message=(
|
||||
f"Scheduled loop `{loop.id}` every {format_duration(seconds)}: {prompt}"
|
||||
)
|
||||
)
|
||||
|
||||
async def _cancel(self, target: str) -> LoopOkResult | LoopErrorResult:
|
||||
if not target:
|
||||
return LoopErrorResult(message="Missing loop id.")
|
||||
if target.lower() == "all":
|
||||
count = len(self._loops)
|
||||
self._loops.clear()
|
||||
await self._persist()
|
||||
return LoopOkResult(message=f"Cancelled {count} scheduled loop(s).")
|
||||
match = next((loop for loop in self._loops if loop.id == target), None)
|
||||
if match is None:
|
||||
return LoopErrorResult(message=f"No scheduled loop with id `{target}`.")
|
||||
self._loops.remove(match)
|
||||
await self._persist()
|
||||
return LoopOkResult(message=f"Cancelled loop `{match.id}`: {match.prompt}")
|
||||
|
||||
async def _persist(self) -> None:
|
||||
metadata = self._session_logger.session_metadata
|
||||
if metadata is not None:
|
||||
metadata.loops = [*self._loops]
|
||||
try:
|
||||
await self._session_logger.persist_loops()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist scheduled loops", exc_info=e)
|
||||
|
|
@ -91,6 +91,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917
|
|||
|
||||
return formatter.finalize()
|
||||
finally:
|
||||
agent_loop.emit_session_closed_telemetry()
|
||||
await agent_loop.telemetry_client.aclose()
|
||||
|
||||
return asyncio.run(_async_run())
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ Never claim completion without verification — a passing test, correct read-bac
|
|||
|
||||
Hard Rules:
|
||||
|
||||
Never Commit
|
||||
Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
|
||||
Never Commit Proactively
|
||||
Do not proactively run `git add`, `git commit`, or `git push`. Saving files is the default — the user usually reviews and commits themselves. If the user explicitly asks you to stage, commit, or push, do it.
|
||||
|
||||
Respect User Constraints
|
||||
"No writes", "just analyze", "plan only", "don't touch X" - these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||
import tempfile
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.session.session_id import shorten_session_id
|
||||
|
||||
_active_scratchpads: dict[str, Path] = {}
|
||||
|
||||
|
|
@ -17,7 +18,11 @@ def init_scratchpad(session_id: str) -> Path | None:
|
|||
return _active_scratchpads[session_id]
|
||||
|
||||
try:
|
||||
dir_path = Path(tempfile.mkdtemp(prefix=f"vibe-scratchpad-{session_id[:8]}-"))
|
||||
dir_path = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"vibe-scratchpad-{shorten_session_id(session_id)}-"
|
||||
)
|
||||
)
|
||||
_active_scratchpads[session_id] = dir_path
|
||||
logger.debug("Scratchpad initialized at %s", dir_path)
|
||||
return dir_path
|
||||
|
|
|
|||
|
|
@ -8,17 +8,14 @@ from vibe.core.config import VibeConfig
|
|||
from vibe.core.logger import logger
|
||||
from vibe.core.nuage.client import WorkflowsClient
|
||||
from vibe.core.nuage.workflow import WorkflowExecutionStatus
|
||||
from vibe.core.session.session_id import shorten_session_id
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
|
||||
ResumeSessionSource = Literal["local", "remote"]
|
||||
|
||||
SHORT_SESSION_ID_LEN = 8
|
||||
|
||||
|
||||
def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str:
|
||||
if source == "remote":
|
||||
return session_id[-SHORT_SESSION_ID_LEN:]
|
||||
return session_id[:SHORT_SESSION_ID_LEN]
|
||||
return shorten_session_id(session_id, from_end=source == "remote")
|
||||
|
||||
|
||||
_ACTIVE_STATUSES = [
|
||||
|
|
|
|||
|
|
@ -22,3 +22,13 @@ def generate_session_id(*, suffix: str | None = None) -> str:
|
|||
def extract_suffix(session_id: str) -> str:
|
||||
"""Extract the stable suffix (last segment after the final hyphen)."""
|
||||
return session_id.rsplit("-", 1)[-1]
|
||||
|
||||
|
||||
_SHORT_LEN = 8
|
||||
|
||||
|
||||
def shorten_session_id(session_id: str, *, from_end: bool = False) -> str:
|
||||
"""Return a short human-readable slice of a session ID (8 chars)."""
|
||||
if from_end:
|
||||
return session_id[-_SHORT_LEN:]
|
||||
return session_id[:_SHORT_LEN]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from vibe.core.session.session_id import shorten_session_id
|
||||
from vibe.core.types import LLMMessage, SessionMetadata
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
|
@ -129,7 +130,7 @@ class SessionLoader:
|
|||
if not save_dir.exists():
|
||||
return []
|
||||
|
||||
short_id = session_id[:8]
|
||||
short_id = shorten_session_id(session_id)
|
||||
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||
|
||||
from anyio import NamedTemporaryFile, Path as AsyncPath
|
||||
|
||||
from vibe.core.session.session_id import shorten_session_id
|
||||
from vibe.core.session.session_loader import (
|
||||
MESSAGES_FILENAME,
|
||||
METADATA_FILENAME,
|
||||
|
|
@ -63,9 +64,20 @@ class SessionLogger:
|
|||
)
|
||||
|
||||
timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
|
||||
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
|
||||
folder_name = (
|
||||
f"{self.session_prefix}_{timestamp}_{shorten_session_id(self.session_id)}"
|
||||
)
|
||||
return self.save_dir / folder_name
|
||||
|
||||
def _get_session_info(self) -> tuple[Path, SessionMetadata] | None:
|
||||
if (
|
||||
not self.enabled
|
||||
or self.session_dir is None
|
||||
or self.session_metadata is None
|
||||
):
|
||||
return None
|
||||
return (self.session_dir, self.session_metadata)
|
||||
|
||||
@property
|
||||
def metadata_filepath(self) -> Path:
|
||||
if self.session_dir is None:
|
||||
|
|
@ -244,34 +256,34 @@ class SessionLogger:
|
|||
tool_manager: ToolManager,
|
||||
agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
if not self.enabled or self.session_dir is None:
|
||||
return
|
||||
|
||||
if self.session_metadata is None:
|
||||
session_info = self._get_session_info()
|
||||
if session_info is None:
|
||||
return
|
||||
session_dir, session_metadata = session_info
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
|
||||
if not any(msg.role != Role.system for msg in messages):
|
||||
return
|
||||
|
||||
# If the session directory does not exist, create it
|
||||
try:
|
||||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}"
|
||||
f"Failed to create session directory at {session_dir}: {type(e).__name__}: {e}"
|
||||
) from e
|
||||
|
||||
# Read old metadata and get total_messages
|
||||
try:
|
||||
if self.metadata_filepath.exists():
|
||||
raw = (await read_safe_async(self.metadata_filepath)).text
|
||||
if metadata_path.exists():
|
||||
raw = (await read_safe_async(metadata_path)).text
|
||||
old_metadata = json.loads(raw)
|
||||
old_total_messages = old_metadata["total_messages"]
|
||||
else:
|
||||
old_total_messages = 0
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to read session metadata at {self.metadata_filepath}: {e}"
|
||||
f"Failed to read session metadata at {metadata_path}: {e}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
|
|
@ -283,7 +295,7 @@ class SessionLogger:
|
|||
return
|
||||
|
||||
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, session_dir)
|
||||
|
||||
# If message update succeeded, write metadata
|
||||
tools_available = [
|
||||
|
|
@ -307,7 +319,7 @@ class SessionLogger:
|
|||
total_messages = len(non_system_messages)
|
||||
|
||||
metadata_dump = {
|
||||
**self.session_metadata.model_dump(),
|
||||
**session_metadata.model_dump(),
|
||||
"end_time": utc_now().isoformat(),
|
||||
"stats": stats.model_dump(),
|
||||
"title": title,
|
||||
|
|
@ -321,14 +333,32 @@ class SessionLogger:
|
|||
"system_prompt": system_prompt,
|
||||
}
|
||||
|
||||
await SessionLogger.persist_metadata(metadata_dump, self.session_dir)
|
||||
await SessionLogger.persist_metadata(metadata_dump, session_dir)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to save session to {self.session_dir}: {e}"
|
||||
) from e
|
||||
raise RuntimeError(f"Failed to save session to {session_dir}: {e}") from e
|
||||
finally:
|
||||
self.maybe_cleanup_tmp_files()
|
||||
|
||||
async def persist_loops(self) -> None:
|
||||
session_info = self._get_session_info()
|
||||
if session_info is None:
|
||||
return
|
||||
session_dir, session_metadata = session_info
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
if not metadata_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = (await read_safe_async(metadata_path)).text
|
||||
metadata = json.loads(raw)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to read session metadata at {metadata_path}: {e}"
|
||||
) from e
|
||||
metadata["loops"] = [
|
||||
loop.model_dump(mode="json") for loop in session_metadata.loops
|
||||
]
|
||||
await SessionLogger.persist_metadata(metadata, session_dir)
|
||||
|
||||
def reset_session(
|
||||
self, session_id: str, *, parent_session_id: str | None = None
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -194,6 +194,15 @@ disabled_agents = ["auto-approve"]
|
|||
|
||||
# Opt-in builtin agents (only affects agents with install_required=True, e.g. lean)
|
||||
installed_agents = ["lean"]
|
||||
|
||||
# Agent profile to use when --agent is not passed in interactive mode
|
||||
# (default: "default"). Valid values: "default", "plan", "accept-edits",
|
||||
# "auto-approve", "lean" (only when listed in installed_agents), or any
|
||||
# custom agent name from ~/.vibe/agents/ or .vibe/agents/. Subagents
|
||||
# (e.g. "explore") are rejected. Ignored in programmatic mode
|
||||
# (-p/--prompt), which falls back to "auto-approve" when --agent is not
|
||||
# provided.
|
||||
default_agent = "plan"
|
||||
```
|
||||
|
||||
### MCP Servers
|
||||
|
|
@ -321,7 +330,7 @@ Tool, skill, and agent names support three matching modes:
|
|||
```
|
||||
vibe [PROMPT] # Start interactive session with optional prompt
|
||||
vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit)
|
||||
vibe --agent NAME # Select agent profile
|
||||
vibe --agent NAME # Select agent profile (falls back to `default_agent` config)
|
||||
vibe --workdir DIR # Change working directory
|
||||
vibe --trust # Trust cwd for this invocation only (not persisted)
|
||||
vibe -c / --continue # Continue most recent session
|
||||
|
|
@ -374,6 +383,15 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
|
|||
- `/mcp` - Display available MCP servers (pass a server name to list its tools)
|
||||
- `/resume` (or `/continue`) - Browse and resume past sessions
|
||||
- `/rewind` - Rewind to a previous message
|
||||
- `/loop <interval> <prompt>` - Schedule a recurring prompt (e.g. `/loop 30s ping`).
|
||||
Intervals: `Ns/Nm/Nh/Nd`, minimum 30s, max 50 loops/session.
|
||||
- `/loop` (or `/loop list` / `/loop ls`) - List current scheduled loops.
|
||||
- `/loop cancel <id|all>` (aliases `rm`, `stop`, `delete`) - Cancel a loop.
|
||||
- Loops fire only when the agent is idle and the input bar is focused. At
|
||||
most one loop fires per poll. Overdue loops fire once on the next poll
|
||||
(no catch-up); `next_fire_at` advances to `now + interval`.
|
||||
- Loops are persisted in the session metadata (`loops` field of `meta.json`)
|
||||
and restored on `--resume`/`--continue`.
|
||||
- `/terminal-setup` - Configure Shift+Enter for newlines
|
||||
- `/proxy-setup` - Configure proxy and SSL certificate settings
|
||||
- `/leanstall` - Install the Lean 4 agent (leanstral)
|
||||
|
|
@ -415,6 +433,13 @@ Detailed instructions for the model...
|
|||
- `MISTRAL_API_KEY` - API key for Mistral provider
|
||||
- `VIBE_ACTIVE_MODEL` - Override active model
|
||||
- `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix
|
||||
- `LOG_LEVEL` - Logging level for `$VIBE_HOME/logs/vibe.log`. One of `DEBUG`,
|
||||
`INFO`, `WARNING` (default), `ERROR`, `CRITICAL`. Invalid values fall back
|
||||
to `WARNING`.
|
||||
- `LOG_MAX_BYTES` - Max size in bytes of `vibe.log` before rotation
|
||||
(default: `10485760`, i.e. 10 MiB).
|
||||
- `DEBUG_MODE` - When `true`, forces `DEBUG`-level logging. Under `vibe-acp`
|
||||
it also attaches `debugpy` on `localhost:5678`.
|
||||
|
||||
## API Keys (.env file)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ from vibe.core.telemetry.types import (
|
|||
AgentEntrypoint,
|
||||
EntrypointMetadata,
|
||||
TelemetryCallType,
|
||||
TeleportCompletedPayload,
|
||||
TeleportFailedPayload,
|
||||
TeleportFailureStage,
|
||||
)
|
||||
from vibe.core.utils import get_server_url_from_api_base, get_user_agent
|
||||
|
||||
|
|
@ -63,7 +66,7 @@ class TelemetryClient:
|
|||
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
|
||||
try:
|
||||
provider = self._config_getter().get_mistral_provider()
|
||||
except ValueError:
|
||||
except Exception:
|
||||
return None
|
||||
if provider is None:
|
||||
return None
|
||||
|
|
@ -77,7 +80,7 @@ class TelemetryClient:
|
|||
"""Check if telemetry is enabled in the current config."""
|
||||
try:
|
||||
return self._config_getter().enable_telemetry
|
||||
except ValueError:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_active(self) -> bool:
|
||||
|
|
@ -270,6 +273,9 @@ class TelemetryClient:
|
|||
}
|
||||
self.send_telemetry_event("vibe.new_session", payload)
|
||||
|
||||
def send_session_closed(self) -> None:
|
||||
self.send_telemetry_event("vibe.session_closed", {})
|
||||
|
||||
def send_onboarding_api_key_added(self) -> None:
|
||||
self.send_telemetry_event(
|
||||
"vibe.onboarding_api_key_added", {"version": __version__}
|
||||
|
|
@ -322,3 +328,35 @@ class TelemetryClient:
|
|||
{"rating": rating, "version": __version__, "model": model},
|
||||
correlation_id=self.last_correlation_id,
|
||||
)
|
||||
|
||||
def send_teleport_completed(
|
||||
self,
|
||||
*,
|
||||
push_required: bool,
|
||||
github_auth_required: bool,
|
||||
nb_session_messages: int,
|
||||
) -> None:
|
||||
payload: TeleportCompletedPayload = {
|
||||
"push_required": push_required,
|
||||
"github_auth_required": github_auth_required,
|
||||
"nb_session_messages": nb_session_messages,
|
||||
}
|
||||
self.send_telemetry_event("vibe.teleport_completed", dict(payload))
|
||||
|
||||
def send_teleport_failed(
|
||||
self,
|
||||
*,
|
||||
stage: TeleportFailureStage,
|
||||
error_class: str,
|
||||
push_required: bool,
|
||||
github_auth_required: bool,
|
||||
nb_session_messages: int,
|
||||
) -> None:
|
||||
payload: TeleportFailedPayload = {
|
||||
"stage": stage,
|
||||
"error_class": error_class,
|
||||
"push_required": push_required,
|
||||
"github_auth_required": github_auth_required,
|
||||
"nb_session_messages": nb_session_messages,
|
||||
}
|
||||
self.send_telemetry_event("vibe.teleport_failed", dict(payload))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -36,3 +36,26 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata):
|
|||
call_type: TelemetryCallType
|
||||
call_source: str = "vibe_code"
|
||||
message_id: str | None = None
|
||||
|
||||
|
||||
TeleportFailureStage = Literal[
|
||||
"no_history",
|
||||
"remote_session",
|
||||
"git_check",
|
||||
"push",
|
||||
"workflow_start",
|
||||
"github_auth",
|
||||
"fetch_url",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
|
||||
class TeleportCompletedPayload(TypedDict):
|
||||
push_required: bool
|
||||
github_auth_required: bool
|
||||
nb_session_messages: int
|
||||
|
||||
|
||||
class TeleportFailedPayload(TeleportCompletedPayload):
|
||||
stage: TeleportFailureStage
|
||||
error_class: str
|
||||
|
|
|
|||
94
vibe/core/teleport/telemetry.py
Normal file
94
vibe/core/teleport/telemetry.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.telemetry.types import TeleportFailureStage
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportAuthRequiredEvent,
|
||||
TeleportCheckingGitEvent,
|
||||
TeleportCompleteEvent,
|
||||
TeleportFetchingUrlEvent,
|
||||
TeleportPushingEvent,
|
||||
TeleportPushRequiredEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
TeleportWaitingForGitHubEvent,
|
||||
TeleportYieldEvent,
|
||||
)
|
||||
|
||||
|
||||
def send_teleport_early_failure_telemetry(
|
||||
telemetry_client: TelemetryClient,
|
||||
*,
|
||||
stage: TeleportFailureStage,
|
||||
error_class: str,
|
||||
nb_session_messages: int,
|
||||
) -> None:
|
||||
telemetry_client.send_teleport_failed(
|
||||
stage=stage,
|
||||
error_class=error_class,
|
||||
push_required=False,
|
||||
github_auth_required=False,
|
||||
nb_session_messages=nb_session_messages,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeleportTelemetryTracker:
|
||||
telemetry_client: TelemetryClient
|
||||
nb_session_messages: int
|
||||
stage: TeleportFailureStage
|
||||
push_required: bool = False
|
||||
github_auth_required: bool = False
|
||||
success: bool = False
|
||||
error_class: str | None = None
|
||||
|
||||
def record_event(self, event: TeleportYieldEvent) -> None:
|
||||
match event:
|
||||
case TeleportCheckingGitEvent():
|
||||
self.stage = "git_check"
|
||||
case TeleportPushRequiredEvent():
|
||||
self.push_required = True
|
||||
self.stage = "cancelled"
|
||||
case TeleportPushingEvent():
|
||||
self.stage = "push"
|
||||
case TeleportStartingWorkflowEvent():
|
||||
self.stage = "workflow_start"
|
||||
case TeleportWaitingForGitHubEvent():
|
||||
self.stage = "github_auth"
|
||||
case TeleportAuthRequiredEvent():
|
||||
self.github_auth_required = True
|
||||
self.stage = "github_auth"
|
||||
case TeleportFetchingUrlEvent():
|
||||
self.stage = "fetch_url"
|
||||
case TeleportCompleteEvent():
|
||||
self.success = True
|
||||
|
||||
def record_service_error(self, error: ServiceTeleportError) -> None:
|
||||
self.error_class = type(error).__name__
|
||||
|
||||
def record_cancelled(self) -> None:
|
||||
self.stage = "cancelled"
|
||||
self.error_class = "CancelledError"
|
||||
|
||||
def record_unexpected_error(self, error: Exception) -> None:
|
||||
self.error_class = type(error).__name__
|
||||
|
||||
def send_success(self) -> None:
|
||||
self.telemetry_client.send_teleport_completed(
|
||||
push_required=self.push_required,
|
||||
github_auth_required=self.github_auth_required,
|
||||
nb_session_messages=self.nb_session_messages,
|
||||
)
|
||||
|
||||
def send_failure_if_needed(self) -> None:
|
||||
if self.success or self.error_class is None:
|
||||
return
|
||||
self.telemetry_client.send_teleport_failed(
|
||||
stage=self.stage,
|
||||
error_class=self.error_class,
|
||||
push_required=self.push_required,
|
||||
github_auth_required=self.github_auth_required,
|
||||
nb_session_messages=self.nb_session_messages,
|
||||
)
|
||||
|
|
@ -141,7 +141,7 @@ class ReadFile(
|
|||
try:
|
||||
raw_lines: list[bytes] = []
|
||||
bytes_read = 0
|
||||
was_truncated = False
|
||||
was_truncated = True
|
||||
|
||||
async with await anyio.Path(file_path).open("rb") as f:
|
||||
line_index = 0
|
||||
|
|
@ -155,12 +155,13 @@ class ReadFile(
|
|||
|
||||
line_bytes = len(raw_line)
|
||||
if bytes_read + line_bytes > self.config.max_read_bytes:
|
||||
was_truncated = True
|
||||
break
|
||||
|
||||
raw_lines.append(raw_line)
|
||||
bytes_read += line_bytes
|
||||
line_index += 1
|
||||
else:
|
||||
was_truncated = False
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Error reading {file_path}: {exc}") from exc
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ VIBE_AGENT_NAME = "mistral-vibe"
|
|||
|
||||
|
||||
def setup_tracing(config: VibeConfig) -> None:
|
||||
if not config.enable_otel:
|
||||
if not config.enable_telemetry or not config.enable_otel:
|
||||
return
|
||||
|
||||
exporter_cfg = config.otel_span_exporter_config
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ from pydantic import (
|
|||
)
|
||||
|
||||
|
||||
class ScheduledLoop(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str
|
||||
interval_seconds: int
|
||||
prompt: str
|
||||
next_fire_at: float
|
||||
created_at: float
|
||||
|
||||
|
||||
class Backend(StrEnum):
|
||||
MISTRAL = auto()
|
||||
GENERIC = auto()
|
||||
|
|
@ -142,6 +152,7 @@ class SessionMetadata(BaseModel):
|
|||
git_branch: str | None
|
||||
environment: dict[str, str | None]
|
||||
username: str
|
||||
loops: list[ScheduledLoop] = Field(default_factory=list)
|
||||
title: str | None = None
|
||||
title_source: Literal["auto", "manual"] = "auto"
|
||||
|
||||
|
|
@ -418,6 +429,8 @@ class CompactEndEvent(BaseEvent):
|
|||
old_context_tokens: int
|
||||
new_context_tokens: int
|
||||
summary_length: int
|
||||
old_session_id: str | None = None
|
||||
new_session_id: str | None = None
|
||||
# WORKAROUND: Using tool_call to communicate compact events to the client.
|
||||
# This should be revisited when the ACP protocol defines how compact events
|
||||
# should be represented.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.session.session_id import shorten_session_id
|
||||
|
||||
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
|
||||
if old_tokens is None or new_tokens is None:
|
||||
return "Compaction complete"
|
||||
|
||||
reduction = old_tokens - new_tokens
|
||||
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
||||
return (
|
||||
f"Compaction complete: {old_tokens:,} → "
|
||||
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
||||
)
|
||||
def compact_reduction_display(
|
||||
old_tokens: int | None,
|
||||
new_tokens: int | None,
|
||||
*,
|
||||
old_session_id: str | None = None,
|
||||
new_session_id: str | None = None,
|
||||
) -> str:
|
||||
|
||||
message = "Compaction complete"
|
||||
if old_tokens is not None and new_tokens is not None:
|
||||
reduction = old_tokens - new_tokens
|
||||
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
||||
message = (
|
||||
f"{message}: {old_tokens:,} → "
|
||||
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
||||
)
|
||||
|
||||
if old_session_id is not None and new_session_id is not None:
|
||||
short_old = shorten_session_id(old_session_id)
|
||||
short_new = shorten_session_id(new_session_id)
|
||||
message = (
|
||||
f"{message}\n"
|
||||
f"session: {short_old} (before compaction) → {short_new} (after compaction)"
|
||||
)
|
||||
|
||||
return message
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# What's new in v2.9.4
|
||||
# What's new in v2.9.5
|
||||
|
||||
- **`/rename` command**: Rename the current session from the slash menu.
|
||||
- **Persistent "always allow"**: Tool permissions granted with "always allow" now stick across sessions.
|
||||
- **Faster bash bang commands**: `!command` runs via async subprocess.
|
||||
- **`/loop` command**: Run a prompt or slash command on a recurring interval.
|
||||
- **`default_agent` config**: Set which agent profile starts each session.
|
||||
- **Parallel-safe history**: Multiple `vibe` instances no longer clobber each other's history file.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue