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>
227 lines
8.5 KiB
Python
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()
|