Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-04-01 18:38:22 +02:00 committed by GitHub
parent 54b9a17457
commit 9c1c32e058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 587 additions and 179 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.7.1"
__version__ = "2.7.2"

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from vibe.cli.narrator_manager.narrator_manager import NarratorManager
from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener,
NarratorManagerPort,
NarratorState,
)
__all__ = [
"NarratorManager",
"NarratorManagerListener",
"NarratorManagerPort",
"NarratorState",
]

View file

@ -0,0 +1,198 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener,
NarratorState,
)
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryResult,
TurnSummaryTracker,
create_narrator_backend,
)
from vibe.core.audio_player.audio_player_port import AudioFormat
from vibe.core.logger import logger
from vibe.core.tts.factory import make_tts_client
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
from vibe.cli.turn_summary import TurnSummaryPort
from vibe.core.audio_player.audio_player_port import AudioPlayerPort
from vibe.core.config import VibeConfig
from vibe.core.tts.tts_client_port import TTSClientPort
from vibe.core.types import BaseEvent
class NarratorManager:
def __init__(
self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort
) -> None:
self._config_getter = config_getter
self._audio_player = audio_player
config = config_getter()
self._turn_summary: TurnSummaryPort = self._make_turn_summary(config)
self._turn_summary.on_summary = self._on_turn_summary
self._tts_client: TTSClientPort | None = self._make_tts_client(config)
self._state = NarratorState.IDLE
self._speak_task: asyncio.Task[None] | None = None
self._cancel_summary: Callable[[], bool] | None = None
self._close_tasks: set[asyncio.Task[Any]] = set()
self._listeners: list[NarratorManagerListener] = []
@property
def state(self) -> NarratorState:
return self._state
@property
def is_playing(self) -> bool:
return self._audio_player.is_playing
@property
def turn_summary(self) -> TurnSummaryPort:
return self._turn_summary
@turn_summary.setter
def turn_summary(self, value: TurnSummaryPort) -> None:
old = self._turn_summary
self._turn_summary = value
self._turn_summary.on_summary = self._on_turn_summary
task = asyncio.create_task(old.close())
self._close_tasks.add(task)
task.add_done_callback(self._close_tasks.discard)
@property
def tts_client(self) -> TTSClientPort | None:
return self._tts_client
@tts_client.setter
def tts_client(self, value: TTSClientPort | None) -> None:
old = self._tts_client
self._tts_client = value
if old is not None:
task = asyncio.create_task(old.close())
self._close_tasks.add(task)
task.add_done_callback(self._close_tasks.discard)
def on_turn_start(self, user_message: str) -> None:
self._turn_summary.start_turn(user_message)
def on_turn_event(self, event: BaseEvent) -> None:
self._turn_summary.track(event)
def on_turn_error(self, message: str) -> None:
self._turn_summary.set_error(message)
def on_turn_cancel(self) -> None:
self._turn_summary.cancel_turn()
def on_turn_end(self) -> None:
cancel_summary = self._turn_summary.end_turn()
if (
cancel_summary is not None
and self._config_getter().narrator_enabled
and self._tts_client is not None
):
self._cancel_summary = cancel_summary
self._set_state(NarratorState.SUMMARIZING)
def cancel(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_state(NarratorState.IDLE)
def sync(self) -> None:
self.cancel()
config = self._config_getter()
self.turn_summary = self._make_turn_summary(config)
self.tts_client = self._make_tts_client(config)
@staticmethod
def _make_turn_summary(config: VibeConfig) -> NoopTurnSummary | TurnSummaryTracker:
if not config.narrator_enabled:
return NoopTurnSummary()
result = create_narrator_backend(config)
if result is None:
return NoopTurnSummary()
backend, model = result
return TurnSummaryTracker(backend=backend, model=model)
@staticmethod
def _make_tts_client(config: VibeConfig) -> TTSClientPort | None:
if not config.narrator_enabled:
return None
try:
model = config.get_active_tts_model()
provider = 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 add_listener(self, listener: NarratorManagerListener) -> None:
if listener not in self._listeners:
self._listeners.append(listener)
def remove_listener(self, listener: NarratorManagerListener) -> None:
try:
self._listeners.remove(listener)
except ValueError:
pass
async def close(self) -> None:
self.cancel()
await self._turn_summary.close()
if self._tts_client is not None:
await self._tts_client.close()
for task in self._close_tasks:
task.cancel()
await asyncio.gather(*self._close_tasks, return_exceptions=True)
self._close_tasks.clear()
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
self._cancel_summary = None
if result.generation != self._turn_summary.generation:
self._set_state(NarratorState.IDLE)
return
if result.summary is None:
self._set_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_state(NarratorState.IDLE)
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_state(NarratorState.SPEAKING)
self._audio_player.play(
tts_result.audio_data,
AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe(
self._set_state, NarratorState.IDLE
),
)
except Exception:
logger.warning("TTS speak failed", exc_info=True)
self._set_state(NarratorState.IDLE)
def _set_state(self, state: NarratorState) -> None:
self._state = state
for listener in list(self._listeners):
try:
listener.on_narrator_state_change(state)
except Exception:
logger.warning("Narrator listener error", exc_info=True)

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import Protocol
from vibe.core.types import BaseEvent
class NarratorState(StrEnum):
IDLE = auto()
SUMMARIZING = auto()
SPEAKING = auto()
class NarratorManagerListener:
def on_narrator_state_change(self, state: NarratorState) -> None:
pass
class NarratorManagerPort(Protocol):
@property
def state(self) -> NarratorState: ...
@property
def is_playing(self) -> bool: ...
def on_turn_start(self, user_message: str) -> None: ...
def on_turn_event(self, event: BaseEvent) -> None: ...
def on_turn_error(self, message: str) -> None: ...
def on_turn_cancel(self) -> None: ...
def on_turn_end(self) -> None: ...
def cancel(self) -> None: ...
def sync(self) -> None: ...
def add_listener(self, listener: NarratorManagerListener) -> None: ...
def remove_listener(self, listener: NarratorManagerListener) -> None: ...
async def close(self) -> None: ...

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
@ -26,6 +25,11 @@ from textual.widgets import Static
from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
from vibe.cli.narrator_manager import (
NarratorManager,
NarratorManagerPort,
NarratorState,
)
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo,
@ -64,7 +68,7 @@ from vibe.cli.textual_ui.widgets.messages import (
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.narrator_status import 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
@ -85,13 +89,6 @@ 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,
@ -109,7 +106,6 @@ 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 VibeConfig
@ -136,8 +132,6 @@ from vibe.core.tools.builtins.ask_user_question import (
)
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,
@ -291,6 +285,7 @@ class VibeApp(App): # noqa: PLR0904
plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None,
voice_manager: VoiceManagerPort | None = None,
narrator_manager: NarratorManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -348,12 +343,9 @@ 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
self._narrator_manager: NarratorManagerPort = (
narrator_manager or self._make_default_narrator_manager()
)
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
@ -364,12 +356,14 @@ class VibeApp(App): # noqa: PLR0904
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
self._banner = Banner(self.config, self.agent_loop.skill_manager)
self._banner = Banner(
self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry
)
yield self._banner
yield VerticalGroup(id="messages")
with Horizontal(id="loading-area"):
yield NarratorStatus()
yield NarratorStatus(self._narrator_manager)
yield Static(id="loading-area-content")
yield FeedbackBar()
@ -616,7 +610,7 @@ class VibeApp(App): # noqa: PLR0904
if non_voice_changes:
VibeConfig.save_updates(non_voice_changes)
self.agent_loop.refresh_config()
self._sync_turn_summary()
self._narrator_manager.sync()
async def on_model_picker_app_model_selected(
self, message: ModelPickerApp.ModelSelected
@ -897,10 +891,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)
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._turn_summary.track(event)
self._narrator_manager.on_turn_event(event)
if self.event_handler:
await self.event_handler.handle_event(
event,
@ -910,7 +904,7 @@ class VibeApp(App): # noqa: PLR0904
except asyncio.CancelledError:
await self._handle_turn_error()
self._turn_summary.cancel_turn()
self._narrator_manager.on_turn_cancel()
raise
except Exception as e:
await self._handle_turn_error()
@ -918,20 +912,13 @@ class VibeApp(App): # noqa: PLR0904
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
self._turn_summary.set_error(message)
self._narrator_manager.on_turn_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._narrator_manager.on_turn_end()
self._agent_running = False
self._interrupt_requested = False
self._agent_task = None
@ -1225,12 +1212,13 @@ 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()
self._narrator_manager.sync()
if self._banner:
self._banner.set_state(
base_config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
)
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
@ -1373,6 +1361,7 @@ class VibeApp(App): # noqa: PLR0904
return self.agent_loop.session_logger.session_id[:8]
async def _exit_app(self) -> None:
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self) -> None:
@ -1583,10 +1572,16 @@ class VibeApp(App): # noqa: PLR0904
# --- Rewind mode ---
def _get_user_message_widgets(self) -> list[UserMessage]:
"""Return all UserMessage widgets currently visible in #messages."""
"""Return all UserMessage widgets currently visible in #messages.
Only includes messages with a valid message_index (i.e. real user
messages, not slash-command echo messages).
"""
messages_area = self._cached_messages_area or self.query_one("#messages")
return [
child for child in messages_area.children if isinstance(child, UserMessage)
child
for child in messages_area.children
if isinstance(child, UserMessage) and child.message_index is not None
]
def _start_rewind_mode(self) -> None:
@ -1835,9 +1830,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()
if (
self._narrator_manager.is_playing
or self._narrator_manager.state != NarratorState.IDLE
):
self._narrator_manager.cancel()
return
if self._agent_running:
@ -1911,7 +1908,10 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_banner(self) -> None:
if self._banner:
self._banner.set_state(
self.config, self.agent_loop.skill_manager, plan_title(self._plan_info)
self.config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
)
def _update_profile_widgets(self, profile: AgentProfile) -> None:
@ -1966,6 +1966,7 @@ class VibeApp(App): # noqa: PLR0904
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None:
@ -2169,86 +2170,11 @@ 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 _make_default_narrator_manager(self) -> NarratorManager:
return NarratorManager(
config_getter=lambda: self.config, audio_player=AudioPlayer()
)
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)
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, startup: StartupOptions | None = None

View file

@ -13,6 +13,7 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.mcp.registry import MCPRegistry
@dataclass
@ -28,14 +29,18 @@ class Banner(Static):
state = reactive(BannerState(), init=False)
def __init__(
self, config: VibeConfig, skill_manager: SkillManager, **kwargs: Any
self,
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.can_focus = False
self._initial_state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=len(config.mcp_servers),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
skills_count=len(skill_manager.available_skills),
plan_description=None,
)
@ -77,12 +82,13 @@ class Banner(Static):
self,
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
plan_description: str | None = None,
) -> None:
self.state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=len(config.mcp_servers),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
skills_count=len(skill_manager.available_skills),
plan_description=plan_description,
)

View file

@ -31,6 +31,8 @@ class ChatTextArea(TextArea):
show=False,
priority=True,
),
Binding("alt+left", "cursor_word_left", "Cursor word left", show=False),
Binding("alt+right", "cursor_word_right", "Cursor word right", show=False),
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]

View file

@ -1,31 +1,40 @@
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
from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener,
NarratorManagerPort,
NarratorState,
)
SHRINK_FRAMES = "█▇▆▅▄▃▂▁"
BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"]
ANIMATION_INTERVAL = 0.15
class NarratorState(StrEnum):
IDLE = auto()
SUMMARIZING = auto()
SPEAKING = auto()
class NarratorStatus(Static):
class NarratorStatus(NarratorManagerListener, Static):
state = reactive(NarratorState.IDLE)
def __init__(self, **kwargs: Any) -> None:
def __init__(self, narrator_manager: NarratorManagerPort, **kwargs: Any) -> None:
super().__init__("", **kwargs)
self._narrator_manager = narrator_manager
self._timer: Timer | None = None
self._frame: int = 0
def on_mount(self) -> None:
self._narrator_manager.add_listener(self)
def on_unmount(self) -> None:
self._narrator_manager.remove_listener(self)
def on_narrator_state_change(self, state: NarratorState) -> None:
self.state = state
def watch_state(self, new_state: NarratorState) -> None:
self._stop_timer()
match new_state:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable
from vibe.cli.turn_summary.port import TurnSummaryPort
from vibe.cli.turn_summary.port import TurnSummaryPort, TurnSummaryResult
from vibe.core.types import BaseEvent
@ -11,6 +11,14 @@ class NoopTurnSummary(TurnSummaryPort):
def generation(self) -> int:
return 0
@property
def on_summary(self) -> Callable[[TurnSummaryResult], None] | None:
return None
@on_summary.setter
def on_summary(self, value: Callable[[TurnSummaryResult], None] | None) -> None:
pass
def start_turn(self, user_message: str) -> None:
pass

View file

@ -24,6 +24,14 @@ class TurnSummaryPort(ABC):
@abstractmethod
def generation(self) -> int: ...
@property
@abstractmethod
def on_summary(self) -> Callable[[TurnSummaryResult], None] | None: ...
@on_summary.setter
@abstractmethod
def on_summary(self, value: Callable[[TurnSummaryResult], None] | None) -> None: ...
@abstractmethod
def start_turn(self, user_message: str) -> None: ...

View file

@ -21,7 +21,7 @@ class TurnSummaryTracker(TurnSummaryPort):
self,
backend: BackendLike,
model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None],
on_summary: Callable[[TurnSummaryResult], None] | None = None,
max_tokens: int = 512,
) -> None:
self._backend = backend
@ -36,6 +36,14 @@ class TurnSummaryTracker(TurnSummaryPort):
def generation(self) -> int:
return self._generation
@property
def on_summary(self) -> Callable[[TurnSummaryResult], None] | None:
return self._on_summary
@on_summary.setter
def on_summary(self, value: Callable[[TurnSummaryResult], None] | None) -> None:
self._on_summary = value
def start_turn(self, user_message: str) -> None:
self._generation += 1
self._data = TurnSummaryData(user_message=user_message)
@ -103,7 +111,9 @@ class TurnSummaryTracker(TurnSummaryPort):
)
summary = result.message.content or ""
self._on_summary(TurnSummaryResult(generation=gen, summary=summary))
if self._on_summary is not None:
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))
if self._on_summary is not None:
self._on_summary(TurnSummaryResult(generation=gen, summary=None))

View file

@ -168,9 +168,9 @@ class AgentLoop:
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
)
self._mcp_registry = MCPRegistry()
self.mcp_registry = MCPRegistry()
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self._mcp_registry
lambda: self.config, mcp_registry=self.mcp_registry
)
self.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler()
@ -1029,7 +1029,8 @@ class AgentLoop:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=f"Tool '{tool_name}' is permanently disabled",
feedback=ctx.reason
or f"Tool '{tool_name}' is permanently disabled",
)
case _:
uncovered = [
@ -1267,7 +1268,7 @@ class AgentLoop:
self._max_price = max_price
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self._mcp_registry
lambda: self.config, mcp_registry=self.mcp_registry
)
self.skill_manager = SkillManager(lambda: self.config)

View file

@ -306,8 +306,13 @@ class Bash(
if not command_parts:
return None
def is_denylisted(command: str) -> bool:
return any(command.startswith(pattern) for pattern in self.config.denylist)
def _matches_pattern(command: str, pattern: str) -> bool:
return command == pattern or command.startswith(pattern + " ")
def find_denylist_match(command: str) -> str | None:
return next(
(p for p in self.config.denylist if _matches_pattern(command, p)), None
)
def is_standalone_denylisted(command: str) -> bool:
parts = command.split()
@ -323,7 +328,9 @@ class Bash(
return False
def is_allowlisted(command: str) -> bool:
return any(command.startswith(pattern) for pattern in self.config.allowlist)
return any(
_matches_pattern(command, pattern) for pattern in self.config.allowlist
)
def is_sensitive(command: str) -> bool:
tokens = command.split()
@ -332,8 +339,16 @@ class Bash(
return tokens[0] in self.config.sensitive_patterns
for part in command_parts:
if is_denylisted(part) or is_standalone_denylisted(part):
return PermissionContext(permission=ToolPermission.NEVER)
if matched := find_denylist_match(part):
return PermissionContext(
permission=ToolPermission.NEVER,
reason=f"Command denied: '{part}' matches denylist pattern '{matched}'. Do not attempt to run this command.",
)
if is_standalone_denylisted(part):
return PermissionContext(
permission=ToolPermission.NEVER,
reason=f"Command denied: '{part}' is not allowed as a standalone command. Do not attempt to run this command.",
)
if self.config.permission == ToolPermission.ALWAYS:
return PermissionContext(permission=ToolPermission.ALWAYS)

View file

@ -162,6 +162,10 @@ class MCPRegistry:
)
return tools
def count_loaded(self, servers: list[MCPServer]) -> int:
"""Return how many of *servers* were successfully discovered (cached)."""
return sum(self._server_key(srv) in self._cache for srv in servers)
def clear(self) -> None:
"""Drop all cached entries, forcing re-discovery on next use."""
self._cache.clear()

View file

@ -24,6 +24,7 @@ class RequiredPermission(BaseModel):
class PermissionContext(BaseModel):
permission: ToolPermission
required_permissions: list[RequiredPermission] = Field(default_factory=list)
reason: str | None = None
class ApprovedRule(BaseModel):