v2.6.0 (#524)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
5103019b01
commit
eb580209d4
180 changed files with 11136 additions and 1030 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "2.6.0"
|
||||
|
|
|
|||
|
|
@ -26,11 +26,14 @@ from acp.schema import (
|
|||
AgentThoughtChunk,
|
||||
AllowedOutcome,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
AuthMethodAgent,
|
||||
AvailableCommand,
|
||||
AvailableCommandInput,
|
||||
ClientCapabilities,
|
||||
CloseSessionResponse,
|
||||
ContentToolCallContent,
|
||||
Cost,
|
||||
EnvVarAuthMethod,
|
||||
ForkSessionResponse,
|
||||
HttpMcpServer,
|
||||
Implementation,
|
||||
|
|
@ -43,11 +46,14 @@ from acp.schema import (
|
|||
SessionListCapabilities,
|
||||
SetSessionConfigOptionResponse,
|
||||
SseMcpServer,
|
||||
TerminalAuthMethod,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
ToolCallProgress,
|
||||
ToolCallUpdate,
|
||||
UnstructuredCommandInput,
|
||||
Usage,
|
||||
UsageUpdate,
|
||||
UserMessageChunk,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
|
@ -71,8 +77,8 @@ from vibe.acp.tools.session_update import (
|
|||
tool_result_session_update,
|
||||
)
|
||||
from vibe.acp.utils import (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
build_permission_options,
|
||||
create_assistant_message_replay,
|
||||
create_compact_end_session_update,
|
||||
create_compact_start_session_update,
|
||||
|
|
@ -101,8 +107,9 @@ from vibe.core.proxy_setup import (
|
|||
unset_proxy_var,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
ApprovalCallback,
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
|
|
@ -173,9 +180,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
and self.client_capabilities.field_meta.get("terminal-auth") is True
|
||||
)
|
||||
|
||||
auth_methods = (
|
||||
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
|
||||
[
|
||||
AuthMethod(
|
||||
TerminalAuthMethod(
|
||||
type="terminal",
|
||||
id="vibe-setup",
|
||||
name="Register your API Key",
|
||||
description="Register your API Key inside Mistral Vibe",
|
||||
|
|
@ -228,9 +236,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"]
|
||||
)
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
|
|
@ -263,18 +269,22 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
config = self._load_config()
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
||||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent_loop.compact is called).
|
||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
try:
|
||||
agent_loop = AgentLoop(
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
||||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent_loop.compact is called).
|
||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
except Exception as e:
|
||||
raise ConfigurationError(str(e)) from e
|
||||
|
||||
agent_loop.emit_new_session_telemetry()
|
||||
|
||||
modes_state, modes_config = make_mode_response(
|
||||
|
|
@ -314,17 +324,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session = self._get_session(session_id)
|
||||
|
||||
def _handle_permission_selection(
|
||||
option_id: str, tool_name: str
|
||||
option_id: str,
|
||||
tool_name: str,
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
match option_id:
|
||||
case ToolOption.ALLOW_ONCE:
|
||||
return (ApprovalResponse.YES, None)
|
||||
case ToolOption.ALLOW_ALWAYS:
|
||||
if tool_name not in session.agent_loop.config.tools:
|
||||
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
|
||||
session.agent_loop.config.tools[
|
||||
tool_name
|
||||
].permission = ToolPermission.ALWAYS
|
||||
session.agent_loop.approve_always(tool_name, required_permissions)
|
||||
return (ApprovalResponse.YES, None)
|
||||
case ToolOption.REJECT_ONCE:
|
||||
return (
|
||||
|
|
@ -335,19 +343,33 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, args: BaseModel, tool_call_id: str
|
||||
tool_name: str,
|
||||
args: BaseModel,
|
||||
tool_call_id: str,
|
||||
required_permissions: list | None = None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
# Create the tool call update
|
||||
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
|
||||
|
||||
response = await self.client.request_permission(
|
||||
session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
|
||||
typed_permissions: list[RequiredPermission] | None = (
|
||||
[
|
||||
rp
|
||||
for rp in required_permissions
|
||||
if isinstance(rp, RequiredPermission)
|
||||
]
|
||||
if required_permissions
|
||||
else None
|
||||
)
|
||||
|
||||
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
|
||||
options = build_permission_options(typed_permissions)
|
||||
|
||||
response = await self.client.request_permission(
|
||||
session_id=session_id, tool_call=tool_call, options=options
|
||||
)
|
||||
|
||||
# Parse the response using isinstance for proper type narrowing
|
||||
if response.outcome.outcome == "selected":
|
||||
outcome = cast(AllowedOutcome, response.outcome)
|
||||
return _handle_permission_selection(outcome.option_id, tool_name)
|
||||
return _handle_permission_selection(
|
||||
outcome.option_id, tool_name, typed_permissions
|
||||
)
|
||||
else:
|
||||
return (
|
||||
ApprovalResponse.NO,
|
||||
|
|
@ -365,6 +387,39 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
raise SessionNotFoundError(session_id)
|
||||
return self.sessions[session_id]
|
||||
|
||||
def _build_usage(self, session: AcpSessionLoop) -> Usage:
|
||||
stats = session.agent_loop.stats
|
||||
return Usage(
|
||||
input_tokens=stats.session_prompt_tokens,
|
||||
output_tokens=stats.session_completion_tokens,
|
||||
total_tokens=stats.session_total_llm_tokens,
|
||||
)
|
||||
|
||||
def _build_usage_update(self, session: AcpSessionLoop) -> UsageUpdate:
|
||||
stats = session.agent_loop.stats
|
||||
active_model = session.agent_loop.config.get_active_model()
|
||||
cost = (
|
||||
Cost(amount=stats.session_cost, currency="USD")
|
||||
if stats.input_price_per_million > 0 or stats.output_price_per_million > 0
|
||||
else None
|
||||
)
|
||||
return UsageUpdate(
|
||||
session_update="usage_update",
|
||||
used=stats.context_tokens,
|
||||
size=active_model.auto_compact_threshold,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
def _send_usage_update(self, session: AcpSessionLoop) -> None:
|
||||
async def _send() -> None:
|
||||
try:
|
||||
update = self._build_usage_update(session)
|
||||
await self.client.session_update(session_id=session.id, update=update)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(_send())
|
||||
|
||||
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
|
||||
if not msg.tool_calls:
|
||||
return
|
||||
|
|
@ -463,7 +518,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
await session.agent_loop.reload_with_initial_messages(
|
||||
base_config=new_config
|
||||
|
|
@ -492,7 +547,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
})
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
await session.agent_loop.reload_with_initial_messages(
|
||||
base_config=new_config
|
||||
|
|
@ -549,6 +604,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session = await self._create_acp_session(session_id, agent_loop)
|
||||
|
||||
await self._replay_conversation_history(session_id, non_system_messages)
|
||||
self._send_usage_update(session)
|
||||
|
||||
modes_state, modes_config = make_mode_response(
|
||||
list(agent_loop.agent_manager.available_agents.values()),
|
||||
|
|
@ -589,7 +645,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
|
|
@ -675,7 +731,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
@override
|
||||
async def prompt(
|
||||
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
|
||||
self,
|
||||
prompt: list[ContentBlock],
|
||||
session_id: str,
|
||||
message_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> PromptResponse:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
|
|
@ -708,7 +768,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
await session.task
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return PromptResponse(stop_reason="cancelled")
|
||||
self._send_usage_update(session)
|
||||
return PromptResponse(
|
||||
stop_reason="cancelled", usage=self._build_usage(session)
|
||||
)
|
||||
|
||||
except CoreRateLimitError as e:
|
||||
raise RateLimitError.from_core(e) from e
|
||||
|
|
@ -722,7 +785,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
finally:
|
||||
session.task = None
|
||||
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
self._send_usage_update(session)
|
||||
return PromptResponse(stop_reason="end_turn", usage=self._build_usage(session))
|
||||
|
||||
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
||||
text_prompt = ""
|
||||
|
|
@ -841,6 +905,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
elif isinstance(event, CompactEndEvent):
|
||||
yield create_compact_end_session_update(event)
|
||||
|
||||
elif isinstance(event, AgentProfileChangedEvent):
|
||||
pass
|
||||
|
||||
@override
|
||||
async def close_session(
|
||||
self, session_id: str, **kwargs: Any
|
||||
) -> CloseSessionResponse | None:
|
||||
raise NotImplementedMethodError("close_session")
|
||||
|
||||
@override
|
||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
session = self._get_session(session_id)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import sys
|
|||
import tomli_w
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig
|
||||
from vibe.core.config.harness_files import (
|
||||
get_harness_files_manager,
|
||||
init_harness_files_manager,
|
||||
|
|
@ -78,13 +78,23 @@ def main() -> None:
|
|||
init_harness_files_manager("user", "project")
|
||||
|
||||
from vibe.acp.acp_agent_loop import run_acp_server
|
||||
from vibe.core.config import VibeConfig, load_dotenv_values
|
||||
from vibe.core.tracing import setup_tracing
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
load_dotenv_values()
|
||||
bootstrap_config_files()
|
||||
args = parse_arguments()
|
||||
if args.setup:
|
||||
run_onboarding()
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
config = VibeConfig.load()
|
||||
setup_tracing(config)
|
||||
except MissingAPIKeyError:
|
||||
pass # tracing disabled, but server can still handle the error properly in new_session
|
||||
|
||||
run_acp_server()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from acp.schema import (
|
|||
ContentToolCallContent,
|
||||
ModelInfo,
|
||||
PermissionOption,
|
||||
SessionConfigOption,
|
||||
SessionConfigOptionSelect,
|
||||
SessionConfigSelectOption,
|
||||
SessionMode,
|
||||
|
|
@ -23,6 +22,7 @@ from acp.schema import (
|
|||
|
||||
from vibe.core.agents.models import AgentProfile, AgentType
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
||||
from vibe.core.utils import compact_reduction_display
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ TOOL_OPTIONS = [
|
|||
),
|
||||
PermissionOption(
|
||||
option_id=ToolOption.ALLOW_ALWAYS,
|
||||
name="Allow always",
|
||||
name="Allow for this session",
|
||||
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
|
||||
),
|
||||
PermissionOption(
|
||||
|
|
@ -56,6 +56,44 @@ TOOL_OPTIONS = [
|
|||
]
|
||||
|
||||
|
||||
def build_permission_options(
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> list[PermissionOption]:
|
||||
"""Build ACP permission options, including granular labels when available."""
|
||||
if not required_permissions:
|
||||
return TOOL_OPTIONS
|
||||
|
||||
labels = ", ".join(rp.label for rp in required_permissions)
|
||||
permissions_meta = [
|
||||
{
|
||||
"scope": rp.scope,
|
||||
"invocation_pattern": rp.invocation_pattern,
|
||||
"session_pattern": rp.session_pattern,
|
||||
"label": rp.label,
|
||||
}
|
||||
for rp in required_permissions
|
||||
]
|
||||
|
||||
return [
|
||||
PermissionOption(
|
||||
option_id=ToolOption.ALLOW_ONCE,
|
||||
name="Allow once",
|
||||
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
|
||||
),
|
||||
PermissionOption(
|
||||
option_id=ToolOption.ALLOW_ALWAYS,
|
||||
name=f"Allow for this session: {labels}",
|
||||
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
|
||||
field_meta={"required_permissions": permissions_meta},
|
||||
),
|
||||
PermissionOption(
|
||||
option_id=ToolOption.REJECT_ONCE,
|
||||
name="Reject once",
|
||||
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
||||
return any(
|
||||
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
|
||||
|
|
@ -64,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
|||
|
||||
def make_mode_response(
|
||||
profiles: list[AgentProfile], current_mode_id: str
|
||||
) -> tuple[SessionModeState, SessionConfigOption]:
|
||||
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
|
||||
session_modes: list[SessionMode] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
|
|
@ -89,22 +127,20 @@ def make_mode_response(
|
|||
state = SessionModeState(
|
||||
current_mode_id=current_mode_id, available_modes=session_modes
|
||||
)
|
||||
config = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
category="mode",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
config = SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
category="mode",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
return state, config
|
||||
|
||||
|
||||
def make_model_response(
|
||||
models: list[ModelConfig], current_model_id: str
|
||||
) -> tuple[SessionModelState, SessionConfigOption]:
|
||||
) -> tuple[SessionModelState, SessionConfigOptionSelect]:
|
||||
model_infos: list[ModelInfo] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
|
|
@ -119,15 +155,13 @@ def make_model_response(
|
|||
state = SessionModelState(
|
||||
current_model_id=current_model_id, available_models=model_infos
|
||||
)
|
||||
config_option = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
category="model",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
config_option = SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
category="model",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
return state, config_option
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from rich import print as rprint
|
|||
import tomli_w
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
|
|
@ -22,6 +22,7 @@ from vibe.core.logger import logger
|
|||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tracing import setup_tracing
|
||||
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
|
@ -104,6 +105,8 @@ def load_session(
|
|||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
elif args.resume is True:
|
||||
return None
|
||||
else:
|
||||
session_to_load = SessionLoader.find_session_by_id(
|
||||
args.resume, config.session_logging
|
||||
|
|
@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
try:
|
||||
initial_agent_name = get_initial_agent_name(args)
|
||||
config = load_config_or_exit()
|
||||
setup_tracing(config)
|
||||
|
||||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
|
@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
startup=StartupOptions(
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
show_resume_picker=args.resume is True,
|
||||
),
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
|
|
|
|||
|
|
@ -22,10 +22,15 @@ class CommandRegistry:
|
|||
handler="_show_help",
|
||||
),
|
||||
"config": Command(
|
||||
aliases=frozenset(["/config", "/model"]),
|
||||
aliases=frozenset(["/config"]),
|
||||
description="Edit config settings",
|
||||
handler="_show_config",
|
||||
),
|
||||
"model": Command(
|
||||
aliases=frozenset(["/model"]),
|
||||
description="Select active model",
|
||||
handler="_show_model",
|
||||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
|
|
@ -79,8 +84,8 @@ class CommandRegistry:
|
|||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Toggle voice mode on/off",
|
||||
handler="_toggle_voice_mode",
|
||||
description="Configure voice settings",
|
||||
handler="_show_voice_settings",
|
||||
),
|
||||
"leanstall": Command(
|
||||
aliases=frozenset(["/leanstall"]),
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
|
|||
)
|
||||
continuation_group.add_argument(
|
||||
"--resume",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_ID",
|
||||
help="Resume a specific session by its ID (supports partial matching)",
|
||||
help="Resume a session. Without SESSION_ID, shows an interactive picker.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
WhoAmIPlanType,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import platform
|
|||
import subprocess
|
||||
from typing import Any, Literal
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class Terminal(Enum):
|
||||
VSCODE = "vscode"
|
||||
|
|
@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
|
|||
|
||||
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
|
||||
if keybindings_path.exists():
|
||||
content = keybindings_path.read_text()
|
||||
content = read_safe(keybindings_path)
|
||||
return _parse_keybindings(content)
|
||||
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
import os
|
||||
|
|
@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
|
|||
NotificationPort,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.session_exit import print_session_resume_message
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
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.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.messages import (
|
||||
|
|
@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
WarningMessage,
|
||||
WhatsNewMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
|
||||
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
|
|
@ -65,6 +72,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
|||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
|
|
@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import (
|
|||
should_resume_history,
|
||||
sync_backfill_state,
|
||||
)
|
||||
from vibe.cli.turn_summary import (
|
||||
NoopTurnSummary,
|
||||
TurnSummaryPort,
|
||||
TurnSummaryResult,
|
||||
TurnSummaryTracker,
|
||||
create_narrator_backend,
|
||||
)
|
||||
from vibe.cli.update_notifier import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
PyPIUpdateGateway,
|
||||
|
|
@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
|
|||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.audio_player.audio_player import AudioPlayer
|
||||
from vibe.core.audio_player.audio_player_port import AudioFormat
|
||||
from vibe.core.audio_recorder import AudioRecorder
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
|
|
@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
|
|||
TeleportSendingGithubTokenEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
)
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
AskUserQuestionResult,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.transcribe import make_transcribe_client
|
||||
from vibe.core.tts.factory import make_tts_client
|
||||
from vibe.core.tts.tts_client_port import TTSClientPort
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalResponse,
|
||||
Backend,
|
||||
LLMMessage,
|
||||
RateLimitError,
|
||||
Role,
|
||||
|
|
@ -129,6 +149,7 @@ from vibe.core.utils import (
|
|||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
)
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class BottomApp(StrEnum):
|
||||
|
|
@ -142,9 +163,11 @@ class BottomApp(StrEnum):
|
|||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
SessionPicker = auto()
|
||||
Voice = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
|
|
@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
|
|||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||
|
||||
def _check_anchor(self) -> None:
|
||||
if self._anchored and self._anchor_released and self.is_at_bottom:
|
||||
self._anchor_released = False
|
||||
_reanchor_pending: bool = False
|
||||
_scrolling_down: bool = False
|
||||
|
||||
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
|
||||
super().watch_scroll_y(old_value, new_value)
|
||||
self._scrolling_down = new_value >= old_value
|
||||
|
||||
def release_anchor(self) -> None:
|
||||
super().release_anchor()
|
||||
# Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER
|
||||
# our override, so any re-anchor we do gets immediately undone.
|
||||
# Defer the re-check until all handlers for this event have finished.
|
||||
if not self._reanchor_pending:
|
||||
self._reanchor_pending = True
|
||||
self.call_later(self._maybe_reanchor)
|
||||
|
||||
def _maybe_reanchor(self) -> None:
|
||||
self._reanchor_pending = False
|
||||
if (
|
||||
self._anchored
|
||||
and self._anchor_released
|
||||
and self.is_at_bottom
|
||||
and self._scrolling_down
|
||||
):
|
||||
self.anchor()
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
|
@ -202,6 +247,13 @@ async def prune_oldest_children(
|
|||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StartupOptions:
|
||||
initial_prompt: str | None = None
|
||||
teleport_on_start: bool = False
|
||||
show_resume_picker: bool = False
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
|
|
@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def __init__(
|
||||
self,
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
startup: StartupOptions | None = None,
|
||||
update_notifier: UpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
|
|
@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._update_cache_repository = update_cache_repository
|
||||
self._current_version = current_version
|
||||
self._plan_offer_gateway = plan_offer_gateway
|
||||
self._initial_prompt = initial_prompt
|
||||
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
|
||||
opts = startup or StartupOptions()
|
||||
self._initial_prompt = opts.initial_prompt
|
||||
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
|
||||
self._show_resume_picker = opts.show_resume_picker
|
||||
self._last_escape_time: float | None = None
|
||||
self._banner: Banner | None = None
|
||||
self._whats_new_message: WhatsNewMessage | None = None
|
||||
|
|
@ -287,6 +340,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
self._plan_info: PlanInfo | None = None
|
||||
self._turn_summary: TurnSummaryPort = self._make_turn_summary()
|
||||
self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set()
|
||||
self._tts_client: TTSClientPort | None = self._make_tts_client()
|
||||
self._audio_player = AudioPlayer()
|
||||
self._speak_task: asyncio.Task[None] | None = None
|
||||
self._cancel_summary: Callable[[], bool] | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
yield VerticalGroup(id="messages")
|
||||
|
||||
with Horizontal(id="loading-area"):
|
||||
yield NarratorStatus()
|
||||
yield Static(id="loading-area-content")
|
||||
yield FeedbackBar()
|
||||
|
||||
with Static(id="bottom-app-container"):
|
||||
yield ChatInputContainer(
|
||||
|
|
@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
self._cached_loading_area = self.query_one("#loading-area-content")
|
||||
self._feedback_bar = self.query_one(FeedbackBar)
|
||||
|
||||
self.event_handler = EventHandler(
|
||||
mount_callback=self._mount_and_scroll,
|
||||
get_tools_collapsed=lambda: self._tools_collapsed,
|
||||
on_profile_changed=self._on_profile_changed,
|
||||
)
|
||||
|
||||
self._chat_input_container = self.query_one(ChatInputContainer)
|
||||
|
|
@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
if self._show_resume_picker:
|
||||
self.run_worker(self._show_session_picker(), exclusive=False)
|
||||
elif self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
gc.collect()
|
||||
|
|
@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
async def on_approval_app_approval_granted_always_tool(
|
||||
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
||||
) -> None:
|
||||
self._set_tool_permission_always(
|
||||
message.tool_name, save_permanently=message.save_permanently
|
||||
)
|
||||
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
|
||||
|
||||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
|
|
@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
|
|||
result = AskUserQuestionResult(answers=[], cancelled=True)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
def on_chat_text_area_feedback_key_pressed(
|
||||
self, message: ChatTextArea.FeedbackKeyPressed
|
||||
) -> None:
|
||||
self._feedback_bar.handle_feedback_key(message.rating)
|
||||
|
||||
def on_chat_text_area_non_feedback_key_pressed(
|
||||
self, message: ChatTextArea.NonFeedbackKeyPressed
|
||||
) -> None:
|
||||
self._feedback_bar.hide()
|
||||
|
||||
def on_feedback_bar_feedback_given(
|
||||
self, message: FeedbackBar.FeedbackGiven
|
||||
) -> None:
|
||||
self.agent_loop.telemetry_client.send_user_rating_feedback(
|
||||
rating=message.rating, model=self.config.active_model
|
||||
)
|
||||
|
||||
async def _remove_loading_widget(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
|
||||
async def on_config_app_open_model_picker(
|
||||
self, _message: ConfigApp.OpenModelPicker
|
||||
) -> None:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
changes = config_app._convert_changes_for_save()
|
||||
if changes:
|
||||
VibeConfig.save_updates(changes)
|
||||
await self._reload_config()
|
||||
await self._switch_to_input_app()
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def on_config_app_config_closed(
|
||||
self, message: ConfigApp.ConfigClosed
|
||||
) -> None:
|
||||
if message.changes:
|
||||
VibeConfig.save_updates(message.changes)
|
||||
await self._handle_config_settings_closed(message.changes)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None:
|
||||
await self._handle_voice_settings_closed(message.changes)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_config_settings_closed(
|
||||
self, changes: dict[str, str | bool]
|
||||
) -> None:
|
||||
if changes:
|
||||
VibeConfig.save_updates(changes)
|
||||
await self._reload_config()
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Configuration closed (no changes saved).")
|
||||
)
|
||||
|
||||
async def _handle_voice_settings_closed(
|
||||
self, changes: dict[str, str | bool]
|
||||
) -> None:
|
||||
if not changes:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Voice settings closed (no changes saved).")
|
||||
)
|
||||
return
|
||||
|
||||
if "voice_mode_enabled" in changes:
|
||||
current = self._voice_manager.is_enabled
|
||||
desired = changes["voice_mode_enabled"]
|
||||
if current != desired:
|
||||
self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.telemetry_client.send_telemetry_event(
|
||||
"vibe.voice_mode_toggled", {"enabled": desired}
|
||||
)
|
||||
self.agent_loop.refresh_config()
|
||||
if desired:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Voice mode enabled. Press ctrl+r to start recording."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Voice mode disabled.")
|
||||
)
|
||||
|
||||
non_voice_changes = {
|
||||
k: v for k, v in changes.items() if k != "voice_mode_enabled"
|
||||
}
|
||||
if non_voice_changes:
|
||||
VibeConfig.save_updates(non_voice_changes)
|
||||
self.agent_loop.refresh_config()
|
||||
self._sync_turn_summary()
|
||||
|
||||
async def on_model_picker_app_model_selected(
|
||||
self, message: ModelPickerApp.ModelSelected
|
||||
) -> None:
|
||||
VibeConfig.save_updates({"active_model": message.alias})
|
||||
await self._reload_config()
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_model_picker_app_cancelled(
|
||||
self, _event: ModelPickerApp.Cancelled
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
|
|
@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
for widget in children[:compact_index]:
|
||||
await widget.remove()
|
||||
|
||||
def _set_tool_permission_always(
|
||||
self, tool_name: str, save_permanently: bool = False
|
||||
) -> None:
|
||||
self.agent_loop.set_tool_permission(
|
||||
tool_name, ToolPermission.ALWAYS, save_permanently
|
||||
)
|
||||
|
||||
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):
|
||||
|
|
@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||
|
||||
try:
|
||||
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
|
||||
skill_content = read_safe(skill_info.skill_path)
|
||||
except OSError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
user_message = UserMessage(message)
|
||||
|
||||
await self._mount_and_scroll(user_message)
|
||||
if self.agent_loop.telemetry_client.is_active():
|
||||
self._feedback_bar.maybe_show()
|
||||
|
||||
if not self._agent_running:
|
||||
self._agent_task = asyncio.create_task(
|
||||
|
|
@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return tool in self.agent_loop.tool_manager.available_tools
|
||||
|
||||
async def _approval_callback(
|
||||
self, tool: str, args: BaseModel, tool_call_id: str
|
||||
self,
|
||||
tool: str,
|
||||
args: BaseModel,
|
||||
tool_call_id: str,
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
|
||||
# This ensures subagents respect the main agent's tool restrictions
|
||||
|
|
@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
await self._switch_to_approval_app(tool, args, required_permissions)
|
||||
result = await self._pending_approval
|
||||
return result
|
||||
finally:
|
||||
|
|
@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._pending_question = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_turn_error(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
|
||||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
self._agent_running = True
|
||||
|
||||
|
|
@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
try:
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
self._cancel_speak()
|
||||
self._turn_summary.start_turn(rendered_prompt)
|
||||
async for event in self.agent_loop.act(rendered_prompt):
|
||||
self._turn_summary.track(event)
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
|
|
@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
await self._handle_turn_error()
|
||||
self._turn_summary.cancel_turn()
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
await self._handle_turn_error()
|
||||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
message = self._rate_limit_message()
|
||||
self._turn_summary.set_error(message)
|
||||
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
finally:
|
||||
cancel_summary = self._turn_summary.end_turn()
|
||||
if (
|
||||
cancel_summary is not None
|
||||
and self.config.narrator_enabled
|
||||
and self._tts_client is not None
|
||||
):
|
||||
self._cancel_summary = cancel_summary
|
||||
self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING
|
||||
self._agent_running = False
|
||||
self._interrupt_requested = False
|
||||
self._agent_task = None
|
||||
|
|
@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_model(self) -> 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:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
|
@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
||||
await self._resolve_plan()
|
||||
self._sync_turn_summary()
|
||||
|
||||
if self._banner:
|
||||
self._banner.set_state(
|
||||
|
|
@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
lambda: self.config,
|
||||
audio_recorder=AudioRecorder(),
|
||||
transcribe_client=transcribe_client,
|
||||
telemetry_client=self.agent_loop.telemetry_client,
|
||||
)
|
||||
|
||||
async def _toggle_voice_mode(self) -> None:
|
||||
result = self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.refresh_config()
|
||||
if result.enabled:
|
||||
msg = "Voice mode enabled. Press ctrl+r to start recording."
|
||||
else:
|
||||
msg = "Voice mode disabled."
|
||||
await self._mount_and_scroll(UserCommandMessage(msg))
|
||||
async def _show_voice_settings(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
await self._switch_to_voice_app()
|
||||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
|
|
@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = False
|
||||
self._chat_input_container.disabled = True
|
||||
|
||||
self._feedback_bar.hide()
|
||||
|
||||
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
|
||||
await bottom_container.mount(widget)
|
||||
|
||||
|
|
@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||
await self._switch_from_input(ConfigApp(self.config))
|
||||
|
||||
async def _switch_to_voice_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Voice settings opened..."))
|
||||
await self._switch_from_input(VoiceApp(self.config))
|
||||
|
||||
async def _switch_to_model_picker_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
|
||||
model_aliases = [m.alias for m in self.config.models]
|
||||
current_model = str(self.config.active_model)
|
||||
await self._switch_from_input(
|
||||
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
|
||||
)
|
||||
|
||||
async def _switch_to_proxy_setup_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
|
@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._switch_from_input(ProxySetupApp())
|
||||
|
||||
async def _switch_to_approval_app(
|
||||
self, tool_name: str, tool_args: BaseModel
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
required_permissions: list[RequiredPermission] | None = None,
|
||||
) -> None:
|
||||
approval_app = ApprovalApp(
|
||||
tool_name=tool_name, tool_args=tool_args, config=self.config
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
config=self.config,
|
||||
required_permissions=required_permissions,
|
||||
)
|
||||
await self._switch_from_input(approval_app, scroll=True)
|
||||
|
||||
|
|
@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ChatInputContainer).focus_input()
|
||||
case BottomApp.Config:
|
||||
self.query_one(ConfigApp).focus()
|
||||
case BottomApp.ModelPicker:
|
||||
self.query_one(ModelPickerApp).focus()
|
||||
case BottomApp.ProxySetup:
|
||||
self.query_one(ProxySetupApp).focus()
|
||||
case BottomApp.Approval:
|
||||
|
|
@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.Voice:
|
||||
self.query_one(VoiceApp).focus()
|
||||
case app:
|
||||
assert_never(app)
|
||||
except Exception:
|
||||
|
|
@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_voice_app_escape(self) -> None:
|
||||
try:
|
||||
voice_app = self.query_one(VoiceApp)
|
||||
voice_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_approval_app_escape(self) -> None:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
|
|
@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_model_picker_app_escape(self) -> None:
|
||||
try:
|
||||
model_picker = self.query_one(ModelPickerApp)
|
||||
model_picker.post_message(ModelPickerApp.Cancelled())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_session_picker_app_escape(self) -> None:
|
||||
try:
|
||||
session_picker = self.query_one(SessionPickerApp)
|
||||
|
|
@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_config_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
self._handle_voice_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
|
|
@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
self._handle_model_picker_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
|
|
@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_input_app_escape()
|
||||
return
|
||||
|
||||
narrator_status = self.query_one(NarratorStatus)
|
||||
if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE:
|
||||
self._cancel_speak()
|
||||
return
|
||||
|
||||
if self._agent_running:
|
||||
self._handle_agent_running_escape()
|
||||
|
||||
|
|
@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def _refresh_profile_widgets(self) -> None:
|
||||
self._update_profile_widgets(self.agent_loop.agent_profile)
|
||||
|
||||
def _on_profile_changed(self) -> None:
|
||||
self._refresh_profile_widgets()
|
||||
self._refresh_banner()
|
||||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
self._banner.set_state(
|
||||
|
|
@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904
|
|||
# force a full layout refresh so the UI isn't garbled.
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _make_turn_summary(self) -> TurnSummaryPort:
|
||||
if not self.config.narrator_enabled:
|
||||
return NoopTurnSummary()
|
||||
result = create_narrator_backend(self.config)
|
||||
if result is None:
|
||||
return NoopTurnSummary()
|
||||
backend, model = result
|
||||
return TurnSummaryTracker(
|
||||
backend=backend, model=model, on_summary=self._on_turn_summary
|
||||
)
|
||||
|
||||
def _print_session_resume_message(session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
|
||||
self._cancel_summary = None
|
||||
if result.generation != self._turn_summary.generation:
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
return
|
||||
if result.summary is None:
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
return
|
||||
if self._tts_client is not None:
|
||||
self._speak_task = asyncio.create_task(self._speak_summary(result.summary))
|
||||
else:
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
|
||||
print()
|
||||
print("To continue this session, run: vibe --continue")
|
||||
print(f"Or: vibe --resume {session_id}")
|
||||
async def _speak_summary(self, text: str) -> None:
|
||||
if self._tts_client is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
tts_result = await self._tts_client.speak(text)
|
||||
self._set_narrator_state(NarratorState.SPEAKING)
|
||||
self._audio_player.play(
|
||||
tts_result.audio_data,
|
||||
AudioFormat.WAV,
|
||||
on_finished=lambda: loop.call_soon_threadsafe(
|
||||
self._set_narrator_state, NarratorState.IDLE
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("TTS speak failed", exc_info=True)
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
|
||||
def _cancel_speak(self) -> None:
|
||||
if self._cancel_summary is not None:
|
||||
self._cancel_summary()
|
||||
self._cancel_summary = None
|
||||
if self._speak_task is not None and not self._speak_task.done():
|
||||
self._speak_task.cancel()
|
||||
self._speak_task = None
|
||||
self._audio_player.stop()
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
|
||||
def _set_narrator_state(self, state: NarratorState) -> None:
|
||||
self.query_one(NarratorStatus).state = state
|
||||
|
||||
def _make_tts_client(self) -> TTSClientPort | None:
|
||||
if not self.config.narrator_enabled:
|
||||
return None
|
||||
try:
|
||||
model = self.config.get_active_tts_model()
|
||||
provider = self.config.get_tts_provider_for_model(model)
|
||||
return make_tts_client(provider, model)
|
||||
except (ValueError, KeyError) as exc:
|
||||
logger.error("Failed to initialize TTS client", exc_info=exc)
|
||||
return None
|
||||
|
||||
def _sync_turn_summary(self) -> None:
|
||||
self._cancel_speak()
|
||||
task = asyncio.create_task(self._turn_summary.close())
|
||||
self._turn_summary_close_tasks.add(task)
|
||||
task.add_done_callback(self._turn_summary_close_tasks.discard)
|
||||
self._turn_summary = self._make_turn_summary()
|
||||
|
||||
old_tts = self._tts_client
|
||||
self._tts_client = self._make_tts_client()
|
||||
if old_tts is not None:
|
||||
close_task = asyncio.create_task(old_tts.close())
|
||||
self._turn_summary_close_tasks.add(close_task)
|
||||
close_task.add_done_callback(self._turn_summary_close_tasks.discard)
|
||||
|
||||
|
||||
def run_textual_ui(
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
agent_loop: AgentLoop, startup: StartupOptions | None = None
|
||||
) -> None:
|
||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway()
|
||||
app = VibeApp(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=initial_prompt,
|
||||
teleport_on_start=teleport_on_start,
|
||||
startup=startup,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
plan_offer_gateway=plan_offer_gateway,
|
||||
)
|
||||
session_id = app.run()
|
||||
_print_session_resume_message(session_id)
|
||||
print_session_resume_message(session_id, agent_loop.stats)
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ Markdown {
|
|||
color: ansi_default;
|
||||
|
||||
.code_inline {
|
||||
color: ansi_yellow;
|
||||
color: ansi_green;
|
||||
background: transparent;
|
||||
text-style: bold;
|
||||
}
|
||||
|
|
@ -625,7 +625,8 @@ StatusMessage {
|
|||
.loading-hint {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
color: ansi_bright_black;
|
||||
|
||||
}
|
||||
|
||||
.history-load-more-message {
|
||||
|
|
@ -655,7 +656,7 @@ StatusMessage {
|
|||
|
||||
}
|
||||
|
||||
#config-app {
|
||||
#config-app, #voice-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
|
|
@ -664,7 +665,7 @@ StatusMessage {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
#config-content {
|
||||
#config-content, #voice-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -675,41 +676,14 @@ StatusMessage {
|
|||
color: ansi_blue;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
#config-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.settings-cursor-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-label-selected {
|
||||
color: ansi_default;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-toggle-on-selected {
|
||||
color: ansi_green;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-toggle-on-unselected {
|
||||
color: ansi_green;
|
||||
}
|
||||
|
||||
.settings-value-toggle-off {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.settings-value-cycle-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-cycle-unselected {
|
||||
color: ansi_blue;
|
||||
#config-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
|
|
@ -953,6 +927,14 @@ ContextProgress {
|
|||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
NarratorStatus {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 0 0 1;
|
||||
}
|
||||
|
||||
#banner-container {
|
||||
align: left middle;
|
||||
padding: 0 1 0 0;
|
||||
|
|
@ -1083,3 +1065,54 @@ ContextProgress {
|
|||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#modelpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#modelpicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.modelpicker-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
}
|
||||
|
||||
#modelpicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#modelpicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modelpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
FeedbackBar {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-left: 1;
|
||||
}
|
||||
|
||||
#feedback-text {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
|
|
|||
11
vibe/cli/textual_ui/constants.py
Normal file
11
vibe/cli/textual_ui/constants.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class MistralColors(StrEnum):
|
||||
RED = "#E10500"
|
||||
ORANGE_DARK = "#FA500F"
|
||||
ORANGE = "#FF8205"
|
||||
ORANGE_LIGHT = "#FFAF00"
|
||||
YELLOW = "#FFD800"
|
||||
|
|
@ -6,6 +6,8 @@ import shlex
|
|||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class ExternalEditor:
|
||||
"""Handles opening an external editor to edit prompt content."""
|
||||
|
|
@ -24,7 +26,7 @@ class ExternalEditor:
|
|||
parts = shlex.split(editor)
|
||||
subprocess.run([*parts, filepath], check=True)
|
||||
|
||||
content = Path(filepath).read_text().rstrip()
|
||||
content = read_safe(Path(filepath)).rstrip()
|
||||
return content if content != initial_content else None
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
AssistantEvent,
|
||||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
|
|
@ -27,10 +28,14 @@ if TYPE_CHECKING:
|
|||
|
||||
class EventHandler:
|
||||
def __init__(
|
||||
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
|
||||
self,
|
||||
mount_callback: Callable,
|
||||
get_tools_collapsed: Callable[[], bool],
|
||||
on_profile_changed: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.on_profile_changed = on_profile_changed
|
||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
|
|
@ -62,6 +67,9 @@ class EventHandler:
|
|||
case CompactEndEvent():
|
||||
await self.finalize_streaming()
|
||||
await self._handle_compact_end(event)
|
||||
case AgentProfileChangedEvent():
|
||||
if self.on_profile_changed:
|
||||
self.on_profile_changed()
|
||||
case UserMessageEvent():
|
||||
await self.finalize_streaming()
|
||||
case _:
|
||||
|
|
|
|||
25
vibe/cli/textual_ui/session_exit.py
Normal file
25
vibe/cli/textual_ui/session_exit.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
|
||||
def format_session_usage(stats: AgentStats) -> str:
|
||||
return (
|
||||
"Total tokens used this session: "
|
||||
f"input={stats.session_prompt_tokens:,} "
|
||||
f"output={stats.session_completion_tokens:,} "
|
||||
f"(total={stats.session_total_llm_tokens:,})"
|
||||
)
|
||||
|
||||
|
||||
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
|
||||
print()
|
||||
print(format_session_usage(stats))
|
||||
print()
|
||||
rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]")
|
||||
rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]")
|
||||
|
|
@ -13,6 +13,7 @@ from textual.widgets import Static
|
|||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
|
||||
|
||||
class ApprovalApp(Container):
|
||||
|
|
@ -38,12 +39,15 @@ class ApprovalApp(Container):
|
|||
|
||||
class ApprovalGrantedAlwaysTool(Message):
|
||||
def __init__(
|
||||
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
required_permissions: list[RequiredPermission],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args
|
||||
self.save_permanently = save_permanently
|
||||
self.required_permissions = required_permissions
|
||||
|
||||
class ApprovalRejected(Message):
|
||||
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
||||
|
|
@ -52,12 +56,17 @@ class ApprovalApp(Container):
|
|||
self.tool_args = tool_args
|
||||
|
||||
def __init__(
|
||||
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
config: VibeConfig,
|
||||
required_permissions: list[RequiredPermission] | None = None,
|
||||
) -> None:
|
||||
super().__init__(id="approval-app")
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args
|
||||
self.config = config
|
||||
self.required_permissions = required_permissions or []
|
||||
self.selected_option = 0
|
||||
self.content_container: Vertical | None = None
|
||||
self.title_widget: Static | None = None
|
||||
|
|
@ -104,9 +113,15 @@ class ApprovalApp(Container):
|
|||
await self.tool_info_container.mount(approval_widget)
|
||||
|
||||
def _update_options(self) -> None:
|
||||
if self.required_permissions:
|
||||
labels = ", ".join(rp.label for rp in self.required_permissions)
|
||||
always_text = f"Yes and always allow for this session: {labels}"
|
||||
else:
|
||||
always_text = f"Yes and always allow {self.tool_name} for this session"
|
||||
|
||||
options = [
|
||||
("Yes", "yes"),
|
||||
(f"Yes and always allow {self.tool_name} for this session", "yes"),
|
||||
(always_text, "yes"),
|
||||
("No and tell the agent what to do instead", "no"),
|
||||
]
|
||||
|
||||
|
|
@ -178,7 +193,7 @@ class ApprovalApp(Container):
|
|||
self.ApprovalGrantedAlwaysTool(
|
||||
tool_name=self.tool_name,
|
||||
tool_args=self.tool_args,
|
||||
save_permanently=False,
|
||||
required_permissions=self.required_permissions,
|
||||
)
|
||||
)
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
|
|||
self.post_message(self.HistoryNext())
|
||||
return True
|
||||
|
||||
class FeedbackKeyPressed(Message):
|
||||
def __init__(self, rating: int) -> None:
|
||||
self.rating = rating
|
||||
super().__init__()
|
||||
|
||||
class NonFeedbackKeyPressed(Message):
|
||||
pass
|
||||
|
||||
feedback_active: bool = False
|
||||
|
||||
async def _handle_voice_key(self, event: events.Key) -> bool:
|
||||
if not self._voice_manager:
|
||||
return False
|
||||
|
|
@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
|
|||
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
if self.feedback_active:
|
||||
if event.character in {"1", "2", "3"}:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.post_message(self.FeedbackKeyPressed(int(event.character)))
|
||||
return
|
||||
if event.character is not None:
|
||||
self.post_message(self.NonFeedbackKeyPressed())
|
||||
|
||||
manager = self._completion_manager
|
||||
if manager:
|
||||
match manager.on_key(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual import events
|
||||
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 Static
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
|
@ -15,22 +17,13 @@ if TYPE_CHECKING:
|
|||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
class SettingDefinition(TypedDict):
|
||||
key: str
|
||||
label: str
|
||||
type: str
|
||||
options: list[str]
|
||||
|
||||
|
||||
class ConfigApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
"""Settings panel with navigatable option picker."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "move_up", "Up", show=False),
|
||||
Binding("down", "move_down", "Down", show=False),
|
||||
Binding("space", "toggle_setting", "Toggle", show=False),
|
||||
Binding("enter", "cycle", "Next", show=False),
|
||||
Binding("escape", "close", "Close", show=False)
|
||||
]
|
||||
|
||||
class SettingChanged(Message):
|
||||
|
|
@ -44,122 +37,92 @@ class ConfigApp(Container):
|
|||
super().__init__()
|
||||
self.changes = changes
|
||||
|
||||
class OpenModelPicker(Message):
|
||||
pass
|
||||
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(id="config-app")
|
||||
self.config = config
|
||||
self.selected_index = 0
|
||||
self.changes: dict[str, str] = {}
|
||||
|
||||
self.settings: list[SettingDefinition] = [
|
||||
{
|
||||
"key": "active_model",
|
||||
"label": "Model",
|
||||
"type": "cycle",
|
||||
"options": [m.alias for m in self.config.models],
|
||||
},
|
||||
{
|
||||
"key": "autocopy_to_clipboard",
|
||||
"label": "Auto-copy",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
{
|
||||
"key": "file_watcher_for_autocomplete",
|
||||
"label": "Autocomplete watcher (may delay first autocompletion)",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
self._toggle_settings: list[tuple[str, str]] = [
|
||||
("autocopy_to_clipboard", "Auto-copy"),
|
||||
(
|
||||
"file_watcher_for_autocomplete",
|
||||
"Autocomplete watcher (may delay first autocompletion)",
|
||||
),
|
||||
]
|
||||
|
||||
self.title_widget: Static | None = None
|
||||
self.setting_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
def _get_current_model(self) -> str:
|
||||
return str(getattr(self.config, "active_model", ""))
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="config-content"):
|
||||
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield self.title_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in self.settings:
|
||||
widget = NoMarkupStatic("", classes="settings-option")
|
||||
self.setting_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def _get_display_value(self, setting: SettingDefinition) -> str:
|
||||
key = setting["key"]
|
||||
def _get_toggle_value(self, key: str) -> str:
|
||||
if key in self.changes:
|
||||
return self.changes[key]
|
||||
raw_value = getattr(self.config, key, "")
|
||||
if isinstance(raw_value, bool):
|
||||
return "On" if raw_value else "Off"
|
||||
return str(raw_value)
|
||||
raw = getattr(self.config, key, False)
|
||||
if isinstance(raw, bool):
|
||||
return "On" if raw else "Off"
|
||||
return str(raw)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, (setting, widget) in enumerate(
|
||||
zip(self.settings, self.setting_widgets, strict=True)
|
||||
):
|
||||
is_selected = i == self.selected_index
|
||||
cursor = "› " if is_selected else " "
|
||||
def _model_prompt(self) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
text.append("Model: ")
|
||||
text.append(self._get_current_model(), style="bold")
|
||||
return text
|
||||
|
||||
label: str = setting["label"]
|
||||
value: str = self._get_display_value(setting)
|
||||
def _toggle_prompt(self, key: str, label: str) -> Text:
|
||||
value = self._get_toggle_value(key)
|
||||
text = Text(no_wrap=True)
|
||||
text.append(f"{label}: ")
|
||||
if value == "On":
|
||||
text.append("On", style="green bold")
|
||||
else:
|
||||
text.append("Off", style="dim")
|
||||
return text
|
||||
|
||||
text = f"{cursor}{label}: {value}"
|
||||
def compose(self) -> ComposeResult:
|
||||
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
|
||||
for key, label in self._toggle_settings:
|
||||
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
|
||||
|
||||
widget.update(text)
|
||||
with Vertical(id="config-content"):
|
||||
yield NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(*options, id="config-options")
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help"
|
||||
)
|
||||
|
||||
widget.remove_class("settings-cursor-selected")
|
||||
widget.remove_class("settings-value-cycle-selected")
|
||||
widget.remove_class("settings-value-cycle-unselected")
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
if is_selected:
|
||||
widget.add_class("settings-value-cycle-selected")
|
||||
else:
|
||||
widget.add_class("settings-value-cycle-unselected")
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def action_move_up(self) -> None:
|
||||
self.selected_index = (self.selected_index - 1) % len(self.settings)
|
||||
self._update_display()
|
||||
def _refresh_options(self) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.replace_option_prompt("action:active_model", self._model_prompt())
|
||||
for key, label in self._toggle_settings:
|
||||
option_list.replace_option_prompt(
|
||||
f"toggle:{key}", self._toggle_prompt(key, label)
|
||||
)
|
||||
|
||||
def action_move_down(self) -> None:
|
||||
self.selected_index = (self.selected_index + 1) % len(self.settings)
|
||||
self._update_display()
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
option_id = event.option.id
|
||||
if not option_id:
|
||||
return
|
||||
|
||||
def action_toggle_setting(self) -> None:
|
||||
setting = self.settings[self.selected_index]
|
||||
key: str = setting["key"]
|
||||
current: str = self._get_display_value(setting)
|
||||
if option_id == "action:active_model":
|
||||
self.post_message(self.OpenModelPicker())
|
||||
return
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
try:
|
||||
current_idx = options.index(current)
|
||||
next_idx = (current_idx + 1) % len(options)
|
||||
new_value = options[next_idx]
|
||||
except (ValueError, IndexError):
|
||||
new_value = options[0] if options else current
|
||||
|
||||
self.changes[key] = new_value
|
||||
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
|
||||
self._update_display()
|
||||
|
||||
def action_cycle(self) -> None:
|
||||
self.action_toggle_setting()
|
||||
if option_id.startswith("toggle:"):
|
||||
key = option_id.removeprefix("toggle:")
|
||||
current = self._get_toggle_value(key)
|
||||
new_value = "Off" if current == "On" else "On"
|
||||
self.changes[key] = new_value
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
self._refresh_options()
|
||||
|
||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||
result: dict[str, str | bool] = {}
|
||||
|
|
@ -172,6 +135,3 @@ class ConfigApp(Container):
|
|||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
|
|||
69
vibe/cli/textual_ui/widgets/feedback_bar.py
Normal file
69
vibe/cli/textual_ui/widgets/feedback_bar.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
|
||||
FEEDBACK_PROBABILITY = 0.02
|
||||
THANK_YOU_DURATION = 2.0
|
||||
|
||||
|
||||
class FeedbackBar(Widget):
|
||||
class FeedbackGiven(Message):
|
||||
def __init__(self, rating: int) -> None:
|
||||
super().__init__()
|
||||
self.rating = rating
|
||||
|
||||
@staticmethod
|
||||
def _prompt_text() -> Text:
|
||||
text = Text()
|
||||
text.append("How is Vibe doing so far? ")
|
||||
text.append("1", style="blue")
|
||||
text.append(": good ")
|
||||
text.append("2", style="blue")
|
||||
text.append(": fine ")
|
||||
text.append("3", style="blue")
|
||||
text.append(": bad")
|
||||
return text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._prompt_text(), id="feedback-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.display = False
|
||||
|
||||
def maybe_show(self) -> None:
|
||||
if self.display:
|
||||
return
|
||||
if random.random() <= FEEDBACK_PROBABILITY:
|
||||
self._set_active(True)
|
||||
|
||||
def hide(self) -> None:
|
||||
if self.display:
|
||||
self._set_active(False)
|
||||
|
||||
def handle_feedback_key(self, rating: int) -> None:
|
||||
try:
|
||||
self.app.query_one(ChatTextArea).feedback_active = False
|
||||
except Exception:
|
||||
pass
|
||||
self.query_one("#feedback-text", Static).update(
|
||||
Text("Thank you for your feedback!")
|
||||
)
|
||||
self.post_message(self.FeedbackGiven(rating))
|
||||
self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False))
|
||||
|
||||
def _set_active(self, active: bool) -> None:
|
||||
if active:
|
||||
self.query_one("#feedback-text", Static).update(self._prompt_text())
|
||||
self.display = active
|
||||
try:
|
||||
self.app.query_one(ChatTextArea).feedback_active = active
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -11,6 +11,7 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.constants import MistralColors
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
|
@ -28,7 +29,13 @@ def _format_elapsed(seconds: int) -> str:
|
|||
|
||||
|
||||
class LoadingWidget(SpinnerMixin, Static):
|
||||
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
|
||||
TARGET_COLORS = (
|
||||
MistralColors.YELLOW,
|
||||
MistralColors.ORANGE_LIGHT,
|
||||
MistralColors.ORANGE,
|
||||
MistralColors.ORANGE_DARK,
|
||||
MistralColors.RED,
|
||||
)
|
||||
SPINNER_TYPE = SpinnerType.SNAKE
|
||||
|
||||
EASTER_EGGS: ClassVar[list[str]] = [
|
||||
|
|
|
|||
75
vibe/cli/textual_ui/widgets/model_picker.py
Normal file
75
vibe/cli/textual_ui/widgets/model_picker.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
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
|
||||
|
||||
|
||||
def _build_option_text(alias: str, is_current: bool) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
marker = "› " if is_current else " "
|
||||
style = "bold" if is_current else ""
|
||||
text.append(marker, style="green" if is_current else "")
|
||||
text.append(alias, style=style)
|
||||
return text
|
||||
|
||||
|
||||
class ModelPickerApp(Container):
|
||||
"""Model picker bottom app for selecting the active model."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Cancel", show=False)
|
||||
]
|
||||
|
||||
class ModelSelected(Message):
|
||||
def __init__(self, alias: str) -> None:
|
||||
self.alias = alias
|
||||
super().__init__()
|
||||
|
||||
class Cancelled(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self, model_aliases: list[str], current_model: str, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(id="modelpicker-app", **kwargs)
|
||||
self._model_aliases = model_aliases
|
||||
self._current_model = current_model
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
options = [
|
||||
Option(_build_option_text(alias, alias == self._current_model), id=alias)
|
||||
for alias in self._model_aliases
|
||||
]
|
||||
with Vertical(id="modelpicker-content"):
|
||||
yield NoMarkupStatic("Select Model", classes="modelpicker-title")
|
||||
yield OptionList(*options, id="modelpicker-options")
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ Navigate Enter Select Esc Cancel", classes="modelpicker-help"
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
# Pre-select the current model
|
||||
for i, alias in enumerate(self._model_aliases):
|
||||
if alias == self._current_model:
|
||||
option_list.highlighted = i
|
||||
break
|
||||
option_list.focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if event.option.id:
|
||||
self.post_message(self.ModelSelected(event.option.id))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.post_message(self.Cancelled())
|
||||
56
vibe/cli/textual_ui/widgets/narrator_status.py
Normal file
56
vibe/cli/textual_ui/widgets/narrator_status.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
from typing import Any
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Static
|
||||
|
||||
SHRINK_FRAMES = "█▇▆▅▄▃▂▁"
|
||||
BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"]
|
||||
ANIMATION_INTERVAL = 0.15
|
||||
|
||||
|
||||
class NarratorState(StrEnum):
|
||||
IDLE = auto()
|
||||
SUMMARIZING = auto()
|
||||
SPEAKING = auto()
|
||||
|
||||
|
||||
class NarratorStatus(Static):
|
||||
state = reactive(NarratorState.IDLE)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__("", **kwargs)
|
||||
self._timer: Timer | None = None
|
||||
self._frame: int = 0
|
||||
|
||||
def watch_state(self, new_state: NarratorState) -> None:
|
||||
self._stop_timer()
|
||||
match new_state:
|
||||
case NarratorState.IDLE:
|
||||
self.update("")
|
||||
case NarratorState.SUMMARIZING | NarratorState.SPEAKING:
|
||||
self._frame = 0
|
||||
self._tick()
|
||||
self._timer = self.set_interval(ANIMATION_INTERVAL, self._tick)
|
||||
|
||||
def _tick(self) -> None:
|
||||
match self.state:
|
||||
case NarratorState.SUMMARIZING:
|
||||
char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)]
|
||||
self.update(
|
||||
f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]"
|
||||
)
|
||||
case NarratorState.SPEAKING:
|
||||
bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)]
|
||||
self.update(
|
||||
f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]"
|
||||
)
|
||||
self._frame += 1
|
||||
|
||||
def _stop_timer(self) -> None:
|
||||
if self._timer is not None:
|
||||
self._timer.stop()
|
||||
self._timer = None
|
||||
173
vibe/cli/textual_ui/widgets/voice_app.py
Normal file
173
vibe/cli/textual_ui/widgets/voice_app.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
||||
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
class SettingDefinition(TypedDict):
|
||||
key: str
|
||||
label: str
|
||||
type: str
|
||||
options: list[str]
|
||||
|
||||
|
||||
class VoiceApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "move_up", "Up", show=False),
|
||||
Binding("down", "move_down", "Down", show=False),
|
||||
Binding("space", "toggle_setting", "Toggle", show=False),
|
||||
Binding("enter", "cycle", "Next", show=False),
|
||||
]
|
||||
|
||||
class SettingChanged(Message):
|
||||
def __init__(self, key: str, value: str) -> None:
|
||||
super().__init__()
|
||||
self.key = key
|
||||
self.value = value
|
||||
|
||||
class ConfigClosed(Message):
|
||||
def __init__(self, changes: dict[str, str | bool]) -> None:
|
||||
super().__init__()
|
||||
self.changes = changes
|
||||
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(id="voice-app")
|
||||
self.config = config
|
||||
self.selected_index = 0
|
||||
self.changes: dict[str, str] = {}
|
||||
|
||||
self.settings: list[SettingDefinition] = [
|
||||
{
|
||||
"key": "voice_mode_enabled",
|
||||
"label": "Voice mode",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
{
|
||||
"key": "narrator_enabled",
|
||||
"label": "Narrator (experimental)",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
]
|
||||
|
||||
self.title_widget: Static | None = None
|
||||
self.setting_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="voice-content"):
|
||||
self.title_widget = NoMarkupStatic(
|
||||
"Voice Settings", classes="settings-title"
|
||||
)
|
||||
yield self.title_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in self.settings:
|
||||
widget = NoMarkupStatic("", classes="settings-option")
|
||||
self.setting_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def _get_display_value(self, setting: SettingDefinition) -> str:
|
||||
key = setting["key"]
|
||||
if key in self.changes:
|
||||
return self.changes[key]
|
||||
raw_value = getattr(self.config, key, "")
|
||||
if isinstance(raw_value, bool):
|
||||
return "On" if raw_value else "Off"
|
||||
return str(raw_value)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, (setting, widget) in enumerate(
|
||||
zip(self.settings, self.setting_widgets, strict=True)
|
||||
):
|
||||
is_selected = i == self.selected_index
|
||||
cursor = "› " if is_selected else " "
|
||||
|
||||
label: str = setting["label"]
|
||||
value: str = self._get_display_value(setting)
|
||||
|
||||
text = f"{cursor}{label}: {value}"
|
||||
|
||||
widget.update(text)
|
||||
|
||||
widget.remove_class("settings-cursor-selected")
|
||||
widget.remove_class("settings-value-cycle-selected")
|
||||
widget.remove_class("settings-value-cycle-unselected")
|
||||
|
||||
if is_selected:
|
||||
widget.add_class("settings-value-cycle-selected")
|
||||
else:
|
||||
widget.add_class("settings-value-cycle-unselected")
|
||||
|
||||
def action_move_up(self) -> None:
|
||||
self.selected_index = (self.selected_index - 1) % len(self.settings)
|
||||
self._update_display()
|
||||
|
||||
def action_move_down(self) -> None:
|
||||
self.selected_index = (self.selected_index + 1) % len(self.settings)
|
||||
self._update_display()
|
||||
|
||||
def action_toggle_setting(self) -> None:
|
||||
setting = self.settings[self.selected_index]
|
||||
key: str = setting["key"]
|
||||
current: str = self._get_display_value(setting)
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
try:
|
||||
current_idx = options.index(current)
|
||||
next_idx = (current_idx + 1) % len(options)
|
||||
new_value = options[next_idx]
|
||||
except (ValueError, IndexError):
|
||||
new_value = options[0] if options else current
|
||||
|
||||
self.changes[key] = new_value
|
||||
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
|
||||
self._update_display()
|
||||
|
||||
def action_cycle(self) -> None:
|
||||
self.action_toggle_setting()
|
||||
|
||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||
result: dict[str, str | bool] = {}
|
||||
for key, value in self.changes.items():
|
||||
if value in {"On", "Off"}:
|
||||
result[key] = value == "On"
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
@ -14,7 +14,7 @@ def patch_vscode_space(event: events.Key) -> None:
|
|||
silently drop the keystroke because there is no printable character.
|
||||
Assigning ``event.character = " "`` restores normal behaviour.
|
||||
"""
|
||||
if event.key == "space" and event.character is None:
|
||||
if event.key in {"space", "shift+space"} and event.character is None:
|
||||
event.character = " "
|
||||
|
||||
|
||||
|
|
|
|||
20
vibe/cli/turn_summary/__init__.py
Normal file
20
vibe/cli/turn_summary/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.turn_summary.noop import NoopTurnSummary
|
||||
from vibe.cli.turn_summary.port import (
|
||||
TurnSummaryData,
|
||||
TurnSummaryPort,
|
||||
TurnSummaryResult,
|
||||
)
|
||||
from vibe.cli.turn_summary.tracker import TurnSummaryTracker
|
||||
from vibe.cli.turn_summary.utils import NARRATOR_MODEL, create_narrator_backend
|
||||
|
||||
__all__ = [
|
||||
"NARRATOR_MODEL",
|
||||
"NoopTurnSummary",
|
||||
"TurnSummaryData",
|
||||
"TurnSummaryPort",
|
||||
"TurnSummaryResult",
|
||||
"TurnSummaryTracker",
|
||||
"create_narrator_backend",
|
||||
]
|
||||
30
vibe/cli/turn_summary/noop.py
Normal file
30
vibe/cli/turn_summary/noop.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from vibe.cli.turn_summary.port import TurnSummaryPort
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class NoopTurnSummary(TurnSummaryPort):
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return 0
|
||||
|
||||
def start_turn(self, user_message: str) -> None:
|
||||
pass
|
||||
|
||||
def track(self, event: BaseEvent) -> None:
|
||||
pass
|
||||
|
||||
def set_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def cancel_turn(self) -> None:
|
||||
pass
|
||||
|
||||
def end_turn(self) -> Callable[[], bool] | None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
43
vibe/cli/turn_summary/port.py
Normal file
43
vibe/cli/turn_summary/port.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class TurnSummaryData(BaseModel):
|
||||
user_message: str
|
||||
assistant_fragments: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class TurnSummaryResult(BaseModel):
|
||||
generation: int
|
||||
summary: str | None
|
||||
|
||||
|
||||
class TurnSummaryPort(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def generation(self) -> int: ...
|
||||
|
||||
@abstractmethod
|
||||
def start_turn(self, user_message: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def track(self, event: BaseEvent) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def set_error(self, message: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def cancel_turn(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def end_turn(self) -> Callable[[], bool] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None: ...
|
||||
109
vibe/cli/turn_summary/tracker.py
Normal file
109
vibe/cli/turn_summary/tracker.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from vibe.cli.turn_summary.port import (
|
||||
TurnSummaryData,
|
||||
TurnSummaryPort,
|
||||
TurnSummaryResult,
|
||||
)
|
||||
from vibe.core.config import ModelConfig
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role
|
||||
|
||||
|
||||
class TurnSummaryTracker(TurnSummaryPort):
|
||||
def __init__(
|
||||
self,
|
||||
backend: BackendLike,
|
||||
model: ModelConfig,
|
||||
on_summary: Callable[[TurnSummaryResult], None],
|
||||
max_tokens: int = 512,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._model = model
|
||||
self._on_summary = on_summary
|
||||
self._max_tokens = max_tokens
|
||||
self._tasks: set[asyncio.Task[Any]] = set()
|
||||
self._data: TurnSummaryData | None = None
|
||||
self._generation: int = 0
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return self._generation
|
||||
|
||||
def start_turn(self, user_message: str) -> None:
|
||||
self._generation += 1
|
||||
self._data = TurnSummaryData(user_message=user_message)
|
||||
|
||||
def track(self, event: BaseEvent) -> None:
|
||||
if self._data is None:
|
||||
return
|
||||
match event:
|
||||
case AssistantEvent(content=c) if c:
|
||||
self._data.assistant_fragments.append(c)
|
||||
|
||||
def set_error(self, message: str) -> None:
|
||||
if self._data is not None:
|
||||
self._data.error = message
|
||||
|
||||
def cancel_turn(self) -> None:
|
||||
self._data = None
|
||||
|
||||
def end_turn(self) -> Callable[[], bool] | None:
|
||||
if self._data is None:
|
||||
return None
|
||||
gen = self._generation
|
||||
task = asyncio.create_task(self._generate_summary(self._data, gen))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
self._data = None
|
||||
return task.cancel
|
||||
|
||||
async def close(self) -> None:
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
self._tasks.clear()
|
||||
|
||||
async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None:
|
||||
try:
|
||||
prompt_text = UtilityPrompt.TURN_SUMMARY.read()
|
||||
|
||||
sections: list[str] = []
|
||||
sections.append(f"## User Request\n{data.user_message}")
|
||||
|
||||
full_text = "".join(data.assistant_fragments)
|
||||
if full_text:
|
||||
sections.append(f"## Assistant Response\n{full_text}")
|
||||
|
||||
if data.error:
|
||||
sections.append(f"## Error\n{data.error}")
|
||||
|
||||
extraction_text = "\n\n".join(sections)
|
||||
|
||||
summary_messages = [
|
||||
LLMMessage(role=Role.system, content=prompt_text),
|
||||
LLMMessage(role=Role.user, content=extraction_text),
|
||||
]
|
||||
|
||||
result = await self._backend.complete(
|
||||
model=self._model,
|
||||
messages=summary_messages,
|
||||
temperature=0.0,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
max_tokens=self._max_tokens,
|
||||
extra_headers={},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
summary = result.message.content or ""
|
||||
self._on_summary(TurnSummaryResult(generation=gen, summary=summary))
|
||||
except Exception:
|
||||
logger.warning("Turn summary generation failed", exc_info=True)
|
||||
self._on_summary(TurnSummaryResult(generation=gen, summary=None))
|
||||
30
vibe/cli/turn_summary/utils.py
Normal file
30
vibe/cli/turn_summary/utils.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.types import BackendLike
|
||||
|
||||
NARRATOR_MODEL = ModelConfig(
|
||||
name="mistral-vibe-cli-fast",
|
||||
provider="mistral",
|
||||
alias="mistral-small",
|
||||
input_price=0.1,
|
||||
output_price=0.3,
|
||||
)
|
||||
|
||||
|
||||
def create_narrator_backend(
|
||||
config: VibeConfig,
|
||||
) -> tuple[BackendLike, ModelConfig] | None:
|
||||
try:
|
||||
provider = config.get_provider_for_model(NARRATOR_MODEL)
|
||||
except ValueError:
|
||||
return None
|
||||
if provider.api_key_env_var and not os.getenv(provider.api_key_env_var):
|
||||
return None
|
||||
backend = BACKEND_FACTORY[provider.backend](
|
||||
provider=provider, timeout=config.api_timeout
|
||||
)
|
||||
return backend, NARRATOR_MODEL
|
||||
|
|
@ -7,6 +7,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
|
|||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
)
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
async def should_show_whats_new(
|
||||
|
|
@ -23,7 +24,7 @@ def load_whats_new_content() -> str | None:
|
|||
if not whats_new_file.exists():
|
||||
return None
|
||||
try:
|
||||
content = whats_new_file.read_text(encoding="utf-8").strip()
|
||||
content = read_safe(whats_new_file).strip()
|
||||
return content if content else None
|
||||
except OSError:
|
||||
return None
|
||||
|
|
|
|||
30
vibe/cli/voice_manager/telemetry.py
Normal file
30
vibe/cli/voice_manager/telemetry.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptionTrackingState:
|
||||
recording_id: str = ""
|
||||
start_time: float = field(default_factory=time.monotonic)
|
||||
accumulated_transcript_length: int = 0
|
||||
last_recording_duration_ms: float | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
self.recording_id = ""
|
||||
self.start_time = time.monotonic()
|
||||
self.accumulated_transcript_length = 0
|
||||
self.last_recording_duration_ms = None
|
||||
|
||||
def set_recording_id(self, recording_id: str) -> None:
|
||||
self.recording_id = recording_id
|
||||
|
||||
def record_text(self, text: str) -> None:
|
||||
self.accumulated_transcript_length += len(text)
|
||||
|
||||
def elapsed_ms(self) -> float:
|
||||
return (time.monotonic() - self.start_time) * 1000
|
||||
|
||||
def set_recording_duration(self, duration_s: float) -> None:
|
||||
self.last_recording_duration_ms = duration_s * 1000
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from asyncio import CancelledError, Task, create_task, wait_for
|
||||
from collections.abc import Callable
|
||||
from asyncio import CancelledError, create_task, wait_for
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceToggleResult,
|
||||
)
|
||||
from vibe.core.audio_recorder import AudioRecorderPort
|
||||
from vibe.core.audio_recorder.audio_recorder_port import (
|
||||
AlreadyRecordingError,
|
||||
AudioBackendUnavailableError,
|
||||
|
|
@ -19,13 +18,21 @@ from vibe.core.audio_recorder.audio_recorder_port import (
|
|||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.transcribe.transcribe_client_port import (
|
||||
TranscribeClientPort,
|
||||
TranscribeDone,
|
||||
TranscribeError,
|
||||
TranscribeSessionCreated,
|
||||
TranscribeTextDelta,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio import Task
|
||||
from collections.abc import Callable
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerListener
|
||||
from vibe.core.audio_recorder import AudioRecorderPort
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
|
||||
|
||||
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
|
||||
|
||||
|
||||
|
|
@ -35,13 +42,16 @@ class VoiceManager:
|
|||
config_getter: Callable[[], VibeConfig],
|
||||
audio_recorder: AudioRecorderPort,
|
||||
transcribe_client: TranscribeClientPort | None,
|
||||
telemetry_client: TelemetryClient | None = None,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._audio_recorder = audio_recorder
|
||||
self._transcribe_client = transcribe_client
|
||||
self._telemetry_client = telemetry_client
|
||||
self._transcribe_state = TranscribeState.IDLE
|
||||
self._transcribe_task: Task[None] | None = None
|
||||
self._listeners: list[VoiceManagerListener] = []
|
||||
self._tracking = TranscriptionTrackingState()
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
|
|
@ -91,6 +101,7 @@ class VoiceManager:
|
|||
except NoAudioInputDeviceError:
|
||||
raise RecordingStartError("No audio input device found")
|
||||
|
||||
self._tracking.reset()
|
||||
self._set_state(TranscribeState.RECORDING)
|
||||
self._transcribe_task = create_task(self._run_transcription())
|
||||
|
||||
|
|
@ -101,7 +112,8 @@ class VoiceManager:
|
|||
|
||||
if should_flush_queue:
|
||||
self._set_state(TranscribeState.FLUSHING)
|
||||
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
|
||||
recording = self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
|
||||
self._tracking.set_recording_duration(recording.duration)
|
||||
|
||||
if self._transcribe_task is not None:
|
||||
try:
|
||||
|
|
@ -111,6 +123,7 @@ class VoiceManager:
|
|||
except TimeoutError:
|
||||
logger.warning("Transcription task timed out, cancelling")
|
||||
self._transcribe_task.cancel()
|
||||
self._on_audio_transcription_error("Transcription timed out")
|
||||
except CancelledError:
|
||||
pass
|
||||
self._transcribe_task = None
|
||||
|
|
@ -129,6 +142,7 @@ class VoiceManager:
|
|||
self._transcribe_task = None
|
||||
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
self._on_audio_transcription_cancel()
|
||||
|
||||
def add_listener(self, listener: VoiceManagerListener) -> None:
|
||||
if listener not in self._listeners:
|
||||
|
|
@ -150,6 +164,7 @@ class VoiceManager:
|
|||
async for event in self._transcribe_client.transcribe(audio_stream):
|
||||
match event:
|
||||
case TranscribeTextDelta(text=text):
|
||||
self._tracking.record_text(text)
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_transcribe_text(text)
|
||||
|
|
@ -158,22 +173,80 @@ class VoiceManager:
|
|||
"Listener raised during transcribe text",
|
||||
exc_info=True,
|
||||
)
|
||||
case TranscribeDone():
|
||||
pass
|
||||
case TranscribeError(message=msg):
|
||||
raise RuntimeError(msg)
|
||||
case TranscribeSessionCreated():
|
||||
case TranscribeSessionCreated(request_id=request_id):
|
||||
self._tracking.set_recording_id(request_id)
|
||||
self._on_audio_transcription_start()
|
||||
case TranscribeDone():
|
||||
pass
|
||||
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
self._on_audio_transcription_done()
|
||||
except CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Transcription failed", exc_info=exc)
|
||||
self._audio_recorder.cancel()
|
||||
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
self._on_audio_transcription_error(str(exc))
|
||||
|
||||
def _on_audio_transcription_start(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.start",
|
||||
{"recording_id": self._tracking.recording_id},
|
||||
)
|
||||
|
||||
def _on_audio_transcription_cancel(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.cancel_recording",
|
||||
{
|
||||
"recording_id": self._tracking.recording_id,
|
||||
"recording_duration_ms": self._tracking.elapsed_ms(),
|
||||
},
|
||||
)
|
||||
|
||||
def _on_audio_transcription_done(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
transcription_duration_ms = self._tracking.elapsed_ms()
|
||||
recording_duration_ms = (
|
||||
self._tracking.last_recording_duration_ms
|
||||
if self._tracking.last_recording_duration_ms is not None
|
||||
else transcription_duration_ms
|
||||
)
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.done",
|
||||
{
|
||||
"recording_id": self._tracking.recording_id,
|
||||
"transcript_length": self._tracking.accumulated_transcript_length,
|
||||
"transcription_duration_ms": transcription_duration_ms,
|
||||
"recording_duration_ms": recording_duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def _on_audio_transcription_error(self, error_message: str) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.error",
|
||||
{
|
||||
"recording_id": self._tracking.recording_id,
|
||||
"error_message": error_message,
|
||||
"transcription_duration_ms": self._tracking.elapsed_ms(),
|
||||
"recording_duration_ms": self._tracking.last_recording_duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def _set_state(self, state: TranscribeState) -> None:
|
||||
if self._transcribe_state == state:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -5,19 +5,19 @@ from collections.abc import AsyncGenerator, Callable, Generator
|
|||
import contextlib
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from opentelemetry import trace
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.cli.terminal_setup import detect_terminal
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.exceptions import BackendError
|
||||
from vibe.core.llm.format import (
|
||||
|
|
@ -52,7 +52,6 @@ from vibe.core.system_prompt import get_universal_system_prompt
|
|||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
|
|
@ -61,8 +60,16 @@ from vibe.core.tools.base import (
|
|||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.tools.mcp import MCPRegistry
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.tools.permissions import (
|
||||
ApprovedRule,
|
||||
PermissionContext,
|
||||
RequiredPermission,
|
||||
)
|
||||
from vibe.core.tools.utils import wildcard_match
|
||||
from vibe.core.tracing import agent_span, set_tool_result, tool_span
|
||||
from vibe.core.trusted_folders import has_agents_md_file
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
AgentStats,
|
||||
ApprovalCallback,
|
||||
ApprovalResponse,
|
||||
|
|
@ -198,6 +205,9 @@ class AgentLoop:
|
|||
self.entrypoint_metadata = entrypoint_metadata
|
||||
self.session_id = str(uuid4())
|
||||
self._current_user_message_id: str | None = None
|
||||
self._is_user_prompt_call: bool = False
|
||||
|
||||
self._session_rules: list[ApprovedRule] = []
|
||||
|
||||
self.telemetry_client = TelemetryClient(
|
||||
config_getter=lambda: self.config, session_id_getter=lambda: self.session_id
|
||||
|
|
@ -238,11 +248,43 @@ class AgentLoop:
|
|||
})
|
||||
|
||||
if tool_name not in self.config.tools:
|
||||
self.config.tools[tool_name] = BaseToolConfig()
|
||||
self.config.tools[tool_name] = {}
|
||||
|
||||
self.config.tools[tool_name].permission = permission
|
||||
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)
|
||||
|
||||
def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool:
|
||||
return any(
|
||||
rule.tool_name == tool_name
|
||||
and rule.scope == rp.scope
|
||||
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
|
||||
for rule in self._session_rules
|
||||
)
|
||||
|
||||
def approve_always(
|
||||
self,
|
||||
tool_name: str,
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
save_permanently: bool = False,
|
||||
) -> None:
|
||||
"""Handle 'Allow Always' approval: add session rules or set tool-level permission."""
|
||||
if required_permissions:
|
||||
for rp in required_permissions:
|
||||
self.add_session_rule(
|
||||
ApprovedRule(
|
||||
tool_name=tool_name,
|
||||
scope=rp.scope,
|
||||
session_pattern=rp.session_pattern,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.set_tool_permission(
|
||||
tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently
|
||||
)
|
||||
|
||||
def refresh_config(self) -> None:
|
||||
self._base_config = VibeConfig.load()
|
||||
self.agent_manager.invalidate_config()
|
||||
|
|
@ -253,6 +295,14 @@ class AgentLoop:
|
|||
if self.entrypoint_metadata
|
||||
else "unknown"
|
||||
)
|
||||
client_name = (
|
||||
self.entrypoint_metadata.client_name if self.entrypoint_metadata else None
|
||||
)
|
||||
client_version = (
|
||||
self.entrypoint_metadata.client_version
|
||||
if self.entrypoint_metadata
|
||||
else None
|
||||
)
|
||||
has_agents_md = has_agents_md_file(Path.cwd())
|
||||
nb_skills = len(self.skill_manager.available_skills)
|
||||
nb_mcp_servers = len(self.config.mcp_servers)
|
||||
|
|
@ -268,6 +318,8 @@ class AgentLoop:
|
|||
nb_mcp_servers=nb_mcp_servers,
|
||||
nb_models=nb_models,
|
||||
entrypoint=entrypoint,
|
||||
client_name=client_name,
|
||||
client_version=client_version,
|
||||
terminal_emulator=terminal_emulator,
|
||||
)
|
||||
|
||||
|
|
@ -288,8 +340,13 @@ class AgentLoop:
|
|||
|
||||
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
|
||||
self._clean_message_history()
|
||||
async for event in self._conversation_loop(msg):
|
||||
yield event
|
||||
try:
|
||||
model_name = self.config.get_active_model().name
|
||||
except ValueError:
|
||||
model_name = None
|
||||
async with agent_span(model=model_name, session_id=self.session_id):
|
||||
async for event in self._conversation_loop(msg):
|
||||
yield event
|
||||
|
||||
@property
|
||||
def teleport_service(self) -> TeleportService:
|
||||
|
|
@ -429,20 +486,22 @@ class AgentLoop:
|
|||
|
||||
def _build_metadata(self) -> dict[str, str]:
|
||||
base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {}
|
||||
return base | {"session_id": self.session_id}
|
||||
metadata = base | {
|
||||
"session_id": self.session_id,
|
||||
"is_user_prompt": "true" if self._is_user_prompt_call else "false",
|
||||
"call_type": (
|
||||
"main_call" if self._is_user_prompt_call else "secondary_call"
|
||||
),
|
||||
}
|
||||
if self._current_user_message_id is not None:
|
||||
metadata["message_id"] = self._current_user_message_id
|
||||
return metadata
|
||||
|
||||
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
}
|
||||
if (
|
||||
provider.backend == Backend.MISTRAL
|
||||
and self._current_user_message_id is not None
|
||||
):
|
||||
headers["metadata"] = json.dumps({
|
||||
"message_id": self._current_user_message_id
|
||||
})
|
||||
return headers
|
||||
|
||||
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
|
||||
|
|
@ -458,7 +517,9 @@ class AgentLoop:
|
|||
|
||||
try:
|
||||
should_break_loop = False
|
||||
first_llm_turn = True
|
||||
while not should_break_loop:
|
||||
self._is_user_prompt_call = False
|
||||
result = await self.middleware_pipeline.run_before_turn(
|
||||
self._get_context()
|
||||
)
|
||||
|
|
@ -470,11 +531,15 @@ class AgentLoop:
|
|||
|
||||
self.stats.steps += 1
|
||||
user_cancelled = False
|
||||
if first_llm_turn:
|
||||
self._is_user_prompt_call = True
|
||||
first_llm_turn = False
|
||||
async for event in self._perform_llm_turn():
|
||||
if is_user_cancellation_event(event):
|
||||
user_cancelled = True
|
||||
yield event
|
||||
await self._save_messages()
|
||||
self._is_user_prompt_call = False
|
||||
|
||||
last_message = self.messages[-1]
|
||||
should_break_loop = last_message.role != Role.tool
|
||||
|
|
@ -502,8 +567,11 @@ class AgentLoop:
|
|||
if not resolved.tool_calls and not resolved.failed_calls:
|
||||
return
|
||||
|
||||
profile_before = self.agent_profile.name
|
||||
async for event in self._handle_tool_calls(resolved):
|
||||
yield event
|
||||
if self.agent_profile.name != profile_before:
|
||||
yield AgentProfileChangedEvent(agent_name=self.agent_profile.name)
|
||||
|
||||
def _build_tool_call_events(
|
||||
self, tool_calls: list[ToolCall] | None, emitted_ids: set[str]
|
||||
|
|
@ -578,12 +646,23 @@ class AgentLoop:
|
|||
|
||||
async def _process_one_tool_call(
|
||||
self, tool_call: ResolvedToolCall
|
||||
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
|
||||
async with tool_span(
|
||||
tool_name=tool_call.tool_name,
|
||||
call_id=tool_call.call_id,
|
||||
arguments=tool_call.validated_args.model_dump_json(),
|
||||
) as span:
|
||||
async for event in self._execute_tool_call(span, tool_call):
|
||||
yield event
|
||||
|
||||
async def _execute_tool_call(
|
||||
self, span: trace.Span, tool_call: ResolvedToolCall
|
||||
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
|
||||
try:
|
||||
tool_instance = self.tool_manager.get(tool_call.tool_name)
|
||||
except Exception as exc:
|
||||
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
|
||||
yield self._tool_failure_event(tool_call, error_msg)
|
||||
yield self._tool_failure_event(tool_call, error_msg, span=span)
|
||||
return
|
||||
|
||||
decision: ToolDecision | None = None
|
||||
|
|
@ -607,7 +686,9 @@ class AgentLoop:
|
|||
cancelled=f"<{CANCELLATION_TAG}>" in skip_reason,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
|
||||
self._handle_tool_response(
|
||||
tool_call, skip_reason, "skipped", decision, span=span
|
||||
)
|
||||
return
|
||||
|
||||
self.stats.tool_calls_agreed += 1
|
||||
|
|
@ -625,6 +706,7 @@ class AgentLoop:
|
|||
sampling_callback=self._sampling_handler,
|
||||
plan_file_path=self._plan_session.plan_file_path,
|
||||
switch_agent_callback=self.switch_agent,
|
||||
skill_manager=self.skill_manager,
|
||||
),
|
||||
**tool_call.args_dict,
|
||||
):
|
||||
|
|
@ -643,7 +725,7 @@ class AgentLoop:
|
|||
if extra:
|
||||
text += "\n\n" + extra
|
||||
self._handle_tool_response(
|
||||
tool_call, text, "success", decision, result_dict
|
||||
tool_call, text, "success", decision, result_dict, span=span
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
|
|
@ -660,7 +742,9 @@ class AgentLoop:
|
|||
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
|
||||
)
|
||||
self.stats.tool_calls_failed += 1
|
||||
yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True)
|
||||
yield self._tool_failure_event(
|
||||
tool_call, cancel, decision, cancelled=True, span=span
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
|
|
@ -670,7 +754,7 @@ class AgentLoop:
|
|||
self.stats.tool_calls_rejected += 1
|
||||
else:
|
||||
self.stats.tool_calls_failed += 1
|
||||
yield self._tool_failure_event(tool_call, error_msg, decision)
|
||||
yield self._tool_failure_event(tool_call, error_msg, decision, span=span)
|
||||
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
|
|
@ -751,6 +835,7 @@ class AgentLoop:
|
|||
status: Literal["success", "failure", "skipped"],
|
||||
decision: ToolDecision | None = None,
|
||||
result: dict[str, Any] | None = None,
|
||||
span: trace.Span | None = None,
|
||||
) -> None:
|
||||
self.messages.append(
|
||||
LLMMessage.model_validate(
|
||||
|
|
@ -758,6 +843,8 @@ class AgentLoop:
|
|||
)
|
||||
)
|
||||
|
||||
if span is not None:
|
||||
set_tool_result(span, text)
|
||||
self.telemetry_client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
agent_profile_name=self.agent_profile.name,
|
||||
|
|
@ -772,9 +859,10 @@ class AgentLoop:
|
|||
error_msg: str,
|
||||
decision: ToolDecision | None = None,
|
||||
cancelled: bool = False,
|
||||
span: trace.Span | None = None,
|
||||
) -> ToolResultEvent:
|
||||
"""Create a ToolResultEvent for a failed tool and record the failure."""
|
||||
self._handle_tool_response(tool_call, error_msg, "failure", decision)
|
||||
self._handle_tool_response(tool_call, error_msg, "failure", decision, span=span)
|
||||
return ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
|
|
@ -812,6 +900,9 @@ class AgentLoop:
|
|||
)
|
||||
self._update_stats(usage=result.usage, time_seconds=end_time - start_time)
|
||||
|
||||
if result.correlation_id:
|
||||
self.telemetry_client.last_correlation_id = result.correlation_id
|
||||
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
result.message
|
||||
)
|
||||
|
|
@ -848,6 +939,8 @@ class AgentLoop:
|
|||
max_tokens=max_tokens,
|
||||
metadata=self._build_metadata(),
|
||||
):
|
||||
if chunk.correlation_id:
|
||||
self.telemetry_client.last_correlation_id = chunk.correlation_id
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
chunk.message
|
||||
)
|
||||
|
|
@ -893,12 +986,13 @@ class AgentLoop:
|
|||
)
|
||||
|
||||
tool_name = tool.get_name()
|
||||
effective = (
|
||||
tool.resolve_permission(args)
|
||||
or self.tool_manager.get_tool_config(tool_name).permission
|
||||
)
|
||||
ctx = tool.resolve_permission(args)
|
||||
|
||||
match effective:
|
||||
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,
|
||||
|
|
@ -911,10 +1005,26 @@ class AgentLoop:
|
|||
feedback=f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
case _:
|
||||
return await self._ask_approval(tool_name, args, tool_call_id)
|
||||
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, tool_name: str, args: BaseModel, tool_call_id: str
|
||||
self,
|
||||
tool_name: str,
|
||||
args: BaseModel,
|
||||
tool_call_id: str,
|
||||
required_permissions: list[RequiredPermission],
|
||||
) -> ToolDecision:
|
||||
if not self.approval_callback:
|
||||
return ToolDecision(
|
||||
|
|
@ -922,7 +1032,9 @@ class AgentLoop:
|
|||
approval_type=ToolPermission.ASK,
|
||||
feedback="Tool execution not permitted.",
|
||||
)
|
||||
response, feedback = await self.approval_callback(tool_name, args, tool_call_id)
|
||||
response, feedback = await self.approval_callback(
|
||||
tool_name, args, tool_call_id, required_permissions
|
||||
)
|
||||
|
||||
match response:
|
||||
case ApprovalResponse.YES:
|
||||
|
|
|
|||
|
|
@ -57,7 +57,17 @@ class AgentProfile:
|
|||
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
|
||||
from vibe.core.config import VibeConfig as VC
|
||||
|
||||
merged = _deep_merge(base.model_dump(), self.overrides)
|
||||
merged = _deep_merge(
|
||||
base.model_dump(),
|
||||
{k: v for k, v in self.overrides.items() if k != "base_disabled"},
|
||||
)
|
||||
base_disabled = self.overrides.get("base_disabled")
|
||||
if isinstance(base_disabled, list):
|
||||
merged["disabled_tools"] = list({
|
||||
*base_disabled,
|
||||
*merged.get("disabled_tools", []),
|
||||
})
|
||||
|
||||
return VC.model_validate(merged)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -92,6 +102,7 @@ DEFAULT = AgentProfile(
|
|||
"Default",
|
||||
"Requires approval for tool executions",
|
||||
AgentSafety.NEUTRAL,
|
||||
overrides={"base_disabled": ["exit_plan_mode"]},
|
||||
)
|
||||
PLAN = AgentProfile(
|
||||
BuiltinAgentName.PLAN,
|
||||
|
|
@ -113,10 +124,11 @@ ACCEPT_EDITS = AgentProfile(
|
|||
"Auto-approves file edits only",
|
||||
AgentSafety.DESTRUCTIVE,
|
||||
overrides={
|
||||
"base_disabled": ["exit_plan_mode"],
|
||||
"tools": {
|
||||
"write_file": {"permission": "always"},
|
||||
"search_replace": {"permission": "always"},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
AUTO_APPROVE = AgentProfile(
|
||||
|
|
@ -124,7 +136,7 @@ AUTO_APPROVE = AgentProfile(
|
|||
"Auto Approve",
|
||||
"Auto-approves all tool executions",
|
||||
AgentSafety.YOLO,
|
||||
overrides={"auto_approve": True},
|
||||
overrides={"auto_approve": True, "base_disabled": ["exit_plan_mode"]},
|
||||
)
|
||||
|
||||
EXPLORE = AgentProfile(
|
||||
|
|
@ -173,6 +185,7 @@ LEAN = AgentProfile(
|
|||
"thinking": "off",
|
||||
},
|
||||
"tools": {"bash": {"default_timeout": 1200}},
|
||||
"base_disabled": ["exit_plan_mode"],
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
21
vibe/core/audio_player/__init__.py
Normal file
21
vibe/core/audio_player/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.audio_player.audio_player import AudioPlayer
|
||||
from vibe.core.audio_player.audio_player_port import (
|
||||
AlreadyPlayingError,
|
||||
AudioBackendUnavailableError,
|
||||
AudioFormat,
|
||||
AudioPlayerPort,
|
||||
NoAudioOutputDeviceError,
|
||||
UnsupportedAudioFormatError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AlreadyPlayingError",
|
||||
"AudioBackendUnavailableError",
|
||||
"AudioFormat",
|
||||
"AudioPlayer",
|
||||
"AudioPlayerPort",
|
||||
"NoAudioOutputDeviceError",
|
||||
"UnsupportedAudioFormatError",
|
||||
]
|
||||
130
vibe/core/audio_player/audio_player.py
Normal file
130
vibe/core/audio_player/audio_player.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.audio_player.audio_player_port import (
|
||||
AlreadyPlayingError,
|
||||
AudioBackendUnavailableError,
|
||||
AudioFormat,
|
||||
NoAudioOutputDeviceError,
|
||||
UnsupportedAudioFormatError,
|
||||
)
|
||||
from vibe.core.audio_player.utils import decode_wav
|
||||
from vibe.core.logger import logger
|
||||
|
||||
# sounddevice raises OSError on import when no audio driver is available.
|
||||
try:
|
||||
import sounddevice as sd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sounddevice import CallbackFlags, RawOutputStream
|
||||
except OSError:
|
||||
sd = None # type: ignore[assignment]
|
||||
|
||||
DEFAULT_BLOCKSIZE = 4096
|
||||
DTYPE = "int16"
|
||||
DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
|
||||
|
||||
|
||||
class AudioPlayer:
|
||||
"""Plays audio through the default output device using sounddevice."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._stream: RawOutputStream | None = None
|
||||
self._playing: bool = False
|
||||
self._audio_data: bytes = b""
|
||||
self._position: int = 0
|
||||
self._frame_size: int = 0
|
||||
self._on_finished: Callable[[], object] | None = None
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
return self._playing
|
||||
|
||||
def play(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
audio_format: AudioFormat,
|
||||
*,
|
||||
on_finished: Callable[[], object] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._playing:
|
||||
raise AlreadyPlayingError("Already playing")
|
||||
|
||||
if not sd:
|
||||
error_message = "sounddevice is not available, audio playback disabled"
|
||||
logger.error(error_message)
|
||||
raise AudioBackendUnavailableError(error_message)
|
||||
|
||||
self._guard_audio_output()
|
||||
|
||||
match audio_format:
|
||||
case AudioFormat.WAV:
|
||||
sample_rate, channels, pcm_data = decode_wav(audio_data)
|
||||
case _:
|
||||
raise UnsupportedAudioFormatError(
|
||||
f"Unsupported audio format: {audio_format}"
|
||||
)
|
||||
self._audio_data = pcm_data
|
||||
self._position = 0
|
||||
self._frame_size = channels * DEFAULT_SAMPLE_WIDTH
|
||||
self._on_finished = on_finished
|
||||
|
||||
self._stream = sd.RawOutputStream(
|
||||
samplerate=sample_rate,
|
||||
channels=channels,
|
||||
dtype=DTYPE,
|
||||
blocksize=DEFAULT_BLOCKSIZE,
|
||||
callback=self._audio_callback,
|
||||
finished_callback=self._on_stream_finished,
|
||||
)
|
||||
self._stream.start()
|
||||
self._playing = True
|
||||
|
||||
def stop(self) -> None:
|
||||
stream = self._stream
|
||||
if not self._playing or stream is None:
|
||||
return
|
||||
stream.close(ignore_errors=True)
|
||||
|
||||
def _audio_callback(
|
||||
self, outdata: memoryview, frames: int, time_info: object, status: CallbackFlags
|
||||
) -> None:
|
||||
if not sd:
|
||||
raise RuntimeError("sounddevice is not available")
|
||||
if status:
|
||||
logger.warning(f"Audio playback callback status: {status}")
|
||||
|
||||
bytes_needed = frames * self._frame_size
|
||||
chunk = self._audio_data[self._position : self._position + bytes_needed]
|
||||
self._position += len(chunk)
|
||||
|
||||
if len(chunk) < bytes_needed:
|
||||
outdata[: len(chunk)] = chunk
|
||||
outdata[len(chunk) :] = b"\x00" * (bytes_needed - len(chunk))
|
||||
raise sd.CallbackStop()
|
||||
else:
|
||||
outdata[:] = chunk
|
||||
|
||||
def _on_stream_finished(self) -> None:
|
||||
on_finished = None
|
||||
with self._lock:
|
||||
self._stream = None
|
||||
self._playing = False
|
||||
on_finished = self._on_finished
|
||||
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
@staticmethod
|
||||
def _guard_audio_output() -> None:
|
||||
if sd is None:
|
||||
raise RuntimeError("sounddevice is not available")
|
||||
try:
|
||||
sd.query_devices(kind="output")
|
||||
except Exception as exc:
|
||||
raise NoAudioOutputDeviceError("No audio output device available") from exc
|
||||
40
vibe/core/audio_player/audio_player_port.py
Normal file
40
vibe/core/audio_player/audio_player_port.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class AudioFormat(StrEnum):
|
||||
WAV = auto()
|
||||
|
||||
|
||||
class AlreadyPlayingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AudioBackendUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoAudioOutputDeviceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedAudioFormatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AudioPlayerPort(Protocol):
|
||||
@property
|
||||
def is_playing(self) -> bool: ...
|
||||
|
||||
def play(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
audio_format: AudioFormat,
|
||||
*,
|
||||
on_finished: Callable[[], object] | None = ...,
|
||||
) -> None: ...
|
||||
|
||||
def stop(self) -> None: ...
|
||||
12
vibe/core/audio_player/utils.py
Normal file
12
vibe/core/audio_player/utils.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import wave
|
||||
|
||||
|
||||
def decode_wav(audio_data: bytes) -> tuple[int, int, bytes]:
|
||||
with wave.open(io.BytesIO(audio_data), "rb") as wf:
|
||||
sample_rate = wf.getframerate()
|
||||
channels = wf.getnchannels()
|
||||
pcm_data = wf.readframes(wf.getnframes())
|
||||
return sample_rate, channels, pcm_data
|
||||
|
|
@ -19,6 +19,7 @@ from vibe.core.audio_recorder.audio_recorder_port import (
|
|||
)
|
||||
from vibe.core.logger import logger
|
||||
|
||||
# sounddevice raises OSError on import when no audio driver is available.
|
||||
try:
|
||||
import sounddevice as sd
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from dataclasses import dataclass
|
|||
import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
DEFAULT_IGNORE_PATTERNS: list[tuple[str, bool]] = [
|
||||
(".git/", True),
|
||||
("__pycache__/", True),
|
||||
|
|
@ -111,7 +113,7 @@ class IgnoreRules:
|
|||
gitignore_path = root / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
text = gitignore_path.read_text(encoding="utf-8")
|
||||
text = read_safe(gitignore_path)
|
||||
except Exception:
|
||||
return patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ from vibe.core.config._settings import (
|
|||
DEFAULT_PROVIDERS,
|
||||
DEFAULT_TRANSCRIBE_MODELS,
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS,
|
||||
Backend,
|
||||
DEFAULT_TTS_MODELS,
|
||||
DEFAULT_TTS_PROVIDERS,
|
||||
MCPHttp,
|
||||
MCPServer,
|
||||
MCPStdio,
|
||||
|
|
@ -14,6 +15,7 @@ from vibe.core.config._settings import (
|
|||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
ModelConfig,
|
||||
OtelExporterConfig,
|
||||
ProjectContextConfig,
|
||||
ProviderConfig,
|
||||
SessionLoggingConfig,
|
||||
|
|
@ -21,6 +23,9 @@ from vibe.core.config._settings import (
|
|||
TranscribeClient,
|
||||
TranscribeModelConfig,
|
||||
TranscribeProviderConfig,
|
||||
TTSClient,
|
||||
TTSModelConfig,
|
||||
TTSProviderConfig,
|
||||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
|
|
@ -31,7 +36,8 @@ __all__ = [
|
|||
"DEFAULT_PROVIDERS",
|
||||
"DEFAULT_TRANSCRIBE_MODELS",
|
||||
"DEFAULT_TRANSCRIBE_PROVIDERS",
|
||||
"Backend",
|
||||
"DEFAULT_TTS_MODELS",
|
||||
"DEFAULT_TTS_PROVIDERS",
|
||||
"MCPHttp",
|
||||
"MCPServer",
|
||||
"MCPStdio",
|
||||
|
|
@ -39,9 +45,13 @@ __all__ = [
|
|||
"MissingAPIKeyError",
|
||||
"MissingPromptFileError",
|
||||
"ModelConfig",
|
||||
"OtelExporterConfig",
|
||||
"ProjectContextConfig",
|
||||
"ProviderConfig",
|
||||
"SessionLoggingConfig",
|
||||
"TTSClient",
|
||||
"TTSModelConfig",
|
||||
"TTSProviderConfig",
|
||||
"TomlFileSettingsSource",
|
||||
"TranscribeClient",
|
||||
"TranscribeModelConfig",
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import re
|
|||
import shlex
|
||||
import tomllib
|
||||
from typing import Annotated, Any, Literal
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from dotenv import dotenv_values
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_core import to_jsonable_python
|
||||
from pydantic_settings import (
|
||||
|
|
@ -21,9 +22,12 @@ from pydantic_settings import (
|
|||
import tomli_w
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||
from vibe.core.prompts import SystemPrompt
|
||||
from vibe.core.tools.base import BaseToolConfig
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
def load_dotenv_values(
|
||||
|
|
@ -114,11 +118,6 @@ class SessionLoggingConfig(BaseSettings):
|
|||
return str(Path(v).expanduser().resolve())
|
||||
|
||||
|
||||
class Backend(StrEnum):
|
||||
MISTRAL = auto()
|
||||
GENERIC = auto()
|
||||
|
||||
|
||||
class ProviderConfig(BaseModel):
|
||||
name: str
|
||||
api_base: str
|
||||
|
|
@ -271,13 +270,42 @@ class TranscribeModelConfig(BaseModel):
|
|||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
class TTSClient(StrEnum):
|
||||
MISTRAL = auto()
|
||||
|
||||
|
||||
class TTSProviderConfig(BaseModel):
|
||||
name: str
|
||||
api_base: str = "https://api.mistral.ai"
|
||||
api_key_env_var: str = ""
|
||||
client: TTSClient = TTSClient.MISTRAL
|
||||
|
||||
|
||||
class TTSModelConfig(BaseModel):
|
||||
name: str
|
||||
provider: str
|
||||
alias: str
|
||||
voice: str = "gb_jane_neutral"
|
||||
response_format: str = "wav"
|
||||
|
||||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
|
||||
class OtelExporterConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
endpoint: str
|
||||
headers: dict[str, str] | None = None
|
||||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
MISTRAL_OTEL_TRACES_PATH = "/telemetry/v1/traces"
|
||||
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
|
||||
|
||||
DEFAULT_PROVIDERS = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
|
||||
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
|
|
@ -328,6 +356,20 @@ DEFAULT_TRANSCRIBE_MODELS = [
|
|||
)
|
||||
]
|
||||
|
||||
DEFAULT_TTS_PROVIDERS = [
|
||||
TTSProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai",
|
||||
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
)
|
||||
]
|
||||
|
||||
DEFAULT_TTS_MODELS = [
|
||||
TTSModelConfig(
|
||||
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class VibeConfig(BaseSettings):
|
||||
active_model: str = "devstral-2"
|
||||
|
|
@ -338,7 +380,9 @@ class VibeConfig(BaseSettings):
|
|||
displayed_workdir: str = ""
|
||||
context_warnings: bool = False
|
||||
voice_mode_enabled: bool = False
|
||||
narrator_enabled: bool = False
|
||||
active_transcribe_model: str = "voxtral-realtime"
|
||||
active_tts_model: str = "voxtral-tts"
|
||||
auto_approve: bool = False
|
||||
enable_telemetry: bool = True
|
||||
system_prompt_id: str = "cli"
|
||||
|
|
@ -360,6 +404,10 @@ class VibeConfig(BaseSettings):
|
|||
# TODO(vibe-nuage): change default value to MISTRAL_API_KEY once prod has shared vibe-nuage workers
|
||||
nuage_api_key_env_var: str = Field(default="STAGING_MISTRAL_API_KEY", exclude=True)
|
||||
|
||||
# TODO(otel): remove exclude=True once the feature is publicly available
|
||||
enable_otel: bool = Field(default=False, exclude=True)
|
||||
otel_endpoint: str = Field(default="", exclude=True)
|
||||
|
||||
providers: list[ProviderConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_PROVIDERS)
|
||||
)
|
||||
|
|
@ -373,9 +421,16 @@ class VibeConfig(BaseSettings):
|
|||
default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
|
||||
)
|
||||
|
||||
tts_providers: list[TTSProviderConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_TTS_PROVIDERS)
|
||||
)
|
||||
tts_models: list[TTSModelConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_TTS_MODELS)
|
||||
)
|
||||
|
||||
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
|
||||
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
|
||||
tools: dict[str, BaseToolConfig] = Field(default_factory=dict)
|
||||
tools: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
tool_paths: list[Path] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
|
|
@ -468,6 +523,39 @@ class VibeConfig(BaseSettings):
|
|||
def nuage_api_key(self) -> str:
|
||||
return os.getenv(self.nuage_api_key_env_var, "")
|
||||
|
||||
@property
|
||||
def otel_exporter_config(self) -> OtelExporterConfig | 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.
|
||||
if self.otel_endpoint:
|
||||
return OtelExporterConfig(endpoint=self.otel_endpoint)
|
||||
|
||||
provider = next(
|
||||
(p for p in self.providers if p.backend == Backend.MISTRAL), None
|
||||
)
|
||||
|
||||
if provider is not None:
|
||||
server_url = get_server_url_from_api_base(provider.api_base)
|
||||
api_key_env = provider.api_key_env_var or DEFAULT_MISTRAL_API_ENV_KEY
|
||||
else:
|
||||
server_url = None
|
||||
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
|
||||
|
||||
endpoint = urljoin(
|
||||
server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_TRACES_PATH
|
||||
)
|
||||
|
||||
if not (api_key := os.getenv(api_key_env)):
|
||||
logger.warning(
|
||||
"OTEL tracing enabled but %s is not set; skipping.", api_key_env
|
||||
)
|
||||
return None
|
||||
|
||||
return OtelExporterConfig(
|
||||
endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"}
|
||||
)
|
||||
|
||||
@property
|
||||
def system_prompt(self) -> str:
|
||||
try:
|
||||
|
|
@ -482,7 +570,7 @@ class VibeConfig(BaseSettings):
|
|||
".md"
|
||||
)
|
||||
if custom_sp_path.is_file():
|
||||
return custom_sp_path.read_text()
|
||||
return read_safe(custom_sp_path)
|
||||
|
||||
raise MissingPromptFileError(
|
||||
self.system_prompt_id, *(str(d) for d in prompt_dirs)
|
||||
|
|
@ -527,6 +615,22 @@ class VibeConfig(BaseSettings):
|
|||
f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
|
||||
)
|
||||
|
||||
def get_active_tts_model(self) -> TTSModelConfig:
|
||||
for model in self.tts_models:
|
||||
if model.alias == self.active_tts_model:
|
||||
return model
|
||||
raise ValueError(
|
||||
f"Active TTS model '{self.active_tts_model}' not found in configuration."
|
||||
)
|
||||
|
||||
def get_tts_provider_for_model(self, model: TTSModelConfig) -> TTSProviderConfig:
|
||||
for provider in self.tts_providers:
|
||||
if provider.name == model.provider:
|
||||
return provider
|
||||
raise ValueError(
|
||||
f"TTS provider '{model.provider}' for TTS model '{model.name}' not found in configuration."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
|
|
@ -608,18 +712,16 @@ class VibeConfig(BaseSettings):
|
|||
|
||||
@field_validator("tools", mode="before")
|
||||
@classmethod
|
||||
def _normalize_tool_configs(cls, v: Any) -> dict[str, BaseToolConfig]:
|
||||
def _normalize_tool_configs(cls, v: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(v, dict):
|
||||
return {}
|
||||
|
||||
normalized: dict[str, BaseToolConfig] = {}
|
||||
normalized: dict[str, dict[str, Any]] = {}
|
||||
for tool_name, tool_config in v.items():
|
||||
if isinstance(tool_config, BaseToolConfig):
|
||||
if isinstance(tool_config, dict):
|
||||
normalized[tool_name] = tool_config
|
||||
elif isinstance(tool_config, dict):
|
||||
normalized[tool_name] = BaseToolConfig.model_validate(tool_config)
|
||||
else:
|
||||
normalized[tool_name] = BaseToolConfig()
|
||||
normalized[tool_name] = {}
|
||||
|
||||
return normalized
|
||||
|
||||
|
|
@ -645,6 +747,17 @@ class VibeConfig(BaseSettings):
|
|||
seen_aliases.add(model.alias)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_tts_model_uniqueness(self) -> VibeConfig:
|
||||
seen_aliases: set[str] = set()
|
||||
for model in self.tts_models:
|
||||
if model.alias in seen_aliases:
|
||||
raise ValueError(
|
||||
f"Duplicate TTS model alias found: '{model.alias}'. Aliases must be unique."
|
||||
)
|
||||
seen_aliases.add(model.alias)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_system_prompt(self) -> VibeConfig:
|
||||
_ = self.system_prompt
|
||||
|
|
@ -674,6 +787,8 @@ class VibeConfig(BaseSettings):
|
|||
"models",
|
||||
"transcribe_providers",
|
||||
"transcribe_models",
|
||||
"tts_providers",
|
||||
"tts_models",
|
||||
"installed_agents",
|
||||
}:
|
||||
target[key] = value
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from vibe.core.config.harness_files._paths import (
|
|||
)
|
||||
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
FileSource = Literal["user", "project"]
|
||||
|
||||
|
|
@ -115,8 +116,7 @@ class HarnessFilesManager:
|
|||
return ""
|
||||
path = VIBE_HOME.path / AGENTS_MD_FILENAME
|
||||
try:
|
||||
content = path.read_text("utf-8", errors="ignore")
|
||||
stripped = content.strip()
|
||||
stripped = read_safe(path).strip()
|
||||
return stripped if stripped else ""
|
||||
except (FileNotFoundError, OSError):
|
||||
return ""
|
||||
|
|
@ -140,8 +140,7 @@ class HarnessFilesManager:
|
|||
break
|
||||
path = current / AGENTS_MD_FILENAME
|
||||
try:
|
||||
content = path.read_text("utf-8", errors="ignore")
|
||||
stripped = content.strip()
|
||||
stripped = read_safe(path).strip()
|
||||
if stripped:
|
||||
docs.append((current, stripped))
|
||||
except (FileNotFoundError, OSError):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.llm.backend.generic import GenericBackend
|
||||
from vibe.core.llm.backend.mistral import MistralBackend
|
||||
from vibe.core.types import Backend
|
||||
|
||||
BACKEND_FACTORY = {Backend.MISTRAL: MistralBackend, Backend.GENERIC: GenericBackend}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from mistralai.client.models import (
|
|||
AssistantMessageContent,
|
||||
ChatCompletionRequestMessage,
|
||||
ChatCompletionStreamRequestToolChoice,
|
||||
ContentChunk,
|
||||
FileChunk,
|
||||
Function,
|
||||
FunctionCall as MistralFunctionCall,
|
||||
|
|
@ -64,15 +65,17 @@ class MistralMapper:
|
|||
case Role.assistant:
|
||||
content: AssistantMessageContent
|
||||
if msg.reasoning_content:
|
||||
content = [
|
||||
chunks: list[ContentChunk] = [
|
||||
ThinkChunk(
|
||||
type="thinking",
|
||||
thinking=[
|
||||
TextChunk(type="text", text=msg.reasoning_content)
|
||||
],
|
||||
),
|
||||
TextChunk(type="text", text=msg.content or ""),
|
||||
)
|
||||
]
|
||||
if msg.content:
|
||||
chunks.append(TextChunk(type="text", text=msg.content))
|
||||
content = chunks
|
||||
else:
|
||||
content = msg.content or ""
|
||||
|
||||
|
|
@ -326,7 +329,7 @@ class MistralBackend:
|
|||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
async for chunk in await self._get_client().chat.stream_async(
|
||||
stream = await self._get_client().chat.stream_async(
|
||||
model=model.name,
|
||||
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
||||
temperature=temperature,
|
||||
|
|
@ -339,7 +342,9 @@ class MistralBackend:
|
|||
else None,
|
||||
http_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
):
|
||||
)
|
||||
correlation_id = stream.response.headers.get("mistral-correlation-id")
|
||||
async for chunk in stream:
|
||||
parsed = (
|
||||
self._mapper.parse_content(chunk.data.choices[0].delta.content)
|
||||
if chunk.data.choices[0].delta.content
|
||||
|
|
@ -364,6 +369,7 @@ class MistralBackend:
|
|||
if chunk.data.usage
|
||||
else 0,
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
except SDKError as e:
|
||||
|
|
|
|||
|
|
@ -48,9 +48,10 @@ class ReasoningAdapter(APIAdapter):
|
|||
{
|
||||
"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": msg.reasoning_content}],
|
||||
},
|
||||
{"type": "text", "text": msg.content or ""},
|
||||
}
|
||||
]
|
||||
if msg.content:
|
||||
content.append({"type": "text", "text": msg.content})
|
||||
result["content"] = content
|
||||
else:
|
||||
result["content"] = msg.content or ""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.paths._local_config_walk import walk_local_config_dirs_all
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
WALK_MAX_DEPTH,
|
||||
has_config_dirs_nearby,
|
||||
walk_local_config_dirs_all,
|
||||
)
|
||||
from vibe.core.paths._vibe_home import (
|
||||
DEFAULT_TOOL_DIR,
|
||||
GLOBAL_ENV_FILE,
|
||||
|
|
@ -26,6 +30,8 @@ __all__ = [
|
|||
"SESSION_LOG_DIR",
|
||||
"TRUSTED_FOLDERS_FILE",
|
||||
"VIBE_HOME",
|
||||
"WALK_MAX_DEPTH",
|
||||
"GlobalPath",
|
||||
"has_config_dirs_nearby",
|
||||
"walk_local_config_dirs_all",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from functools import cache
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
_VIBE_DIR = ".vibe"
|
||||
_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
|
||||
_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
|
||||
|
|
@ -13,27 +17,117 @@ _AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
|
|||
_AGENTS_DIR = ".agents"
|
||||
_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
|
||||
|
||||
WALK_MAX_DEPTH = 4
|
||||
_MAX_DIRS = 2000
|
||||
|
||||
|
||||
def _collect_config_dirs_at(
|
||||
path: Path,
|
||||
entries: 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 (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 (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."""
|
||||
children: list[Path] = []
|
||||
for name in sorted(entries):
|
||||
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
|
||||
continue
|
||||
child = path / name
|
||||
try:
|
||||
if child.is_dir():
|
||||
children.append(child)
|
||||
except OSError:
|
||||
continue
|
||||
return children
|
||||
|
||||
|
||||
@cache
|
||||
def walk_local_config_dirs_all(
|
||||
root: Path,
|
||||
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
|
||||
"""Discover .vibe/ and .agents/ config directories under *root*.
|
||||
|
||||
Uses breadth-first search bounded by ``WALK_MAX_DEPTH`` and ``_MAX_DIRS``
|
||||
to avoid unbounded traversal in large repositories.
|
||||
"""
|
||||
tools_dirs: list[Path] = []
|
||||
skills_dirs: list[Path] = []
|
||||
agents_dirs: list[Path] = []
|
||||
|
||||
resolved_root = root.resolve()
|
||||
for dirpath, dirnames, _ in os.walk(resolved_root, topdown=True):
|
||||
dir_set = frozenset(dirnames)
|
||||
path = Path(dirpath)
|
||||
if _VIBE_DIR in dir_set:
|
||||
if (candidate := path / _TOOLS_SUBDIR).is_dir():
|
||||
tools_dirs.append(candidate)
|
||||
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
|
||||
skills_dirs.append(candidate)
|
||||
if (candidate := path / _AGENTS_SUBDIR).is_dir():
|
||||
agents_dirs.append(candidate)
|
||||
if _AGENTS_DIR in dir_set:
|
||||
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
|
||||
skills_dirs.append(candidate)
|
||||
dirnames[:] = sorted(d for d in dirnames if d not in WALK_SKIP_DIR_NAMES)
|
||||
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
|
||||
visited = 0
|
||||
|
||||
while queue and visited < _MAX_DIRS:
|
||||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
|
||||
try:
|
||||
entries = set(os.listdir(current))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
_collect_config_dirs_at(current, entries, tools_dirs, skills_dirs, agents_dirs)
|
||||
|
||||
if depth < WALK_MAX_DEPTH:
|
||||
queue.extend(
|
||||
(child, depth + 1) for child in _iter_child_dirs(current, entries)
|
||||
)
|
||||
|
||||
if visited >= _MAX_DIRS:
|
||||
logger.warning(
|
||||
"Config directory scan reached directory limit (%d dirs) at %s",
|
||||
_MAX_DIRS,
|
||||
resolved_root,
|
||||
)
|
||||
|
||||
return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))
|
||||
|
||||
|
||||
def has_config_dirs_nearby(
|
||||
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = 200
|
||||
) -> bool:
|
||||
"""Quick check for .vibe/ or .agents/ config dirs in the near subtree.
|
||||
|
||||
Returns ``True`` as soon as any config directory is found, without
|
||||
enumerating all of them.
|
||||
"""
|
||||
resolved = root.resolve()
|
||||
queue: deque[tuple[Path, int]] = deque([(resolved, 0)])
|
||||
visited = 0
|
||||
found: list[Path] = []
|
||||
|
||||
while queue and visited < max_dirs:
|
||||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
|
||||
try:
|
||||
entries = set(os.listdir(current))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
_collect_config_dirs_at(current, entries, found, found, found)
|
||||
if found:
|
||||
return True
|
||||
|
||||
if depth < max_depth:
|
||||
queue.extend(
|
||||
(child, depth + 1) for child in _iter_child_dirs(current, entries)
|
||||
)
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
|||
import time
|
||||
|
||||
from vibe.core.paths import PLANS_DIR
|
||||
from vibe.core.slug import create_slug
|
||||
from vibe.core.utils.slug import create_slug
|
||||
|
||||
|
||||
class PlanSession:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from enum import StrEnum, auto
|
|||
from pathlib import Path
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
_PROMPTS_DIR = VIBE_ROOT / "core" / "prompts"
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ class Prompt(StrEnum):
|
|||
return (_PROMPTS_DIR / self.value).with_suffix(".md")
|
||||
|
||||
def read(self) -> str:
|
||||
return self.path.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
return read_safe(self.path).strip()
|
||||
|
||||
|
||||
class SystemPrompt(Prompt):
|
||||
|
|
@ -29,6 +30,7 @@ class UtilityPrompt(Prompt):
|
|||
COMPACT = auto()
|
||||
DANGEROUS_DIRECTORY = auto()
|
||||
PROJECT_CONTEXT = auto()
|
||||
TURN_SUMMARY = auto()
|
||||
|
||||
|
||||
__all__ = ["SystemPrompt", "UtilityPrompt"]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. You have no internet access.
|
||||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
|
||||
CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself.
|
||||
|
||||
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
|
||||
|
||||
1. Find the matching file in your skill directories.
|
||||
2. Read it with `read_file`.
|
||||
3. Follow its instructions step by step. You are the executor.
|
||||
|
||||
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
|
||||
|
||||
Phase 1 — Orient
|
||||
Before ANY action:
|
||||
Restate the goal in one line.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,6 @@ You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with
|
|||
|
||||
Use markdown when appropriate. Communicate clearly to the user.
|
||||
|
||||
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
|
||||
|
||||
1. Find the matching file in your skill directories.
|
||||
2. Read it with `read_file`.
|
||||
3. Follow its instructions step by step. You are the executor.
|
||||
|
||||
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
|
||||
|
||||
Phase 1 — Orient
|
||||
Before ANY action:
|
||||
Restate the goal in one line.
|
||||
|
|
|
|||
10
vibe/core/prompts/turn_summary.md
Normal file
10
vibe/core/prompts/turn_summary.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
You are a concise turn summarizer for an AI coding agent. Given a structured extraction of what happened during one agent turn, produce a brief summary (2-4 sentences).
|
||||
|
||||
Cover:
|
||||
- What the user asked for
|
||||
- What actions were taken (tools used, files modified)
|
||||
- The outcome (success, partial, errors)
|
||||
- Any open questions or issues for the user (only if genuinely relevant, do not force this)
|
||||
- Do not expand if it does not bring value (e.g. no question to ask)
|
||||
|
||||
Be factual and terse. Do not editorialize. Do not repeat the full user message. Respond with ONLY the summary text.
|
||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
|||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from vibe.core.types import LLMMessage, SessionMetadata
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
|
|
@ -181,7 +182,7 @@ class SessionLoader:
|
|||
raise ValueError(f"Session metadata not found at {session_dir}")
|
||||
|
||||
try:
|
||||
metadata_content = metadata_path.read_text("utf-8", errors="ignore")
|
||||
metadata_content = read_safe(metadata_path)
|
||||
return SessionMetadata.model_validate_json(metadata_content)
|
||||
except ValueError:
|
||||
raise
|
||||
|
|
@ -196,8 +197,9 @@ class SessionLoader:
|
|||
messages_filepath = filepath / MESSAGES_FILENAME
|
||||
|
||||
try:
|
||||
with messages_filepath.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.readlines()
|
||||
content = read_safe(messages_filepath).split("\n")
|
||||
if content and content[-1] == "":
|
||||
content.pop()
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Error reading session messages at {filepath}: {e}"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from vibe.core.session.session_loader import (
|
|||
)
|
||||
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
|
||||
from vibe.core.utils import is_windows, utc_now
|
||||
from vibe.core.utils.io import read_safe_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.models import AgentProfile
|
||||
|
|
@ -230,11 +231,9 @@ class SessionLogger:
|
|||
# Read old metadata and get total_messages
|
||||
try:
|
||||
if self.metadata_filepath.exists():
|
||||
async with await AsyncPath(self.metadata_filepath).open(
|
||||
encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
old_metadata = json.loads(await f.read())
|
||||
old_total_messages = old_metadata["total_messages"]
|
||||
raw = await read_safe_async(self.metadata_filepath)
|
||||
old_metadata = json.loads(raw)
|
||||
old_total_messages = old_metadata["total_messages"]
|
||||
else:
|
||||
old_total_messages = 0
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
|||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int:
|
||||
|
|
@ -22,8 +23,7 @@ async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
|
|||
session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
|
||||
for session_file in session_files:
|
||||
try:
|
||||
with open(session_file) as f:
|
||||
session_data = f.read()
|
||||
session_data = read_safe(session_file)
|
||||
session_json = json.loads(session_data)
|
||||
metadata = session_json["metadata"]
|
||||
messages = session_json["messages"]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from vibe.core.logger import logger
|
|||
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
||||
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
|
||||
from vibe.core.utils import name_matches
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
|
@ -106,7 +107,7 @@ class SkillManager:
|
|||
|
||||
def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
|
||||
try:
|
||||
content = skill_path.read_text(encoding="utf-8")
|
||||
content = read_safe(skill_path)
|
||||
except OSError as e:
|
||||
raise SkillParseError(f"Cannot read file: {e}") from e
|
||||
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str:
|
|||
"# Available Skills",
|
||||
"",
|
||||
"You have access to the following skills. When a task matches a skill's description,",
|
||||
"read the full SKILL.md file to load detailed instructions.",
|
||||
"use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually.",
|
||||
"",
|
||||
"<available_skills>",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||
import httpx
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -28,6 +29,7 @@ class TelemetryClient:
|
|||
self._session_id_getter = session_id_getter
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._pending_tasks: set[asyncio.Task[Any]] = set()
|
||||
self.last_correlation_id: str | None = None
|
||||
|
||||
def _get_telemetry_user_agent(self) -> str:
|
||||
try:
|
||||
|
|
@ -62,6 +64,9 @@ class TelemetryClient:
|
|||
except ValueError:
|
||||
return False
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self._is_enabled() and self._get_mistral_api_key() is not None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
|
|
@ -71,7 +76,13 @@ class TelemetryClient:
|
|||
)
|
||||
return self._client
|
||||
|
||||
def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None:
|
||||
def send_telemetry_event(
|
||||
self,
|
||||
event_name: str,
|
||||
properties: dict[str, Any],
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
mistral_api_key = self._get_mistral_api_key()
|
||||
if mistral_api_key is None or not self._is_enabled():
|
||||
return
|
||||
|
|
@ -82,11 +93,15 @@ class TelemetryClient:
|
|||
):
|
||||
properties = {**properties, "session_id": session_id}
|
||||
|
||||
payload: dict[str, Any] = {"event": event_name, "properties": properties}
|
||||
if correlation_id:
|
||||
payload["correlation_id"] = correlation_id
|
||||
|
||||
async def _send() -> None:
|
||||
try:
|
||||
await self.client.post(
|
||||
DATALAKE_EVENTS_URL,
|
||||
json={"event": event_name, "properties": properties},
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {mistral_api_key}",
|
||||
|
|
@ -178,6 +193,8 @@ class TelemetryClient:
|
|||
nb_mcp_servers: int,
|
||||
nb_models: int,
|
||||
entrypoint: Literal["cli", "acp", "programmatic", "unknown"],
|
||||
client_name: str | None,
|
||||
client_version: str | None,
|
||||
terminal_emulator: str | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
|
|
@ -187,6 +204,8 @@ class TelemetryClient:
|
|||
"nb_models": nb_models,
|
||||
"entrypoint": entrypoint,
|
||||
"version": __version__,
|
||||
"client_name": client_name,
|
||||
"client_version": client_version,
|
||||
"terminal_emulator": terminal_emulator,
|
||||
}
|
||||
self.send_telemetry_event("vibe.new_session", payload)
|
||||
|
|
@ -195,3 +214,10 @@ class TelemetryClient:
|
|||
self.send_telemetry_event(
|
||||
"vibe.onboarding_api_key_added", {"version": __version__}
|
||||
)
|
||||
|
||||
def send_user_rating_feedback(self, rating: int, model: str) -> None:
|
||||
self.send_telemetry_event(
|
||||
"vibe.user_rating_feedback",
|
||||
{"rating": rating, "version": __version__, "model": model},
|
||||
correlation_id=self.last_correlation_id,
|
||||
)
|
||||
|
|
|
|||
158
vibe/core/tools/arity.py
Normal file
158
vibe/core/tools/arity.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from __future__ import annotations
|
||||
|
||||
ARITY: dict[str, int] = {
|
||||
"cat": 1,
|
||||
"cd": 1,
|
||||
"chmod": 1,
|
||||
"chown": 1,
|
||||
"cp": 1,
|
||||
"echo": 1,
|
||||
"env": 1,
|
||||
"export": 1,
|
||||
"grep": 1,
|
||||
"kill": 1,
|
||||
"killall": 1,
|
||||
"ln": 1,
|
||||
"ls": 1,
|
||||
"mkdir": 1,
|
||||
"mv": 1,
|
||||
"ps": 1,
|
||||
"pwd": 1,
|
||||
"rm": 1,
|
||||
"rmdir": 1,
|
||||
"sleep": 1,
|
||||
"source": 1,
|
||||
"tail": 1,
|
||||
"touch": 1,
|
||||
"unset": 1,
|
||||
"which": 1,
|
||||
"aws": 3,
|
||||
"az": 3,
|
||||
"bazel": 2,
|
||||
"brew": 2,
|
||||
"bun": 2,
|
||||
"bun run": 3,
|
||||
"bun x": 3,
|
||||
"cargo": 2,
|
||||
"cargo add": 3,
|
||||
"cargo run": 3,
|
||||
"cdk": 2,
|
||||
"cf": 2,
|
||||
"cmake": 2,
|
||||
"composer": 2,
|
||||
"consul": 2,
|
||||
"consul kv": 3,
|
||||
"crictl": 2,
|
||||
"deno": 2,
|
||||
"deno task": 3,
|
||||
"doctl": 3,
|
||||
"docker": 2,
|
||||
"docker builder": 3,
|
||||
"docker compose": 3,
|
||||
"docker container": 3,
|
||||
"docker image": 3,
|
||||
"docker network": 3,
|
||||
"docker volume": 3,
|
||||
"eksctl": 2,
|
||||
"eksctl create": 3,
|
||||
"firebase": 2,
|
||||
"flyctl": 2,
|
||||
"gcloud": 3,
|
||||
"gh": 3,
|
||||
"git": 2,
|
||||
"git config": 3,
|
||||
"git remote": 3,
|
||||
"git stash": 3,
|
||||
"go": 2,
|
||||
"gradle": 2,
|
||||
"helm": 2,
|
||||
"heroku": 2,
|
||||
"hugo": 2,
|
||||
"ip": 2,
|
||||
"ip addr": 3,
|
||||
"ip link": 3,
|
||||
"ip netns": 3,
|
||||
"ip route": 3,
|
||||
"kind": 2,
|
||||
"kind create": 3,
|
||||
"kubectl": 2,
|
||||
"kubectl kustomize": 3,
|
||||
"kubectl rollout": 3,
|
||||
"kustomize": 2,
|
||||
"make": 2,
|
||||
"mc": 2,
|
||||
"mc admin": 3,
|
||||
"minikube": 2,
|
||||
"mongosh": 2,
|
||||
"mysql": 2,
|
||||
"mvn": 2,
|
||||
"ng": 2,
|
||||
"npm": 2,
|
||||
"npm exec": 3,
|
||||
"npm init": 3,
|
||||
"npm run": 3,
|
||||
"npm view": 3,
|
||||
"nvm": 2,
|
||||
"nx": 2,
|
||||
"openssl": 2,
|
||||
"openssl req": 3,
|
||||
"openssl x509": 3,
|
||||
"pip": 2,
|
||||
"pipenv": 2,
|
||||
"pnpm": 2,
|
||||
"pnpm dlx": 3,
|
||||
"pnpm exec": 3,
|
||||
"pnpm run": 3,
|
||||
"poetry": 2,
|
||||
"podman": 2,
|
||||
"podman container": 3,
|
||||
"podman image": 3,
|
||||
"psql": 2,
|
||||
"pulumi": 2,
|
||||
"pulumi stack": 3,
|
||||
"pyenv": 2,
|
||||
"python": 2,
|
||||
"rake": 2,
|
||||
"rbenv": 2,
|
||||
"redis-cli": 2,
|
||||
"rustup": 2,
|
||||
"serverless": 2,
|
||||
"sfdx": 3,
|
||||
"skaffold": 2,
|
||||
"sls": 2,
|
||||
"sst": 2,
|
||||
"swift": 2,
|
||||
"systemctl": 2,
|
||||
"terraform": 2,
|
||||
"terraform workspace": 3,
|
||||
"tmux": 2,
|
||||
"turbo": 2,
|
||||
"ufw": 2,
|
||||
"uv": 2,
|
||||
"uv run": 3,
|
||||
"vault": 2,
|
||||
"vault auth": 3,
|
||||
"vault kv": 3,
|
||||
"vercel": 2,
|
||||
"volta": 2,
|
||||
"wp": 2,
|
||||
"yarn": 2,
|
||||
"yarn dlx": 3,
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
|
||||
def build_session_pattern(tokens: list[str]) -> str:
|
||||
"""Build a session-level permission pattern from command tokens.
|
||||
|
||||
Uses arity rules to find the meaningful command prefix, then appends " *"
|
||||
to allow matching any arguments. Falls back to first token.
|
||||
"""
|
||||
if not tokens:
|
||||
return ""
|
||||
for length in range(len(tokens), 0, -1):
|
||||
prefix = " ".join(tokens[:length])
|
||||
arity = ARITY.get(prefix)
|
||||
if arity is not None:
|
||||
return " ".join(tokens[:arity]) + " *"
|
||||
return tokens[0] + " *"
|
||||
|
|
@ -24,10 +24,13 @@ from typing import (
|
|||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.types import (
|
||||
ApprovalCallback,
|
||||
EntrypointMetadata,
|
||||
|
|
@ -51,6 +54,7 @@ class InvokeContext:
|
|||
entrypoint_metadata: EntrypointMetadata | None = field(default=None)
|
||||
plan_file_path: Path | None = field(default=None)
|
||||
switch_agent_callback: SwitchAgentCallback | None = field(default=None)
|
||||
skill_manager: SkillManager | None = field(default=None)
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
|
|
@ -97,6 +101,7 @@ class BaseToolConfig(BaseModel):
|
|||
permission: The permission level required to use the tool.
|
||||
allowlist: Patterns that automatically allow tool execution.
|
||||
denylist: Patterns that automatically deny tool execution.
|
||||
sensitive_patterns: Patterns that trigger ASK even when permission is ALWAYS.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
|
@ -104,6 +109,7 @@ class BaseToolConfig(BaseModel):
|
|||
permission: ToolPermission = ToolPermission.ASK
|
||||
allowlist: list[str] = Field(default_factory=list)
|
||||
denylist: list[str] = Field(default_factory=list)
|
||||
sensitive_patterns: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BaseToolState(BaseModel):
|
||||
|
|
@ -153,7 +159,7 @@ class BaseTool[
|
|||
prompt_dir = class_path.parent / "prompts"
|
||||
prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md"
|
||||
|
||||
return prompt_path.read_text("utf-8")
|
||||
return read_safe(prompt_path)
|
||||
except (FileNotFoundError, TypeError, OSError):
|
||||
pass
|
||||
|
||||
|
|
@ -338,12 +344,12 @@ class BaseTool[
|
|||
config_class = cls._get_tool_config_class()
|
||||
return config_class(permission=permission)
|
||||
|
||||
def resolve_permission(self, args: ToolArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: ToolArgs) -> PermissionContext | None:
|
||||
"""Per-invocation permission override, checked before config-level permission.
|
||||
|
||||
Returns:
|
||||
ALWAYS if auto-approved, NEVER if blocked, ASK to force approval,
|
||||
or None to fall through to config permission.
|
||||
PermissionContext with granular required_permissions and a permission
|
||||
level (ALWAYS/NEVER/ASK), or None to fall through to config permission.
|
||||
|
||||
Override in subclasses for domain-specific rules (e.g. workdir checks).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import asyncio
|
|||
from collections.abc import AsyncGenerator
|
||||
from functools import lru_cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
from typing import ClassVar, Literal, final
|
||||
|
|
@ -12,6 +13,7 @@ from pydantic import BaseModel, Field
|
|||
from tree_sitter import Language, Node, Parser
|
||||
import tree_sitter_bash as tsbash
|
||||
|
||||
from vibe.core.tools.arity import build_session_pattern
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
|
|
@ -20,7 +22,13 @@ from vibe.core.tools.base import (
|
|||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import (
|
||||
PermissionContext,
|
||||
PermissionScope,
|
||||
RequiredPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import is_path_within_workdir
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils import is_windows
|
||||
|
||||
|
|
@ -116,7 +124,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
|
|||
|
||||
|
||||
def _get_default_allowlist() -> list[str]:
|
||||
common = ["echo", "git diff", "git log", "git status", "tree", "whoami"]
|
||||
common = ["cd", "echo", "git diff", "git log", "git status", "tree", "whoami"]
|
||||
|
||||
if is_windows():
|
||||
return common + ["dir", "findstr", "more", "type", "ver", "where"]
|
||||
|
|
@ -165,6 +173,68 @@ def _get_default_denylist_standalone() -> list[str]:
|
|||
return common + ["bash", "sh", "nohup", "vi", "vim", "emacs", "nano", "su"]
|
||||
|
||||
|
||||
_PATH_COMMANDS = {
|
||||
"cat",
|
||||
"cd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"cp",
|
||||
"head",
|
||||
"ls",
|
||||
"mkdir",
|
||||
"mv",
|
||||
"rm",
|
||||
"stat",
|
||||
"tail",
|
||||
"touch",
|
||||
"wc",
|
||||
}
|
||||
|
||||
|
||||
def _collect_outside_dirs(command_parts: list[str]) -> set[str]:
|
||||
"""Collect parent directories referenced outside the workdir.
|
||||
|
||||
Iterates file-manipulating commands (see _PATH_COMMANDS) and inspects
|
||||
their arguments as candidate paths. Skips flags (-r, --recursive) and
|
||||
chmod mode strings (+x). For any argument that resolves outside the current
|
||||
working directory, adds the parent directory (or the path itself when it is
|
||||
a directory) to the result set — suitable for building an OUTSIDE_DIRECTORY
|
||||
RequiredPermission.
|
||||
"""
|
||||
dirs: set[str] = set()
|
||||
for part in command_parts:
|
||||
tokens = part.split()
|
||||
command = tokens[0] if tokens else None
|
||||
if not command or command not in _PATH_COMMANDS:
|
||||
continue
|
||||
for token in tokens[1:]:
|
||||
# Skip CLI flags like -r, --recursive
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
# Skip chmod mode strings like +x, +rwx — they are not file paths
|
||||
if command == "chmod" and token.startswith("+"):
|
||||
continue
|
||||
# Only consider tokens that look like paths
|
||||
if not (
|
||||
token.startswith(os.sep)
|
||||
or token.startswith("~")
|
||||
or token.startswith(".")
|
||||
or os.sep in token
|
||||
):
|
||||
continue
|
||||
if is_path_within_workdir(token):
|
||||
continue
|
||||
# Resolve relative / home-relative paths, then collect parent dir
|
||||
resolved = Path(token).expanduser()
|
||||
if not resolved.is_absolute():
|
||||
resolved = Path.cwd() / resolved
|
||||
resolved = resolved.resolve()
|
||||
# For a directory target use the dir itself; for a file use its parent
|
||||
parent = str(resolved) if resolved.is_dir() else str(resolved.parent)
|
||||
dirs.add(parent)
|
||||
return dirs
|
||||
|
||||
|
||||
class BashToolConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
max_output_bytes: int = Field(
|
||||
|
|
@ -185,6 +255,10 @@ class BashToolConfig(BaseToolConfig):
|
|||
default_factory=_get_default_denylist_standalone,
|
||||
description="Commands that are denied only when run without arguments",
|
||||
)
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["sudo"],
|
||||
description="Command prefixes that always ASK regardless of arity approval.",
|
||||
)
|
||||
|
||||
|
||||
class BashArgs(BaseModel):
|
||||
|
|
@ -224,7 +298,10 @@ class Bash(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Running command"
|
||||
|
||||
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: BashArgs) -> PermissionContext | None: # noqa: PLR0911, PLR0912
|
||||
if is_windows():
|
||||
return None
|
||||
|
||||
command_parts = _extract_commands(args.command)
|
||||
if not command_parts:
|
||||
return None
|
||||
|
|
@ -236,32 +313,92 @@ class Bash(
|
|||
parts = command.split()
|
||||
if not parts:
|
||||
return False
|
||||
|
||||
base_command = parts[0]
|
||||
has_args = len(parts) > 1
|
||||
|
||||
if not has_args:
|
||||
if len(parts) == 1:
|
||||
command_name = os.path.basename(base_command)
|
||||
if command_name in self.config.denylist_standalone:
|
||||
return True
|
||||
if base_command in self.config.denylist_standalone:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_allowlisted(command: str) -> bool:
|
||||
return any(command.startswith(pattern) for pattern in self.config.allowlist)
|
||||
|
||||
def is_sensitive(command: str) -> bool:
|
||||
tokens = command.split()
|
||||
if not tokens:
|
||||
return False
|
||||
return tokens[0] in self.config.sensitive_patterns
|
||||
|
||||
for part in command_parts:
|
||||
if is_denylisted(part):
|
||||
return ToolPermission.NEVER
|
||||
if is_standalone_denylisted(part):
|
||||
return ToolPermission.NEVER
|
||||
if is_denylisted(part) or is_standalone_denylisted(part):
|
||||
return PermissionContext(permission=ToolPermission.NEVER)
|
||||
|
||||
if all(is_allowlisted(part) for part in command_parts):
|
||||
return ToolPermission.ALWAYS
|
||||
if self.config.permission == ToolPermission.ALWAYS:
|
||||
return PermissionContext(permission=ToolPermission.ALWAYS)
|
||||
|
||||
return None
|
||||
has_sensitive = any(is_sensitive(part) for part in command_parts)
|
||||
all_allowlisted = not has_sensitive and all(
|
||||
is_allowlisted(part) for part in command_parts
|
||||
)
|
||||
outside_dirs = _collect_outside_dirs(command_parts)
|
||||
|
||||
if all_allowlisted and not outside_dirs:
|
||||
return PermissionContext(permission=ToolPermission.ALWAYS)
|
||||
|
||||
required: list[RequiredPermission] = []
|
||||
seen_session: set[str] = set()
|
||||
|
||||
for part in command_parts:
|
||||
if not part:
|
||||
continue
|
||||
tokens = part.split()
|
||||
if not tokens:
|
||||
continue
|
||||
if not is_sensitive(part) and is_allowlisted(part):
|
||||
continue
|
||||
|
||||
if is_sensitive(part):
|
||||
required.append(
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
invocation_pattern=part,
|
||||
session_pattern=part,
|
||||
label=part,
|
||||
)
|
||||
)
|
||||
else:
|
||||
session_pat = build_session_pattern(tokens)
|
||||
if session_pat not in seen_session:
|
||||
seen_session.add(session_pat)
|
||||
required.append(
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
invocation_pattern=part,
|
||||
session_pattern=session_pat,
|
||||
label=session_pat,
|
||||
)
|
||||
)
|
||||
|
||||
if outside_dirs:
|
||||
globs = sorted(str(Path(d) / "*") for d in outside_dirs)
|
||||
for glob in globs:
|
||||
required.append(
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.OUTSIDE_DIRECTORY,
|
||||
invocation_pattern=glob,
|
||||
session_pattern=glob,
|
||||
label=f"outside workdir ({glob})",
|
||||
)
|
||||
)
|
||||
|
||||
if not required:
|
||||
return None
|
||||
|
||||
return PermissionContext(
|
||||
permission=ToolPermission.ASK, required_permissions=required
|
||||
)
|
||||
|
||||
@final
|
||||
def _build_timeout_error(self, command: str, timeout: int) -> ToolError:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from vibe.core.tools.builtins.ask_user_question import (
|
|||
Question,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class ExitPlanModeArgs(BaseModel):
|
||||
|
|
@ -74,7 +75,7 @@ class ExitPlanMode(
|
|||
plan_content: str | None = None
|
||||
if ctx.plan_file_path and ctx.plan_file_path.is_file():
|
||||
try:
|
||||
plan_content = ctx.plan_file_path.read_text()
|
||||
plan_content = read_safe(ctx.plan_file_path)
|
||||
except OSError as e:
|
||||
raise ToolError(
|
||||
f"Failed to read plan file at {ctx.plan_file_path}: {e}"
|
||||
|
|
|
|||
|
|
@ -17,8 +17,11 @@ from vibe.core.tools.base import (
|
|||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
|
@ -31,6 +34,10 @@ class GrepBackend(StrEnum):
|
|||
|
||||
class GrepToolConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ALWAYS
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
|
||||
max_output_bytes: int = Field(
|
||||
default=64_000, description="Hard cap for the total size of matched lines."
|
||||
|
|
@ -103,6 +110,16 @@ class Grep(
|
|||
"Respects .gitignore and .codeignore files by default when using ripgrep."
|
||||
)
|
||||
|
||||
def resolve_permission(self, args: GrepArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
def _detect_backend(self) -> GrepBackend:
|
||||
if shutil.which("rg"):
|
||||
return GrepBackend.RIPGREP
|
||||
|
|
@ -150,7 +167,7 @@ class Grep(
|
|||
def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]:
|
||||
patterns = []
|
||||
try:
|
||||
content = codeignore_path.read_text("utf-8")
|
||||
content = read_safe(codeignore_path)
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
|
|
|
|||
19
vibe/core/tools/builtins/prompts/skill.md
Normal file
19
vibe/core/tools/builtins/prompts/skill.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Use `skill` to load specialized skills that provide domain-specific instructions and workflows.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
- When a task matches one of the available skills listed in your system prompt
|
||||
- When the user references a skill by name (e.g., "use the review skill")
|
||||
- When you need specialized workflows, templates, or scripts bundled with a skill
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Call `skill` with the skill's `name` from the `<available_skills>` section
|
||||
2. The tool returns the full skill instructions along with a list of bundled files
|
||||
3. Follow the loaded instructions step by step — you are the executor
|
||||
|
||||
## Notes
|
||||
|
||||
- Skills may include bundled resources (scripts, references, templates) in their directory
|
||||
- File paths in skill output are relative to the skill's base directory
|
||||
- Each skill is loaded once per invocation — re-invoke if you need to reload
|
||||
|
|
@ -16,6 +16,7 @@ from vibe.core.tools.base import (
|
|||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
|
@ -53,6 +54,10 @@ class ReadFileResult(BaseModel):
|
|||
|
||||
class ReadFileToolConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ALWAYS
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
|
||||
max_read_bytes: int = Field(
|
||||
default=64_000, description="Maximum total bytes to read from a file in one go."
|
||||
|
|
@ -87,12 +92,14 @@ class ReadFile(
|
|||
was_truncated=read_result.was_truncated,
|
||||
)
|
||||
|
||||
def resolve_permission(self, args: ReadFileArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: ReadFileArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
def get_result_extra(self, result: ReadFileResult) -> str | None:
|
||||
|
|
@ -127,13 +134,26 @@ class ReadFile(
|
|||
return file_path
|
||||
|
||||
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
|
||||
try:
|
||||
return await self._do_read_file(args, file_path, encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return await self._do_read_file(args, file_path, errors="replace")
|
||||
|
||||
async def _do_read_file(
|
||||
self,
|
||||
args: ReadFileArgs,
|
||||
file_path: Path,
|
||||
*,
|
||||
encoding: str | None = None,
|
||||
errors: str | None = None,
|
||||
) -> _ReadResult:
|
||||
try:
|
||||
lines_to_return: list[str] = []
|
||||
bytes_read = 0
|
||||
was_truncated = False
|
||||
|
||||
async with await anyio.Path(file_path).open(
|
||||
encoding="utf-8", errors="ignore"
|
||||
encoding=encoding, errors=errors
|
||||
) as f:
|
||||
line_index = 0
|
||||
async for line in f:
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ from vibe.core.tools.base import (
|
|||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils.io import read_safe_async
|
||||
|
||||
SEARCH_REPLACE_BLOCK_RE = re.compile(
|
||||
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
|
||||
|
|
@ -65,6 +66,10 @@ class SearchReplaceResult(BaseModel):
|
|||
|
||||
|
||||
class SearchReplaceConfig(BaseToolConfig):
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
max_content_size: int = 100_000
|
||||
create_backup: bool = False
|
||||
fuzzy_threshold: float = 0.9
|
||||
|
|
@ -105,12 +110,14 @@ class SearchReplace(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Editing files"
|
||||
|
||||
def resolve_permission(self, args: SearchReplaceArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.file_path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
@final
|
||||
|
|
@ -211,12 +218,11 @@ class SearchReplace(
|
|||
|
||||
async def _read_file(self, file_path: Path) -> str:
|
||||
try:
|
||||
async with await anyio.Path(file_path).open(encoding="utf-8") as f:
|
||||
return await f.read()
|
||||
except UnicodeDecodeError as e:
|
||||
raise ToolError(f"Unicode decode error reading {file_path}: {e}") from e
|
||||
return await read_safe_async(file_path, raise_on_error=True)
|
||||
except PermissionError:
|
||||
raise ToolError(f"Permission denied reading file: {file_path}")
|
||||
except OSError as e:
|
||||
raise ToolError(f"OS error reading {file_path}: {e}") from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
|
||||
|
||||
|
|
|
|||
139
vibe/core/tools/builtins/skill.py
Normal file
139
vibe/core/tools/builtins/skill.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import (
|
||||
PermissionContext,
|
||||
PermissionScope,
|
||||
RequiredPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
_MAX_LISTED_FILES = 10
|
||||
|
||||
|
||||
class SkillArgs(BaseModel):
|
||||
name: str = Field(description="The name of the skill to load from available_skills")
|
||||
|
||||
|
||||
class SkillResult(BaseModel):
|
||||
name: str = Field(description="The name of the loaded skill")
|
||||
content: str = Field(description="The full skill content block")
|
||||
skill_dir: str = Field(description="Absolute path to the skill directory")
|
||||
|
||||
|
||||
class SkillToolConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
|
||||
|
||||
class Skill(
|
||||
BaseTool[SkillArgs, SkillResult, SkillToolConfig, BaseToolState],
|
||||
ToolUIData[SkillArgs, SkillResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows. "
|
||||
"When you recognize that a task matches one of the available skills listed in your system prompt, "
|
||||
"use this tool to load the full skill instructions. "
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources "
|
||||
"(scripts, references, templates) into the conversation context."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: SkillArgs) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary=f"Loading skill: {args.name}")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if event.error:
|
||||
return ToolResultDisplay(success=False, message=event.error)
|
||||
if not isinstance(event.result, SkillResult):
|
||||
return ToolResultDisplay(success=True, message="Skill loaded")
|
||||
return ToolResultDisplay(
|
||||
success=True, message=f"Loaded skill: {event.result.name}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Loading skill"
|
||||
|
||||
def resolve_permission(self, args: SkillArgs) -> PermissionContext | None:
|
||||
return PermissionContext(
|
||||
permission=self.config.permission,
|
||||
required_permissions=[
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.FILE_PATTERN,
|
||||
invocation_pattern=args.name,
|
||||
session_pattern=args.name,
|
||||
label=f"Load skill: {args.name}",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def run(
|
||||
self, args: SkillArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | SkillResult, None]:
|
||||
if ctx is None or ctx.skill_manager is None:
|
||||
raise ToolError("Skill manager not available")
|
||||
|
||||
skill_manager = ctx.skill_manager
|
||||
skill_info = skill_manager.get_skill(args.name)
|
||||
|
||||
if skill_info is None:
|
||||
available = ", ".join(sorted(skill_manager.available_skills.keys()))
|
||||
raise ToolError(
|
||||
f'Skill "{args.name}" not found. Available skills: {available or "none"}'
|
||||
)
|
||||
|
||||
try:
|
||||
raw = read_safe(skill_info.skill_path)
|
||||
_, body = parse_frontmatter(raw)
|
||||
except (OSError, SkillParseError) as e:
|
||||
raise ToolError(f"Cannot load skill file: {e}") from e
|
||||
|
||||
skill_dir = skill_info.skill_dir
|
||||
files: list[str] = []
|
||||
try:
|
||||
for entry in sorted(skill_dir.rglob("*")):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
if entry.name == "SKILL.md":
|
||||
continue
|
||||
files.append(str(entry.relative_to(skill_dir)))
|
||||
if len(files) >= _MAX_LISTED_FILES:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
file_lines = "\n".join(f"<file>{f}</file>" for f in files)
|
||||
|
||||
output = "\n".join([
|
||||
f'<skill_content name="{args.name}">',
|
||||
f"# Skill: {args.name}",
|
||||
"",
|
||||
body.strip(),
|
||||
"",
|
||||
f"Base directory for this skill: {skill_dir}",
|
||||
"Relative paths in this skill are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
file_lines,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
])
|
||||
|
||||
yield SkillResult(name=args.name, content=output, skill_dir=str(skill_dir))
|
||||
|
|
@ -17,6 +17,7 @@ from vibe.core.tools.base import (
|
|||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import (
|
||||
ToolCallDisplay,
|
||||
ToolResultDisplay,
|
||||
|
|
@ -89,16 +90,16 @@ class Task(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Running subagent"
|
||||
|
||||
def resolve_permission(self, args: TaskArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: TaskArgs) -> PermissionContext | None:
|
||||
agent_name = args.agent
|
||||
|
||||
for pattern in self.config.denylist:
|
||||
if fnmatch.fnmatch(agent_name, pattern):
|
||||
return ToolPermission.NEVER
|
||||
return PermissionContext(permission=ToolPermission.NEVER)
|
||||
|
||||
for pattern in self.config.allowlist:
|
||||
if fnmatch.fnmatch(agent_name, pattern):
|
||||
return ToolPermission.ALWAYS
|
||||
return PermissionContext(permission=ToolPermission.ALWAYS)
|
||||
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ from vibe.core.tools.base import (
|
|||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import (
|
||||
PermissionContext,
|
||||
PermissionScope,
|
||||
RequiredPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
|
|
@ -71,14 +76,43 @@ class WebFetch(
|
|||
"Fetch content from a URL. Converts HTML to markdown for readability."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Normalise a URL to always have an http(s) scheme.
|
||||
|
||||
Handles protocol-relative URLs (//example.com) and bare URLs (example.com).
|
||||
"""
|
||||
raw = url.lstrip("/") if url.startswith("//") else url
|
||||
return raw if raw.startswith(("http://", "https://")) else "https://" + raw
|
||||
|
||||
def resolve_permission(self, args: WebFetchArgs) -> PermissionContext | None:
|
||||
if self.config.permission in {ToolPermission.ALWAYS, ToolPermission.NEVER}:
|
||||
return PermissionContext(permission=self.config.permission)
|
||||
|
||||
parsed = urlparse(self._normalize_url(args.url))
|
||||
domain = parsed.netloc or parsed.path.split("/")[0]
|
||||
if not domain:
|
||||
return None
|
||||
|
||||
return PermissionContext(
|
||||
permission=ToolPermission.ASK,
|
||||
required_permissions=[
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.URL_PATTERN,
|
||||
invocation_pattern=domain,
|
||||
session_pattern=domain,
|
||||
label=f"fetching from {domain}",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: WebFetchArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | WebFetchResult, None]:
|
||||
self._validate_args(args)
|
||||
|
||||
raw = args.url.lstrip("/") if args.url.startswith("//") else args.url
|
||||
url = raw if raw.startswith(("http://", "https://")) else "https://" + raw
|
||||
url = self._normalize_url(args.url)
|
||||
timeout = self._resolve_timeout(args.timeout)
|
||||
|
||||
content, content_type = await self._fetch_url(url, timeout)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from mistralai.client.models import (
|
|||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
|
|
@ -24,7 +23,7 @@ from vibe.core.tools.base import (
|
|||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.types import Backend, ToolStreamEvent
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from vibe.core.tools.base import (
|
|||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
|
|
@ -37,6 +38,10 @@ class WriteFileResult(BaseModel):
|
|||
|
||||
class WriteFileConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
max_write_bytes: int = 64_000
|
||||
create_parent_dirs: bool = True
|
||||
|
||||
|
|
@ -70,12 +75,14 @@ class WriteFile(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Writing file"
|
||||
|
||||
def resolve_permission(self, args: WriteFileArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: WriteFileArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
@final
|
||||
|
|
|
|||
|
|
@ -221,10 +221,9 @@ class ToolManager:
|
|||
|
||||
user_overrides = self._config.tools.get(tool_name)
|
||||
if user_overrides is None:
|
||||
merged_dict = default_config.model_dump()
|
||||
else:
|
||||
merged_dict = {**default_config.model_dump(), **user_overrides.model_dump()}
|
||||
return config_class()
|
||||
|
||||
merged_dict = {**default_config.model_dump(), **user_overrides}
|
||||
return config_class.model_validate(merged_dict)
|
||||
|
||||
def get(self, tool_name: str) -> BaseTool:
|
||||
|
|
|
|||
32
vibe/core/tools/permissions.py
Normal file
32
vibe/core/tools/permissions.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
|
||||
|
||||
class PermissionScope(StrEnum):
|
||||
COMMAND_PATTERN = auto()
|
||||
OUTSIDE_DIRECTORY = auto()
|
||||
FILE_PATTERN = auto()
|
||||
URL_PATTERN = auto()
|
||||
|
||||
|
||||
class RequiredPermission(BaseModel):
|
||||
scope: PermissionScope
|
||||
invocation_pattern: str
|
||||
session_pattern: str
|
||||
label: str
|
||||
|
||||
|
||||
class PermissionContext(BaseModel):
|
||||
permission: ToolPermission
|
||||
required_permissions: list[RequiredPermission] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApprovedRule(BaseModel):
|
||||
tool_name: str
|
||||
scope: PermissionScope
|
||||
session_pattern: str
|
||||
|
|
@ -1,41 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.tools.permissions import (
|
||||
PermissionContext,
|
||||
PermissionScope,
|
||||
RequiredPermission,
|
||||
)
|
||||
|
||||
|
||||
def wildcard_match(text: str, pattern: str) -> bool:
|
||||
"""Match text against a wildcard pattern using fnmatch.
|
||||
|
||||
If pattern ends with " *", trailing part is optional (matches with or without args).
|
||||
"""
|
||||
if fnmatch.fnmatch(text, pattern):
|
||||
return True
|
||||
if pattern.endswith(" *") and fnmatch.fnmatch(text, pattern[:-2]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _make_absolute(path_str: str) -> Path:
|
||||
path = Path(path_str).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def resolve_path_permission(
|
||||
path_str: str, *, allowlist: list[str], denylist: list[str]
|
||||
) -> ToolPermission | None:
|
||||
) -> PermissionContext | None:
|
||||
"""Resolve permission for a file path against glob patterns.
|
||||
|
||||
Returns NEVER on denylist match, ALWAYS on allowlist match, None otherwise.
|
||||
"""
|
||||
file_path = Path(path_str).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
file_str = str(file_path.resolve())
|
||||
file_str = str(_make_absolute(path_str).resolve())
|
||||
|
||||
for pattern in denylist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.NEVER
|
||||
return PermissionContext(permission=ToolPermission.NEVER)
|
||||
|
||||
for pattern in allowlist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.ALWAYS
|
||||
return PermissionContext(permission=ToolPermission.ALWAYS)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_path_within_workdir(path_str: str) -> bool:
|
||||
"""Return True if the resolved path is inside cwd."""
|
||||
file_path = Path(path_str).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
try:
|
||||
file_path.resolve().relative_to(Path.cwd().resolve())
|
||||
_make_absolute(path_str).resolve().relative_to(Path.cwd().resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
|
@ -44,15 +62,16 @@ def is_path_within_workdir(path_str: str) -> bool:
|
|||
def resolve_file_tool_permission(
|
||||
path_str: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
allowlist: list[str],
|
||||
denylist: list[str],
|
||||
config_permission: ToolPermission,
|
||||
) -> ToolPermission | None:
|
||||
sensitive_patterns: list[str],
|
||||
) -> PermissionContext | None:
|
||||
"""Resolve permission for a file-based tool invocation.
|
||||
|
||||
Checks allowlist/denylist first, then escalates to ASK for paths outside
|
||||
the working directory (unless the tool is configured as NEVER).
|
||||
Returns None to fall back to the tool's default config permission.
|
||||
Checks allowlist/denylist, then sensitive patterns, then workdir boundary.
|
||||
Returns PermissionContext with granular required_permissions when applicable.
|
||||
"""
|
||||
if (
|
||||
result := resolve_path_permission(
|
||||
|
|
@ -61,9 +80,41 @@ def resolve_file_tool_permission(
|
|||
) is not None:
|
||||
return result
|
||||
|
||||
required: list[RequiredPermission] = []
|
||||
|
||||
file_path = _make_absolute(path_str)
|
||||
file_str = str(file_path.resolve())
|
||||
|
||||
for pattern in sensitive_patterns:
|
||||
if PurePath(file_str).match(pattern):
|
||||
required.append(
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.FILE_PATTERN,
|
||||
invocation_pattern=file_path.name,
|
||||
session_pattern="*",
|
||||
label=f"accessing sensitive files ({tool_name})",
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
if not is_path_within_workdir(path_str):
|
||||
if config_permission == ToolPermission.NEVER:
|
||||
return ToolPermission.NEVER
|
||||
return ToolPermission.ASK
|
||||
return PermissionContext(permission=ToolPermission.NEVER)
|
||||
resolved = file_path.resolve()
|
||||
parent_dir = str(resolved.parent)
|
||||
glob = str(Path(parent_dir) / "*")
|
||||
required.append(
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.OUTSIDE_DIRECTORY,
|
||||
invocation_pattern=glob,
|
||||
session_pattern=glob,
|
||||
label=f"outside workdir ({glob})",
|
||||
)
|
||||
)
|
||||
|
||||
if required:
|
||||
return PermissionContext(
|
||||
permission=ToolPermission.ASK, required_permissions=required
|
||||
)
|
||||
|
||||
return None
|
||||
|
|
|
|||
137
vibe/core/tracing.py
Normal file
137
vibe/core/tracing.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opentelemetry import baggage, context, trace
|
||||
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
from vibe import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
from vibe.core.logger import logger
|
||||
|
||||
VIBE_TRACER_NAME = "mistral_vibe"
|
||||
VIBE_AGENT_NAME = "mistral-vibe"
|
||||
|
||||
|
||||
def setup_tracing(config: VibeConfig) -> None:
|
||||
if not config.enable_otel:
|
||||
return
|
||||
|
||||
exporter_cfg = config.otel_exporter_config
|
||||
if exporter_cfg is None:
|
||||
return
|
||||
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
|
||||
resource = Resource.create({
|
||||
"service.name": VIBE_AGENT_NAME,
|
||||
"service.version": __version__,
|
||||
})
|
||||
exporter = OTLPSpanExporter(**exporter_cfg.model_dump())
|
||||
provider = TracerProvider(resource=resource)
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
atexit.register(provider.shutdown)
|
||||
|
||||
|
||||
def _get_tracer() -> trace.Tracer:
|
||||
return trace.get_tracer(VIBE_TRACER_NAME, __version__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _safe_span(
|
||||
name: str, attributes: dict[str, Any]
|
||||
) -> AsyncGenerator[trace.Span]:
|
||||
# Tracing errors are logged, never raised.
|
||||
try:
|
||||
tracer = _get_tracer()
|
||||
cm = tracer.start_as_current_span(name, attributes=attributes)
|
||||
span = cm.__enter__()
|
||||
except Exception:
|
||||
logger.warning("Failed to create span", exc_info=True)
|
||||
yield trace.INVALID_SPAN
|
||||
return
|
||||
|
||||
exc_info: BaseException | None = None
|
||||
try:
|
||||
yield span
|
||||
except BaseException as exc:
|
||||
exc_info = exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
if isinstance(exc_info, Exception):
|
||||
span.set_status(StatusCode.ERROR, str(exc_info))
|
||||
span.record_exception(exc_info)
|
||||
elif exc_info is None:
|
||||
span.set_status(StatusCode.OK)
|
||||
except Exception:
|
||||
logger.warning("Failed to record span status", exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
cm.__exit__(None, None, None)
|
||||
except Exception:
|
||||
logger.warning("Failed to end span", exc_info=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def agent_span(
|
||||
*, model: str | None = None, session_id: str | None = None
|
||||
) -> AsyncGenerator[trace.Span]:
|
||||
attributes: dict[str, Any] = {
|
||||
gen_ai_attributes.GEN_AI_OPERATION_NAME: gen_ai_attributes.GenAiOperationNameValues.INVOKE_AGENT.value,
|
||||
gen_ai_attributes.GEN_AI_PROVIDER_NAME: gen_ai_attributes.GenAiProviderNameValues.MISTRAL_AI.value,
|
||||
gen_ai_attributes.GEN_AI_AGENT_NAME: VIBE_AGENT_NAME,
|
||||
}
|
||||
if model:
|
||||
attributes[gen_ai_attributes.GEN_AI_REQUEST_MODEL] = model
|
||||
if session_id:
|
||||
attributes[gen_ai_attributes.GEN_AI_CONVERSATION_ID] = session_id
|
||||
|
||||
# Propagate conversation ID as OTEL baggage so descendant spans — including
|
||||
# those created by the Mistral SDK — can read and attach it.
|
||||
token = None
|
||||
if session_id:
|
||||
ctx = baggage.set_baggage(gen_ai_attributes.GEN_AI_CONVERSATION_ID, session_id)
|
||||
token = context.attach(ctx)
|
||||
try:
|
||||
async with _safe_span(f"invoke_agent {VIBE_AGENT_NAME}", attributes) as span:
|
||||
yield span
|
||||
finally:
|
||||
if token is not None:
|
||||
context.detach(token)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def tool_span(
|
||||
*, tool_name: str, call_id: str, arguments: str
|
||||
) -> AsyncGenerator[trace.Span]:
|
||||
attributes: dict[str, Any] = {
|
||||
gen_ai_attributes.GEN_AI_OPERATION_NAME: gen_ai_attributes.GenAiOperationNameValues.EXECUTE_TOOL.value,
|
||||
gen_ai_attributes.GEN_AI_TOOL_NAME: tool_name,
|
||||
gen_ai_attributes.GEN_AI_TOOL_CALL_ID: call_id,
|
||||
gen_ai_attributes.GEN_AI_TOOL_CALL_ARGUMENTS: arguments,
|
||||
gen_ai_attributes.GEN_AI_TOOL_TYPE: "function",
|
||||
}
|
||||
if conv_id := baggage.get_baggage(gen_ai_attributes.GEN_AI_CONVERSATION_ID):
|
||||
attributes[gen_ai_attributes.GEN_AI_CONVERSATION_ID] = conv_id
|
||||
|
||||
async with _safe_span(f"execute_tool {tool_name}", attributes) as span:
|
||||
yield span
|
||||
|
||||
|
||||
def set_tool_result(span: trace.Span, result: str) -> None:
|
||||
try:
|
||||
span.set_attribute(gen_ai_attributes.GEN_AI_TOOL_CALL_RESULT, result)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -52,7 +52,7 @@ class MistralTranscribeClient:
|
|||
target_streaming_delay_ms=self._target_streaming_delay_ms,
|
||||
):
|
||||
if isinstance(event, RealtimeTranscriptionSessionCreated):
|
||||
yield TranscribeSessionCreated()
|
||||
yield TranscribeSessionCreated(request_id=event.session.request_id)
|
||||
elif isinstance(event, TranscriptionStreamTextDelta):
|
||||
yield TranscribeTextDelta(text=event.text)
|
||||
elif isinstance(event, TranscriptionStreamDone):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscribeSessionCreated:
|
||||
pass
|
||||
request_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import tomli_w
|
|||
from vibe.core.paths import (
|
||||
AGENTS_MD_FILENAME,
|
||||
TRUSTED_FOLDERS_FILE,
|
||||
walk_local_config_dirs_all,
|
||||
has_config_dirs_nearby,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -19,8 +19,9 @@ def has_agents_md_file(path: Path) -> bool:
|
|||
def has_trustable_content(path: Path) -> bool:
|
||||
if (path / ".vibe").exists():
|
||||
return True
|
||||
tools_dirs, skills_dirs, agents_dirs = walk_local_config_dirs_all(path)
|
||||
return bool(tools_dirs or skills_dirs or agents_dirs) or has_agents_md_file(path)
|
||||
if has_agents_md_file(path):
|
||||
return True
|
||||
return has_config_dirs_nearby(path)
|
||||
|
||||
|
||||
class TrustedFoldersManager:
|
||||
|
|
|
|||
7
vibe/core/tts/__init__.py
Normal file
7
vibe/core/tts/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.tts.factory import make_tts_client
|
||||
from vibe.core.tts.mistral_tts_client import MistralTTSClient
|
||||
from vibe.core.tts.tts_client_port import TTSClientPort, TTSResult
|
||||
|
||||
__all__ = ["MistralTTSClient", "TTSClientPort", "TTSResult", "make_tts_client"]
|
||||
15
vibe/core/tts/factory.py
Normal file
15
vibe/core/tts/factory.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config import TTSClient, TTSModelConfig, TTSProviderConfig
|
||||
from vibe.core.tts.mistral_tts_client import MistralTTSClient
|
||||
from vibe.core.tts.tts_client_port import TTSClientPort
|
||||
|
||||
TTS_CLIENT_MAP: dict[TTSClient, type[TTSClientPort]] = {
|
||||
TTSClient.MISTRAL: MistralTTSClient
|
||||
}
|
||||
|
||||
|
||||
def make_tts_client(
|
||||
provider: TTSProviderConfig, model: TTSModelConfig
|
||||
) -> TTSClientPort:
|
||||
return TTS_CLIENT_MAP[provider.client](provider=provider, model=model)
|
||||
44
vibe/core/tts/mistral_tts_client.py
Normal file
44
vibe/core/tts/mistral_tts_client.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
|
||||
|
||||
class MistralTTSClient:
|
||||
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None:
|
||||
self._model_name = model.name
|
||||
self._voice = model.voice
|
||||
self._response_format = model.response_format
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{provider.api_base}/v1",
|
||||
headers={
|
||||
"Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
response = await self._client.post(
|
||||
"/audio/speech",
|
||||
json={
|
||||
"model": self._model_name,
|
||||
"input": text,
|
||||
"voice_id": self._voice,
|
||||
"stream": False,
|
||||
"response_format": self._response_format,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
audio_bytes = base64.b64decode(data["audio_data"])
|
||||
return TTSResult(audio_data=audio_bytes)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
19
vibe/core/tts/tts_client_port.py
Normal file
19
vibe/core/tts/tts_client_port.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TTSResult:
|
||||
audio_data: bytes
|
||||
|
||||
|
||||
class TTSClientPort(Protocol):
|
||||
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: ...
|
||||
|
||||
async def speak(self, text: str) -> TTSResult: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
|
@ -11,6 +11,7 @@ from uuid import uuid4
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.tools.base import BaseTool
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
else:
|
||||
BaseTool = Any
|
||||
|
||||
|
|
@ -25,6 +26,11 @@ from pydantic import (
|
|||
)
|
||||
|
||||
|
||||
class Backend(StrEnum):
|
||||
MISTRAL = auto()
|
||||
GENERIC = auto()
|
||||
|
||||
|
||||
class AgentStats(BaseModel):
|
||||
steps: int = 0
|
||||
session_prompt_tokens: int = 0
|
||||
|
|
@ -314,13 +320,18 @@ class LLMChunk(BaseModel):
|
|||
model_config = ConfigDict(frozen=True)
|
||||
message: LLMMessage
|
||||
usage: LLMUsage | None = None
|
||||
correlation_id: str | None = None
|
||||
|
||||
def __add__(self, other: LLMChunk) -> LLMChunk:
|
||||
if self.usage is None and other.usage is None:
|
||||
new_usage = None
|
||||
else:
|
||||
new_usage = (self.usage or LLMUsage()) + (other.usage or LLMUsage())
|
||||
return LLMChunk(message=self.message + other.message, usage=new_usage)
|
||||
return LLMChunk(
|
||||
message=self.message + other.message,
|
||||
usage=new_usage,
|
||||
correlation_id=other.correlation_id or self.correlation_id,
|
||||
)
|
||||
|
||||
|
||||
class BaseEvent(BaseModel, ABC):
|
||||
|
|
@ -398,6 +409,12 @@ class CompactEndEvent(BaseEvent):
|
|||
tool_call_id: str
|
||||
|
||||
|
||||
class AgentProfileChangedEvent(BaseEvent):
|
||||
"""Emitted when the active agent profile changes during a turn."""
|
||||
|
||||
agent_name: str
|
||||
|
||||
|
||||
class OutputFormat(StrEnum):
|
||||
TEXT = auto()
|
||||
JSON = auto()
|
||||
|
|
@ -405,9 +422,11 @@ class OutputFormat(StrEnum):
|
|||
|
||||
|
||||
type ApprovalCallback = Callable[
|
||||
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
|
||||
[str, BaseModel, str, list[RequiredPermission] | None],
|
||||
Awaitable[tuple[ApprovalResponse, str | None]],
|
||||
]
|
||||
|
||||
|
||||
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
|
||||
|
||||
type SwitchAgentCallback = Callable[[str], Awaitable[None]]
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
||||
import concurrent.futures
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum, auto
|
||||
from fnmatch import fnmatch
|
||||
import functools
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.types import BaseEvent, ToolResultEvent
|
||||
|
||||
CANCELLATION_TAG = "user_cancellation"
|
||||
TOOL_ERROR_TAG = "tool_error"
|
||||
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
||||
VIBE_WARNING_TAG = "vibe_warning"
|
||||
|
||||
KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG]
|
||||
|
||||
|
||||
class TaggedText:
|
||||
_TAG_PATTERN = re.compile(
|
||||
rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)</\1>",
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
def __init__(self, message: str, tag: str = "") -> None:
|
||||
self.message = message
|
||||
self.tag = tag
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self.tag:
|
||||
return self.message
|
||||
return f"<{self.tag}>{self.message}</{self.tag}>"
|
||||
|
||||
@staticmethod
|
||||
def from_string(text: str) -> TaggedText:
|
||||
found_tag = ""
|
||||
result = text
|
||||
|
||||
def replace_tag(match: re.Match[str]) -> str:
|
||||
nonlocal found_tag
|
||||
tag_name = match.group(1)
|
||||
content = match.group(2)
|
||||
if not found_tag:
|
||||
found_tag = tag_name
|
||||
return content
|
||||
|
||||
result = TaggedText._TAG_PATTERN.sub(replace_tag, text)
|
||||
|
||||
if found_tag:
|
||||
return TaggedText(result, found_tag)
|
||||
|
||||
return TaggedText(text, "")
|
||||
|
||||
|
||||
class CancellationReason(Enum):
|
||||
OPERATION_CANCELLED = auto()
|
||||
TOOL_INTERRUPTED = auto()
|
||||
TOOL_NO_RESPONSE = auto()
|
||||
TOOL_SKIPPED = auto()
|
||||
|
||||
|
||||
def get_user_cancellation_message(
|
||||
cancellation_reason: CancellationReason, tool_name: str | None = None
|
||||
) -> TaggedText:
|
||||
match cancellation_reason:
|
||||
case CancellationReason.OPERATION_CANCELLED:
|
||||
return TaggedText("User cancelled the operation.", CANCELLATION_TAG)
|
||||
case CancellationReason.TOOL_INTERRUPTED:
|
||||
return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG)
|
||||
case CancellationReason.TOOL_NO_RESPONSE:
|
||||
return TaggedText(
|
||||
"Tool execution interrupted - no response available", CANCELLATION_TAG
|
||||
)
|
||||
case CancellationReason.TOOL_SKIPPED:
|
||||
return TaggedText(
|
||||
tool_name or "Tool execution skipped by user.", CANCELLATION_TAG
|
||||
)
|
||||
|
||||
|
||||
def is_user_cancellation_event(event: BaseEvent) -> bool:
|
||||
if not isinstance(event, ToolResultEvent):
|
||||
return False
|
||||
return event.cancelled
|
||||
|
||||
|
||||
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
||||
"""Check if the current directory is a dangerous folder that would cause
|
||||
issues if we were to run the tool there.
|
||||
|
||||
Args:
|
||||
path: Path to check (defaults to current directory)
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous
|
||||
"""
|
||||
path = Path(path).resolve()
|
||||
|
||||
home_dir = Path.home()
|
||||
|
||||
dangerous_paths = {
|
||||
home_dir: "home directory",
|
||||
home_dir / "Documents": "Documents folder",
|
||||
home_dir / "Desktop": "Desktop folder",
|
||||
home_dir / "Downloads": "Downloads folder",
|
||||
home_dir / "Pictures": "Pictures folder",
|
||||
home_dir / "Movies": "Movies folder",
|
||||
home_dir / "Music": "Music folder",
|
||||
home_dir / "Library": "Library folder",
|
||||
Path("/Applications"): "Applications folder",
|
||||
Path("/System"): "System folder",
|
||||
Path("/Library"): "System Library folder",
|
||||
Path("/usr"): "System usr folder",
|
||||
Path("/private"): "System private folder",
|
||||
}
|
||||
|
||||
for dangerous_path, description in dangerous_paths.items():
|
||||
try:
|
||||
if path == dangerous_path:
|
||||
return True, f"You are in the {description}"
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return False, ""
|
||||
|
||||
|
||||
def get_user_agent(backend: Backend | None) -> str:
|
||||
user_agent = f"Mistral-Vibe/{__version__}"
|
||||
if backend == Backend.MISTRAL:
|
||||
mistral_sdk_prefix = "mistral-client-python/"
|
||||
user_agent = f"{mistral_sdk_prefix}{user_agent}"
|
||||
return user_agent
|
||||
|
||||
|
||||
def _is_retryable_http_error(e: Exception) -> bool:
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504}
|
||||
return False
|
||||
|
||||
|
||||
def async_retry[T, **P](
|
||||
tries: int = 3,
|
||||
delay_seconds: float = 0.5,
|
||||
backoff_factor: float = 2.0,
|
||||
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
||||
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
||||
"""Args:
|
||||
tries: Number of retry attempts
|
||||
delay_seconds: Initial delay between retries in seconds
|
||||
backoff_factor: Multiplier for delay on each retry
|
||||
is_retryable: Function to determine if an exception should trigger a retry
|
||||
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
||||
|
||||
Returns:
|
||||
Decorated function with retry logic
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
last_exc = None
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < tries - 1 and is_retryable(e):
|
||||
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
||||
0.05 * attempt
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
continue
|
||||
raise e
|
||||
raise RuntimeError(
|
||||
f"Retries exhausted. Last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def async_generator_retry[T, **P](
|
||||
tries: int = 3,
|
||||
delay_seconds: float = 0.5,
|
||||
backoff_factor: float = 2.0,
|
||||
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
||||
) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]:
|
||||
"""Retry decorator for async generators.
|
||||
|
||||
Args:
|
||||
tries: Number of retry attempts
|
||||
delay_seconds: Initial delay between retries in seconds
|
||||
backoff_factor: Multiplier for delay on each retry
|
||||
is_retryable: Function to determine if an exception should trigger a retry
|
||||
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
||||
|
||||
Returns:
|
||||
Decorated async generator function with retry logic
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[P, AsyncGenerator[T]],
|
||||
) -> Callable[P, AsyncGenerator[T]]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
|
||||
last_exc = None
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
async for item in func(*args, **kwargs):
|
||||
yield item
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < tries - 1 and is_retryable(e):
|
||||
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
||||
0.05 * attempt
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
continue
|
||||
raise e
|
||||
raise RuntimeError(
|
||||
f"Retries exhausted. Last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class ConversationLimitException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
|
||||
"""Run an async coroutine synchronously, handling nested event loops.
|
||||
|
||||
If called from within an async context (running event loop), runs the
|
||||
coroutine in a thread pool executor. Otherwise, uses asyncio.run().
|
||||
|
||||
This mirrors the pattern used by ToolManager for MCP integration.
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(asyncio.run, coro)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _compile_icase(expr: str) -> re.Pattern[str] | None:
|
||||
try:
|
||||
return re.compile(expr, re.IGNORECASE)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def name_matches(name: str, patterns: list[str]) -> bool:
|
||||
"""Check if a name matches any of the provided patterns.
|
||||
|
||||
Supports two forms (case-insensitive):
|
||||
- Glob wildcards using fnmatch (e.g., 'serena_*')
|
||||
- Regex when prefixed with 're:' (e.g., 're:serena.*')
|
||||
"""
|
||||
n = name.lower()
|
||||
for raw in patterns:
|
||||
if not (p := (raw or "").strip()):
|
||||
continue
|
||||
|
||||
if p.startswith("re:"):
|
||||
rx = _compile_icase(p.removeprefix("re:"))
|
||||
if rx is not None and rx.fullmatch(name) is not None:
|
||||
return True
|
||||
elif fnmatch(n, p.lower()):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class AsyncExecutor:
|
||||
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
|
||||
|
||||
def __init__(
|
||||
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
|
||||
) -> None:
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=max_workers, thread_name_prefix=name
|
||||
)
|
||||
self._timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> AsyncExecutor:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
self.shutdown(wait=False)
|
||||
|
||||
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(
|
||||
self._executor, functools.partial(fn, *args, **kwargs)
|
||||
)
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=self._timeout)
|
||||
except TimeoutError as e:
|
||||
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
|
||||
|
||||
def shutdown(self, wait: bool = True) -> None:
|
||||
self._executor.shutdown(wait=wait)
|
||||
|
||||
|
||||
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
|
||||
if old_tokens is None or new_tokens is None:
|
||||
return "Compaction complete"
|
||||
|
||||
reduction = old_tokens - new_tokens
|
||||
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
||||
return (
|
||||
f"Compaction complete: {old_tokens:,} → "
|
||||
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
||||
)
|
||||
|
||||
|
||||
def get_server_url_from_api_base(api_base: str) -> str | None:
|
||||
match = re.match(r"(https?://[^/]+)(/v.*)", api_base)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
55
vibe/core/utils/__init__.py
Normal file
55
vibe/core/utils/__init__.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Utilities package. Re-exports all public and test-used symbols from submodules.
|
||||
|
||||
Import read_safe/read_safe_async from vibe.core.utils.io and create_slug from
|
||||
vibe.core.utils.slug when needed to avoid circular imports with config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.core.utils.concurrency import (
|
||||
AsyncExecutor,
|
||||
ConversationLimitException,
|
||||
run_sync,
|
||||
)
|
||||
from vibe.core.utils.display import compact_reduction_display
|
||||
from vibe.core.utils.http import get_server_url_from_api_base, get_user_agent
|
||||
from vibe.core.utils.matching import name_matches
|
||||
from vibe.core.utils.paths import is_dangerous_directory
|
||||
from vibe.core.utils.platform import is_windows
|
||||
from vibe.core.utils.retry import async_generator_retry, async_retry
|
||||
from vibe.core.utils.tags import (
|
||||
CANCELLATION_TAG,
|
||||
KNOWN_TAGS,
|
||||
TOOL_ERROR_TAG,
|
||||
VIBE_STOP_EVENT_TAG,
|
||||
VIBE_WARNING_TAG,
|
||||
CancellationReason,
|
||||
TaggedText,
|
||||
get_user_cancellation_message,
|
||||
is_user_cancellation_event,
|
||||
)
|
||||
from vibe.core.utils.time import utc_now
|
||||
|
||||
__all__ = [
|
||||
"CANCELLATION_TAG",
|
||||
"KNOWN_TAGS",
|
||||
"TOOL_ERROR_TAG",
|
||||
"VIBE_STOP_EVENT_TAG",
|
||||
"VIBE_WARNING_TAG",
|
||||
"AsyncExecutor",
|
||||
"CancellationReason",
|
||||
"ConversationLimitException",
|
||||
"TaggedText",
|
||||
"async_generator_retry",
|
||||
"async_retry",
|
||||
"compact_reduction_display",
|
||||
"get_server_url_from_api_base",
|
||||
"get_user_agent",
|
||||
"get_user_cancellation_message",
|
||||
"is_dangerous_directory",
|
||||
"is_user_cancellation_event",
|
||||
"is_windows",
|
||||
"name_matches",
|
||||
"run_sync",
|
||||
"utc_now",
|
||||
]
|
||||
59
vibe/core/utils/concurrency.py
Normal file
59
vibe/core/utils/concurrency.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
import concurrent.futures
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConversationLimitException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
|
||||
"""Run an async coroutine synchronously, handling nested event loops.
|
||||
|
||||
If called from within an async context (running event loop), runs the
|
||||
coroutine in a thread pool executor. Otherwise, uses asyncio.run().
|
||||
|
||||
This mirrors the pattern used by ToolManager for MCP integration.
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(asyncio.run, coro)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class AsyncExecutor:
|
||||
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
|
||||
|
||||
def __init__(
|
||||
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
|
||||
) -> None:
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=max_workers, thread_name_prefix=name
|
||||
)
|
||||
self._timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> AsyncExecutor:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
self.shutdown(wait=False)
|
||||
|
||||
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(
|
||||
self._executor, functools.partial(fn, *args, **kwargs)
|
||||
)
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=self._timeout)
|
||||
except TimeoutError as e:
|
||||
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
|
||||
|
||||
def shutdown(self, wait: bool = True) -> None:
|
||||
self._executor.shutdown(wait=wait)
|
||||
13
vibe/core/utils/display.py
Normal file
13
vibe/core/utils/display.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
|
||||
if old_tokens is None or new_tokens is None:
|
||||
return "Compaction complete"
|
||||
|
||||
reduction = old_tokens - new_tokens
|
||||
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
||||
return (
|
||||
f"Compaction complete: {old_tokens:,} → "
|
||||
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
||||
)
|
||||
19
vibe/core/utils/http.py
Normal file
19
vibe/core/utils/http.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def get_user_agent(backend: Backend | None) -> str:
|
||||
user_agent = f"Mistral-Vibe/{__version__}"
|
||||
if backend == Backend.MISTRAL:
|
||||
mistral_sdk_prefix = "mistral-client-python/"
|
||||
user_agent = f"{mistral_sdk_prefix}{user_agent}"
|
||||
return user_agent
|
||||
|
||||
|
||||
def get_server_url_from_api_base(api_base: str) -> str | None:
|
||||
match = re.match(r"(https?://[^/]+)(/v.*)", api_base)
|
||||
return match.group(1) if match else None
|
||||
29
vibe/core/utils/io.py
Normal file
29
vibe/core/utils/io.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
|
||||
def read_safe(path: Path, *, raise_on_error: bool = False) -> str:
|
||||
"""Read a text file trying UTF-8 first, falling back to OS-default encoding.
|
||||
|
||||
On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER).
|
||||
When raise_on_error is True, decode errors propagate.
|
||||
"""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
if raise_on_error:
|
||||
return path.read_text()
|
||||
return path.read_text(errors="replace")
|
||||
|
||||
|
||||
async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str:
|
||||
apath = anyio.Path(path)
|
||||
try:
|
||||
return await apath.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
if raise_on_error:
|
||||
return await apath.read_text()
|
||||
return await apath.read_text(errors="replace")
|
||||
35
vibe/core/utils/matching.py
Normal file
35
vibe/core/utils/matching.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fnmatch import fnmatch
|
||||
import functools
|
||||
import re
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _compile_icase(expr: str) -> re.Pattern[str] | None:
|
||||
try:
|
||||
return re.compile(expr, re.IGNORECASE)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def name_matches(name: str, patterns: list[str]) -> bool:
|
||||
"""Check if a name matches any of the provided patterns.
|
||||
|
||||
Supports two forms (case-insensitive):
|
||||
- Glob wildcards using fnmatch (e.g., 'serena_*')
|
||||
- Regex when prefixed with 're:' (e.g., 're:serena.*')
|
||||
"""
|
||||
n = name.lower()
|
||||
for raw in patterns:
|
||||
if not (p := (raw or "").strip()):
|
||||
continue
|
||||
|
||||
if p.startswith("re:"):
|
||||
rx = _compile_icase(p.removeprefix("re:"))
|
||||
if rx is not None and rx.fullmatch(name) is not None:
|
||||
return True
|
||||
elif fnmatch(n, p.lower()):
|
||||
return True
|
||||
|
||||
return False
|
||||
42
vibe/core/utils/paths.py
Normal file
42
vibe/core/utils/paths.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
||||
"""Check if the current directory is a dangerous folder that would cause
|
||||
issues if we were to run the tool there.
|
||||
|
||||
Args:
|
||||
path: Path to check (defaults to current directory)
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous
|
||||
"""
|
||||
path = Path(path).resolve()
|
||||
|
||||
home_dir = Path.home()
|
||||
|
||||
dangerous_paths = {
|
||||
home_dir: "home directory",
|
||||
home_dir / "Documents": "Documents folder",
|
||||
home_dir / "Desktop": "Desktop folder",
|
||||
home_dir / "Downloads": "Downloads folder",
|
||||
home_dir / "Pictures": "Pictures folder",
|
||||
home_dir / "Movies": "Movies folder",
|
||||
home_dir / "Music": "Music folder",
|
||||
home_dir / "Library": "Library folder",
|
||||
Path("/Applications"): "Applications folder",
|
||||
Path("/System"): "System folder",
|
||||
Path("/Library"): "System Library folder",
|
||||
Path("/usr"): "System usr folder",
|
||||
Path("/private"): "System private folder",
|
||||
}
|
||||
|
||||
for dangerous_path, description in dangerous_paths.items():
|
||||
try:
|
||||
if path == dangerous_path:
|
||||
return True, f"You are in the {description}"
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return False, ""
|
||||
7
vibe/core/utils/platform.py
Normal file
7
vibe/core/utils/platform.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
103
vibe/core/utils/retry.py
Normal file
103
vibe/core/utils/retry.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
import functools
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _is_retryable_http_error(e: Exception) -> bool:
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504}
|
||||
return False
|
||||
|
||||
|
||||
def async_retry[T, **P](
|
||||
tries: int = 3,
|
||||
delay_seconds: float = 0.5,
|
||||
backoff_factor: float = 2.0,
|
||||
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
||||
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
||||
"""Args:
|
||||
tries: Number of retry attempts
|
||||
delay_seconds: Initial delay between retries in seconds
|
||||
backoff_factor: Multiplier for delay on each retry
|
||||
is_retryable: Function to determine if an exception should trigger a retry
|
||||
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
||||
|
||||
Returns:
|
||||
Decorated function with retry logic
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
last_exc = None
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < tries - 1 and is_retryable(e):
|
||||
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
||||
0.05 * attempt
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
continue
|
||||
raise e
|
||||
raise RuntimeError(
|
||||
f"Retries exhausted. Last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def async_generator_retry[T, **P](
|
||||
tries: int = 3,
|
||||
delay_seconds: float = 0.5,
|
||||
backoff_factor: float = 2.0,
|
||||
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
||||
) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]:
|
||||
"""Retry decorator for async generators.
|
||||
|
||||
Args:
|
||||
tries: Number of retry attempts
|
||||
delay_seconds: Initial delay between retries in seconds
|
||||
backoff_factor: Multiplier for delay on each retry
|
||||
is_retryable: Function to determine if an exception should trigger a retry
|
||||
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
||||
|
||||
Returns:
|
||||
Decorated async generator function with retry logic
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[P, AsyncGenerator[T]],
|
||||
) -> Callable[P, AsyncGenerator[T]]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
|
||||
last_exc = None
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
async for item in func(*args, **kwargs):
|
||||
yield item
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < tries - 1 and is_retryable(e):
|
||||
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
||||
0.05 * attempt
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
continue
|
||||
raise e
|
||||
raise RuntimeError(
|
||||
f"Retries exhausted. Last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
80
vibe/core/utils/tags.py
Normal file
80
vibe/core/utils/tags.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, auto
|
||||
import re
|
||||
|
||||
from vibe.core.types import BaseEvent, ToolResultEvent
|
||||
|
||||
CANCELLATION_TAG = "user_cancellation"
|
||||
TOOL_ERROR_TAG = "tool_error"
|
||||
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
||||
VIBE_WARNING_TAG = "vibe_warning"
|
||||
|
||||
KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG]
|
||||
|
||||
|
||||
class TaggedText:
|
||||
_TAG_PATTERN = re.compile(
|
||||
rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)</\1>",
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
def __init__(self, message: str, tag: str = "") -> None:
|
||||
self.message = message
|
||||
self.tag = tag
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self.tag:
|
||||
return self.message
|
||||
return f"<{self.tag}>{self.message}</{self.tag}>"
|
||||
|
||||
@staticmethod
|
||||
def from_string(text: str) -> TaggedText:
|
||||
found_tag = ""
|
||||
result = text
|
||||
|
||||
def replace_tag(match: re.Match[str]) -> str:
|
||||
nonlocal found_tag
|
||||
tag_name = match.group(1)
|
||||
content = match.group(2)
|
||||
if not found_tag:
|
||||
found_tag = tag_name
|
||||
return content
|
||||
|
||||
result = TaggedText._TAG_PATTERN.sub(replace_tag, text)
|
||||
|
||||
if found_tag:
|
||||
return TaggedText(result, found_tag)
|
||||
|
||||
return TaggedText(text, "")
|
||||
|
||||
|
||||
class CancellationReason(Enum):
|
||||
OPERATION_CANCELLED = auto()
|
||||
TOOL_INTERRUPTED = auto()
|
||||
TOOL_NO_RESPONSE = auto()
|
||||
TOOL_SKIPPED = auto()
|
||||
|
||||
|
||||
def get_user_cancellation_message(
|
||||
cancellation_reason: CancellationReason, tool_name: str | None = None
|
||||
) -> TaggedText:
|
||||
match cancellation_reason:
|
||||
case CancellationReason.OPERATION_CANCELLED:
|
||||
return TaggedText("User cancelled the operation.", CANCELLATION_TAG)
|
||||
case CancellationReason.TOOL_INTERRUPTED:
|
||||
return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG)
|
||||
case CancellationReason.TOOL_NO_RESPONSE:
|
||||
return TaggedText(
|
||||
"Tool execution interrupted - no response available", CANCELLATION_TAG
|
||||
)
|
||||
case CancellationReason.TOOL_SKIPPED:
|
||||
return TaggedText(
|
||||
tool_name or "Tool execution skipped by user.", CANCELLATION_TAG
|
||||
)
|
||||
|
||||
|
||||
def is_user_cancellation_event(event: BaseEvent) -> bool:
|
||||
if not isinstance(event, ToolResultEvent):
|
||||
return False
|
||||
return event.cancelled
|
||||
7
vibe/core/utils/time.py
Normal file
7
vibe/core/utils/time.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
|
@ -13,9 +13,10 @@ from textual.widgets import Input, Link, Static
|
|||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
PROVIDER_HELP = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# What's new in v2.5.0
|
||||
- **Lean mode**: Setup a dedicated theorem proving agent powered by leanstral with /leanstall
|
||||
- **Parallel tool execution**: Parallel tool calls are sped up by being ran at the same time
|
||||
- **Voice mode**: Real-time transcription support with /voice
|
||||
# What's new in v2.6.0
|
||||
- **Text-to-speech**: Added TTS functionality
|
||||
- **Standalone resume**: New --resume command for session picker
|
||||
- **Fine-grained permissions**: Improved permissions granularity and persistence
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue