vibe/vibe/core/middleware.py
Mathias Gesbert 5103019b01
v2.5.0 (#495)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-03-16 17:51:47 +01:00

227 lines
8.5 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Protocol
from vibe.core.agents import AgentProfile
from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
from vibe.core.types import AgentStats, MessageList
class MiddlewareAction(StrEnum):
CONTINUE = auto()
STOP = auto()
COMPACT = auto()
INJECT_MESSAGE = auto()
class ResetReason(StrEnum):
STOP = auto()
COMPACT = auto()
@dataclass
class ConversationContext:
messages: MessageList
stats: AgentStats
config: VibeConfig
@dataclass
class MiddlewareResult:
action: MiddlewareAction = MiddlewareAction.CONTINUE
message: str | None = None
reason: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
class ConversationMiddleware(Protocol):
async def before_turn(self, context: ConversationContext) -> MiddlewareResult: ...
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: ...
class TurnLimitMiddleware:
def __init__(self, max_turns: int) -> None:
self.max_turns = max_turns
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if context.stats.steps - 1 >= self.max_turns:
return MiddlewareResult(
action=MiddlewareAction.STOP,
reason=f"Turn limit of {self.max_turns} reached",
)
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
class PriceLimitMiddleware:
def __init__(self, max_price: float) -> None:
self.max_price = max_price
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if context.stats.session_cost > self.max_price:
return MiddlewareResult(
action=MiddlewareAction.STOP,
reason=f"Price limit exceeded: ${context.stats.session_cost:.4f} > ${self.max_price:.2f}",
)
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
class AutoCompactMiddleware:
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
threshold = context.config.get_active_model().auto_compact_threshold
if threshold > 0 and context.stats.context_tokens >= threshold:
return MiddlewareResult(
action=MiddlewareAction.COMPACT,
metadata={
"old_tokens": context.stats.context_tokens,
"threshold": threshold,
},
)
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
class ContextWarningMiddleware:
def __init__(self, threshold_percent: float = 0.5) -> None:
self.threshold_percent = threshold_percent
self.has_warned = False
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if self.has_warned:
return MiddlewareResult()
max_context = context.config.get_active_model().auto_compact_threshold
if max_context <= 0:
return MiddlewareResult()
if context.stats.context_tokens >= max_context * self.threshold_percent:
self.has_warned = True
percentage_used = (context.stats.context_tokens / max_context) * 100
warning_msg = f"<{VIBE_WARNING_TAG}>You have used {percentage_used:.0f}% of your total context ({context.stats.context_tokens:,}/{max_context:,} tokens)</{VIBE_WARNING_TAG}>"
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=warning_msg
)
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
self.has_warned = False
def make_plan_agent_reminder(plan_file_path: str) -> str:
return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
## Plan File Info
Create or edit your plan at {plan_file_path} using the write_file and search_replace tools.
Build your plan incrementally by writing to or editing this file.
This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan.
## Instructions
1. Research the user's query using read-only tools (grep, read_file, etc.)
2. If you are unsure about requirements or approach, use the ask_user_question tool to clarify before finalizing your plan
3. Write your plan to the plan file above
4. When your plan is complete, call the exit_plan_mode tool to request user approval and switch to implementation mode</{VIBE_WARNING_TAG}>"""
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
1. Answer the user's questions directly and comprehensively
2. Explain code, concepts, or architecture as requested
3. Use read-only tools (grep, read_file) to look up relevant code when needed
4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action</{VIBE_WARNING_TAG}>"""
CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
class ReadOnlyAgentMiddleware:
def __init__(
self,
profile_getter: Callable[[], AgentProfile],
agent_name: str,
reminder: str | Callable[[], str],
exit_message: str,
) -> None:
self._profile_getter = profile_getter
self._agent_name = agent_name
self._reminder = reminder
self.exit_message = exit_message
self._was_active = False
@property
def reminder(self) -> str:
return self._reminder() if callable(self._reminder) else self._reminder
def _is_active(self) -> bool:
return self._profile_getter().name == self._agent_name
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
is_active = self._is_active()
was_active = self._was_active
if was_active and not is_active:
self._was_active = False
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
)
if is_active and not was_active:
self._was_active = True
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
self._was_active = is_active
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
self._was_active = False
class MiddlewarePipeline:
def __init__(self) -> None:
self.middlewares: list[ConversationMiddleware] = []
def add(self, middleware: ConversationMiddleware) -> MiddlewarePipeline:
self.middlewares.append(middleware)
return self
def clear(self) -> None:
self.middlewares.clear()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
for mw in self.middlewares:
mw.reset(reset_reason)
async def run_before_turn(self, context: ConversationContext) -> MiddlewareResult:
messages_to_inject = []
for mw in self.middlewares:
result = await mw.before_turn(context)
if result.action == MiddlewareAction.INJECT_MESSAGE and result.message:
messages_to_inject.append(result.message)
elif result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
return result
if messages_to_inject:
combined_message = "\n\n".join(messages_to_inject)
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=combined_message
)
return MiddlewareResult()