v2.7.4 (#579)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
90763daf81
commit
e9a9217cc8
113 changed files with 7202 additions and 541 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.7.3"
|
||||
__version__ = "2.7.4"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
|
@ -445,14 +446,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
elif msg.role == Role.assistant:
|
||||
if text_update := create_assistant_message_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=text_update
|
||||
)
|
||||
if reasoning_update := create_reasoning_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=reasoning_update
|
||||
)
|
||||
if text_update := create_assistant_message_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=text_update
|
||||
)
|
||||
await self._replay_tool_calls(session_id, msg)
|
||||
|
||||
elif msg.role == Role.tool:
|
||||
|
|
@ -885,61 +886,64 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
) -> AsyncGenerator[SessionUpdate]:
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
|
||||
async for event in session.agent_loop.act(
|
||||
rendered_prompt, client_message_id=client_message_id
|
||||
):
|
||||
if isinstance(event, AssistantEvent):
|
||||
yield AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=event.content),
|
||||
message_id=event.message_id,
|
||||
)
|
||||
|
||||
elif isinstance(event, ReasoningEvent):
|
||||
yield AgentThoughtChunk(
|
||||
session_update="agent_thought_chunk",
|
||||
content=TextContentBlock(type="text", text=event.content),
|
||||
message_id=event.message_id,
|
||||
)
|
||||
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
if issubclass(event.tool_class, BaseAcpTool):
|
||||
event.tool_class.update_tool_state(
|
||||
tool_manager=session.agent_loop.tool_manager,
|
||||
client=self.client,
|
||||
session_id=session.id,
|
||||
tool_call_id=event.tool_call_id,
|
||||
async with aclosing(
|
||||
session.agent_loop.act(rendered_prompt, client_message_id=client_message_id)
|
||||
) as events:
|
||||
async for event in events:
|
||||
if isinstance(event, AssistantEvent):
|
||||
yield AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=event.content),
|
||||
message_id=event.message_id,
|
||||
)
|
||||
|
||||
session_update = tool_call_session_update(event)
|
||||
if session_update:
|
||||
yield session_update
|
||||
elif isinstance(event, ReasoningEvent):
|
||||
yield AgentThoughtChunk(
|
||||
session_update="agent_thought_chunk",
|
||||
content=TextContentBlock(type="text", text=event.content),
|
||||
message_id=event.message_id,
|
||||
)
|
||||
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
session_update = tool_result_session_update(event)
|
||||
if session_update:
|
||||
yield session_update
|
||||
|
||||
elif isinstance(event, ToolStreamEvent):
|
||||
yield ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(type="text", text=event.message),
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
if issubclass(event.tool_class, BaseAcpTool):
|
||||
event.tool_class.update_tool_state(
|
||||
tool_manager=session.agent_loop.tool_manager,
|
||||
client=self.client,
|
||||
session_id=session.id,
|
||||
tool_call_id=event.tool_call_id,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
elif isinstance(event, CompactStartEvent):
|
||||
yield create_compact_start_session_update(event)
|
||||
session_update = tool_call_session_update(event)
|
||||
if session_update:
|
||||
yield session_update
|
||||
|
||||
elif isinstance(event, CompactEndEvent):
|
||||
yield create_compact_end_session_update(event)
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
session_update = tool_result_session_update(event)
|
||||
if session_update:
|
||||
yield session_update
|
||||
|
||||
elif isinstance(event, AgentProfileChangedEvent):
|
||||
pass
|
||||
elif isinstance(event, ToolStreamEvent):
|
||||
yield ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=event.message
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
elif isinstance(event, CompactStartEvent):
|
||||
yield create_compact_start_session_update(event)
|
||||
|
||||
elif isinstance(event, CompactEndEvent):
|
||||
yield create_compact_end_session_update(event)
|
||||
|
||||
elif isinstance(event, AgentProfileChangedEvent):
|
||||
pass
|
||||
|
||||
@override
|
||||
async def close_session(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import sys
|
|||
import tomli_w
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config.harness_files import (
|
||||
get_harness_files_manager,
|
||||
init_harness_files_manager,
|
||||
|
|
@ -92,8 +92,8 @@ def main() -> None:
|
|||
try:
|
||||
config = VibeConfig.load()
|
||||
setup_tracing(config)
|
||||
except MissingAPIKeyError:
|
||||
pass # tracing disabled, but server can still handle the error properly in new_session
|
||||
except Exception:
|
||||
pass # tracing disabled
|
||||
|
||||
run_acp_server()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import subprocess
|
|||
import pyperclip
|
||||
from textual.app import App
|
||||
|
||||
_PREVIEW_MAX_LENGTH = 40
|
||||
|
||||
|
||||
def _copy_osc52(text: str) -> None:
|
||||
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
|
|
@ -117,13 +115,6 @@ def _copy_to_clipboard(text: str) -> None:
|
|||
raise RuntimeError("All clipboard strategies failed")
|
||||
|
||||
|
||||
def _shorten_preview(texts: list[str]) -> str:
|
||||
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}…"
|
||||
return dense_text
|
||||
|
||||
|
||||
def _get_selected_texts(app: App) -> list[str]:
|
||||
selected_texts = []
|
||||
|
||||
|
|
@ -156,7 +147,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None
|
|||
_copy_to_clipboard(combined_text)
|
||||
if show_toast:
|
||||
app.notify(
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
"Selection copied to clipboard",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class CommandRegistry:
|
|||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
description="Reload configuration, agent instructions, and skills from disk",
|
||||
handler="_reload_config",
|
||||
),
|
||||
"clear": Command(
|
||||
|
|
@ -49,6 +49,11 @@ class CommandRegistry:
|
|||
description="Show path to current interaction log file",
|
||||
handler="_show_log_path",
|
||||
),
|
||||
"debug": Command(
|
||||
aliases=frozenset(["/debug"]),
|
||||
description="Toggle debug console",
|
||||
handler="action_toggle_debug_console",
|
||||
),
|
||||
"compact": Command(
|
||||
aliases=frozenset(["/compact"]),
|
||||
description="Compact conversation history by summarizing",
|
||||
|
|
@ -85,6 +90,11 @@ class CommandRegistry:
|
|||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
"mcp": Command(
|
||||
aliases=frozenset(["/mcp"]),
|
||||
description="Display available MCP servers. Pass the name of a server to list its tools",
|
||||
handler="_show_mcp",
|
||||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Configure voice settings",
|
||||
|
|
@ -120,13 +130,23 @@ class CommandRegistry:
|
|||
for alias in cmd.aliases:
|
||||
self._alias_map[alias] = cmd_name
|
||||
|
||||
def find_command(self, user_input: str) -> Command | None:
|
||||
cmd_name = self.get_command_name(user_input)
|
||||
return self.commands.get(cmd_name) if cmd_name else None
|
||||
|
||||
def get_command_name(self, user_input: str) -> str | None:
|
||||
return self._alias_map.get(user_input.lower().strip())
|
||||
|
||||
def parse_command(self, user_input: str) -> tuple[str, Command, str] | None:
|
||||
parts = user_input.strip().split(None, 1)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
cmd_word = parts[0]
|
||||
cmd_args = parts[1] if len(parts) > 1 else ""
|
||||
cmd_name = self.get_command_name(cmd_word)
|
||||
if cmd_name is None:
|
||||
return None
|
||||
|
||||
command = self.commands[cmd_name]
|
||||
return cmd_name, command, cmd_args
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
lines: list[str] = [
|
||||
"### Keyboard Shortcuts",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class HistoryManager:
|
|||
|
||||
def add(self, text: str) -> None:
|
||||
text = text.strip()
|
||||
if not text or text.startswith("/"):
|
||||
if not text:
|
||||
return
|
||||
|
||||
if self._entries and self._entries[-1] == text:
|
||||
|
|
|
|||
81
vibe/cli/profiler.py
Normal file
81
vibe/cli/profiler.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""General-purpose profiler for measuring any section of the application.
|
||||
|
||||
Wraps pyinstrument (dev-only dependency). Silently no-ops when not installed.
|
||||
Activated by the VIBE_PROFILE=1 environment variable.
|
||||
|
||||
Usage:
|
||||
from vibe.cli import profiler
|
||||
|
||||
profiler.start("startup")
|
||||
# ... code to profile ...
|
||||
profiler.stop_and_print()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyinstrument import Profiler
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _State:
|
||||
profiler: Profiler | None = None
|
||||
label: str = "default"
|
||||
|
||||
|
||||
_state = _State()
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Return True when profiling is activated via environment variable."""
|
||||
return bool(os.environ.get("VIBE_PROFILE"))
|
||||
|
||||
|
||||
def start(label: str = "default") -> None:
|
||||
"""Start profiling. The label is used to name the output file.
|
||||
|
||||
No-op if pyinstrument is missing or env var unset.
|
||||
"""
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
from pyinstrument import Profiler
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
if _state.profiler is not None:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Profiler already running; stop it before starting a new one.", stacklevel=2
|
||||
)
|
||||
return
|
||||
|
||||
_state.label = label
|
||||
_state.profiler = Profiler()
|
||||
_state.profiler.start()
|
||||
|
||||
|
||||
def stop_and_print() -> None:
|
||||
"""Stop profiling, write an HTML report, and print a text summary to stderr."""
|
||||
if _state.profiler is None:
|
||||
return
|
||||
_state.profiler.stop()
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
output_path = Path(f"{_state.label}-profile.html")
|
||||
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
|
||||
print(
|
||||
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(_state.profiler.output_text(color=True), file=sys.stderr)
|
||||
|
||||
_state.profiler = None
|
||||
_state.label = "default"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import aclosing
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
|
|
@ -56,9 +57,11 @@ from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
|||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
||||
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
|
||||
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
BashOutputMessage,
|
||||
ErrorMessage,
|
||||
|
|
@ -112,6 +115,7 @@ from vibe.core.audio_recorder import AudioRecorder
|
|||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
from vibe.core.log_reader import LogReader
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.rewind import RewindError
|
||||
|
|
@ -171,6 +175,7 @@ class BottomApp(StrEnum):
|
|||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
MCP = auto()
|
||||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
|
|
@ -184,7 +189,7 @@ class ChatScroll(VerticalScroll):
|
|||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||
return self.scroll_target_y >= self.max_scroll_y
|
||||
|
||||
_reanchor_pending: bool = False
|
||||
_scrolling_down: bool = False
|
||||
|
|
@ -281,6 +286,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
Binding(
|
||||
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
|
||||
),
|
||||
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
|
||||
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("alt+down", "rewind_next", "Rewind Next", show=False, priority=True),
|
||||
|
|
@ -354,6 +360,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._log_reader = LogReader()
|
||||
self._debug_console: DebugConsole | None = None
|
||||
self._switch_agent_generation = 0
|
||||
self._plan_info: PlanInfo | None = None
|
||||
self._narrator_manager: NarratorManagerPort = (
|
||||
|
|
@ -663,6 +671,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None:
|
||||
await self._mount_and_scroll(UserCommandMessage("MCP servers closed."))
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
self, message: ProxySetupApp.ProxySetupClosed
|
||||
) -> None:
|
||||
|
|
@ -700,17 +712,17 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await widget.remove()
|
||||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
if cmd_name := self.commands.get_command_name(user_input):
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
if resolved := self.commands.parse_command(user_input):
|
||||
cmd_name, command, cmd_args = resolved
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
await handler(cmd_args=cmd_args)
|
||||
else:
|
||||
handler()
|
||||
handler(cmd_args=cmd_args)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -781,17 +793,103 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(
|
||||
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
await self.agent_loop.inject_user_context(
|
||||
self._format_manual_command_context(
|
||||
command=command,
|
||||
cwd=str(Path.cwd()),
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
stdout = (
|
||||
error.stdout.decode("utf-8", errors="replace")
|
||||
if isinstance(error.stdout, bytes)
|
||||
else (error.stdout or "")
|
||||
)
|
||||
stderr = (
|
||||
error.stderr.decode("utf-8", errors="replace")
|
||||
if isinstance(error.stderr, bytes)
|
||||
else (error.stderr or "")
|
||||
)
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"Command timed out after 30 seconds",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
await self.agent_loop.inject_user_context(
|
||||
self._format_manual_command_context(
|
||||
command=command,
|
||||
cwd=str(Path.cwd()),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
status="timed out after 30 seconds",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
|
||||
)
|
||||
await self.agent_loop.inject_user_context(
|
||||
self._format_manual_command_context(
|
||||
command=command,
|
||||
cwd=str(Path.cwd()),
|
||||
status=f"failed before completion: {e}",
|
||||
)
|
||||
)
|
||||
|
||||
def _get_bash_max_output_bytes(self) -> int:
|
||||
from vibe.core.tools.builtins.bash import BashToolConfig
|
||||
|
||||
config = self.agent_loop.tool_manager.get_tool_config("bash")
|
||||
if isinstance(config, BashToolConfig):
|
||||
return config.max_output_bytes
|
||||
return BashToolConfig().max_output_bytes
|
||||
|
||||
@staticmethod
|
||||
def _cap_output(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "\n... [truncated]"
|
||||
|
||||
def _format_manual_command_context(
|
||||
self,
|
||||
*,
|
||||
command: str,
|
||||
cwd: str,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
exit_code: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> str:
|
||||
limit = self._get_bash_max_output_bytes()
|
||||
stdout = self._cap_output(stdout, limit)
|
||||
stderr = self._cap_output(stderr, limit)
|
||||
|
||||
sections = [
|
||||
"Manual `!` command result from the user. Use this as context only.",
|
||||
f"Command: `{command}`",
|
||||
f"Working directory: `{cwd}`",
|
||||
]
|
||||
|
||||
if status is not None:
|
||||
sections.append(f"Status: {status}")
|
||||
|
||||
if exit_code is not None:
|
||||
sections.append(f"Exit code: {exit_code}")
|
||||
|
||||
if stdout:
|
||||
sections.append(f"Stdout:\n```text\n{stdout.rstrip()}\n```")
|
||||
|
||||
if stderr:
|
||||
sections.append(f"Stderr:\n```text\n{stderr.rstrip()}\n```")
|
||||
|
||||
if not stdout and not stderr:
|
||||
sections.append("Output:\n```text\n(no output)\n```")
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _handle_user_message(self, message: str) -> None:
|
||||
if self._remote_manager.is_active:
|
||||
|
|
@ -967,20 +1065,21 @@ class VibeApp(App): # noqa: PLR0904
|
|||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
self._narrator_manager.cancel()
|
||||
self._narrator_manager.on_turn_start(rendered_prompt)
|
||||
async for event in self.agent_loop.act(rendered_prompt):
|
||||
self._narrator_manager.on_turn_event(event)
|
||||
if isinstance(event, WaitingForInputEvent):
|
||||
await self._remove_loading_widget()
|
||||
if self._remote_manager.is_active:
|
||||
await self._handle_remote_waiting_input(event)
|
||||
elif self._loading_widget is None and is_progress_event(event):
|
||||
await self._ensure_loading_widget()
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
loading_active=self._loading_widget is not None,
|
||||
loading_widget=self._loading_widget,
|
||||
)
|
||||
async with aclosing(self.agent_loop.act(rendered_prompt)) as events:
|
||||
async for event in events:
|
||||
self._narrator_manager.on_turn_event(event)
|
||||
if isinstance(event, WaitingForInputEvent):
|
||||
await self._remove_loading_widget()
|
||||
if self._remote_manager.is_active:
|
||||
await self._handle_remote_waiting_input(event)
|
||||
elif self._loading_widget is None and is_progress_event(event):
|
||||
await self._ensure_loading_widget()
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
loading_active=self._loading_widget is not None,
|
||||
loading_widget=self._loading_widget,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._handle_turn_error()
|
||||
|
|
@ -1020,7 +1119,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
return "Rate limits exceeded. Please wait a moment before trying again."
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
async def _teleport_command(self, **kwargs: Any) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
||||
async def _handle_teleport_command(
|
||||
|
|
@ -1172,11 +1271,39 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self._interrupt_requested = False
|
||||
|
||||
async def _show_help(self) -> None:
|
||||
async def _show_help(self, **kwargs: Any) -> None:
|
||||
help_text = self.commands.get_help_text()
|
||||
await self._mount_and_scroll(UserCommandMessage(help_text))
|
||||
|
||||
async def _show_status(self) -> None:
|
||||
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
|
||||
mcp_servers = self.config.mcp_servers
|
||||
if not mcp_servers:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No MCP servers configured.")
|
||||
)
|
||||
return
|
||||
if self._current_bottom_app == BottomApp.MCP:
|
||||
return
|
||||
name = cmd_args.strip()
|
||||
if name and not any(s.name == name for s in mcp_servers):
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Unknown MCP server: {name}. Known servers: "
|
||||
+ ", ".join(s.name for s in mcp_servers),
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
await self._mount_and_scroll(UserCommandMessage("MCP servers opened..."))
|
||||
await self._switch_from_input(
|
||||
MCPApp(
|
||||
mcp_servers=mcp_servers,
|
||||
tool_manager=self.agent_loop.tool_manager,
|
||||
initial_server=name,
|
||||
)
|
||||
)
|
||||
|
||||
async def _show_status(self, **kwargs: Any) -> None:
|
||||
stats = self.agent_loop.stats
|
||||
status_text = f"""## Agent Statistics
|
||||
|
||||
|
|
@ -1189,27 +1316,27 @@ class VibeApp(App): # noqa: PLR0904
|
|||
"""
|
||||
await self._mount_and_scroll(UserCommandMessage(status_text))
|
||||
|
||||
async def _show_config(self) -> None:
|
||||
async def _show_config(self, **kwargs: Any) -> None:
|
||||
"""Switch to the configuration app in the bottom panel."""
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_model(self) -> None:
|
||||
async def _show_model(self, **kwargs: Any) -> None:
|
||||
"""Switch to the model picker in the bottom panel."""
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def _show_proxy_setup(self) -> None:
|
||||
async def _show_proxy_setup(self, **kwargs: Any) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _show_data_retention(self) -> None:
|
||||
async def _show_data_retention(self, **kwargs: Any) -> None:
|
||||
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
|
||||
|
||||
async def _show_session_picker(self) -> None:
|
||||
async def _show_session_picker(self, **kwargs: Any) -> None:
|
||||
cwd = str(Path.cwd())
|
||||
local_sessions = (
|
||||
list_local_resume_sessions(self.config, cwd)
|
||||
|
|
@ -1405,7 +1532,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def loading_widget(self) -> LoadingWidget | None:
|
||||
return self._loading_widget
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
async def _reload_config(self, **kwargs: Any) -> None:
|
||||
try:
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
|
|
@ -1422,7 +1549,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.mcp_registry,
|
||||
plan_title(self._plan_info),
|
||||
)
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Configuration reloaded (includes agent instructions and skills)."
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1430,7 +1561,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _install_lean(self) -> None:
|
||||
async def _install_lean(self, **kwargs: Any) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" in current:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -1440,7 +1571,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
|
||||
await self._reload_config()
|
||||
|
||||
async def _uninstall_lean(self) -> None:
|
||||
async def _uninstall_lean(self, **kwargs: Any) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" not in current:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -1452,7 +1583,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
})
|
||||
await self._reload_config()
|
||||
|
||||
async def _clear_history(self) -> None:
|
||||
async def _clear_history(self, **kwargs: Any) -> None:
|
||||
try:
|
||||
self._reset_ui_state()
|
||||
if self._remote_manager.is_active:
|
||||
|
|
@ -1482,7 +1613,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _show_log_path(self) -> None:
|
||||
async def _show_log_path(self, **kwargs: Any) -> None:
|
||||
if not self.agent_loop.session_logger.enabled:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1506,7 +1637,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _compact_history(self) -> None:
|
||||
async def _compact_history(self, **kwargs: Any) -> None:
|
||||
if self._agent_running:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1570,11 +1701,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return None
|
||||
return short_session_id(self.agent_loop.session_logger.session_id)
|
||||
|
||||
async def _exit_app(self) -> None:
|
||||
async def _exit_app(self, **kwargs: Any) -> None:
|
||||
self._log_reader.shutdown()
|
||||
await self._narrator_manager.close()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
async def _setup_terminal(self) -> None:
|
||||
async def _setup_terminal(self, **kwargs: Any) -> None:
|
||||
result = setup_terminal()
|
||||
|
||||
if result.success:
|
||||
|
|
@ -1612,7 +1744,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
telemetry_client=self.agent_loop.telemetry_client,
|
||||
)
|
||||
|
||||
async def _show_voice_settings(self) -> None:
|
||||
async def _show_voice_settings(self, **kwargs: Any) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
await self._switch_to_voice_app()
|
||||
|
|
@ -1720,6 +1852,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.MCP:
|
||||
self.query_one(MCPApp).focus()
|
||||
case BottomApp.Rewind:
|
||||
self.query_one(RewindApp).focus()
|
||||
case BottomApp.Voice:
|
||||
|
|
@ -1794,7 +1928,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if isinstance(child, UserMessage) and child.message_index is not None
|
||||
]
|
||||
|
||||
def _start_rewind_mode(self) -> None:
|
||||
def _start_rewind_mode(self, **kwargs: Any) -> None:
|
||||
self.action_rewind_prev()
|
||||
|
||||
def action_rewind_prev(self) -> None:
|
||||
|
|
@ -1987,6 +2121,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def _handle_bottom_app_close_escape(
|
||||
self, widget_type: type[MCPApp] | type[ProxySetupApp]
|
||||
) -> None:
|
||||
try:
|
||||
self.query_one(widget_type).action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def action_interrupt(self) -> None: # noqa: PLR0911
|
||||
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
||||
self._voice_manager.cancel_recording()
|
||||
|
|
@ -2002,13 +2145,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_voice_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.MCP:
|
||||
self._handle_bottom_app_close_escape(MCPApp)
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
proxy_setup_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
self._handle_bottom_app_close_escape(ProxySetupApp)
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
|
|
@ -2170,6 +2312,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.call_after_refresh(schedule_switch)
|
||||
|
||||
async def action_toggle_debug_console(self) -> None:
|
||||
if self._debug_console is not None:
|
||||
await self._debug_console.remove()
|
||||
self._debug_console = None
|
||||
else:
|
||||
self._debug_console = DebugConsole(log_reader=self._log_reader)
|
||||
await self.mount(self._debug_console)
|
||||
|
||||
def action_clear_quit(self) -> None:
|
||||
input_widgets = self.query(ChatInputContainer)
|
||||
if input_widgets:
|
||||
|
|
@ -2185,6 +2335,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._agent_task.cancel()
|
||||
self._remote_manager.cancel_stream_task()
|
||||
|
||||
self._log_reader.shutdown()
|
||||
self._narrator_manager.cancel()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
|
|
|
|||
|
|
@ -84,10 +84,17 @@ TextArea > .text-area--cursor {
|
|||
}
|
||||
|
||||
#completion-popup {
|
||||
width: 100%;
|
||||
padding: 1;
|
||||
padding-left: 3;
|
||||
overlay: screen;
|
||||
constrain: inside inside;
|
||||
display: none;
|
||||
width: auto;
|
||||
max-width: 80;
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
padding: 0 1;
|
||||
color: ansi_default;
|
||||
background: $surface;
|
||||
border: solid ansi_bright_black;
|
||||
}
|
||||
|
||||
#input-box {
|
||||
|
|
@ -679,7 +686,7 @@ StatusMessage {
|
|||
|
||||
}
|
||||
|
||||
#config-app, #voice-app {
|
||||
#config-app, #voice-app, #mcp-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
|
|
@ -688,7 +695,7 @@ StatusMessage {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
#config-content, #voice-content {
|
||||
#config-content, #voice-content, #mcp-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -699,13 +706,13 @@ StatusMessage {
|
|||
color: ansi_blue;
|
||||
}
|
||||
|
||||
#config-options {
|
||||
#config-options, #mcp-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#config-options:focus {
|
||||
#config-options:focus, #mcp-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
|
@ -1089,6 +1096,35 @@ NarratorStatus {
|
|||
margin-top: 1;
|
||||
}
|
||||
|
||||
/* Debug Console */
|
||||
|
||||
#debug-console {
|
||||
dock: right;
|
||||
width: 40%;
|
||||
max-width: 80;
|
||||
min-width: 40;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-left: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#debug-console-header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
padding: 0 0 1 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#debug-console-log {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
background: transparent;
|
||||
overflow-x: hidden;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
#modelpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -33,15 +33,12 @@ class CompletionPopup(Static):
|
|||
text.append(description, style=description_style)
|
||||
|
||||
self.update(text)
|
||||
self.show()
|
||||
self.styles.display = "block"
|
||||
|
||||
def hide(self) -> None:
|
||||
self.update("")
|
||||
self.styles.display = "none"
|
||||
|
||||
def show(self) -> None:
|
||||
self.styles.display = "block"
|
||||
|
||||
def _display_label(self, label: str) -> str:
|
||||
if label.startswith("@"):
|
||||
return label[1:]
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ class ChatInputContainer(Vertical):
|
|||
self,
|
||||
),
|
||||
])
|
||||
self._completion_popup: CompletionPopup | None = None
|
||||
self._body: ChatInputBody | None = None
|
||||
|
||||
def _get_slash_entries(self) -> list[tuple[str, str]]:
|
||||
|
|
@ -86,8 +85,7 @@ class ChatInputContainer(Vertical):
|
|||
return sorted(entries)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._completion_popup = CompletionPopup()
|
||||
yield self._completion_popup
|
||||
yield CompletionPopup()
|
||||
|
||||
border_class = self._get_border_class()
|
||||
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
|
||||
|
|
@ -138,12 +136,32 @@ class ChatInputContainer(Vertical):
|
|||
def render_completion_suggestions(
|
||||
self, suggestions: list[tuple[str, str]], selected_index: int
|
||||
) -> None:
|
||||
if self._completion_popup:
|
||||
self._completion_popup.update_suggestions(suggestions, selected_index)
|
||||
try:
|
||||
popup = self.query_one(CompletionPopup)
|
||||
except Exception:
|
||||
return
|
||||
popup.update_suggestions(suggestions, selected_index)
|
||||
self._position_popup(popup, len(suggestions))
|
||||
|
||||
def clear_completion_suggestions(self) -> None:
|
||||
if self._completion_popup:
|
||||
self._completion_popup.hide()
|
||||
try:
|
||||
popup = self.query_one(CompletionPopup)
|
||||
except Exception:
|
||||
return
|
||||
popup.hide()
|
||||
|
||||
def _position_popup(self, popup: CompletionPopup, line_count: int) -> None:
|
||||
widget = self.input_widget
|
||||
if not widget:
|
||||
return
|
||||
cursor = widget.cursor_screen_offset
|
||||
my_region = self.region
|
||||
# Place popup bottom edge just above the cursor row
|
||||
popup_height = line_count + 2 # +2 for solid border
|
||||
popup.styles.offset = (
|
||||
cursor.x - my_region.x,
|
||||
cursor.y - popup_height - my_region.y,
|
||||
)
|
||||
|
||||
def _format_insertion(self, replacement: str, suffix: str) -> str:
|
||||
"""Format the insertion text with appropriate spacing.
|
||||
|
|
|
|||
244
vibe/cli/textual_ui/widgets/debug_console.py
Normal file
244
vibe/cli/textual_ui/widgets/debug_console.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from collections.abc import Callable
|
||||
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.cache import LRUCache
|
||||
from textual.containers import Vertical
|
||||
from textual.geometry import Size
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.core.log_reader import LogEntry, LogReader
|
||||
from vibe.core.logger import decode_log_message
|
||||
|
||||
LOG_LEVEL_COLORS: dict[str, str] = {
|
||||
"DEBUG": "dim",
|
||||
"INFO": "cyan",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "bold red",
|
||||
}
|
||||
|
||||
DEFAULT_LOG_PAGE_SIZE = 30
|
||||
|
||||
|
||||
class _LogView(ScrollView, can_focus=True):
|
||||
def __init__(
|
||||
self,
|
||||
load_page: Callable[[], None],
|
||||
has_more: Callable[[], bool],
|
||||
*,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(id=id)
|
||||
self._lines: list[str] = []
|
||||
self._wrap_counts: list[int] = []
|
||||
self._wrap_prefix: list[int] = [0]
|
||||
self._total_visual: int = 0
|
||||
self._cached_width: int = 0
|
||||
self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024)
|
||||
self._load_page = load_page
|
||||
self._has_more = has_more
|
||||
|
||||
def _wrap_markup(self, markup: str) -> int:
|
||||
"""Return the number of visual lines this markup produces at current width."""
|
||||
width = self._cached_width
|
||||
if width <= 0:
|
||||
return 1
|
||||
|
||||
text = Text.from_markup(markup, style=self.rich_style)
|
||||
|
||||
return len(text.wrap(self.app.console, width))
|
||||
|
||||
def _recompute_prefix(self) -> None:
|
||||
self._wrap_prefix = [0]
|
||||
for count in self._wrap_counts:
|
||||
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
|
||||
self._total_visual = self._wrap_prefix[-1]
|
||||
|
||||
def _reflow(self) -> None:
|
||||
"""Re-wrap all lines at current widget width."""
|
||||
width = self.size.width
|
||||
if width <= 0:
|
||||
return
|
||||
self._cached_width = width
|
||||
self._render_line_cache.clear()
|
||||
self._wrap_counts = [self._wrap_markup(m) for m in self._lines]
|
||||
self._recompute_prefix()
|
||||
self.virtual_size = Size(width, self._total_visual)
|
||||
|
||||
def write_line(self, markup: str, scroll_end: bool | None = None) -> None:
|
||||
at_bottom = self.is_vertical_scroll_end
|
||||
width = self._cached_width or self.size.width
|
||||
self._cached_width = width
|
||||
|
||||
self._lines.append(markup)
|
||||
count = self._wrap_markup(markup)
|
||||
self._wrap_counts.append(count)
|
||||
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
|
||||
self._total_visual += count
|
||||
self.virtual_size = Size(width, self._total_visual)
|
||||
|
||||
if scroll_end or (scroll_end is None and at_bottom):
|
||||
self.scroll_end(animate=False, immediate=True, x_axis=False)
|
||||
|
||||
def prepend_lines(self, markups: list[str]) -> None:
|
||||
if not markups:
|
||||
return
|
||||
width = self._cached_width or self.size.width
|
||||
self._cached_width = width
|
||||
|
||||
new_counts = [self._wrap_markup(m) for m in markups]
|
||||
new_visual = sum(new_counts)
|
||||
|
||||
self._lines[0:0] = markups
|
||||
self._wrap_counts[0:0] = new_counts
|
||||
self._recompute_prefix()
|
||||
self._render_line_cache.clear()
|
||||
self.virtual_size = Size(width, self._total_visual)
|
||||
self.scroll_to(y=self.scroll_y + new_visual, animate=False, immediate=True)
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
_, scroll_y = self.scroll_offset
|
||||
abs_y = scroll_y + y
|
||||
width = self.size.width
|
||||
wrap_width = self._cached_width or width
|
||||
rich_style = self.rich_style
|
||||
|
||||
if abs_y >= self._total_visual:
|
||||
return Strip.blank(width, rich_style)
|
||||
if abs_y in self._render_line_cache:
|
||||
return self._render_line_cache[abs_y]
|
||||
|
||||
logical_idx = bisect.bisect_right(self._wrap_prefix, abs_y) - 1
|
||||
text = Text.from_markup(self._lines[logical_idx], style=rich_style)
|
||||
wrapped = text.wrap(self.app.console, wrap_width)
|
||||
|
||||
base = self._wrap_prefix[logical_idx]
|
||||
for i, line_text in enumerate(wrapped):
|
||||
strip = Strip(line_text.render(self.app.console), line_text.cell_len)
|
||||
strip = strip.crop_extend(0, width, rich_style)
|
||||
self._render_line_cache[base + i] = strip
|
||||
|
||||
try:
|
||||
return self._render_line_cache[abs_y]
|
||||
except KeyError:
|
||||
return Strip.blank(width, rich_style)
|
||||
|
||||
def notify_style_update(self) -> None:
|
||||
super().notify_style_update()
|
||||
self._render_line_cache.clear()
|
||||
|
||||
def on_resize(self, event: events.Resize) -> None:
|
||||
if event.size.width != self._cached_width:
|
||||
self._reflow()
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
_, scroll_y = self.scroll_offset
|
||||
visual_y = scroll_y + event.y
|
||||
logical_idx = bisect.bisect_right(self._wrap_prefix, visual_y) - 1
|
||||
if 0 <= logical_idx < len(self._lines):
|
||||
plain = Text.from_markup(self._lines[logical_idx]).plain
|
||||
self.app.copy_to_clipboard(plain)
|
||||
self.app.notify("Copied to clipboard", timeout=2.0)
|
||||
|
||||
def _try_load_previous(self) -> None:
|
||||
if not self._has_more() or self.scroll_y > 0:
|
||||
return
|
||||
self._load_page()
|
||||
|
||||
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
||||
super()._on_mouse_scroll_up(event)
|
||||
self._try_load_previous()
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
super().action_scroll_up()
|
||||
self._try_load_previous()
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
super().action_page_up()
|
||||
self._try_load_previous()
|
||||
|
||||
def action_scroll_home(self) -> None:
|
||||
super().action_scroll_home()
|
||||
self._try_load_previous()
|
||||
|
||||
|
||||
class DebugConsole(Vertical):
|
||||
def __init__(
|
||||
self, log_reader: LogReader, page_size: int = DEFAULT_LOG_PAGE_SIZE
|
||||
) -> None:
|
||||
super().__init__(id="debug-console")
|
||||
self._log_reader = log_reader
|
||||
self._log_view: _LogView | None = None
|
||||
self._cursor: int | None = None
|
||||
self._has_more: bool = True
|
||||
self._page_size = page_size
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
"Debug Console [dim](ctrl+\\ to close)[/dim]", id="debug-console-header"
|
||||
)
|
||||
self._log_view = _LogView(
|
||||
load_page=self._load_page,
|
||||
has_more=lambda: self._has_more and self._cursor is not None,
|
||||
id="debug-console-log",
|
||||
)
|
||||
yield self._log_view
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._fill_viewport()
|
||||
self._log_reader.set_consumer(self._on_log_entry)
|
||||
self._log_reader.start_watching()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._log_reader.set_consumer(None)
|
||||
self._log_reader.stop_watching()
|
||||
|
||||
def _load_page(self) -> None:
|
||||
if self._log_view is None:
|
||||
return
|
||||
result = self._log_reader.get_logs(
|
||||
limit=self._page_size, offset=self._cursor or 0
|
||||
)
|
||||
self._cursor = result.cursor
|
||||
self._has_more = result.has_more
|
||||
markups = [self._format_entry(e) for e in reversed(result.entries)]
|
||||
self._log_view.prepend_lines(markups)
|
||||
|
||||
def _fill_viewport(self) -> None:
|
||||
"""Load enough logs to fill the viewport, then scroll to the bottom."""
|
||||
if self._log_view is None or not self._has_more:
|
||||
return
|
||||
self.call_after_refresh(self._check_and_fill)
|
||||
|
||||
def _check_and_fill(self) -> None:
|
||||
if self._log_view is None or not self._has_more:
|
||||
return
|
||||
if self._log_view.virtual_size.height <= self._log_view.size.height:
|
||||
self._load_page()
|
||||
self._fill_viewport()
|
||||
else:
|
||||
self._log_view.scroll_end(animate=False)
|
||||
|
||||
def _on_log_entry(self, entry: LogEntry) -> None:
|
||||
self.app.call_from_thread(self._append_log_entry, entry)
|
||||
|
||||
def _append_log_entry(self, entry: LogEntry) -> None:
|
||||
if self._log_view is None:
|
||||
return
|
||||
self._log_view.write_line(self._format_entry(entry))
|
||||
|
||||
@staticmethod
|
||||
def _format_entry(entry: LogEntry) -> str:
|
||||
color = LOG_LEVEL_COLORS.get(entry.level, "dim")
|
||||
ts = entry.timestamp.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
message = decode_log_message(entry.message)
|
||||
safe_message = escape(message)
|
||||
return f"[dim]{ts}[/dim] [{color}]{entry.level:<8}[/{color}] {safe_message}"
|
||||
157
vibe/cli/textual_ui/widgets/mcp_app.py
Normal file
157
vibe/cli/textual_ui/widgets/mcp_app.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.events import DescendantBlur
|
||||
from textual.message import Message
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.tools.mcp.tools import MCPTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import MCPServer
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
|
||||
class MCPToolIndex(NamedTuple):
|
||||
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
|
||||
enabled_tools: dict[str, type[Any]]
|
||||
|
||||
|
||||
def collect_mcp_tool_index(
|
||||
mcp_servers: Sequence[MCPServer], tool_manager: ToolManager
|
||||
) -> MCPToolIndex:
|
||||
registered = tool_manager.registered_tools
|
||||
available = tool_manager.available_tools
|
||||
configured_servers = {server.name for server in mcp_servers}
|
||||
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
|
||||
|
||||
for tool_name, cls in registered.items():
|
||||
if not issubclass(cls, MCPTool):
|
||||
continue
|
||||
server_name = cls.get_server_name()
|
||||
if server_name is None or server_name not in configured_servers:
|
||||
continue
|
||||
server_tools.setdefault(server_name, []).append((tool_name, cls))
|
||||
|
||||
return MCPToolIndex(server_tools, enabled_tools=available)
|
||||
|
||||
|
||||
class MCPApp(Container):
|
||||
can_focus_children = True
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "close", "Close", show=False),
|
||||
Binding("backspace", "back", "Back", show=False),
|
||||
]
|
||||
|
||||
class MCPClosed(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_servers: Sequence[MCPServer],
|
||||
tool_manager: ToolManager,
|
||||
initial_server: str = "",
|
||||
) -> None:
|
||||
super().__init__(id="mcp-app")
|
||||
self._mcp_servers = mcp_servers
|
||||
self._tool_manager = tool_manager
|
||||
self._index = collect_mcp_tool_index(mcp_servers, tool_manager)
|
||||
self._viewing_server: str | None = initial_server.strip() or None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="mcp-content"):
|
||||
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(id="mcp-options")
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh_view(self._viewing_server)
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
option_id = event.option.id or ""
|
||||
if option_id.startswith("server:"):
|
||||
self._refresh_view(option_id.removeprefix("server:"))
|
||||
|
||||
def action_back(self) -> None:
|
||||
if self._viewing_server is not None:
|
||||
self._refresh_view(None)
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.MCPClosed())
|
||||
|
||||
def _refresh_view(self, server_name: str | None) -> None:
|
||||
index = self._index
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
server_names = {s.name for s in self._mcp_servers}
|
||||
if server_name is None or server_name not in server_names:
|
||||
self._viewing_server = None
|
||||
self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers")
|
||||
self.query_one("#mcp-help", NoMarkupStatic).update(
|
||||
"↑↓ Navigate Enter Show tools Esc Close"
|
||||
)
|
||||
for srv in self._mcp_servers:
|
||||
tools = index.server_tools.get(srv.name, [])
|
||||
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
|
||||
status = _server_status(enabled)
|
||||
label = Text(no_wrap=True)
|
||||
label.append(srv.name)
|
||||
label.append(f" [{srv.transport}] {status}")
|
||||
option_list.add_option(Option(label, id=f"server:{srv.name}"))
|
||||
if self._mcp_servers:
|
||||
option_list.highlighted = 0
|
||||
return
|
||||
|
||||
self._viewing_server = server_name
|
||||
self.query_one("#mcp-title", NoMarkupStatic).update(
|
||||
f"MCP Server: {server_name}"
|
||||
)
|
||||
self.query_one("#mcp-help", NoMarkupStatic).update(
|
||||
"↑↓ Navigate Backspace Back Esc Close"
|
||||
)
|
||||
enabled_tools = [
|
||||
(tool_name, cls)
|
||||
for tool_name, cls in sorted(
|
||||
index.server_tools.get(server_name, []), key=lambda t: t[0]
|
||||
)
|
||||
if tool_name in index.enabled_tools
|
||||
]
|
||||
if not enabled_tools:
|
||||
option_list.add_option(
|
||||
Option("No enabled tools for this server", disabled=True)
|
||||
)
|
||||
return
|
||||
for tool_name, cls in enabled_tools:
|
||||
remote_name = cls.get_remote_name()
|
||||
raw_desc = (
|
||||
(cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0]
|
||||
)
|
||||
label = Text(no_wrap=True)
|
||||
label.append(remote_name, style="bold")
|
||||
if raw_desc:
|
||||
label.append(f" - {raw_desc}")
|
||||
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
|
||||
if enabled_tools:
|
||||
option_list.highlighted = 0
|
||||
|
||||
|
||||
def _server_status(enabled: int) -> str:
|
||||
if enabled == 0:
|
||||
return "unavailable"
|
||||
noun = "tool" if enabled == 1 else "tools"
|
||||
return f"{enabled} {noun} enabled"
|
||||
|
|
@ -1,5 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = ["run_programmatic"]
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.programmatic import run_programmatic as run_programmatic
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
if name == "run_programmatic":
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
|
||||
return run_programmatic
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
|
|
|||
|
|
@ -159,11 +159,14 @@ class AgentLoop:
|
|||
backend: BackendLike | None = None,
|
||||
enable_streaming: bool = False,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
is_subagent: bool = False,
|
||||
) -> None:
|
||||
self._base_config = config
|
||||
self.mcp_registry = MCPRegistry()
|
||||
self.agent_manager = AgentManager(
|
||||
lambda: self._base_config, initial_agent=agent_name
|
||||
lambda: self._base_config,
|
||||
initial_agent=agent_name,
|
||||
allow_subagent=is_subagent,
|
||||
)
|
||||
self.tool_manager = ToolManager(
|
||||
lambda: self.config, mcp_registry=self.mcp_registry
|
||||
|
|
@ -209,6 +212,7 @@ class AgentLoop:
|
|||
self._is_user_prompt_call: bool = False
|
||||
|
||||
self._session_rules: list[ApprovedRule] = []
|
||||
self._approval_lock = asyncio.Lock()
|
||||
|
||||
self.telemetry_client = TelemetryClient(
|
||||
config_getter=lambda: self.config, session_id_getter=lambda: self.session_id
|
||||
|
|
@ -267,7 +271,6 @@ class AgentLoop:
|
|||
self.config.tools[tool_name] = {}
|
||||
|
||||
self.config.tools[tool_name]["permission"] = permission.value
|
||||
self.tool_manager.invalidate_tool(tool_name)
|
||||
|
||||
def add_session_rule(self, rule: ApprovedRule) -> None:
|
||||
self._session_rules.append(rule)
|
||||
|
|
@ -350,6 +353,10 @@ class AgentLoop:
|
|||
self.agent_profile,
|
||||
)
|
||||
|
||||
async def inject_user_context(self, content: str) -> None:
|
||||
self.messages.append(LLMMessage(role=Role.user, content=content, injected=True))
|
||||
await self._save_messages()
|
||||
|
||||
async def act(
|
||||
self, msg: str, client_message_id: str | None = None
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
|
|
@ -1019,40 +1026,41 @@ class AgentLoop:
|
|||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
|
||||
tool_name = tool.get_name()
|
||||
ctx = tool.resolve_permission(args)
|
||||
async with self._approval_lock:
|
||||
tool_name = tool.get_name()
|
||||
ctx = tool.resolve_permission(args)
|
||||
|
||||
if ctx is None:
|
||||
config_perm = self.tool_manager.get_tool_config(tool_name).permission
|
||||
ctx = PermissionContext(permission=config_perm)
|
||||
if ctx is None:
|
||||
config_perm = self.tool_manager.get_tool_config(tool_name).permission
|
||||
ctx = PermissionContext(permission=config_perm)
|
||||
|
||||
match ctx.permission:
|
||||
case ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
case ToolPermission.NEVER:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=ctx.reason
|
||||
or f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
case _:
|
||||
uncovered = [
|
||||
rp
|
||||
for rp in ctx.required_permissions
|
||||
if not self._is_permission_covered(tool_name, rp)
|
||||
]
|
||||
if ctx.required_permissions and not uncovered:
|
||||
match ctx.permission:
|
||||
case ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
return await self._ask_approval(
|
||||
tool_name, args, tool_call_id, uncovered
|
||||
)
|
||||
case ToolPermission.NEVER:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=ctx.reason
|
||||
or f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
case _:
|
||||
uncovered = [
|
||||
rp
|
||||
for rp in ctx.required_permissions
|
||||
if not self._is_permission_covered(tool_name, rp)
|
||||
]
|
||||
if ctx.required_permissions and not uncovered:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
return await self._ask_approval(
|
||||
tool_name, args, tool_call_id, uncovered
|
||||
)
|
||||
|
||||
async def _ask_approval(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class AgentManager:
|
|||
self,
|
||||
config_getter: Callable[[], VibeConfig],
|
||||
initial_agent: str = BuiltinAgentName.DEFAULT,
|
||||
allow_subagent: bool = False,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._search_paths = self._compute_search_paths(self._config)
|
||||
|
|
@ -39,9 +40,18 @@ class AgentManager:
|
|||
" ".join(str(p) for p in self._search_paths),
|
||||
)
|
||||
|
||||
self.active_profile = self._available.get(
|
||||
initial_agent, self._available[BuiltinAgentName.DEFAULT]
|
||||
)
|
||||
profile = self._available.get(initial_agent)
|
||||
if (
|
||||
not allow_subagent
|
||||
and profile is not None
|
||||
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._cached_config: VibeConfig | None = None
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ LEAN = AgentProfile(
|
|||
}
|
||||
],
|
||||
"compaction_model": {
|
||||
"name": "mistral-vibe-cli-latest",
|
||||
"name": "mistral-small-latest",
|
||||
"provider": "mistral-testing",
|
||||
"alias": "devstral-compact",
|
||||
"temperature": 0.2,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ class WatchController:
|
|||
thread.start()
|
||||
ready_event.wait(timeout=0.5)
|
||||
|
||||
@property
|
||||
def is_watching(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def stop(self) -> None:
|
||||
thread = self._thread
|
||||
if self._stop_event:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from vibe.core.config._settings import (
|
|||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
ModelConfig,
|
||||
OtelExporterConfig,
|
||||
OtelSpanExporterConfig,
|
||||
ProjectContextConfig,
|
||||
ProviderConfig,
|
||||
SessionLoggingConfig,
|
||||
|
|
@ -45,7 +45,7 @@ __all__ = [
|
|||
"MissingAPIKeyError",
|
||||
"MissingPromptFileError",
|
||||
"ModelConfig",
|
||||
"OtelExporterConfig",
|
||||
"OtelSpanExporterConfig",
|
||||
"ProjectContextConfig",
|
||||
"ProviderConfig",
|
||||
"SessionLoggingConfig",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ from typing import Annotated, Any, Literal
|
|||
from urllib.parse import urljoin
|
||||
|
||||
from dotenv import dotenv_values
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
DEFAULT_TRACES_EXPORT_PATH,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
|
@ -291,7 +294,7 @@ class TTSModelConfig(BaseModel):
|
|||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
|
||||
class OtelExporterConfig(BaseModel):
|
||||
class OtelSpanExporterConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
endpoint: str
|
||||
|
|
@ -299,7 +302,7 @@ class OtelExporterConfig(BaseModel):
|
|||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
MISTRAL_OTEL_TRACES_PATH = "/telemetry/v1/traces"
|
||||
MISTRAL_OTEL_PATH = "/telemetry"
|
||||
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
|
||||
|
||||
DEFAULT_PROVIDERS = [
|
||||
|
|
@ -523,12 +526,17 @@ class VibeConfig(BaseSettings):
|
|||
return os.getenv(self.nuage_api_key_env_var, "")
|
||||
|
||||
@property
|
||||
def otel_exporter_config(self) -> OtelExporterConfig | None:
|
||||
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
|
||||
# When otel_endpoint is set explicitly, authentication is the user's responsibility
|
||||
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
|
||||
# Otherwise endpoint and API key are derived from the first MISTRAL provider.
|
||||
traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
|
||||
if self.otel_endpoint:
|
||||
return OtelExporterConfig(endpoint=self.otel_endpoint)
|
||||
return OtelSpanExporterConfig(
|
||||
endpoint=urljoin(
|
||||
f"{self.otel_endpoint.rstrip('/')}/", traces_export_path
|
||||
)
|
||||
)
|
||||
|
||||
provider = next(
|
||||
(p for p in self.providers if p.backend == Backend.MISTRAL), None
|
||||
|
|
@ -542,7 +550,8 @@ class VibeConfig(BaseSettings):
|
|||
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
|
||||
|
||||
endpoint = urljoin(
|
||||
server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_TRACES_PATH
|
||||
f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
|
||||
traces_export_path,
|
||||
)
|
||||
|
||||
if not (api_key := os.getenv(api_key_env)):
|
||||
|
|
@ -551,7 +560,7 @@ class VibeConfig(BaseSettings):
|
|||
)
|
||||
return None
|
||||
|
||||
return OtelExporterConfig(
|
||||
return OtelSpanExporterConfig(
|
||||
endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import httpx
|
|||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
|
||||
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
|
|
@ -160,14 +159,27 @@ class OpenAIAdapter(APIAdapter):
|
|||
return LLMChunk(message=message, usage=usage)
|
||||
|
||||
|
||||
ADAPTERS: dict[str, APIAdapter] = {
|
||||
_ADAPTERS: dict[str, APIAdapter] = {
|
||||
"openai": OpenAIAdapter(),
|
||||
"anthropic": AnthropicAdapter(),
|
||||
"vertex-anthropic": VertexAnthropicAdapter(),
|
||||
"reasoning": ReasoningAdapter(),
|
||||
}
|
||||
|
||||
|
||||
def _get_adapter(api_style: str) -> APIAdapter:
|
||||
"""Loads the appropriate adapter for the given API style,
|
||||
lazily if the adapter is not already loaded.
|
||||
"""
|
||||
if api_style not in _ADAPTERS:
|
||||
if api_style == "vertex-anthropic":
|
||||
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
|
||||
|
||||
_ADAPTERS["vertex-anthropic"] = VertexAnthropicAdapter()
|
||||
else:
|
||||
raise KeyError(api_style)
|
||||
return _ADAPTERS[api_style]
|
||||
|
||||
|
||||
class GenericBackend:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -232,7 +244,7 @@ class GenericBackend:
|
|||
)
|
||||
|
||||
api_style = getattr(self._provider, "api_style", "openai")
|
||||
adapter = ADAPTERS[api_style]
|
||||
adapter = _get_adapter(api_style)
|
||||
|
||||
req = adapter.prepare_request(
|
||||
model_name=model.name,
|
||||
|
|
@ -300,7 +312,7 @@ class GenericBackend:
|
|||
)
|
||||
|
||||
api_style = getattr(self._provider, "api_style", "openai")
|
||||
adapter = ADAPTERS[api_style]
|
||||
adapter = _get_adapter(api_style)
|
||||
|
||||
req = adapter.prepare_request(
|
||||
model_name=model.name,
|
||||
|
|
|
|||
|
|
@ -32,10 +32,7 @@ from mistralai.client.models import (
|
|||
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
|
||||
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import (
|
||||
merge_consecutive_user_messages,
|
||||
strip_reasoning as strip_reasoning_message,
|
||||
)
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
Content,
|
||||
|
|
@ -171,9 +168,6 @@ class MistralMapper:
|
|||
for tool_call in tool_calls
|
||||
]
|
||||
|
||||
def strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
|
||||
return strip_reasoning_message(msg)
|
||||
|
||||
|
||||
ReasoningEffortValue = Literal["none", "high"]
|
||||
|
||||
|
|
@ -271,10 +265,6 @@ class MistralBackend:
|
|||
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
|
||||
if reasoning_effort is not None:
|
||||
temperature = 1.0
|
||||
else:
|
||||
merged_messages = [
|
||||
strip_reasoning_message(msg) for msg in merged_messages
|
||||
]
|
||||
|
||||
response = await self._get_client().chat.complete_async(
|
||||
model=model.name,
|
||||
|
|
@ -355,10 +345,6 @@ class MistralBackend:
|
|||
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
|
||||
if reasoning_effort is not None:
|
||||
temperature = 1.0
|
||||
else:
|
||||
merged_messages = [
|
||||
strip_reasoning_message(msg) for msg in merged_messages
|
||||
]
|
||||
|
||||
stream = await self._get_client().chat.stream_async(
|
||||
model=model.name,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any, ClassVar
|
|||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages, strip_reasoning
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
FunctionCall,
|
||||
|
|
@ -122,8 +122,6 @@ class ReasoningAdapter(APIAdapter):
|
|||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
if thinking == "off":
|
||||
merged_messages = [strip_reasoning(msg) for msg in merged_messages]
|
||||
converted_messages = [self._convert_message(msg) for msg in merged_messages]
|
||||
|
||||
payload = self._build_payload(
|
||||
|
|
|
|||
|
|
@ -5,14 +5,6 @@ from collections.abc import Sequence
|
|||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def strip_reasoning(msg: LLMMessage) -> LLMMessage:
|
||||
if msg.role != Role.assistant or not msg.reasoning_content:
|
||||
return msg
|
||||
return msg.model_copy(
|
||||
update={"reasoning_content": None, "reasoning_signature": None}
|
||||
)
|
||||
|
||||
|
||||
def merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
|
||||
"""Merge consecutive user messages into a single message.
|
||||
|
||||
|
|
|
|||
224
vibe/core/log_reader.py
Normal file
224
vibe/core/log_reader.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import LOG_FILE
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LogEntry:
|
||||
timestamp: datetime
|
||||
ppid: int
|
||||
pid: int
|
||||
level: str
|
||||
message: str
|
||||
raw_line: str
|
||||
line_number: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PaginatedLogs:
|
||||
entries: list[LogEntry]
|
||||
has_more: bool
|
||||
cursor: int | None
|
||||
|
||||
|
||||
LogConsumer = Callable[[LogEntry], None]
|
||||
|
||||
# Format: timestamp ppid pid level message [exception]
|
||||
# Timestamp is ISO 8601 from datetime.isoformat(), e.g. 2026-02-21T10:28:51.529718+00:00
|
||||
DEFAULT_LOG_PATTERN = re.compile(
|
||||
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+(?:[+-]\d{2}:\d{2})?)\s+"
|
||||
r"(\d+)\s+(\d+)\s+"
|
||||
r"(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+"
|
||||
r"(.+)$"
|
||||
)
|
||||
|
||||
LOG_POLL_INTERVAL = 0.5
|
||||
|
||||
|
||||
class LogReader:
|
||||
def __init__(
|
||||
self,
|
||||
log_file: Path | None = None,
|
||||
consumer: LogConsumer | None = None,
|
||||
log_pattern: re.Pattern[str] = DEFAULT_LOG_PATTERN,
|
||||
poll_interval: float = LOG_POLL_INTERVAL,
|
||||
) -> None:
|
||||
self._log_file = log_file if log_file is not None else LOG_FILE.path
|
||||
self._consumer = consumer
|
||||
self._log_pattern = log_pattern
|
||||
self._lock = threading.Lock()
|
||||
self._last_position: int = 0
|
||||
self._new_lines_count: int = 0
|
||||
self._stop_event: threading.Event | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
@property
|
||||
def log_file(self) -> Path:
|
||||
return self._log_file
|
||||
|
||||
@property
|
||||
def is_watching(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def get_logs(self, limit: int = 100, offset: int = 0) -> PaginatedLogs:
|
||||
if not self._log_file.exists():
|
||||
return PaginatedLogs(entries=[], has_more=False, cursor=None)
|
||||
|
||||
entries: list[LogEntry] = []
|
||||
|
||||
for line, relative_position in self._read_lines_backward(start_position=offset):
|
||||
if entry := self._parse_line(line, relative_position):
|
||||
entries.append(entry)
|
||||
if len(entries) >= limit:
|
||||
return PaginatedLogs(
|
||||
entries=entries, has_more=True, cursor=offset + len(entries)
|
||||
)
|
||||
|
||||
return PaginatedLogs(entries=entries, has_more=False, cursor=None)
|
||||
|
||||
def _read_lines_backward(self, start_position: int) -> Iterator[tuple[str, int]]:
|
||||
if not self._log_file.exists():
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
# Snapshot once: file end-position is also captured once (seek EOF below),
|
||||
# so new lines appended by the poll thread after this point are invisible
|
||||
# to this iteration and the skip count stays consistent.
|
||||
adjusted_skip = start_position + self._new_lines_count
|
||||
chunk_size = 8192
|
||||
relative_position = 0
|
||||
skipped = 0
|
||||
|
||||
with self._log_file.open("rb") as f:
|
||||
f.seek(0, 2)
|
||||
position = f.tell()
|
||||
remainder = b""
|
||||
|
||||
while position > 0:
|
||||
read_size = min(chunk_size, position)
|
||||
position -= read_size
|
||||
f.seek(position)
|
||||
chunk = f.read(read_size) + remainder
|
||||
|
||||
lines = chunk.split(b"\n")
|
||||
remainder = lines[0]
|
||||
|
||||
for line in reversed(lines[1:]):
|
||||
if not line:
|
||||
continue
|
||||
if skipped < adjusted_skip:
|
||||
skipped += 1
|
||||
continue
|
||||
relative_position += 1
|
||||
yield line.decode("utf-8", errors="replace"), relative_position
|
||||
|
||||
if remainder:
|
||||
if skipped < adjusted_skip:
|
||||
return
|
||||
relative_position += 1
|
||||
yield remainder.decode("utf-8", errors="replace"), relative_position
|
||||
|
||||
def set_consumer(self, consumer: LogConsumer | None) -> None:
|
||||
self._consumer = consumer
|
||||
|
||||
def start_watching(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._last_position = (
|
||||
self._log_file.stat().st_size if self._log_file.exists() else 0
|
||||
)
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_log_loop, name="log-reader-poll", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop_watching(self) -> None:
|
||||
if self._stop_event:
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1)
|
||||
self._thread = None
|
||||
self._stop_event = None
|
||||
with self._lock:
|
||||
self._new_lines_count = 0
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.stop_watching()
|
||||
|
||||
def _poll_log_loop(self) -> None:
|
||||
stop_event = self._stop_event
|
||||
if stop_event is None:
|
||||
return
|
||||
while not stop_event.is_set():
|
||||
stop_event.wait(self._poll_interval)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
self._process_new_content()
|
||||
|
||||
def _process_new_content(self) -> None:
|
||||
consumer = self._consumer
|
||||
if consumer is None:
|
||||
return
|
||||
|
||||
lines: list[str]
|
||||
with self._lock:
|
||||
if not self._log_file.exists():
|
||||
self._last_position = 0
|
||||
return
|
||||
|
||||
current_size = self._log_file.stat().st_size
|
||||
|
||||
if current_size < self._last_position:
|
||||
self._last_position = 0
|
||||
self._new_lines_count = 0
|
||||
|
||||
if current_size == self._last_position:
|
||||
return
|
||||
|
||||
with self._log_file.open("r") as f:
|
||||
f.seek(self._last_position)
|
||||
new_content = f.read()
|
||||
self._last_position = f.tell()
|
||||
|
||||
lines = new_content.splitlines()
|
||||
self._new_lines_count += len(lines)
|
||||
|
||||
for line in lines:
|
||||
if entry := self._parse_line(line, 0):
|
||||
consumer(entry)
|
||||
|
||||
def _parse_line(self, line: str, line_number: int) -> LogEntry | None:
|
||||
try:
|
||||
match = self._log_pattern.match(line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
timestamp_str, ppid_str, pid_str, level, message = match.groups()
|
||||
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
|
||||
return LogEntry(
|
||||
timestamp=timestamp,
|
||||
ppid=int(ppid_str),
|
||||
pid=int(pid_str),
|
||||
level=level,
|
||||
message=message,
|
||||
raw_line=line,
|
||||
line_number=line_number,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to parse log line: %s", line[:100])
|
||||
return None
|
||||
|
|
@ -4,6 +4,7 @@ from datetime import UTC, datetime
|
|||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import re
|
||||
|
||||
from vibe.core.paths import LOG_DIR, LOG_FILE
|
||||
|
||||
|
|
@ -18,17 +19,27 @@ class StructuredLogFormatter(logging.Formatter):
|
|||
ppid = os.getppid()
|
||||
pid = os.getpid()
|
||||
level = record.levelname
|
||||
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
|
||||
message = encode_log_message(record.getMessage())
|
||||
|
||||
line = f"{timestamp} {ppid} {pid} {level} {message}"
|
||||
|
||||
if record.exc_info:
|
||||
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
|
||||
exc_text = encode_log_message(self.formatException(record.exc_info))
|
||||
line = f"{line} {exc_text}"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def encode_log_message(message: str) -> str:
|
||||
return message.replace("\\", "\\\\").replace("\n", "\\n")
|
||||
|
||||
|
||||
def decode_log_message(encoded: str) -> str:
|
||||
return re.sub(
|
||||
r"\\(.)", lambda m: "\n" if m.group(1) == "n" else m.group(1), encoded
|
||||
)
|
||||
|
||||
|
||||
def apply_logging_config(target_logger: logging.Logger) -> None:
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,37 +23,48 @@ _MAX_DIRS = 2000
|
|||
|
||||
def _collect_config_dirs_at(
|
||||
path: Path,
|
||||
entries: set[str],
|
||||
entry_names: set[str],
|
||||
tools: list[Path],
|
||||
skills: list[Path],
|
||||
agents: list[Path],
|
||||
) -> None:
|
||||
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
|
||||
if _VIBE_DIR in entries:
|
||||
if _VIBE_DIR in entry_names:
|
||||
if (candidate := path / _TOOLS_SUBDIR).is_dir():
|
||||
tools.append(candidate)
|
||||
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
|
||||
skills.append(candidate)
|
||||
if (candidate := path / _AGENTS_SUBDIR).is_dir():
|
||||
agents.append(candidate)
|
||||
if _AGENTS_DIR in entries:
|
||||
if _AGENTS_DIR in entry_names:
|
||||
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
|
||||
skills.append(candidate)
|
||||
|
||||
|
||||
def _iter_child_dirs(path: Path, entries: set[str]) -> list[Path]:
|
||||
"""Return sorted child directories to descend into, skipping ignored and dot-dirs."""
|
||||
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
|
||||
"""Scan a directory, returning entry names and sorted child directories to descend into.
|
||||
|
||||
Uses ``os.scandir`` so that ``DirEntry.is_dir()`` leverages the dirent
|
||||
d_type field and avoids a separate ``stat`` syscall on most filesystems.
|
||||
"""
|
||||
try:
|
||||
entries = list(os.scandir(path))
|
||||
except OSError:
|
||||
return set(), []
|
||||
|
||||
entry_names = {e.name for e in entries}
|
||||
children: list[Path] = []
|
||||
for name in sorted(entries):
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
|
||||
continue
|
||||
child = path / name
|
||||
try:
|
||||
if child.is_dir():
|
||||
children.append(child)
|
||||
if entry.is_dir():
|
||||
children.append(path / name)
|
||||
except OSError:
|
||||
continue
|
||||
return children
|
||||
children.sort()
|
||||
return entry_names, children
|
||||
|
||||
|
||||
@cache
|
||||
|
|
@ -77,17 +88,16 @@ def walk_local_config_dirs_all(
|
|||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
|
||||
try:
|
||||
entries = set(os.listdir(current))
|
||||
except OSError:
|
||||
entry_names, children = _scandir_entries(current)
|
||||
if not entry_names:
|
||||
continue
|
||||
|
||||
_collect_config_dirs_at(current, entries, tools_dirs, skills_dirs, agents_dirs)
|
||||
_collect_config_dirs_at(
|
||||
current, entry_names, tools_dirs, skills_dirs, agents_dirs
|
||||
)
|
||||
|
||||
if depth < WALK_MAX_DEPTH:
|
||||
queue.extend(
|
||||
(child, depth + 1) for child in _iter_child_dirs(current, entries)
|
||||
)
|
||||
queue.extend((child, depth + 1) for child in children)
|
||||
|
||||
if visited >= _MAX_DIRS:
|
||||
logger.warning(
|
||||
|
|
@ -116,18 +126,15 @@ def has_config_dirs_nearby(
|
|||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
|
||||
try:
|
||||
entries = set(os.listdir(current))
|
||||
except OSError:
|
||||
entry_names, children = _scandir_entries(current)
|
||||
if not entry_names:
|
||||
continue
|
||||
|
||||
_collect_config_dirs_at(current, entries, found, found, found)
|
||||
_collect_config_dirs_at(current, entry_names, found, found, found)
|
||||
if found:
|
||||
return True
|
||||
|
||||
if depth < max_depth:
|
||||
queue.extend(
|
||||
(child, depth + 1) for child in _iter_child_dirs(current, entries)
|
||||
)
|
||||
queue.extend((child, depth + 1) for child in children)
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import aclosing
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
|
|
@ -79,13 +80,14 @@ def run_programmatic(
|
|||
)
|
||||
formatter.on_event(next_event)
|
||||
else:
|
||||
async for event in agent_loop.act(prompt):
|
||||
formatter.on_event(event)
|
||||
if (
|
||||
isinstance(event, AssistantEvent)
|
||||
and event.stopped_by_middleware
|
||||
):
|
||||
raise ConversationLimitException(event.content)
|
||||
async with aclosing(agent_loop.act(prompt)) as events:
|
||||
async for event in events:
|
||||
formatter.on_event(event)
|
||||
if (
|
||||
isinstance(event, AssistantEvent)
|
||||
and event.stopped_by_middleware
|
||||
):
|
||||
raise ConversationLimitException(event.content)
|
||||
|
||||
return formatter.finalize()
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class GitHubPublicData(BaseModel):
|
|||
|
||||
|
||||
class ChatAssistantPublicData(BaseModel):
|
||||
chat_url: str
|
||||
chat_url: str | None = None
|
||||
|
||||
|
||||
class GetChatAssistantIntegrationResponse(BaseModel):
|
||||
|
|
@ -195,7 +195,7 @@ class NuageClient:
|
|||
await asyncio.sleep(min(interval, remaining))
|
||||
raise ServiceTeleportError("GitHub connection timed out")
|
||||
|
||||
async def get_chat_assistant_url(self, execution_id: str) -> str:
|
||||
async def get_chat_assistant_url(self, execution_id: str) -> str | None:
|
||||
response = await self._http_client.post(
|
||||
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
|
||||
headers=self._headers(),
|
||||
|
|
|
|||
|
|
@ -193,6 +193,9 @@ class TeleportService:
|
|||
yield TeleportFetchingUrlEvent()
|
||||
chat_url = await self._nuage_client.get_chat_assistant_url(execution_id)
|
||||
|
||||
if not chat_url:
|
||||
raise ServiceTeleportError("Chat assistant URL is not available yet")
|
||||
|
||||
yield TeleportCompleteEvent(url=chat_url)
|
||||
|
||||
async def _push_or_fail(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum, auto
|
||||
import functools
|
||||
|
|
@ -134,10 +134,16 @@ class BaseTool[
|
|||
|
||||
prompt_path: ClassVar[Path] | None = None
|
||||
|
||||
def __init__(self, config: ToolConfig, state: ToolState) -> None:
|
||||
self.config = config
|
||||
def __init__(
|
||||
self, config_getter: Callable[[], ToolConfig], state: ToolState
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self.state = state
|
||||
|
||||
@property
|
||||
def config(self) -> ToolConfig:
|
||||
return self._config_getter()
|
||||
|
||||
@abstractmethod
|
||||
async def run(
|
||||
self, args: ToolArgs, ctx: InvokeContext | None = None
|
||||
|
|
@ -184,11 +190,11 @@ class BaseTool[
|
|||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: ToolConfig
|
||||
cls, config_getter: Callable[[], ToolConfig]
|
||||
) -> BaseTool[ToolArgs, ToolResult, ToolConfig, ToolState]:
|
||||
state_class = cls._get_tool_state_class()
|
||||
initial_state = state_class()
|
||||
return cls(config=config, state=initial_state)
|
||||
return cls(config_getter=config_getter, state=initial_state)
|
||||
|
||||
@classmethod
|
||||
def _get_tool_config_class(cls) -> type[ToolConfig]:
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ Use this tool when you need to retrieve and analyze web content.
|
|||
- Prefer a more specialized tool over `web_fetch` when one is available.
|
||||
- URLs must be valid.
|
||||
- Read-only: does not modify any files.
|
||||
- Content is capped at a byte limit. If `was_truncated` is true, the page had more content that was cut off.
|
||||
|
|
|
|||
|
|
@ -99,9 +99,10 @@ class SearchReplace(
|
|||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if isinstance(event.result, SearchReplaceResult):
|
||||
path_name = Path(event.result.file).name
|
||||
return ToolResultDisplay(
|
||||
success=True,
|
||||
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
|
||||
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}",
|
||||
warnings=event.result.warnings,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
import fnmatch
|
||||
from typing import ClassVar
|
||||
|
||||
|
|
@ -133,6 +134,7 @@ class Task(
|
|||
config=base_config,
|
||||
agent_name=args.agent,
|
||||
entrypoint_metadata=ctx.entrypoint_metadata,
|
||||
is_subagent=True,
|
||||
)
|
||||
|
||||
if ctx and ctx.approval_callback:
|
||||
|
|
@ -141,23 +143,24 @@ class Task(
|
|||
accumulated_response: list[str] = []
|
||||
completed = True
|
||||
try:
|
||||
async for event in subagent_loop.act(args.task):
|
||||
if isinstance(event, AssistantEvent) and event.content:
|
||||
accumulated_response.append(event.content)
|
||||
if event.stopped_by_middleware:
|
||||
completed = False
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
if event.skipped:
|
||||
completed = False
|
||||
elif event.result and event.tool_class:
|
||||
adapter = ToolUIDataAdapter(event.tool_class)
|
||||
display = adapter.get_result_display(event)
|
||||
message = f"{event.tool_name}: {display.message}"
|
||||
yield ToolStreamEvent(
|
||||
tool_name=self.get_name(),
|
||||
message=message,
|
||||
tool_call_id=ctx.tool_call_id,
|
||||
)
|
||||
async with aclosing(subagent_loop.act(args.task)) as events:
|
||||
async for event in events:
|
||||
if isinstance(event, AssistantEvent) and event.content:
|
||||
accumulated_response.append(event.content)
|
||||
if event.stopped_by_middleware:
|
||||
completed = False
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
if event.skipped:
|
||||
completed = False
|
||||
elif event.result and event.tool_class:
|
||||
adapter = ToolUIDataAdapter(event.tool_class)
|
||||
display = adapter.get_result_display(event)
|
||||
message = f"{event.tool_name}: {display.message}"
|
||||
yield ToolStreamEvent(
|
||||
tool_name=self.get_name(),
|
||||
message=message,
|
||||
tool_call_id=ctx.tool_call_id,
|
||||
)
|
||||
|
||||
turns_used = sum(
|
||||
msg.role == Role.assistant for msg in subagent_loop.messages
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import functools
|
||||
from typing import TYPE_CHECKING, ClassVar, final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from markdownify import MarkdownConverter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.tools.base import (
|
||||
|
|
@ -32,10 +32,16 @@ _HONEST_USER_AGENT = "vibe-cli"
|
|||
_HTTP_FORBIDDEN = 403
|
||||
|
||||
|
||||
class _Converter(MarkdownConverter):
|
||||
convert_script = convert_style = convert_noscript = convert_iframe = (
|
||||
convert_object
|
||||
) = convert_embed = lambda *_, **__: ""
|
||||
@functools.cache
|
||||
def _make_converter_class() -> type:
|
||||
from markdownify import MarkdownConverter
|
||||
|
||||
class _Converter(MarkdownConverter):
|
||||
convert_script = convert_style = convert_noscript = convert_iframe = (
|
||||
convert_object
|
||||
) = convert_embed = lambda *_, **__: ""
|
||||
|
||||
return _Converter
|
||||
|
||||
|
||||
class WebFetchArgs(BaseModel):
|
||||
|
|
@ -49,6 +55,7 @@ class WebFetchResult(BaseModel):
|
|||
url: str
|
||||
content: str
|
||||
content_type: str
|
||||
was_truncated: bool = False
|
||||
|
||||
|
||||
class WebFetchConfig(BaseToolConfig):
|
||||
|
|
@ -57,7 +64,8 @@ class WebFetchConfig(BaseToolConfig):
|
|||
default_timeout: int = Field(default=30, description="Default timeout in seconds.")
|
||||
max_timeout: int = Field(default=120, description="Maximum allowed timeout.")
|
||||
max_content_bytes: int = Field(
|
||||
default=512_000, description="Maximum content size to fetch."
|
||||
default=120_000,
|
||||
description="Maximum content size in bytes returned to the model.",
|
||||
)
|
||||
user_agent: str = Field(
|
||||
default=(
|
||||
|
|
@ -120,7 +128,20 @@ class WebFetch(
|
|||
if "text/html" in content_type:
|
||||
content = _html_to_markdown(content)
|
||||
|
||||
yield WebFetchResult(url=url, content=content, content_type=content_type)
|
||||
content_bytes = content.encode("utf-8")
|
||||
was_truncated = len(content_bytes) > self.config.max_content_bytes
|
||||
if was_truncated:
|
||||
content = content_bytes[: self.config.max_content_bytes].decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
content += "\n\n[Content truncated due to size limit]"
|
||||
|
||||
yield WebFetchResult(
|
||||
url=url,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
was_truncated=was_truncated,
|
||||
)
|
||||
|
||||
def _validate_args(self, args: WebFetchArgs) -> None:
|
||||
if not args.url.strip():
|
||||
|
|
@ -169,11 +190,7 @@ class WebFetch(
|
|||
|
||||
content_type = response.headers.get("Content-Type", "text/plain")
|
||||
|
||||
content_bytes = response.content[: self.config.max_content_bytes]
|
||||
content = content_bytes.decode("utf-8", errors="ignore")
|
||||
|
||||
if len(response.content) > self.config.max_content_bytes:
|
||||
content += "[Content truncated due to size limit]"
|
||||
content = response.content.decode("utf-8", errors="ignore")
|
||||
|
||||
return content, content_type
|
||||
|
||||
|
|
@ -222,6 +239,8 @@ class WebFetch(
|
|||
message = (
|
||||
f"Fetched {content_len:,} chars ({event.result.content_type.split(';')[0]})"
|
||||
)
|
||||
if event.result.was_truncated:
|
||||
message += " [truncated]"
|
||||
|
||||
return ToolResultDisplay(success=True, message=message)
|
||||
|
||||
|
|
@ -231,4 +250,5 @@ class WebFetch(
|
|||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
return _Converter(heading_style="ATX", bullets="-").convert(html)
|
||||
converter_class = _make_converter_class()
|
||||
return converter_class(heading_style="ATX", bullets="-").convert(html)
|
||||
|
|
|
|||
|
|
@ -174,6 +174,10 @@ class ToolManager:
|
|||
continue
|
||||
return defaults
|
||||
|
||||
@property
|
||||
def registered_tools(self) -> dict[str, type[BaseTool]]:
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def available_tools(self) -> dict[str, type[BaseTool]]:
|
||||
runtime_available = {
|
||||
|
|
@ -241,12 +245,10 @@ class ToolManager:
|
|||
)
|
||||
|
||||
tool_class = self._available[tool_name]
|
||||
tool_config = self.get_tool_config(tool_name)
|
||||
self._instances[tool_name] = tool_class.from_config(tool_config)
|
||||
self._instances[tool_name] = tool_class.from_config(
|
||||
lambda: self.get_tool_config(tool_name)
|
||||
)
|
||||
return self._instances[tool_name]
|
||||
|
||||
def reset_all(self) -> None:
|
||||
self._instances.clear()
|
||||
|
||||
def invalidate_tool(self, tool_name: str) -> None:
|
||||
self._instances.pop(tool_name, None)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,22 @@ class MCPToolResult(BaseModel):
|
|||
structured: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class MCPTool(
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
|
||||
ToolUIData[_OpenArgs, MCPToolResult],
|
||||
):
|
||||
_server_name: ClassVar[str] = ""
|
||||
_remote_name: ClassVar[str] = ""
|
||||
|
||||
@classmethod
|
||||
def get_server_name(cls) -> str | None:
|
||||
return cls._server_name or None
|
||||
|
||||
@classmethod
|
||||
def get_remote_name(cls) -> str:
|
||||
return cls._remote_name or cls.get_name()
|
||||
|
||||
|
||||
class RemoteTool(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -207,17 +223,16 @@ def create_mcp_http_proxy_tool_class(
|
|||
port = f"_{p.port}" if p.port else ""
|
||||
return f"{host}{port}"
|
||||
|
||||
published_name = f"{(alias or _alias_from_url(url))}_{remote.name}"
|
||||
computed_alias = alias or _alias_from_url(url)
|
||||
published_name = f"{computed_alias}_{remote.name}"
|
||||
|
||||
class MCPHttpProxyTool(
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
|
||||
ToolUIData[_OpenArgs, MCPToolResult],
|
||||
):
|
||||
class MCPHttpProxyTool(MCPTool):
|
||||
description: ClassVar[str] = (
|
||||
(f"[{alias}] " if alias else "")
|
||||
(f"[{computed_alias}] " if computed_alias else "")
|
||||
+ (remote.description or f"MCP tool '{remote.name}' from {url}")
|
||||
+ (f"\nHint: {server_hint}" if server_hint else "")
|
||||
)
|
||||
_server_name: ClassVar[str] = computed_alias
|
||||
_mcp_url: ClassVar[str] = url
|
||||
_remote_name: ClassVar[str] = remote.name
|
||||
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
|
||||
|
|
@ -269,7 +284,7 @@ def create_mcp_http_proxy_tool_class(
|
|||
def get_status_text(cls) -> str:
|
||||
return f"Calling MCP tool {remote.name}"
|
||||
|
||||
MCPHttpProxyTool.__name__ = f"MCP_{(alias or _alias_from_url(url))}__{remote.name}"
|
||||
MCPHttpProxyTool.__name__ = f"MCP_{computed_alias}__{remote.name}"
|
||||
return MCPHttpProxyTool
|
||||
|
||||
|
||||
|
|
@ -344,10 +359,7 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
computed_alias = alias or _alias_from_command(command)
|
||||
published_name = f"{computed_alias}_{remote.name}"
|
||||
|
||||
class MCPStdioProxyTool(
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
|
||||
ToolUIData[_OpenArgs, MCPToolResult],
|
||||
):
|
||||
class MCPStdioProxyTool(MCPTool):
|
||||
description: ClassVar[str] = (
|
||||
(f"[{computed_alias}] " if computed_alias else "")
|
||||
+ (
|
||||
|
|
@ -356,6 +368,7 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
)
|
||||
+ (f"\nHint: {server_hint}" if server_hint else "")
|
||||
)
|
||||
_server_name: ClassVar[str] = computed_alias
|
||||
_stdio_command: ClassVar[list[str]] = command
|
||||
_remote_name: ClassVar[str] = remote.name
|
||||
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def setup_tracing(config: VibeConfig) -> None:
|
|||
if not config.enable_otel:
|
||||
return
|
||||
|
||||
exporter_cfg = config.otel_exporter_config
|
||||
exporter_cfg = config.otel_span_exporter_config
|
||||
if exporter_cfg is None:
|
||||
return
|
||||
|
||||
|
|
|
|||
21
vibe/setup/auth/__init__.py
Normal file
21
vibe/setup/auth/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.setup.auth.browser_sign_in import BrowserSignInService
|
||||
from vibe.setup.auth.browser_sign_in_gateway import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInGateway,
|
||||
BrowserSignInPollResult,
|
||||
BrowserSignInProcess,
|
||||
)
|
||||
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
|
||||
|
||||
__all__ = [
|
||||
"BrowserSignInError",
|
||||
"BrowserSignInErrorCode",
|
||||
"BrowserSignInGateway",
|
||||
"BrowserSignInPollResult",
|
||||
"BrowserSignInProcess",
|
||||
"BrowserSignInService",
|
||||
"HttpBrowserSignInGateway",
|
||||
]
|
||||
147
vibe/setup/auth/browser_sign_in.py
Normal file
147
vibe/setup/auth/browser_sign_in.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import secrets
|
||||
import webbrowser
|
||||
|
||||
from vibe.setup.auth.browser_sign_in_gateway import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInGateway,
|
||||
BrowserSignInProcess,
|
||||
)
|
||||
|
||||
StatusCallback = Callable[[str], None]
|
||||
BrowserOpener = Callable[[str], bool]
|
||||
SleepFn = Callable[[float], Awaitable[None]]
|
||||
NowFn = Callable[[], datetime]
|
||||
|
||||
|
||||
class BrowserSignInService:
|
||||
_max_consecutive_poll_failures = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway: BrowserSignInGateway,
|
||||
*,
|
||||
open_browser: BrowserOpener | None = None,
|
||||
sleep: SleepFn = asyncio.sleep,
|
||||
now: NowFn | None = None,
|
||||
poll_interval: float = 3.0,
|
||||
) -> None:
|
||||
self._gateway = gateway
|
||||
self._open_browser = open_browser or webbrowser.open
|
||||
self._sleep = sleep
|
||||
self._now = now or (lambda: datetime.now(UTC))
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._gateway.aclose()
|
||||
|
||||
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
|
||||
verifier, challenge = _generate_pkce_pair()
|
||||
process = await self._gateway.create_process(challenge)
|
||||
self._emit(status_callback, "opening_browser")
|
||||
self._open_browser_or_raise(process.sign_in_url)
|
||||
self._emit(status_callback, "waiting_for_browser_sign_in")
|
||||
exchange_token = await self._wait_for_completion(process)
|
||||
self._emit(status_callback, "exchanging")
|
||||
api_key = await self._gateway.exchange(
|
||||
process.process_id, exchange_token, verifier
|
||||
)
|
||||
self._emit(status_callback, "completed")
|
||||
return api_key
|
||||
|
||||
async def _wait_for_completion(self, process: BrowserSignInProcess) -> str:
|
||||
consecutive_poll_failures = 0
|
||||
while self._now() < process.expires_at:
|
||||
try:
|
||||
payload = await self._gateway.poll(process.poll_url)
|
||||
except BrowserSignInError as err:
|
||||
if err.code is not BrowserSignInErrorCode.POLL_FAILED:
|
||||
raise
|
||||
consecutive_poll_failures += 1
|
||||
if consecutive_poll_failures >= self._max_consecutive_poll_failures:
|
||||
raise
|
||||
await self._sleep_until_next_poll_or_timeout(process.expires_at)
|
||||
continue
|
||||
|
||||
consecutive_poll_failures = 0
|
||||
match payload.status:
|
||||
case "pending":
|
||||
await self._sleep_until_next_poll_or_timeout(process.expires_at)
|
||||
case "completed":
|
||||
if payload.exchange_token:
|
||||
return payload.exchange_token
|
||||
raise BrowserSignInError(
|
||||
"Sign-in worked, but setup couldn't finish.",
|
||||
code=BrowserSignInErrorCode.MISSING_EXCHANGE_TOKEN,
|
||||
)
|
||||
case "expired":
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in expired.", code=BrowserSignInErrorCode.EXPIRED
|
||||
)
|
||||
case "denied":
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in was denied.",
|
||||
code=BrowserSignInErrorCode.DENIED,
|
||||
)
|
||||
case "error":
|
||||
raise BrowserSignInError(
|
||||
payload.message or "Browser sign-in failed.",
|
||||
code=BrowserSignInErrorCode.PROVIDER_ERROR,
|
||||
)
|
||||
case _:
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in returned an unknown state.",
|
||||
code=BrowserSignInErrorCode.UNKNOWN_STATE,
|
||||
)
|
||||
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in timed out.", code=BrowserSignInErrorCode.TIMED_OUT
|
||||
)
|
||||
|
||||
async def _sleep_until_next_poll_or_timeout(self, expires_at: datetime) -> None:
|
||||
remaining_seconds = (expires_at - self._now()).total_seconds()
|
||||
if remaining_seconds <= 0:
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in timed out.", code=BrowserSignInErrorCode.TIMED_OUT
|
||||
)
|
||||
await self._sleep(min(self._poll_interval, remaining_seconds))
|
||||
|
||||
def _emit(self, callback: StatusCallback | None, status: str) -> None:
|
||||
if callback is not None:
|
||||
callback(status)
|
||||
|
||||
def _open_browser_or_raise(self, sign_in_url: str) -> None:
|
||||
try:
|
||||
browser_opened = self._open_browser(sign_in_url)
|
||||
except Exception as err:
|
||||
raise BrowserSignInError(
|
||||
"Failed to open browser for sign-in.",
|
||||
code=BrowserSignInErrorCode.OPEN_BROWSER_FAILED,
|
||||
) from err
|
||||
|
||||
if not browser_opened:
|
||||
raise BrowserSignInError(
|
||||
"Failed to open browser for sign-in.",
|
||||
code=BrowserSignInErrorCode.OPEN_BROWSER_FAILED,
|
||||
)
|
||||
|
||||
|
||||
def _generate_code_verifier() -> str:
|
||||
return secrets.token_urlsafe(64)
|
||||
|
||||
|
||||
def _generate_pkce_pair() -> tuple[str, str]:
|
||||
verifier = _generate_code_verifier()
|
||||
return verifier, _generate_code_challenge(verifier)
|
||||
|
||||
|
||||
def _generate_code_challenge(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
55
vibe/setup/auth/browser_sign_in_gateway.py
Normal file
55
vibe/setup/auth/browser_sign_in_gateway.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum, auto
|
||||
from typing import Literal, Protocol
|
||||
|
||||
|
||||
class BrowserSignInErrorCode(StrEnum):
|
||||
START_FAILED = auto()
|
||||
POLL_FAILED = auto()
|
||||
UNKNOWN_STATE = auto()
|
||||
EXCHANGE_FAILED = auto()
|
||||
MISSING_API_KEY = auto()
|
||||
MISSING_EXCHANGE_TOKEN = auto()
|
||||
EXPIRED = auto()
|
||||
DENIED = auto()
|
||||
PROVIDER_ERROR = auto()
|
||||
TIMED_OUT = auto()
|
||||
OPEN_BROWSER_FAILED = auto()
|
||||
|
||||
|
||||
class BrowserSignInError(Exception):
|
||||
def __init__(
|
||||
self, message: str, *, code: BrowserSignInErrorCode | None = None
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSignInProcess:
|
||||
process_id: str
|
||||
sign_in_url: str
|
||||
poll_url: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSignInPollResult:
|
||||
status: Literal["pending", "completed", "expired", "denied", "error"]
|
||||
exchange_token: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class BrowserSignInGateway(Protocol):
|
||||
async def create_process(self, code_challenge: str) -> BrowserSignInProcess: ...
|
||||
|
||||
async def poll(self, poll_url: str) -> BrowserSignInPollResult: ...
|
||||
|
||||
async def exchange(
|
||||
self, process_id: str, exchange_token: str, code_verifier: str
|
||||
) -> str: ...
|
||||
|
||||
async def aclose(self) -> None: ...
|
||||
319
vibe/setup/auth/http_browser_sign_in_gateway.py
Normal file
319
vibe/setup/auth/http_browser_sign_in_gateway.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
import posixpath
|
||||
import types
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
from urllib.parse import SplitResult, unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.setup.auth.browser_sign_in_gateway import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInGateway,
|
||||
BrowserSignInPollResult,
|
||||
BrowserSignInProcess,
|
||||
)
|
||||
|
||||
|
||||
class CreateProcessPayload(TypedDict):
|
||||
process_id: str
|
||||
sign_in_url: str
|
||||
poll_url: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class PollPayload(TypedDict, total=False):
|
||||
status: Literal["pending", "completed", "expired", "denied", "error"]
|
||||
exchange_token: str
|
||||
message: str
|
||||
|
||||
|
||||
class ExchangePayload(TypedDict, total=False):
|
||||
api_key: str
|
||||
|
||||
|
||||
HTTP_GONE = 410
|
||||
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
|
||||
|
||||
class HttpBrowserSignInGateway(BrowserSignInGateway):
|
||||
def __init__(
|
||||
self,
|
||||
browser_base_url: str,
|
||||
api_base_url: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._browser_base_url = browser_base_url.rstrip("/")
|
||||
self._api_base_url = api_base_url.rstrip("/")
|
||||
self._client = client
|
||||
self._should_manage_client = client is None
|
||||
|
||||
async def __aenter__(self) -> HttpBrowserSignInGateway:
|
||||
self._ensure_client()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._should_manage_client and self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def create_process(self, code_challenge: str) -> BrowserSignInProcess:
|
||||
message = "Failed to start browser sign-in."
|
||||
code = BrowserSignInErrorCode.START_FAILED
|
||||
try:
|
||||
response = await self._ensure_client().post(
|
||||
f"{self._api_base_url}/vibe/sign-in",
|
||||
json={
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as err:
|
||||
logger.warning(
|
||||
"Browser sign-in start request failed for api_base_url=%s: %s",
|
||||
self._api_base_url,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
|
||||
if not response.is_success:
|
||||
logger.warning(
|
||||
"Browser sign-in start request returned status_code=%s for api_base_url=%s",
|
||||
response.status_code,
|
||||
self._api_base_url,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code)
|
||||
|
||||
payload = _response_json_or_raise(response, message=message, code=code)
|
||||
data = cast(CreateProcessPayload, payload)
|
||||
try:
|
||||
return BrowserSignInProcess(
|
||||
process_id=data["process_id"],
|
||||
sign_in_url=_validate_url_against_base_url(
|
||||
data["sign_in_url"],
|
||||
base_url=self._browser_base_url,
|
||||
message=message,
|
||||
code=code,
|
||||
),
|
||||
poll_url=_validate_url_against_base_url(
|
||||
data["poll_url"],
|
||||
base_url=self._api_base_url,
|
||||
message=message,
|
||||
code=code,
|
||||
),
|
||||
expires_at=_parse_expires_at(data["expires_at"]),
|
||||
)
|
||||
except BrowserSignInError:
|
||||
sign_in_url_details, poll_url_details = _build_start_payload_log_details(
|
||||
data
|
||||
)
|
||||
logger.warning(
|
||||
"Browser sign-in start payload validation failed for browser_base_url=%s api_base_url=%s sign_in_url=%s poll_url=%s",
|
||||
self._browser_base_url,
|
||||
self._api_base_url,
|
||||
sign_in_url_details,
|
||||
poll_url_details,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
except (KeyError, TypeError, ValueError) as err:
|
||||
sign_in_url_details, poll_url_details = _build_start_payload_log_details(
|
||||
payload
|
||||
)
|
||||
logger.warning(
|
||||
"Browser sign-in start payload parsing failed for api_base_url=%s payload_keys=%s sign_in_url=%s poll_url=%s",
|
||||
self._api_base_url,
|
||||
sorted(str(key) for key in payload),
|
||||
sign_in_url_details,
|
||||
poll_url_details,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
|
||||
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
|
||||
message = "Browser sign-in status could not be retrieved."
|
||||
code = BrowserSignInErrorCode.POLL_FAILED
|
||||
validated_poll_url = _validate_url_against_base_url(
|
||||
poll_url, base_url=self._api_base_url, message=message, code=code
|
||||
)
|
||||
try:
|
||||
response = await self._ensure_client().get(validated_poll_url)
|
||||
except httpx.HTTPError as err:
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
|
||||
if response.status_code == HTTP_GONE:
|
||||
return BrowserSignInPollResult(status="expired")
|
||||
|
||||
if not response.is_success:
|
||||
raise BrowserSignInError(message, code=code)
|
||||
|
||||
raw_payload = _response_json_or_raise(response, message=message, code=code)
|
||||
payload = cast(PollPayload, raw_payload)
|
||||
status = payload.get("status")
|
||||
if status not in {"pending", "completed", "expired", "denied", "error"}:
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in returned an unknown state.",
|
||||
code=BrowserSignInErrorCode.UNKNOWN_STATE,
|
||||
)
|
||||
|
||||
return BrowserSignInPollResult(
|
||||
status=status,
|
||||
exchange_token=payload.get("exchange_token"),
|
||||
message=payload.get("message"),
|
||||
)
|
||||
|
||||
async def exchange(
|
||||
self, process_id: str, exchange_token: str, code_verifier: str
|
||||
) -> str:
|
||||
message = "Failed to exchange browser sign-in for an API key."
|
||||
code = BrowserSignInErrorCode.EXCHANGE_FAILED
|
||||
try:
|
||||
response = await self._ensure_client().post(
|
||||
f"{self._api_base_url}/vibe/sign-in/{process_id}/exchange",
|
||||
json={"exchange_token": exchange_token, "code_verifier": code_verifier},
|
||||
)
|
||||
except httpx.HTTPError as err:
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
|
||||
if not response.is_success:
|
||||
raise BrowserSignInError(message, code=code)
|
||||
|
||||
raw_payload = _response_json_or_raise(response, message=message, code=code)
|
||||
payload = cast(ExchangePayload, raw_payload)
|
||||
if api_key := payload.get("api_key"):
|
||||
return api_key
|
||||
raise BrowserSignInError(
|
||||
"Browser sign-in exchange did not return an API key.",
|
||||
code=BrowserSignInErrorCode.MISSING_API_KEY,
|
||||
)
|
||||
|
||||
def _ensure_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient()
|
||||
self._should_manage_client = True
|
||||
return self._client
|
||||
|
||||
|
||||
def _parse_expires_at(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
|
||||
|
||||
|
||||
def _validate_url_against_base_url(
|
||||
value: str, *, base_url: str, message: str, code: BrowserSignInErrorCode
|
||||
) -> str:
|
||||
current_url = urlsplit(value)
|
||||
base = urlsplit(base_url)
|
||||
safe_url_details = _build_safe_url_log_details(value)
|
||||
try:
|
||||
current_origin = _normalized_origin(current_url)
|
||||
base_origin = _normalized_origin(base)
|
||||
except ValueError as err:
|
||||
logger.warning(
|
||||
"Browser sign-in URL origin validation failed for returned_url=%s expected_base_url=%s",
|
||||
safe_url_details,
|
||||
base_url,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
if current_origin != base_origin:
|
||||
logger.warning(
|
||||
"Browser sign-in URL host validation failed for returned_url=%s expected_base_url=%s",
|
||||
safe_url_details,
|
||||
base_url,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code)
|
||||
if not _is_path_under_base_path(current_url.path, base.path):
|
||||
logger.warning(
|
||||
"Browser sign-in URL path validation failed for returned_url=%s expected_base_url=%s",
|
||||
safe_url_details,
|
||||
base_url,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code)
|
||||
return value
|
||||
|
||||
|
||||
def _is_path_under_base_path(path: str, base_path: str) -> bool:
|
||||
normalized_path = _normalize_url_path(path)
|
||||
normalized_base_path = _normalize_url_path(base_path).rstrip("/")
|
||||
if not normalized_base_path:
|
||||
return True
|
||||
if normalized_path == normalized_base_path:
|
||||
return True
|
||||
return normalized_path.startswith(f"{normalized_base_path}/")
|
||||
|
||||
|
||||
def _normalized_origin(parsed: SplitResult) -> tuple[str, str | None, int | None]:
|
||||
scheme = parsed.scheme.lower()
|
||||
port = parsed.port
|
||||
return scheme, parsed.hostname, _effective_port(scheme, port)
|
||||
|
||||
|
||||
def _effective_port(scheme: str, port: int | None) -> int | None:
|
||||
if port is not None:
|
||||
return port
|
||||
return _DEFAULT_PORTS.get(scheme)
|
||||
|
||||
|
||||
def _normalize_url_path(path: str) -> str:
|
||||
if not path:
|
||||
return "/"
|
||||
|
||||
decoded_path = unquote(path)
|
||||
return posixpath.normpath(decoded_path)
|
||||
|
||||
|
||||
def _build_start_payload_log_details(payload: Mapping[str, object]) -> tuple[str, str]:
|
||||
return (
|
||||
_build_safe_url_log_details(payload.get("sign_in_url")),
|
||||
_build_safe_url_log_details(payload.get("poll_url")),
|
||||
)
|
||||
|
||||
|
||||
def _response_json_or_raise(
|
||||
response: httpx.Response, *, message: str, code: BrowserSignInErrorCode
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as err:
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
|
||||
if not isinstance(payload, Mapping):
|
||||
raise BrowserSignInError(message, code=code)
|
||||
|
||||
return dict(payload)
|
||||
|
||||
|
||||
def _build_safe_url_log_details(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "unavailable"
|
||||
|
||||
parsed = urlsplit(value)
|
||||
return (
|
||||
f"scheme={parsed.scheme or None!r} "
|
||||
f"hostname={parsed.hostname!r} "
|
||||
f"port={_safe_port(parsed)!r} "
|
||||
f"has_path={bool(parsed.path)} "
|
||||
f"has_query={bool(parsed.query)} "
|
||||
f"has_fragment={bool(parsed.fragment)}"
|
||||
)
|
||||
|
||||
|
||||
def _safe_port(parsed: SplitResult) -> int | None:
|
||||
try:
|
||||
return parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
# What's new in v2.7.3
|
||||
|
||||
- **Data retention command**: Added a `/data-retention` slash command to view Mistral AI's data retention notice and privacy settings
|
||||
# What's new in v2.7.4
|
||||
- **MCP command**: New `/mcp` command to display MCP servers and their status
|
||||
- **Manual command output**: Manual `!` commands now forward their output to the agent as context
|
||||
- **Console view**: New `ctrl+\` keybind to toggle logs display.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue