v2.7.3 (#564)
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:
parent
9c1c32e058
commit
90763daf81
61 changed files with 6046 additions and 694 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
8
vibe/cli/textual_ui/remote/__init__.py
Normal file
8
vibe/cli/textual_ui/remote/__init__.py
Normal 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"]
|
||||
216
vibe/cli/textual_ui/remote/remote_session_manager.py
Normal file
216
vibe/cli/textual_ui/remote/remote_session_manager.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
vibe/core/data_retention.py
Normal file
7
vibe/core/data_retention.py
Normal 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)"""
|
||||
0
vibe/core/nuage/__init__.py
Normal file
0
vibe/core/nuage/__init__.py
Normal file
26
vibe/core/nuage/agent_models.py
Normal file
26
vibe/core/nuage/agent_models.py
Normal 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
195
vibe/core/nuage/client.py
Normal 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
227
vibe/core/nuage/events.py
Normal 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),
|
||||
]
|
||||
46
vibe/core/nuage/exceptions.py
Normal file
46
vibe/core/nuage/exceptions.py
Normal 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)
|
||||
202
vibe/core/nuage/remote_events_source.py
Normal file
202
vibe/core/nuage/remote_events_source.py
Normal 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)
|
||||
137
vibe/core/nuage/remote_workflow_event_models.py
Normal file
137
vibe/core/nuage/remote_workflow_event_models.py
Normal 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
|
||||
1228
vibe/core/nuage/remote_workflow_event_translator.py
Normal file
1228
vibe/core/nuage/remote_workflow_event_translator.py
Normal file
File diff suppressed because it is too large
Load diff
32
vibe/core/nuage/streaming.py
Normal file
32
vibe/core/nuage/streaming.py
Normal 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
|
||||
45
vibe/core/nuage/workflow.py
Normal file
45
vibe/core/nuage/workflow.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
93
vibe/core/session/resume_sessions.py
Normal file
93
vibe/core/session/resume_sessions.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue