Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-03 15:56:50 +02:00 committed by GitHub
parent 9c1c32e058
commit 90763daf81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 6046 additions and 694 deletions

View file

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

View file

@ -100,6 +100,7 @@ from vibe.core.config import (
VibeConfig,
load_dotenv_values,
)
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.proxy_setup import (
ProxySetupError,
parse_proxy_command,
@ -476,6 +477,11 @@ class VibeAcpAgentLoop(AcpAgent):
description="Install the Lean 4 agent (leanstral)",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
AvailableCommand(
name="data-retention",
description="Show data retention information",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
AvailableCommand(
name="unleanstall",
description="Uninstall the Lean 4 agent",
@ -545,6 +551,19 @@ class VibeAcpAgentLoop(AcpAgent):
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_data_retention_command(
self, session_id: str, message_id: str
) -> PromptResponse:
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=DATA_RETENTION_MESSAGE),
message_id=str(uuid4()),
),
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_unleanstall_command(
self, session_id: str, message_id: str
) -> PromptResponse:
@ -772,6 +791,11 @@ class VibeAcpAgentLoop(AcpAgent):
if text_prompt.strip().lower().startswith("/leanstall"):
return await self._handle_leanstall_command(session_id, resolved_message_id)
if text_prompt.strip().lower().startswith("/data-retention"):
return await self._handle_data_retention_command(
session_id, resolved_message_id
)
async def agent_loop_task() -> None:
async for update in self._run_agent_loop(
session, text_prompt, resolved_message_id

View file

@ -9,7 +9,7 @@ import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
MissingAPIKeyError,
@ -176,12 +176,13 @@ def run_cli(args: argparse.Namespace) -> None:
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt,
prompt=programmatic_prompt or "",
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
teleport=args.teleport and config.nuage_enabled,
)
if final_response:
print(final_response)
@ -189,6 +190,9 @@ def run_cli(args: argparse.Namespace) -> None:
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View file

@ -105,6 +105,11 @@ class CommandRegistry:
description="Rewind to a previous message",
handler="_start_rewind_mode",
),
"data-retention": Command(
aliases=frozenset(["/data-retention"]),
description="Show data retention information",
handler="_show_data_retention",
),
}
for command in excluded_commands:

View file

@ -11,6 +11,7 @@ import subprocess
import time
from typing import Any, ClassVar, assert_never, cast
from weakref import WeakKeyDictionary
import webbrowser
from pydantic import BaseModel
from rich import print as rprint
@ -46,6 +47,7 @@ from vibe.cli.textual_ui.notifications import (
NotificationPort,
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
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
@ -109,20 +111,28 @@ from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
from vibe.core.session.resume_sessions import (
ResumeSessionInfo,
list_local_resume_sessions,
list_remote_resume_sessions,
short_session_id,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
)
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
@ -136,9 +146,11 @@ from vibe.core.types import (
AgentStats,
ApprovalResponse,
Backend,
BaseEvent,
LLMMessage,
RateLimitError,
Role,
WaitingForInputEvent,
)
from vibe.core.utils import (
CancellationReason,
@ -302,6 +314,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
self._remote_manager = RemoteSessionManager()
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
@ -398,6 +411,7 @@ class VibeApp(App): # noqa: PLR0904
mount_callback=self._mount_and_scroll,
get_tools_collapsed=lambda: self._tools_collapsed,
on_profile_changed=self._on_profile_changed,
is_remote=self._remote_manager.is_active,
)
self._chat_input_container = self.query_one(ChatInputContainer)
@ -512,11 +526,21 @@ class VibeApp(App): # noqa: PLR0904
await self._remove_loading_widget()
async def on_question_app_answered(self, message: QuestionApp.Answered) -> None:
if self._remote_manager.has_pending_input and self._remote_manager.is_active:
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
await self._handle_remote_answer(result)
return
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
self._pending_question.set_result(result)
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
if self._remote_manager.has_pending_input:
self._remote_manager.cancel_pending_input()
await self._switch_to_input_app()
return
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
@ -554,6 +578,21 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_to_input_app()
await self._switch_to_model_picker_app()
async def _ensure_loading_widget(self, status: str = "Generating") -> None:
if self._loading_widget and self._loading_widget.parent:
self._loading_widget.set_status(status)
return
loading_area = self._cached_loading_area
if loading_area is None:
try:
loading_area = self.query_one("#loading-area-content")
except Exception:
return
loading = LoadingWidget(status=status)
self._loading_widget = loading
await loading_area.mount(loading)
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
) -> None:
@ -755,6 +794,10 @@ class VibeApp(App): # noqa: PLR0904
)
async def _handle_user_message(self, message: str) -> None:
if self._remote_manager.is_active:
await self._handle_remote_user_message(message)
return
# message_index is where the user message will land in agent_loop.messages
# (checkpoint is created in agent_loop.act())
message_index = len(self.agent_loop.messages)
@ -765,10 +808,46 @@ class VibeApp(App): # noqa: PLR0904
self._feedback_bar.maybe_show()
if not self._agent_running:
await self._remote_manager.stop_stream()
await self._remove_loading_widget()
self._agent_task = asyncio.create_task(
self._handle_agent_loop_turn(message)
)
async def _handle_remote_user_message(self, message: str) -> None:
warning = self._remote_manager.validate_input()
if warning:
await self._mount_and_scroll(WarningMessage(warning))
return
try:
await self._remote_manager.send_prompt(message)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to send message: {e}", collapsed=self._tools_collapsed
)
)
return
await self._ensure_loading_widget()
async def _handle_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
self._remote_manager.set_pending_input(event)
if question_args := self._remote_manager.build_question_args(event):
await self._switch_to_question_app(question_args)
return
await self._switch_to_input_app()
async def _handle_remote_answer(self, result: AskUserQuestionResult) -> None:
if result.cancelled or not result.answers:
self._remote_manager.cancel_pending_input()
await self._switch_to_input_app()
return
await self._remote_manager.send_prompt(
result.answers[0].answer, require_source=False
)
await self._switch_to_input_app()
await self._ensure_loading_widget()
def _reset_ui_state(self) -> None:
self._windowing.reset()
self._tool_call_map = None
@ -881,13 +960,8 @@ class VibeApp(App): # noqa: PLR0904
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
loading_area = self._cached_loading_area or self.query_one(
"#loading-area-content"
)
loading = LoadingWidget()
self._loading_widget = loading
await loading_area.mount(loading)
await self._remove_loading_widget()
await self._ensure_loading_widget()
try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
@ -895,6 +969,12 @@ class VibeApp(App): # noqa: PLR0904
self._narrator_manager.on_turn_start(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif self._loading_widget is None and is_progress_event(event):
await self._ensure_loading_widget()
if self.event_handler:
await self.event_handler.handle_event(
event,
@ -972,32 +1052,47 @@ class VibeApp(App): # noqa: PLR0904
teleport_msg = TeleportMessage()
await self._mount_and_scroll(teleport_msg)
if self._remote_manager.is_active:
await loading.remove()
await self._mount_and_scroll(
ErrorMessage(
"Teleport is not available for remote sessions.",
collapsed=self._tools_collapsed,
)
)
return
try:
gen = self.agent_loop.teleport_to_vibe_nuage(prompt)
async for event in gen:
match event:
case TeleportCheckingGitEvent():
teleport_msg.set_status("Checking git status...")
case TeleportPushRequiredEvent(unpushed_count=count):
teleport_msg.set_status("Preparing workspace...")
case TeleportPushRequiredEvent(
unpushed_count=count, branch_not_pushed=branch_not_pushed
):
await loading.remove()
response = await self._ask_push_approval(count)
response = await self._ask_push_approval(
count, branch_not_pushed
)
await loading_area.mount(loading)
teleport_msg.set_status("Teleporting...")
await gen.asend(response)
next_event = await gen.asend(response)
if isinstance(next_event, TeleportPushingEvent):
teleport_msg.set_status("Syncing with remote...")
case TeleportPushingEvent():
teleport_msg.set_status("Pushing to remote...")
case TeleportAuthRequiredEvent(
user_code=code, verification_uri=uri
):
teleport_msg.set_status(
f"GitHub auth required. Code: {code} (copied)\nOpen: {uri}"
)
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authenticated.")
teleport_msg.set_status("Syncing with remote...")
case TeleportStartingWorkflowEvent():
teleport_msg.set_status("Starting Nuage workflow...")
case TeleportSendingGithubTokenEvent():
teleport_msg.set_status("Sending encrypted GitHub token...")
teleport_msg.set_status("Teleporting...")
case TeleportWaitingForGitHubEvent():
teleport_msg.set_status("Connecting to GitHub...")
case TeleportAuthRequiredEvent(oauth_url=url):
webbrowser.open(url)
teleport_msg.set_status("Authorizing GitHub...")
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authorized")
case TeleportFetchingUrlEvent():
teleport_msg.set_status("Finalizing...")
case TeleportCompleteEvent(url=url):
teleport_msg.set_complete(url)
except TeleportError as e:
@ -1009,14 +1104,20 @@ class VibeApp(App): # noqa: PLR0904
if loading.parent:
await loading.remove()
async def _ask_push_approval(self, count: int) -> TeleportPushResponseEvent:
word = f"commit{'s' if count != 1 else ''}"
async def _ask_push_approval(
self, count: int, branch_not_pushed: bool
) -> TeleportPushResponseEvent:
if branch_not_pushed:
question = "Your branch doesn't exist on remote. Push to continue?"
else:
word = f"commit{'s' if count != 1 else ''}"
question = f"You have {count} unpushed {word}. Push to continue?"
push_label = "Push and continue"
result = await self._user_input_callback(
AskUserQuestionArgs(
questions=[
Question(
question=f"You have {count} unpushed {word}. Push to continue?",
question=question,
header="Push",
options=[Choice(label=push_label), Choice(label="Cancel")],
hide_other=True,
@ -1105,20 +1206,41 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_proxy_setup_app()
async def _show_data_retention(self) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
async def _show_session_picker(self) -> None:
session_config = self.config.session_logging
if not session_config.enabled:
await self._mount_and_scroll(
ErrorMessage(
"Session logging is disabled in configuration.",
collapsed=self._tools_collapsed,
)
)
return
cwd = str(Path.cwd())
raw_sessions = SessionLoader.list_sessions(session_config, cwd=cwd)
local_sessions = (
list_local_resume_sessions(self.config, cwd)
if self.config.session_logging.enabled
else []
)
remote_list_timeout = max(float(self.config.api_timeout), 10.0)
remote_error: str | None = None
await self._ensure_loading_widget("Loading sessions")
try:
remote_sessions = await asyncio.wait_for(
list_remote_resume_sessions(self.config), timeout=remote_list_timeout
)
except TimeoutError:
remote_sessions = []
remote_error = (
"Timed out while listing remote sessions "
f"after {remote_list_timeout:.0f}s."
)
except Exception as e:
remote_sessions = []
remote_error = f"Failed to list remote sessions: {e}"
finally:
await self._remove_loading_widget()
if remote_error is not None:
await self._mount_and_scroll(
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
)
raw_sessions = [*local_sessions, *remote_sessions]
if not raw_sessions:
await self._mount_and_scroll(
@ -1126,16 +1248,20 @@ class VibeApp(App): # noqa: PLR0904
)
return
sessions = sorted(
raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
)
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
latest_messages = {
s["session_id"]: SessionLoader.get_first_user_message(
s["session_id"], session_config
s.option_id: SessionLoader.get_first_user_message(
s.session_id, self.config.session_logging
)
for s in sessions
if s.source == "local"
}
for session in sessions:
if session.source == "remote":
latest_messages[session.option_id] = (
f"{session.title or 'Remote workflow'} ({(session.status or 'RUNNING').lower()})"
)
picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
await self._switch_from_input(picker)
@ -1144,53 +1270,21 @@ class VibeApp(App): # noqa: PLR0904
self, event: SessionPickerApp.SessionSelected
) -> None:
await self._switch_to_input_app()
session_config = self.config.session_logging
session_path = SessionLoader.find_session_by_id(
event.session_id, session_config
session = ResumeSessionInfo(
session_id=event.session_id,
source=event.source,
cwd="",
title=None,
end_time=None,
)
if not session_path:
await self._mount_and_scroll(
ErrorMessage(
f"Session `{event.session_id[:8]}` not found.",
collapsed=self._tools_collapsed,
)
)
return
try:
loaded_messages, _ = SessionLoader.load_session(session_path)
current_system_messages = [
msg for msg in self.agent_loop.messages if msg.role == Role.system
]
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
]
self.agent_loop.session_id = event.session_id
self.agent_loop.session_logger.resume_existing_session(
event.session_id, session_path
)
self.agent_loop.messages.reset(
current_system_messages + non_system_messages
)
self._reset_ui_state()
await self._load_more.hide()
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
await self._resume_history_from_messages()
await self._mount_and_scroll(
UserCommandMessage(f"Resumed session `{event.session_id[:8]}`")
)
except ValueError as e:
if event.source == "local":
await self._resume_local_session(session)
elif event.source == "remote":
await self._resume_remote_session(session)
else:
raise ValueError(f"Unknown session source: {event.source}")
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to load session: {e}", collapsed=self._tools_collapsed
@ -1204,6 +1298,113 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
async def _resume_local_session(self, session: ResumeSessionInfo) -> None:
await self._remote_manager.detach()
session_config = self.config.session_logging
session_path = SessionLoader.find_session_by_id(
session.session_id, session_config
)
if not session_path:
raise ValueError(
f"Session `{short_session_id(session.session_id)}` not found."
)
loaded_messages, _ = SessionLoader.load_session(session_path)
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
current_system_messages = [
msg for msg in self.agent_loop.messages if msg.role == Role.system
]
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
]
self.agent_loop.session_id = session.session_id
self.agent_loop.session_logger.resume_existing_session(
session.session_id, session_path
)
self.agent_loop.messages.reset(current_system_messages + non_system_messages)
self._refresh_profile_widgets()
self._reset_ui_state()
await self._load_more.hide()
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
if self.event_handler:
self.event_handler.is_remote = False
await self._resume_history_from_messages()
await self._mount_and_scroll(
UserCommandMessage(
f"Resumed session `{short_session_id(session.session_id)}`"
)
)
async def _resume_remote_session(self, session: ResumeSessionInfo) -> None:
await self._remote_manager.attach(
session_id=session.session_id, config=self.config
)
self._refresh_profile_widgets()
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
self._reset_ui_state()
await self._load_more.hide()
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
if self.event_handler:
self.event_handler.is_remote = True
self._remote_manager.start_stream(self)
async def on_remote_event(
self, event: BaseEvent, loading_active: bool, loading_widget: Any
) -> None:
if self.event_handler:
await self.event_handler.handle_event(
event, loading_active=loading_active, loading_widget=loading_widget
)
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
await self._handle_remote_waiting_input(event)
async def on_remote_user_message_cleared_input(self) -> None:
await self._switch_to_input_app()
async def on_remote_stream_error(self, error: str) -> None:
await self._mount_and_scroll(
ErrorMessage(error, collapsed=self._tools_collapsed)
)
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None:
if msg_type == "error":
widget = ErrorMessage(text, collapsed=self._tools_collapsed)
elif msg_type == "warning":
widget = WarningMessage(text)
else:
widget = UserCommandMessage(text)
await self._mount_and_scroll(widget)
if self._chat_input_container:
self._chat_input_container.set_custom_border("Remote session ended")
async def on_remote_finalize_streaming(self) -> None:
if self.event_handler:
await self.event_handler.finalize_streaming()
async def remove_loading(self) -> None:
await self._remove_loading_widget()
async def ensure_loading(self, status: str = "Generating") -> None:
await self._ensure_loading_widget(status)
@property
def loading_widget(self) -> LoadingWidget | None:
return self._loading_widget
async def _reload_config(self) -> None:
try:
self._reset_ui_state()
@ -1254,6 +1455,13 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self) -> None:
try:
self._reset_ui_state()
if self._remote_manager.is_active:
await self._remote_manager.detach()
self._refresh_profile_widgets()
if self.event_handler:
self.event_handler.is_remote = False
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
await self.agent_loop.clear_history()
if self.event_handler:
await self.event_handler.finalize_streaming()
@ -1348,6 +1556,8 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler.current_compact = None
def _get_session_resume_info(self) -> str | None:
if self._remote_manager.is_active:
return None
if not self.agent_loop.session_logger.enabled:
return None
if not self.agent_loop.session_logger.session_id:
@ -1358,7 +1568,7 @@ class VibeApp(App): # noqa: PLR0904
)
if session_path is None:
return None
return self.agent_loop.session_logger.session_id[:8]
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self) -> None:
await self._narrator_manager.close()
@ -1918,6 +2128,14 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container:
self._chat_input_container.set_safety(profile.safety)
self._chat_input_container.set_agent_name(profile.display_name.lower())
if self._remote_manager.is_active:
session_id = self._remote_manager.session_id
self._chat_input_container.set_custom_border(
f"Remote session {short_session_id(session_id, source='remote') if session_id else ''}",
ChatInputContainer.REMOTE_BORDER_CLASS,
)
else:
self._chat_input_container.set_custom_border(None)
async def _cycle_agent(self) -> None:
new_profile = self.agent_loop.agent_manager.next_agent(
@ -1965,6 +2183,7 @@ class VibeApp(App): # noqa: PLR0904
def action_force_quit(self) -> None:
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
self._remote_manager.cancel_stream_task()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())

View file

@ -118,6 +118,11 @@ TextArea > .text-area--cursor {
border: solid $mistral_orange;
border-title-color: $mistral_orange;
}
&.border-remote {
border: solid $mistral_orange;
border-title-color: $mistral_orange;
}
}
#input-body {

View file

@ -4,7 +4,11 @@ from collections.abc import Callable
from typing import TYPE_CHECKING
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
ReasoningMessage,
UserMessage,
)
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
@ -19,6 +23,7 @@ from vibe.core.types import (
ToolResultEvent,
ToolStreamEvent,
UserMessageEvent,
WaitingForInputEvent,
)
from vibe.core.utils import TaggedText
@ -32,10 +37,12 @@ class EventHandler:
mount_callback: Callable,
get_tools_collapsed: Callable[[], bool],
on_profile_changed: Callable[[], None] | None = None,
is_remote: bool = False,
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.on_profile_changed = on_profile_changed
self.is_remote = is_remote
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
@ -72,6 +79,10 @@ class EventHandler:
self.on_profile_changed()
case UserMessageEvent():
await self.finalize_streaming()
if self.is_remote:
await self.mount_callback(UserMessage(event.content))
case WaitingForInputEvent():
await self.finalize_streaming()
case _:
await self.finalize_streaming()
await self._handle_unknown_event(event)

View file

@ -0,0 +1,8 @@
from __future__ import annotations
from vibe.cli.textual_ui.remote.remote_session_manager import (
RemoteSessionManager,
is_progress_event,
)
__all__ = ["RemoteSessionManager", "is_progress_event"]

View file

@ -0,0 +1,216 @@
from __future__ import annotations
import asyncio
from typing import Any, Protocol
from vibe.core.config import VibeConfig
from vibe.core.nuage.remote_events_source import RemoteEventsSource
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
from vibe.core.types import (
AssistantEvent,
BaseEvent,
ReasoningEvent,
ToolCallEvent,
ToolStreamEvent,
UserMessageEvent,
WaitingForInputEvent,
)
_MIN_QUESTION_OPTIONS = 2
_MAX_QUESTION_OPTIONS = 4
class RemoteSessionUI(Protocol):
async def on_remote_event(
self, event: BaseEvent, loading_active: bool, loading_widget: Any
) -> None: ...
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None: ...
async def on_remote_user_message_cleared_input(self) -> None: ...
async def on_remote_stream_error(self, error: str) -> None: ...
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None: ...
async def on_remote_finalize_streaming(self) -> None: ...
async def remove_loading(self) -> None: ...
async def ensure_loading(self, status: str = "Generating") -> None: ...
@property
def loading_widget(self) -> Any: ...
def is_progress_event(event: object) -> bool:
return isinstance(
event, (AssistantEvent, ReasoningEvent, ToolCallEvent, ToolStreamEvent)
)
class RemoteSessionManager:
def __init__(self) -> None:
self._events_source: RemoteEventsSource | None = None
self._stream_task: asyncio.Task | None = None
self._pending_waiting_input: WaitingForInputEvent | None = None
@property
def is_active(self) -> bool:
return self._events_source is not None
@property
def is_terminated(self) -> bool:
if self._events_source is None:
return False
return self._events_source.is_terminated
@property
def is_waiting_for_input(self) -> bool:
if self._events_source is None:
return False
return self._events_source.is_waiting_for_input
@property
def has_pending_input(self) -> bool:
return self._pending_waiting_input is not None
@property
def session_id(self) -> str | None:
if self._events_source is None:
return None
return self._events_source.session_id
async def attach(self, session_id: str, config: VibeConfig) -> None:
await self.detach()
self._events_source = RemoteEventsSource(session_id=session_id, config=config)
async def detach(self) -> None:
await self._stop_stream()
if self._events_source is not None:
await self._events_source.close()
self._events_source = None
self._pending_waiting_input = None
def validate_input(self) -> str | None:
if self.is_terminated:
return (
"Remote session has ended. Use /clear to start a new session"
" or /resume to attach to another."
)
if not self.is_waiting_for_input:
return (
"Remote session is not waiting for input. Please wait for the"
" current task to complete."
)
return None
async def send_prompt(self, message: str, *, require_source: bool = True) -> None:
if self._events_source is None:
if require_source:
raise RuntimeError("No active remote session")
return
saved_pending = self._pending_waiting_input
self._pending_waiting_input = None
try:
await self._events_source.send_prompt(message)
except Exception:
self._pending_waiting_input = saved_pending
raise
def cancel_pending_input(self) -> None:
self._pending_waiting_input = None
def build_question_args(
self, event: WaitingForInputEvent
) -> AskUserQuestionArgs | None:
if (
not event.predefined_answers
or len(event.predefined_answers) < _MIN_QUESTION_OPTIONS
):
return None
question = event.label or "Choose an answer"
return AskUserQuestionArgs(
questions=[
Question(
question=question,
options=[
Choice(label=answer)
for answer in event.predefined_answers[:_MAX_QUESTION_OPTIONS]
],
)
]
)
def set_pending_input(self, event: WaitingForInputEvent) -> None:
self._pending_waiting_input = event
def start_stream(self, ui: RemoteSessionUI) -> None:
if self._events_source is None:
return
if self._stream_task and not self._stream_task.done():
return
self._stream_task = asyncio.create_task(
self._consume_stream(ui), name="remote-session-stream"
)
async def stop_stream(self) -> None:
await self._stop_stream()
def build_terminal_message(self) -> tuple[str, str]:
if self._events_source is None:
return ("info", "Remote session completed")
if self._events_source.is_failed:
return ("error", "Remote session failed")
if self._events_source.is_canceled:
return ("warning", "Remote session was canceled")
return ("info", "Remote session completed")
def cancel_stream_task(self) -> None:
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
async def _stop_stream(self) -> None:
if self._stream_task is None or self._stream_task.done():
self._stream_task = None
return
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
async def _consume_stream(self, ui: RemoteSessionUI) -> None:
events_source = self._events_source
if events_source is None:
return
await ui.ensure_loading("Generating")
try:
async for event in events_source.attach():
if isinstance(event, WaitingForInputEvent):
await ui.remove_loading()
self._pending_waiting_input = event
await ui.on_remote_waiting_input(event)
elif (
isinstance(event, UserMessageEvent)
and self._pending_waiting_input is not None
):
self._pending_waiting_input = None
await ui.on_remote_user_message_cleared_input()
elif ui.loading_widget is None and is_progress_event(event):
await ui.ensure_loading()
await ui.on_remote_event(
event,
loading_active=ui.loading_widget is not None,
loading_widget=ui.loading_widget,
)
except asyncio.CancelledError:
raise
except Exception as e:
await ui.on_remote_stream_error(f"Remote stream stopped: {e}")
finally:
await ui.on_remote_finalize_streaming()
await ui.remove_loading()
self._stream_task = None
self._pending_waiting_input = None
if events_source.is_terminated:
msg_type, text = self.build_terminal_message()
await ui.on_remote_stream_ended(msg_type, text)

View file

@ -30,6 +30,7 @@ SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box"
REMOTE_BORDER_CLASS = "border-remote"
class Submitted(Message):
def __init__(self, value: str) -> None:
@ -59,6 +60,8 @@ class ChatInputContainer(Vertical):
)
self._nuage_enabled = nuage_enabled
self._voice_manager = voice_manager
self._custom_border_label: str | None = None
self._custom_border_class: str | None = None
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@ -86,9 +89,9 @@ class ChatInputContainer(Vertical):
self._completion_popup = CompletionPopup()
yield self._completion_popup
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
border_class = self._get_border_class()
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
input_box.border_title = self._agent_name
input_box.border_title = self._get_border_title()
self._body = ChatInputBody(
history_file=self._history_file,
id="input-body",
@ -195,23 +198,43 @@ class ChatInputContainer(Vertical):
def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety
self._apply_input_box_chrome()
def set_agent_name(self, name: str) -> None:
self._agent_name = name
self._apply_input_box_chrome()
def set_custom_border(
self, label: str | None, border_class: str | None = None
) -> None:
self._custom_border_label = label
self._custom_border_class = border_class
self._apply_input_box_chrome()
def _get_border_class(self) -> str:
if self._custom_border_class is not None:
return self._custom_border_class
if self._custom_border_label is not None:
return ""
return SAFETY_BORDER_CLASSES.get(self._safety, "")
def _get_border_title(self) -> str:
if self._custom_border_label is not None:
return self._custom_border_label
return self._agent_name
def _apply_input_box_chrome(self) -> None:
try:
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
except Exception:
return
input_box.remove_class(self.REMOTE_BORDER_CLASS)
for border_class in SAFETY_BORDER_CLASSES.values():
input_box.remove_class(border_class)
if safety in SAFETY_BORDER_CLASSES:
input_box.add_class(SAFETY_BORDER_CLASSES[safety])
border_class = self._get_border_class()
if border_class:
input_box.add_class(border_class)
def set_agent_name(self, name: str) -> None:
self._agent_name = name
try:
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
input_box.border_title = name
except Exception:
pass
input_box.border_title = self._get_border_title()

View file

@ -78,6 +78,7 @@ class LoadingWidget(SpinnerMixin, Static):
self.current_color_index = 0
self._color_direction = 1
self.transition_progress = 0
self._indicator_widget: Static | None = None
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
self.start_time: float | None = None

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar
from typing import Any, ClassVar, cast
from rich.text import Text
from textual.app import ComposeResult
@ -12,9 +12,11 @@ from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.session.session_loader import SessionInfo
from vibe.core.session.resume_sessions import (
ResumeSessionInfo,
ResumeSessionSource,
short_session_id,
)
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 3600
@ -46,12 +48,15 @@ def _format_relative_time(iso_time: str | None) -> str:
return "unknown"
def _build_option_text(session: SessionInfo, message: str) -> Text:
def _build_option_text(session: ResumeSessionInfo, message: str) -> Text:
text = Text(no_wrap=True)
time_str = _format_relative_time(session.get("end_time"))
session_id = session["session_id"][:8]
time_str = _format_relative_time(session.end_time)
session_id = short_session_id(session.session_id, source=session.source)
source = session.source
text.append(f"{time_str:10}", style="dim")
text.append(" ")
text.append(f"{source:6}", style="cyan")
text.append(" ")
text.append(f"{session_id} ", style="dim")
text.append(message)
return text
@ -67,7 +72,15 @@ class SessionPickerApp(Container):
]
class SessionSelected(Message):
def __init__(self, session_id: str) -> None:
option_id: str
source: ResumeSessionSource
session_id: str
def __init__(
self, option_id: str, source: ResumeSessionSource, session_id: str
) -> None:
self.option_id = option_id
self.source = source
self.session_id = session_id
super().__init__()
@ -76,7 +89,7 @@ class SessionPickerApp(Container):
def __init__(
self,
sessions: list[SessionInfo],
sessions: list[ResumeSessionInfo],
latest_messages: dict[str, str],
**kwargs: Any,
) -> None:
@ -89,9 +102,9 @@ class SessionPickerApp(Container):
Option(
_build_option_text(
session,
self._latest_messages.get(session["session_id"], "(empty session)"),
self._latest_messages.get(session.option_id, "(empty session)"),
),
id=session["session_id"],
id=session.option_id,
)
for session in self._sessions
]
@ -106,7 +119,15 @@ class SessionPickerApp(Container):
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option.id:
self.post_message(self.SessionSelected(event.option.id))
option_id = event.option.id
source, _, session_id = option_id.partition(":")
self.post_message(
self.SessionSelected(
option_id=option_id,
source=cast(ResumeSessionSource, source),
session_id=session_id,
)
)
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -76,9 +76,7 @@ class ToolCallMessage(StatusMessage):
self._stream_widget.display = True
def stop_spinning(self, success: bool = True) -> None:
"""Stop the spinner and hide the stream widget."""
if self._stream_widget:
self._stream_widget.display = False
"""Stop the spinner while keeping stream row stable to avoid layout jumps."""
super().stop_spinning(success)
def set_result_text(self, text: str) -> None:

View file

@ -161,18 +161,19 @@ class AgentLoop:
entrypoint_metadata: EntrypointMetadata | None = None,
) -> None:
self._base_config = config
self._max_turns = max_turns
self._max_price = max_price
self._plan_session = PlanSession()
self.mcp_registry = MCPRegistry()
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
)
self.mcp_registry = MCPRegistry()
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self.mcp_registry
)
self.skill_manager = SkillManager(lambda: self.config)
self.message_observer = message_observer
self._max_turns = max_turns
self._max_price = max_price
self._plan_session = PlanSession()
self.format_handler = APIToolFormatHandler()
self.backend_factory = lambda: backend or self._select_backend()
@ -181,7 +182,6 @@ class AgentLoop:
backend_getter=lambda: self.backend, config_getter=lambda: self.config
)
self.message_observer = message_observer
self.enable_streaming = enable_streaming
self.middleware_pipeline = MiddlewarePipeline()
self._setup_middleware()
@ -193,6 +193,11 @@ class AgentLoop:
self.messages = MessageList(initial=[system_message], observer=message_observer)
self.stats = AgentStats()
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
self.entrypoint_metadata = entrypoint_metadata
self.session_id = str(uuid4())
try:
active_model = config.get_active_model()
self.stats.input_price_per_million = active_model.input_price
@ -200,11 +205,6 @@ class AgentLoop:
except ValueError:
pass
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
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
@ -245,6 +245,16 @@ class AgentLoop:
def auto_approve(self) -> bool:
return self.config.auto_approve
def refresh_config(self) -> None:
self._base_config = VibeConfig.load()
self.agent_manager.invalidate_config()
def set_approval_callback(self, callback: ApprovalCallback) -> None:
self.approval_callback = callback
def set_user_input_callback(self, callback: UserInputCallback) -> None:
self.user_input_callback = callback
def set_tool_permission(
self, tool_name: str, permission: ToolPermission, save_permanently: bool = False
) -> None:
@ -291,10 +301,6 @@ class AgentLoop:
tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently
)
def refresh_config(self) -> None:
self._base_config = VibeConfig.load()
self.agent_manager.invalidate_config()
def emit_new_session_telemetry(self) -> None:
entrypoint = (
self.entrypoint_metadata.agent_entrypoint
@ -346,7 +352,7 @@ class AgentLoop:
async def act(
self, msg: str, client_message_id: str | None = None
) -> AsyncGenerator[BaseEvent]:
) -> AsyncGenerator[BaseEvent, None]:
self._clean_message_history()
self.rewind_manager.create_checkpoint()
try:
@ -376,6 +382,7 @@ class AgentLoop:
nuage_workflow_id=self.config.nuage_workflow_id,
nuage_api_key=self.config.nuage_api_key,
nuage_task_queue=self.config.nuage_task_queue,
vibe_config=self._base_config,
)
return self._teleport_service
@ -1130,12 +1137,6 @@ class AgentLoop:
self.session_id = str(uuid4())
self.session_logger.reset_session(self.session_id)
def set_approval_callback(self, callback: ApprovalCallback) -> None:
self.approval_callback = callback
def set_user_input_callback(self, callback: UserInputCallback) -> None:
self.user_input_callback = callback
async def clear_history(self) -> None:
await self.session_logger.save_interaction(
self.messages,

View file

@ -396,13 +396,12 @@ class VibeConfig(BaseSettings):
api_timeout: float = 720.0
auto_compact_threshold: int = 200_000
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available
nuage_enabled: bool = Field(default=False, exclude=True)
nuage_base_url: str = Field(default="https://api.globalaegis.net", exclude=True)
nuage_base_url: str = Field(default="https://api.mistral.ai", exclude=True)
nuage_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True)
nuage_task_queue: str | None = Field(default="shared-vibe-nuage", exclude=True)
# 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)
nuage_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True)
nuage_project_name: str = Field(default="Vibe", exclude=True)
# TODO(otel): remove exclude=True once the feature is publicly available
enable_otel: bool = Field(default=False, exclude=True)

View file

@ -0,0 +1,7 @@
from __future__ import annotations
DATA_RETENTION_MESSAGE = """## Your Data Helps Improve Mistral AI
At Mistral AI, we're committed to delivering the best possible experience. When you use Mistral models on our API, your interactions may be collected to improve our models, ensuring they stay cutting-edge, accurate, and helpful.
Manage your data settings [here](https://admin.mistral.ai/plateforme/privacy)"""

View file

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict
_SUBMIT_INPUT_UPDATE_NAME = "__submit_input"
class AgentCompletionState(BaseModel):
content: str = ""
reasoning_content: str = ""
class InterruptSignal(BaseModel):
prompt: str
class ChatInputModel(BaseModel):
model_config = ConfigDict(title="ChatInput", extra="forbid")
message: list[Any]
class SubmitInputModel(BaseModel):
task_id: str
input: Any

195
vibe/core/nuage/client.py Normal file
View file

@ -0,0 +1,195 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import json
from typing import Any
import httpx
from pydantic import BaseModel
from vibe.core.logger import logger
from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException
from vibe.core.nuage.streaming import StreamEvent, StreamEventsQueryParams
from vibe.core.nuage.workflow import (
SignalWorkflowResponse,
UpdateWorkflowResponse,
WorkflowExecutionListResponse,
)
class WorkflowsClient:
def __init__(
self, base_url: str, api_key: str | None = None, timeout: float = 60.0
) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._timeout = timeout
self._client: httpx.AsyncClient | None = None
self._owns_client = True
async def __aenter__(self) -> WorkflowsClient:
headers: dict[str, str] = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(timeout=self._timeout, headers=headers)
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
if self._owns_client and self._client:
await self._client.aclose()
self._client = None
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
headers: dict[str, str] = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(timeout=self._timeout, headers=headers)
self._owns_client = True
return self._client
def _api_url(self, endpoint: str) -> str:
return f"{self._base_url}/v1/workflows{endpoint}"
def _parse_sse_data(
self, raw_data: str, event_type: str | None
) -> StreamEvent | None:
parsed = json.loads(raw_data)
if event_type == "error" or (isinstance(parsed, dict) and "error" in parsed):
error_msg = (
parsed.get("error", "Unknown stream error")
if isinstance(parsed, dict)
else str(parsed)
)
raise WorkflowsException(
message=f"Stream error from server: {error_msg}",
code=ErrorCode.GET_EVENTS_STREAM_ERROR,
)
return StreamEvent.model_validate(parsed)
async def stream_events(
self, params: StreamEventsQueryParams
) -> AsyncGenerator[StreamEvent, None]:
endpoint = "/events/stream"
query = params.model_dump(exclude_none=True)
try:
async with self._http_client.stream(
"GET", self._api_url(endpoint), params=query
) as response:
response.raise_for_status()
async for event in self._iter_sse_events(response):
yield event
except WorkflowsException:
raise
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to stream events",
code=ErrorCode.GET_EVENTS_STREAM_ERROR,
) from exc
async def _iter_sse_events(
self, response: httpx.Response
) -> AsyncGenerator[StreamEvent, None]:
event_type: str | None = None
async for line in response.aiter_lines():
if line is None or line == "" or line.startswith(":"):
continue
if line.startswith("event:"):
event_type = line[len("event:") :].strip()
continue
if not line.startswith("data:"):
continue
raw_data = line[len("data:") :].strip()
try:
event = self._parse_sse_data(raw_data, event_type)
if event:
yield event
except WorkflowsException:
raise
except Exception:
logger.warning(
"Failed to parse SSE event",
exc_info=True,
extra={"event_data": raw_data},
)
finally:
event_type = None
async def signal_workflow(
self, execution_id: str, signal_name: str, input_data: BaseModel | None = None
) -> SignalWorkflowResponse:
endpoint = f"/executions/{execution_id}/signals"
try:
input_data_dict = input_data.model_dump(mode="json") if input_data else {}
request_body = {"name": signal_name, "input": input_data_dict}
response = await self._http_client.post(
self._api_url(endpoint),
json=request_body,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return SignalWorkflowResponse.model_validate(response.json())
except WorkflowsException:
raise
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to signal workflow",
code=ErrorCode.POST_EXECUTIONS_SIGNALS_ERROR,
) from exc
async def update_workflow(
self, execution_id: str, update_name: str, input_data: BaseModel | None = None
) -> UpdateWorkflowResponse:
endpoint = f"/executions/{execution_id}/updates"
try:
input_data_dict = input_data.model_dump(mode="json") if input_data else {}
request_body = {"name": update_name, "input": input_data_dict}
response = await self._http_client.post(
self._api_url(endpoint),
json=request_body,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return UpdateWorkflowResponse.model_validate(response.json())
except WorkflowsException:
raise
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to update workflow",
code=ErrorCode.POST_EXECUTIONS_UPDATES_ERROR,
) from exc
async def get_workflow_runs(
self,
workflow_identifier: str | None = None,
page_size: int = 50,
next_page_token: str | None = None,
) -> WorkflowExecutionListResponse:
params: dict[str, Any] = {"page_size": page_size}
if workflow_identifier:
params["workflow_identifier"] = workflow_identifier
if next_page_token:
params["next_page_token"] = next_page_token
endpoint = "/runs"
try:
response = await self._http_client.get(
self._api_url(endpoint), params=params
)
response.raise_for_status()
return WorkflowExecutionListResponse.model_validate(response.json())
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to get workflow runs",
code=ErrorCode.GET_EXECUTIONS_ERROR,
) from exc

227
vibe/core/nuage/events.py Normal file
View file

@ -0,0 +1,227 @@
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
class WorkflowEventType(StrEnum):
WORKFLOW_EXECUTION_COMPLETED = "WORKFLOW_EXECUTION_COMPLETED"
WORKFLOW_EXECUTION_FAILED = "WORKFLOW_EXECUTION_FAILED"
WORKFLOW_EXECUTION_CANCELED = "WORKFLOW_EXECUTION_CANCELED"
CUSTOM_TASK_STARTED = "CUSTOM_TASK_STARTED"
CUSTOM_TASK_IN_PROGRESS = "CUSTOM_TASK_IN_PROGRESS"
CUSTOM_TASK_COMPLETED = "CUSTOM_TASK_COMPLETED"
CUSTOM_TASK_FAILED = "CUSTOM_TASK_FAILED"
CUSTOM_TASK_TIMED_OUT = "CUSTOM_TASK_TIMED_OUT"
CUSTOM_TASK_CANCELED = "CUSTOM_TASK_CANCELED"
class JSONPatchBase(BaseModel):
path: str
value: Any = None
class JSONPatchAdd(JSONPatchBase):
op: Literal["add"] = "add"
class JSONPatchReplace(JSONPatchBase):
op: Literal["replace"] = "replace"
class JSONPatchRemove(JSONPatchBase):
op: Literal["remove"] = "remove"
class JSONPatchAppend(JSONPatchBase):
op: Literal["append"] = "append"
value: str = ""
JSONPatch = Annotated[
Annotated[JSONPatchAppend, Tag("append")]
| Annotated[JSONPatchAdd, Tag("add")]
| Annotated[JSONPatchReplace, Tag("replace")]
| Annotated[JSONPatchRemove, Tag("remove")],
Discriminator("op"),
]
class JSONPatchPayload(BaseModel):
type: Literal["json_patch"] = "json_patch"
value: list[JSONPatch] = Field(default_factory=list)
class JSONPayload(BaseModel):
type: Literal["json"] = "json"
value: Any = None
Payload = Annotated[
Annotated[JSONPayload, Tag("json")]
| Annotated[JSONPatchPayload, Tag("json_patch")],
Discriminator("type"),
]
class Failure(BaseModel):
message: str
class BaseEvent(BaseModel):
event_id: str
event_timestamp: int = 0
root_workflow_exec_id: str = ""
parent_workflow_exec_id: str | None = None
workflow_exec_id: str = ""
workflow_run_id: str = ""
workflow_name: str = ""
class WorkflowExecutionFailedAttributes(BaseModel):
task_id: str = ""
failure: Failure
class WorkflowExecutionCanceledAttributes(BaseModel):
task_id: str = ""
reason: str | None = None
class WorkflowExecutionCompletedAttributes(BaseModel):
task_id: str = ""
result: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None))
class CustomTaskStartedAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
payload: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None))
class CustomTaskInProgressAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
payload: Payload
class CustomTaskCompletedAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
payload: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None))
class CustomTaskFailedAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
failure: Failure
class CustomTaskTimedOutAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
timeout_type: str | None = None
class CustomTaskCanceledAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
reason: str | None = None
class WorkflowExecutionCompleted(BaseEvent):
event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED] = (
WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED
)
attributes: WorkflowExecutionCompletedAttributes
class WorkflowExecutionFailed(BaseEvent):
event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_FAILED] = (
WorkflowEventType.WORKFLOW_EXECUTION_FAILED
)
attributes: WorkflowExecutionFailedAttributes
class WorkflowExecutionCanceled(BaseEvent):
event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_CANCELED] = (
WorkflowEventType.WORKFLOW_EXECUTION_CANCELED
)
attributes: WorkflowExecutionCanceledAttributes
class CustomTaskStarted(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_STARTED] = (
WorkflowEventType.CUSTOM_TASK_STARTED
)
attributes: CustomTaskStartedAttributes
class CustomTaskInProgress(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_IN_PROGRESS] = (
WorkflowEventType.CUSTOM_TASK_IN_PROGRESS
)
attributes: CustomTaskInProgressAttributes
class CustomTaskCompleted(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_COMPLETED] = (
WorkflowEventType.CUSTOM_TASK_COMPLETED
)
attributes: CustomTaskCompletedAttributes
class CustomTaskFailed(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_FAILED] = (
WorkflowEventType.CUSTOM_TASK_FAILED
)
attributes: CustomTaskFailedAttributes
class CustomTaskTimedOut(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_TIMED_OUT] = (
WorkflowEventType.CUSTOM_TASK_TIMED_OUT
)
attributes: CustomTaskTimedOutAttributes
class CustomTaskCanceled(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_CANCELED] = (
WorkflowEventType.CUSTOM_TASK_CANCELED
)
attributes: CustomTaskCanceledAttributes
def _get_event_type_discriminator(v: Any) -> str:
if isinstance(v, dict):
event_type_val = v.get("event_type", "")
if isinstance(event_type_val, WorkflowEventType):
return event_type_val.value
return str(event_type_val)
event_type_attr = getattr(v, "event_type", "")
if isinstance(event_type_attr, WorkflowEventType):
return event_type_attr.value
return str(event_type_attr)
WorkflowEvent = Annotated[
Annotated[
WorkflowExecutionCompleted, Tag(WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED)
]
| Annotated[
WorkflowExecutionFailed, Tag(WorkflowEventType.WORKFLOW_EXECUTION_FAILED)
]
| Annotated[
WorkflowExecutionCanceled, Tag(WorkflowEventType.WORKFLOW_EXECUTION_CANCELED)
]
| Annotated[CustomTaskStarted, Tag(WorkflowEventType.CUSTOM_TASK_STARTED)]
| Annotated[CustomTaskInProgress, Tag(WorkflowEventType.CUSTOM_TASK_IN_PROGRESS)]
| Annotated[CustomTaskCompleted, Tag(WorkflowEventType.CUSTOM_TASK_COMPLETED)]
| Annotated[CustomTaskFailed, Tag(WorkflowEventType.CUSTOM_TASK_FAILED)]
| Annotated[CustomTaskTimedOut, Tag(WorkflowEventType.CUSTOM_TASK_TIMED_OUT)]
| Annotated[CustomTaskCanceled, Tag(WorkflowEventType.CUSTOM_TASK_CANCELED)],
Discriminator(_get_event_type_discriminator),
]

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from enum import StrEnum
from http import HTTPStatus
import httpx
class ErrorCode(StrEnum):
TEMPORAL_CONNECTION_ERROR = "temporal_connection_error"
GET_EVENTS_STREAM_ERROR = "get_events_stream_error"
POST_EXECUTIONS_SIGNALS_ERROR = "post_executions_signals_error"
POST_EXECUTIONS_UPDATES_ERROR = "post_executions_updates_error"
GET_EXECUTIONS_ERROR = "get_executions_error"
class WorkflowsException(Exception):
def __init__(
self,
message: str,
status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
code: ErrorCode = ErrorCode.TEMPORAL_CONNECTION_ERROR,
) -> None:
self.status = status
self.message = message
self.code = code
def __str__(self) -> str:
return f"{self.message} (code={self.code}, status={self.status.value})"
@classmethod
def from_api_client_error(
cls,
exc: Exception,
message: str = "HTTP request failed",
code: ErrorCode = ErrorCode.TEMPORAL_CONNECTION_ERROR,
) -> WorkflowsException:
status = HTTPStatus.INTERNAL_SERVER_ERROR
if isinstance(exc, httpx.HTTPStatusError):
try:
status = HTTPStatus(exc.response.status_code)
except ValueError:
pass
if isinstance(exc, httpx.ConnectError | httpx.TimeoutException):
status = HTTPStatus.BAD_GATEWAY
return cls(message=f"{message}: {exc}", code=code, status=status)

View file

@ -0,0 +1,202 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from typing import Any
from pydantic import TypeAdapter, ValidationError
from vibe.core.agent_loop import AgentLoopStateError
from vibe.core.config import VibeConfig
from vibe.core.nuage.agent_models import (
_SUBMIT_INPUT_UPDATE_NAME,
ChatInputModel,
SubmitInputModel,
)
from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.events import WorkflowEvent
from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException
from vibe.core.nuage.remote_workflow_event_translator import (
PendingInputRequest,
RemoteWorkflowEventTranslator,
)
from vibe.core.nuage.streaming import StreamEventsQueryParams
from vibe.core.nuage.workflow import WorkflowExecutionStatus
from vibe.core.tools.manager import ToolManager
from vibe.core.types import AgentStats, BaseEvent, LLMMessage, Role
_RETRYABLE_STREAM_ERRORS = ("peer closed connection", "incomplete chunked read")
_WORKFLOW_EVENT_ADAPTER = TypeAdapter(WorkflowEvent)
class RemoteEventsSource:
def __init__(self, session_id: str, config: VibeConfig) -> None:
self.session_id = session_id
self._config = config
self.messages: list[LLMMessage] = []
self.stats = AgentStats()
self._tool_manager = ToolManager(lambda: config)
self._next_start_seq = 0
self._client: WorkflowsClient | None = None
self._translator = RemoteWorkflowEventTranslator(
available_tools=self._tool_manager._available,
stats=self.stats,
merge_message=self._merge_message,
)
@property
def is_waiting_for_input(self) -> bool:
return self._translator.pending_input_request is not None
@property
def _pending_input_request(self) -> PendingInputRequest | None:
return self._translator.pending_input_request
@_pending_input_request.setter
def _pending_input_request(self, value: PendingInputRequest | None) -> None:
self._translator.pending_input_request = value
@property
def _task_state(self) -> dict[str, dict[str, Any]]:
return self._translator.task_state
@property
def is_terminated(self) -> bool:
return self._translator.last_status is not None
@property
def is_failed(self) -> bool:
return self._translator.last_status == WorkflowExecutionStatus.FAILED
@property
def is_canceled(self) -> bool:
return self._translator.last_status == WorkflowExecutionStatus.CANCELED
@property
def client(self) -> WorkflowsClient:
if self._client is None:
self._client = WorkflowsClient(
base_url=self._config.nuage_base_url,
api_key=self._config.nuage_api_key,
timeout=self._config.api_timeout,
)
return self._client
async def close(self) -> None:
if self._client is not None:
await self._client.__aexit__(None, None, None)
self._client = None
async def attach(self) -> AsyncGenerator[BaseEvent, None]:
async for event in self._stream_remote_events(stop_on_idle_boundary=False):
yield event
for event in self._translator.flush_open_tool_calls():
yield event
async def send_prompt(self, msg: str) -> None:
pending = self._translator.pending_input_request
if pending is None:
return
if not self._is_chat_input_request(pending):
raise AgentLoopStateError(
"Remote workflow is waiting for structured input that this UI does not support."
)
await self.client.update_workflow(
self.session_id,
_SUBMIT_INPUT_UPDATE_NAME,
SubmitInputModel(
task_id=pending.task_id,
input={"message": [{"type": "text", "text": msg}]},
),
)
self._translator.pending_input_request = None
def _is_chat_input_request(self, request: PendingInputRequest) -> bool:
title = request.input_schema.get("title")
return title == ChatInputModel.model_config.get("title")
async def _stream_remote_events(
self, stop_on_idle_boundary: bool = True
) -> AsyncGenerator[BaseEvent]:
retry_count = 0
max_retry_count = 3
done = False
while not done:
params = StreamEventsQueryParams(
workflow_exec_id=self.session_id, start_seq=self._next_start_seq
)
stream = self.client.stream_events(params)
try:
async for payload in stream:
retry_count = 0
if payload.broker_sequence is not None:
self._next_start_seq = payload.broker_sequence + 1
event = self._normalize_stream_event(payload.data)
if event is None:
continue
for emitted_event in self._consume_workflow_event(event):
yield emitted_event
if self.is_terminated:
done = True
break
if stop_on_idle_boundary and self._is_idle_boundary(event):
done = True
break
else:
break
except WorkflowsException as exc:
if self._is_retryable_stream_disconnect(exc):
retry_count += 1
if retry_count > max_retry_count:
break
await asyncio.sleep(0.2 * retry_count)
continue
raise AgentLoopStateError(str(exc)) from exc
finally:
await stream.aclose()
def _normalize_stream_event(
self, event: WorkflowEvent | dict[str, Any]
) -> WorkflowEvent | None:
if not isinstance(event, dict):
return event
try:
return _WORKFLOW_EVENT_ADAPTER.validate_python(event)
except ValidationError:
return None
def _consume_workflow_event(self, event: WorkflowEvent) -> list[BaseEvent]:
return self._translator.consume_workflow_event(event)
def _is_retryable_stream_disconnect(self, exc: WorkflowsException) -> bool:
if exc.code != ErrorCode.GET_EVENTS_STREAM_ERROR:
return False
msg = str(exc).lower()
return any(needle in msg for needle in _RETRYABLE_STREAM_ERRORS)
def _is_idle_boundary(self, event: WorkflowEvent) -> bool:
return self._translator.is_idle_boundary(event)
def _merge_message(self, message: LLMMessage) -> None:
if not self.messages:
self.messages.append(message)
return
last_message = self.messages[-1]
if (
last_message.role == message.role
and last_message.message_id == message.message_id
and message.role == Role.assistant
):
self.messages[-1] = last_message + message
return
self.messages.append(message)

View file

@ -0,0 +1,137 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
class BaseUIState(BaseModel):
toolCallId: str = ""
class FileOperation(BaseModel):
type: str = ""
uri: str = ""
content: str = ""
class FileUIState(BaseUIState):
type: Literal["file"] = "file"
operations: list[FileOperation] = []
class CommandResult(BaseModel):
status: str = ""
output: str = ""
class CommandUIState(BaseUIState):
type: Literal["command"] = "command"
command: str = ""
result: CommandResult | None = None
class GenericToolResult(BaseModel):
status: str = ""
error: str | None = None
class GenericToolUIState(BaseUIState):
model_config = ConfigDict(extra="allow")
type: Literal["generic_tool"] = "generic_tool"
arguments: dict[str, Any] = {}
result: GenericToolResult | None = None
AnyToolUIState = FileUIState | CommandUIState | GenericToolUIState
def parse_tool_ui_state(raw: dict[str, Any]) -> AnyToolUIState | None:
ui_type = raw.get("type")
if ui_type == "file":
return FileUIState.model_validate(raw)
if ui_type == "command":
return CommandUIState.model_validate(raw)
if ui_type == "generic_tool":
return GenericToolUIState.model_validate(raw)
return None
class WorkingState(BaseModel):
title: str = ""
content: str = ""
type: str = ""
toolUIState: dict[str, Any] | None = None
class ContentChunk(BaseModel):
type: str = ""
text: str = ""
class AssistantMessageState(BaseModel):
contentChunks: list[ContentChunk] = []
class AgentToolCallState(BaseModel):
model_config = ConfigDict(extra="allow")
name: str = ""
tool_call_id: str = ""
kwargs: dict[str, Any] = {}
output: Any = None
class MessageSchema(BaseModel):
model_config = ConfigDict(extra="allow")
examples: list[Any] = []
class InputSchemaProperties(BaseModel):
model_config = ConfigDict(extra="allow")
message: MessageSchema | None = None
class InputSchema(BaseModel):
model_config = ConfigDict(extra="allow")
properties: InputSchemaProperties | None = None
class PredefinedAnswersState(BaseModel):
model_config = ConfigDict(extra="allow")
input_schema: InputSchema | None = None
class WaitForInputInput(BaseModel):
model_config = ConfigDict(extra="allow")
message: Any = None
class WaitForInputPayload(BaseModel):
model_config = ConfigDict(extra="allow")
input: WaitForInputInput | None = None
class AskUserQuestion(BaseModel):
question: str
class AskUserQuestionArgs(BaseModel):
model_config = ConfigDict(extra="allow")
questions: list[AskUserQuestion] = []
class PendingInputRequest(BaseModel):
task_id: str
input_schema: dict[str, Any]
label: str | None = None
class RemoteToolArgs(BaseModel):
model_config = ConfigDict(extra="allow")
summary: str | None = None
content: str | None = None
class RemoteToolResult(BaseModel):
model_config = ConfigDict(extra="allow")
message: str | None = None

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
from __future__ import annotations
import time
from typing import Any
from pydantic import BaseModel, Field
from vibe.core.nuage.events import WorkflowEvent
class StreamEventWorkflowContext(BaseModel):
namespace: str = ""
workflow_name: str = ""
workflow_exec_id: str = ""
parent_workflow_exec_id: str | None = None
root_workflow_exec_id: str | None = None
class StreamEvent(BaseModel):
stream: str = ""
timestamp_unix_nano: int = Field(default_factory=lambda: time.time_ns())
data: WorkflowEvent | dict[str, Any]
workflow_context: StreamEventWorkflowContext = Field(
default_factory=StreamEventWorkflowContext
)
metadata: dict[str, Any] = Field(default_factory=dict)
broker_sequence: int | None = None
class StreamEventsQueryParams(BaseModel):
workflow_exec_id: str = ""
start_seq: int = 0

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
class WorkflowExecutionStatus(StrEnum):
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELED = "CANCELED"
TERMINATED = "TERMINATED"
CONTINUED_AS_NEW = "CONTINUED_AS_NEW"
TIMED_OUT = "TIMED_OUT"
RETRYING_AFTER_ERROR = "RETRYING_AFTER_ERROR"
class WorkflowExecutionWithoutResultResponse(BaseModel):
workflow_name: str
execution_id: str
parent_execution_id: str | None = None
root_execution_id: str = ""
status: WorkflowExecutionStatus | None = None
start_time: datetime
end_time: datetime | None = None
total_duration_ms: int | None = None
class WorkflowExecutionListResponse(BaseModel):
executions: list[WorkflowExecutionWithoutResultResponse] = Field(
default_factory=list
)
next_page_token: str | None = None
class SignalWorkflowResponse(BaseModel):
message: str = "Signal accepted"
class UpdateWorkflowResponse(BaseModel):
update_name: str = ""
result: Any = None

View file

@ -5,6 +5,17 @@ import json
import sys
from typing import TextIO
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
)
from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, OutputFormat
@ -36,9 +47,31 @@ class TextOutputFormatter(OutputFormatter):
def on_message_added(self, message: LLMMessage) -> None:
self._messages.append(message)
def _print(self, text: str) -> None:
print(text, file=self.stream)
def on_event(self, event: BaseEvent) -> None:
if isinstance(event, AssistantEvent):
self._final_response = event.content
match event:
case AssistantEvent():
self._final_response = event.content
case TeleportCheckingGitEvent():
self._print("Preparing workspace...")
case TeleportPushRequiredEvent(unpushed_count=count):
self._print(f"Pushing {count} commit(s)...")
case TeleportPushingEvent():
self._print("Syncing with remote...")
case TeleportStartingWorkflowEvent():
self._print("Teleporting...")
case TeleportWaitingForGitHubEvent():
self._print("Connecting to GitHub...")
case TeleportAuthRequiredEvent(oauth_url=url):
self._print(f"Open to authorize GitHub: {url}")
case TeleportAuthCompleteEvent():
self._print("GitHub authorized")
case TeleportFetchingUrlEvent():
self._print("Finalizing...")
case TeleportCompleteEvent():
self._final_response = event.url
def finalize(self) -> str | None:
return self._final_response

View file

@ -3,11 +3,15 @@ from __future__ import annotations
import asyncio
from vibe import __version__
from vibe.core.agent_loop import AgentLoop
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.output_formatters import create_formatter
from vibe.core.teleport.types import (
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
)
from vibe.core.types import (
AssistantEvent,
ClientMetadata,
@ -18,6 +22,8 @@ from vibe.core.types import (
)
from vibe.core.utils import ConversationLimitException
__all__ = ["TeleportError", "run_programmatic"]
_DEFAULT_CLIENT_METADATA = ClientMetadata(name="vibe_programmatic", version=__version__)
@ -30,6 +36,7 @@ def run_programmatic(
previous_messages: list[LLMMessage] | None = None,
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA,
teleport: bool = False,
) -> str | None:
formatter = create_formatter(output_format)
@ -62,10 +69,23 @@ def run_programmatic(
agent_loop.emit_new_session_telemetry()
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)
if teleport and config.nuage_enabled:
gen = agent_loop.teleport_to_vibe_nuage(prompt or None)
async for event in gen:
formatter.on_event(event)
if isinstance(event, TeleportPushRequiredEvent):
next_event = await gen.asend(
TeleportPushResponseEvent(approved=True)
)
formatter.on_event(next_event)
else:
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if (
isinstance(event, AssistantEvent)
and event.stopped_by_middleware
):
raise ConversationLimitException(event.content)
return formatter.finalize()
finally:

View file

@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.workflow import WorkflowExecutionStatus
from vibe.core.session.session_loader import SessionLoader
ResumeSessionSource = Literal["local", "remote"]
SHORT_SESSION_ID_LEN = 8
def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str:
if source == "remote":
return session_id[-SHORT_SESSION_ID_LEN:]
return session_id[:SHORT_SESSION_ID_LEN]
_ACTIVE_STATUSES = {
WorkflowExecutionStatus.RUNNING,
WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
}
@dataclass(frozen=True)
class ResumeSessionInfo:
session_id: str
source: ResumeSessionSource
cwd: str
title: str | None
end_time: str | None
status: str | None = None
@property
def option_id(self) -> str:
return f"{self.source}:{self.session_id}"
def list_local_resume_sessions(
config: VibeConfig, cwd: str | None
) -> list[ResumeSessionInfo]:
return [
ResumeSessionInfo(
session_id=session["session_id"],
source="local",
cwd=session["cwd"],
title=session.get("title"),
end_time=session.get("end_time"),
)
for session in SessionLoader.list_sessions(config.session_logging, cwd=cwd)
]
async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionInfo]:
if not config.nuage_enabled or not config.nuage_api_key:
logger.debug("Remote resume listing skipped: missing Nuage configuration")
return []
async with WorkflowsClient(
base_url=config.nuage_base_url,
api_key=config.nuage_api_key,
timeout=config.api_timeout,
) as client:
response = await client.get_workflow_runs(
workflow_identifier=config.nuage_workflow_id, page_size=50
)
sessions: list[ResumeSessionInfo] = []
for execution in response.executions:
if execution.status not in _ACTIVE_STATUSES:
continue
sessions.append(
ResumeSessionInfo(
session_id=execution.execution_id,
source="remote",
cwd="",
title="Vibe Nuage",
end_time=(
execution.end_time.isoformat()
if execution.end_time
else execution.start_time.isoformat()
),
status=execution.status,
)
)
logger.debug("Remote resume listing filtered sessions: %d", len(sessions))
return sessions

View file

@ -76,11 +76,29 @@ class GitRepository:
diff=diff,
)
async def is_commit_pushed(self, commit: str, remote: str = "origin") -> bool:
async def fetch(self, remote: str = "origin") -> None:
repo = self._repo_or_raise()
await self._fetch(repo, remote)
async def is_commit_pushed(
self, commit: str, remote: str = "origin", *, fetch: bool = True
) -> bool:
repo = self._repo_or_raise()
if fetch:
await self._fetch(repo, remote)
return await self._branch_contains(repo, commit, remote)
async def is_branch_pushed(
self, remote: str = "origin", *, fetch: bool = True
) -> bool:
repo = self._repo_or_raise()
if repo.head.is_detached:
return True # Detached HEAD doesn't have a branch to check
branch = repo.active_branch.name
if fetch:
await self._fetch(repo, remote)
return await self._ref_exists(repo, f"{remote}/{branch}")
async def get_unpushed_commit_count(self, remote: str = "origin") -> int:
repo = self._repo_or_raise()
@ -179,7 +197,13 @@ class GitRepository:
async def _push(self, repo: Repo, branch: str, remote: str) -> bool:
try:
await self._executor.run(lambda: repo.remote(remote).push(branch))
result = await self._executor.run(
lambda: repo.remote(remote).push(branch, set_upstream=True)
)
# Check if any push info indicates an error
for info in result:
if info.flags & info.ERROR:
return False
return True
except (TimeoutError, ValueError, GitCommandError):
return False

View file

@ -1,67 +1,96 @@
from __future__ import annotations
from dataclasses import asdict
import asyncio
from enum import StrEnum, auto
import time
import types
from typing import Any
import httpx
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
from vibe.core.auth import EncryptedPayload, encrypt
from vibe.core.teleport.errors import ServiceTeleportError
class GitRepoConfig(BaseModel):
url: str
class GitHubParams(BaseModel):
repo: str | None = None
branch: str | None = None
commit: str | None = None
class VibeSandboxConfig(BaseModel):
git_repo: GitRepoConfig | None = None
class VibeNewSandbox(BaseModel):
type: str = "new"
config: VibeSandboxConfig = Field(default_factory=VibeSandboxConfig)
pr_number: int | None = None
teleported_diffs: bytes | None = None
class ChatAssistantParams(BaseModel):
create_thread: bool = False
user_message: str | None = None
project_name: str | None = None
class TeleportSession(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
messages: list[dict[str, Any]] = Field(default_factory=list)
class WorkflowIntegrations(BaseModel):
github: GitHubParams | None = None
chat_assistant: ChatAssistantParams | None = None
class VibeAgent(BaseModel):
polymorphic_type: str = "vibe_agent"
name: str = "vibe-agent"
vibe_config: dict[str, Any] | None = None
session: TeleportSession | None = None
class WorkflowConfig(BaseModel):
agent: VibeAgent = Field(default_factory=VibeAgent)
class WorkflowParams(BaseModel):
prompt: str
sandbox: VibeNewSandbox
session: TeleportSession | None = None
config: WorkflowConfig = Field(default_factory=WorkflowConfig)
integrations: WorkflowIntegrations = Field(default_factory=WorkflowIntegrations)
class WorkflowExecuteResponse(BaseModel):
execution_id: str
class PublicKeyResult(BaseModel):
public_key: str
class GitHubStatus(StrEnum):
PENDING = auto()
WAITING_FOR_OAUTH = auto()
CONNECTED = auto()
OAUTH_TIMEOUT = auto()
ERROR = auto()
class QueryResponse(BaseModel):
result: PublicKeyResult
class GitHubPublicData(BaseModel):
status: GitHubStatus
oauth_url: str | None = None
error: str | None = None
working_branch: str | None = None
repo: str | None = None
@property
def connected(self) -> bool:
return self.status == GitHubStatus.CONNECTED
@property
def is_error(self) -> bool:
return self.status in {GitHubStatus.OAUTH_TIMEOUT, GitHubStatus.ERROR}
class CreateLeChatThreadInput(BaseModel):
encrypted_api_key: dict[str, str]
user_message: str
project_name: str | None = None
class CreateLeChatThreadOutput(BaseModel):
class ChatAssistantPublicData(BaseModel):
chat_url: str
class UpdateResponse(BaseModel):
result: CreateLeChatThreadOutput
class GetChatAssistantIntegrationResponse(BaseModel):
result: ChatAssistantPublicData
class GetGitHubIntegrationResponse(BaseModel):
result: GitHubPublicData
class NuageClient:
@ -122,64 +151,62 @@ class NuageClient:
)
if not response.is_success:
error_msg = f"Nuage workflow trigger failed: {response.text}"
# TODO(vibe-nuage): remove this once prod has shared vibe-nuage workers
if "Unauthorized" in response.text or "unauthorized" in response.text:
error_msg += (
"\n\nHint: This version uses Mistral staging environment. "
"Set STAGING_MISTRAL_API_KEY from https://console.globalaegis.net/"
)
raise ServiceTeleportError(error_msg)
result = WorkflowExecuteResponse.model_validate(response.json())
return result.execution_id
async def send_github_token(self, execution_id: str, token: str) -> None:
public_key_pem = await self._query_public_key(execution_id)
encrypted = encrypt(token, public_key_pem)
await self._signal_encrypted_token(execution_id, encrypted)
async def _query_public_key(self, execution_id: str) -> bytes:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/queries",
headers=self._headers(),
json={"name": "get_public_key", "input": {}},
)
if not response.is_success:
raise ServiceTeleportError(f"Failed to get public key: {response.text}")
result = QueryResponse.model_validate(response.json())
return result.result.public_key.encode("utf-8")
async def _signal_encrypted_token(
self, execution_id: str, encrypted: EncryptedPayload
) -> None:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/signals",
headers=self._headers(),
json={"name": "github_token", "input": {"payload": asdict(encrypted)}},
)
if not response.is_success:
raise ServiceTeleportError(f"Failed to send GitHub token: {response.text}")
async def create_le_chat_thread(
self, execution_id: str, user_message: str, project_name: str | None = None
) -> str:
public_key_pem = await self._query_public_key(execution_id)
encrypted = encrypt(self._api_key, public_key_pem)
input_data = CreateLeChatThreadInput(
encrypted_api_key={
k: v for k, v in asdict(encrypted).items() if v is not None
},
user_message=user_message,
project_name=project_name,
)
async def get_github_integration(self, execution_id: str) -> GitHubPublicData:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
headers=self._headers(),
json={"name": "create_le_chat_thread", "input": input_data.model_dump()},
json={"name": "get_integration", "input": {"integration_id": "github"}},
)
if not response.is_success:
raise ServiceTeleportError(
f"Failed to create Le Chat thread: {response.text}"
f"Failed to get GitHub integration: {response.text}"
)
result = UpdateResponse.model_validate(response.json())
try:
result = GetGitHubIntegrationResponse.model_validate(response.json())
except ValidationError as e:
data = response.json()
error = data.get("result", {}).get("error")
status = data.get("result", {}).get("status")
raise ServiceTeleportError(
f"GitHub integration error: {error or status}"
) from e
return result.result
async def wait_for_github_connection(
self, execution_id: str, timeout: float = 600.0, interval: float = 2.0
) -> GitHubPublicData:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
github_data = await self.get_github_integration(execution_id)
if github_data.connected:
return github_data
if github_data.is_error:
raise ServiceTeleportError(
github_data.error
or f"GitHub integration failed: {github_data.status.value}"
)
remaining = deadline - time.monotonic()
if remaining <= 0:
break
await asyncio.sleep(min(interval, remaining))
raise ServiceTeleportError("GitHub connection timed out")
async def get_chat_assistant_url(self, execution_id: str) -> str:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
headers=self._headers(),
json={
"name": "get_integration",
"input": {"integration_id": "chat_assistant"},
},
)
if not response.is_success:
raise ServiceTeleportError(
f"Failed to get chat assistant integration: {response.text}"
)
result = GetChatAssistantIntegrationResponse.model_validate(response.json())
return result.result.chat_url

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncGenerator
from pathlib import Path
@ -8,16 +9,18 @@ import types
import httpx
import zstandard
from vibe.core.auth.github import GitHubAuthProvider
from vibe.core.config import VibeConfig
from vibe.core.session.session_logger import SessionLogger
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.git import GitRepoInfo, GitRepository
from vibe.core.teleport.nuage import (
GitRepoConfig,
ChatAssistantParams,
GitHubParams,
NuageClient,
TeleportSession,
VibeNewSandbox,
VibeSandboxConfig,
VibeAgent,
WorkflowConfig,
WorkflowIntegrations,
WorkflowParams,
)
from vibe.core.teleport.types import (
@ -25,18 +28,17 @@ from vibe.core.teleport.types import (
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportSendEvent,
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
TeleportYieldEvent,
)
# TODO(vibe-nuage): update URL once prod has shared vibe-nuage workers
_NUAGE_EXECUTION_URL_TEMPLATE = "https://console.globalaegis.net/build/workflows/{workflow_id}?tab=executions&executionId={execution_id}"
_DEFAULT_TELEPORT_PROMPT = "please continue where you left off"
_DEFAULT_TELEPORT_PROMPT = "Your session has been teleported on a remote workspace. Changes of workspace has been automatically teleported. External workspace changes has NOT been teleported. Environment variables has NOT been teleported. Please continue where you left off."
class TeleportService:
@ -49,6 +51,7 @@ class TeleportService:
workdir: Path | None = None,
*,
nuage_task_queue: str | None = None,
vibe_config: VibeConfig | None = None,
client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
@ -57,17 +60,19 @@ class TeleportService:
self._nuage_workflow_id = nuage_workflow_id
self._nuage_api_key = nuage_api_key
self._nuage_task_queue = nuage_task_queue
self._nuage_project_name = (
vibe_config.nuage_project_name if vibe_config else "Vibe"
)
self._vibe_config = vibe_config
self._git = GitRepository(workdir)
self._client = client
self._owns_client = client is None
self._timeout = timeout
self._github_auth: GitHubAuthProvider | None = None
self._nuage: NuageClient | None = None
async def __aenter__(self) -> TeleportService:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._github_auth = GitHubAuthProvider(client=self._client)
self._nuage = NuageClient(
self._nuage_base_url,
self._nuage_api_key,
@ -96,12 +101,6 @@ class TeleportService:
self._owns_client = True
return self._client
@property
def _github_auth_provider(self) -> GitHubAuthProvider:
if self._github_auth is None:
self._github_auth = GitHubAuthProvider(client=self._http_client)
return self._github_auth
@property
def _nuage_client(self) -> NuageClient:
if self._nuage is None:
@ -123,56 +122,76 @@ class TeleportService:
async def execute(
self, prompt: str | None, session: TeleportSession
) -> AsyncGenerator[TeleportYieldEvent, TeleportSendEvent]:
prompt = prompt or _DEFAULT_TELEPORT_PROMPT
if prompt:
lechat_user_message = prompt
else:
last_user_message = self._get_last_user_message(session)
if not last_user_message:
raise ServiceTeleportError(
"No prompt provided and no user message found in session."
)
lechat_user_message = f"{last_user_message} (continue)"
prompt = _DEFAULT_TELEPORT_PROMPT
self._validate_config()
git_info = await self._git.get_info()
yield TeleportCheckingGitEvent()
if not await self._git.is_commit_pushed(git_info.commit):
await self._git.fetch()
commit_pushed, branch_pushed = await asyncio.gather(
self._git.is_commit_pushed(git_info.commit, fetch=False),
self._git.is_branch_pushed(fetch=False),
)
if not commit_pushed or not branch_pushed:
unpushed_count = await self._git.get_unpushed_commit_count()
response = yield TeleportPushRequiredEvent(
unpushed_count=max(1, unpushed_count)
unpushed_count=max(1, unpushed_count),
branch_not_pushed=not branch_pushed,
)
if (
not isinstance(response, TeleportPushResponseEvent)
or not response.approved
):
raise ServiceTeleportError("Teleport cancelled: commit not pushed.")
raise ServiceTeleportError("Teleport cancelled: changes not pushed.")
yield TeleportPushingEvent()
await self._push_or_fail()
github_token = await self._github_auth_provider.get_valid_token()
if not github_token:
handle = await self._github_auth_provider.start_device_flow(
open_browser=True
)
yield TeleportAuthRequiredEvent(
user_code=handle.info.user_code,
verification_uri=handle.info.verification_uri,
)
github_token = await self._github_auth_provider.wait_for_token(handle)
yield TeleportAuthCompleteEvent()
yield TeleportStartingWorkflowEvent()
execution_id = await self._nuage_client.start_workflow(
WorkflowParams(
prompt=prompt, sandbox=self._build_sandbox(git_info), session=session
prompt=prompt,
config=WorkflowConfig(
agent=VibeAgent(
vibe_config=self._vibe_config.model_dump()
if self._vibe_config
else None,
session=session,
)
),
integrations=WorkflowIntegrations(
github=self._build_github_params(git_info),
chat_assistant=ChatAssistantParams(
create_thread=True,
user_message=lechat_user_message,
project_name=self._nuage_project_name,
),
),
)
)
yield TeleportSendingGithubTokenEvent()
await self._nuage_client.send_github_token(execution_id, github_token)
yield TeleportWaitingForGitHubEvent()
github_data = await self._nuage_client.get_github_integration(execution_id)
chat_url = _NUAGE_EXECUTION_URL_TEMPLATE.format(
workflow_id=self._nuage_workflow_id, execution_id=execution_id
)
# chat_url = await nuage.create_le_chat_thread(
# execution_id=execution_id, user_message=prompt
# )
if not github_data.connected:
if github_data.oauth_url:
yield TeleportAuthRequiredEvent(oauth_url=github_data.oauth_url)
await self._nuage_client.wait_for_github_connection(execution_id)
yield TeleportAuthCompleteEvent()
yield TeleportFetchingUrlEvent()
chat_url = await self._nuage_client.get_chat_assistant_url(execution_id)
yield TeleportCompleteEvent(url=chat_url)
@ -181,22 +200,19 @@ class TeleportService:
raise ServiceTeleportError("Failed to push current branch to remote.")
def _validate_config(self) -> None:
# TODO(vibe-nuage): update error message once prod has shared vibe-nuage workers
if not self._nuage_api_key:
raise ServiceTeleportError(
"STAGING_MISTRAL_API_KEY not set. "
"Set it from https://console.globalaegis.net/ to use teleport."
env_var = (
self._vibe_config.nuage_api_key_env_var
if self._vibe_config
else "MISTRAL_API_KEY"
)
raise ServiceTeleportError(f"{env_var} not set.")
def _build_sandbox(self, git_info: GitRepoInfo) -> VibeNewSandbox:
return VibeNewSandbox(
config=VibeSandboxConfig(
git_repo=GitRepoConfig(
url=git_info.remote_url,
branch=git_info.branch,
commit=git_info.commit,
)
),
def _build_github_params(self, git_info: GitRepoInfo) -> GitHubParams:
return GitHubParams(
repo=f"{git_info.owner}/{git_info.repo}",
branch=git_info.branch,
commit=git_info.commit,
teleported_diffs=self._compress_diff(git_info.diff or ""),
)
@ -210,3 +226,11 @@ class TeleportService:
"Diff too large to teleport. Please commit and push your changes first."
)
return encoded
def _get_last_user_message(self, session: TeleportSession) -> str | None:
for msg in reversed(session.messages):
if msg.get("role") == "user":
content = msg.get("content")
if isinstance(content, str) and content:
return content
return None

View file

@ -4,8 +4,7 @@ from vibe.core.types import BaseEvent
class TeleportAuthRequiredEvent(BaseEvent):
user_code: str
verification_uri: str
oauth_url: str
class TeleportAuthCompleteEvent(BaseEvent):
@ -22,6 +21,7 @@ class TeleportCheckingGitEvent(BaseEvent):
class TeleportPushRequiredEvent(BaseEvent):
unpushed_count: int = 1
branch_not_pushed: bool = False
class TeleportPushResponseEvent(BaseEvent):
@ -32,7 +32,11 @@ class TeleportPushingEvent(BaseEvent):
pass
class TeleportSendingGithubTokenEvent(BaseEvent):
class TeleportWaitingForGitHubEvent(BaseEvent):
pass
class TeleportFetchingUrlEvent(BaseEvent):
pass
@ -47,7 +51,8 @@ type TeleportYieldEvent = (
| TeleportPushRequiredEvent
| TeleportPushingEvent
| TeleportStartingWorkflowEvent
| TeleportSendingGithubTokenEvent
| TeleportWaitingForGitHubEvent
| TeleportFetchingUrlEvent
| TeleportCompleteEvent
)

View file

@ -397,6 +397,12 @@ class ToolStreamEvent(BaseEvent):
tool_call_id: str
class WaitingForInputEvent(BaseEvent):
task_id: str
label: str | None = None
predefined_answers: list[str] | None = None
class CompactStartEvent(BaseEvent):
current_context_tokens: int
threshold: int

View file

@ -1,2 +1,3 @@
# What's new in v2.7.0
- **Rewind mode**: Added navigation and forking of conversation history with /rewind
# What's new in v2.7.3
- **Data retention command**: Added a `/data-retention` slash command to view Mistral AI's data retention notice and privacy settings