codemaster-cli 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,1075 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator, Callable
5
+ from enum import StrEnum, auto
6
+ from http import HTTPStatus
7
+ import json
8
+ from pathlib import Path
9
+ from threading import Thread
10
+ import time
11
+ from typing import TYPE_CHECKING, Any, Literal, cast
12
+ from uuid import uuid4
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from vibe.core.agents.manager import AgentManager
17
+ from vibe.core.agents.models import AgentProfile, BuiltinAgentName
18
+ from vibe.core.config import Backend, ProviderConfig, VibeConfig
19
+ from vibe.core.llm.backend.factory import BACKEND_FACTORY
20
+ from vibe.core.llm.exceptions import BackendError
21
+ from vibe.core.llm.format import (
22
+ APIToolFormatHandler,
23
+ FailedToolCall,
24
+ ResolvedMessage,
25
+ ResolvedToolCall,
26
+ )
27
+ from vibe.core.llm.types import BackendLike
28
+ from vibe.core.middleware import (
29
+ AutoCompactMiddleware,
30
+ ContextWarningMiddleware,
31
+ ConversationContext,
32
+ MiddlewareAction,
33
+ MiddlewarePipeline,
34
+ MiddlewareResult,
35
+ PlanAgentMiddleware,
36
+ PriceLimitMiddleware,
37
+ ResetReason,
38
+ TurnLimitMiddleware,
39
+ )
40
+ from vibe.core.prompts import UtilityPrompt
41
+ from vibe.core.session.session_logger import SessionLogger
42
+ from vibe.core.session.session_migration import migrate_sessions_entrypoint
43
+ from vibe.core.skills.manager import SkillManager
44
+ from vibe.core.system_prompt import get_universal_system_prompt
45
+ from vibe.core.telemetry.send import TelemetryClient
46
+ from vibe.core.tools.base import (
47
+ BaseTool,
48
+ BaseToolConfig,
49
+ InvokeContext,
50
+ ToolError,
51
+ ToolPermission,
52
+ ToolPermissionError,
53
+ )
54
+ from vibe.core.tools.manager import ToolManager
55
+ from vibe.core.trusted_folders import has_agents_md_file
56
+ from vibe.core.types import (
57
+ AgentStats,
58
+ ApprovalCallback,
59
+ ApprovalResponse,
60
+ AssistantEvent,
61
+ AsyncApprovalCallback,
62
+ BaseEvent,
63
+ CompactEndEvent,
64
+ CompactStartEvent,
65
+ LLMChunk,
66
+ LLMMessage,
67
+ LLMUsage,
68
+ RateLimitError,
69
+ ReasoningEvent,
70
+ Role,
71
+ SyncApprovalCallback,
72
+ ToolCallEvent,
73
+ ToolResultEvent,
74
+ ToolStreamEvent,
75
+ UserInputCallback,
76
+ UserMessageEvent,
77
+ )
78
+ from vibe.core.utils import (
79
+ TOOL_ERROR_TAG,
80
+ VIBE_STOP_EVENT_TAG,
81
+ CancellationReason,
82
+ get_user_agent,
83
+ get_user_cancellation_message,
84
+ is_user_cancellation_event,
85
+ )
86
+
87
+ try:
88
+ from vibe.core.teleport.teleport import TeleportService as _TeleportService
89
+
90
+ _TELEPORT_AVAILABLE = True
91
+ except ImportError:
92
+ _TELEPORT_AVAILABLE = False
93
+ _TeleportService = None
94
+
95
+ if TYPE_CHECKING:
96
+ from vibe.core.teleport.nuage import TeleportSession
97
+ from vibe.core.teleport.teleport import TeleportService
98
+ from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent
99
+
100
+
101
+ class ToolExecutionResponse(StrEnum):
102
+ SKIP = auto()
103
+ EXECUTE = auto()
104
+
105
+
106
+ class ToolDecision(BaseModel):
107
+ verdict: ToolExecutionResponse
108
+ approval_type: ToolPermission
109
+ feedback: str | None = None
110
+
111
+
112
+ class AgentLoopError(Exception):
113
+ """Base exception for AgentLoop errors."""
114
+
115
+
116
+ class AgentLoopStateError(AgentLoopError):
117
+ """Raised when agent loop is in an invalid state."""
118
+
119
+
120
+ class AgentLoopLLMResponseError(AgentLoopError):
121
+ """Raised when LLM response is malformed or missing expected data."""
122
+
123
+
124
+ class TeleportError(AgentLoopError):
125
+ """Raised when teleport to Vibe Nuage fails."""
126
+
127
+
128
+ def _should_raise_rate_limit_error(e: Exception) -> bool:
129
+ return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS
130
+
131
+
132
+ class AgentLoop:
133
+ def __init__(
134
+ self,
135
+ config: VibeConfig,
136
+ agent_name: str = BuiltinAgentName.DEFAULT,
137
+ message_observer: Callable[[LLMMessage], None] | None = None,
138
+ max_turns: int | None = None,
139
+ max_price: float | None = None,
140
+ backend: BackendLike | None = None,
141
+ enable_streaming: bool = False,
142
+ ) -> None:
143
+ self._base_config = config
144
+ self._max_turns = max_turns
145
+ self._max_price = max_price
146
+
147
+ self.agent_manager = AgentManager(
148
+ lambda: self._base_config, initial_agent=agent_name
149
+ )
150
+ self.tool_manager = ToolManager(lambda: self.config)
151
+ self.skill_manager = SkillManager(lambda: self.config)
152
+ self.format_handler = APIToolFormatHandler()
153
+
154
+ self.backend_factory = lambda: backend or self._select_backend()
155
+ self.backend = self.backend_factory()
156
+
157
+ self.message_observer = message_observer
158
+ self._last_observed_message_index: int = 0
159
+ self.enable_streaming = enable_streaming
160
+ self.middleware_pipeline = MiddlewarePipeline()
161
+ self._setup_middleware()
162
+
163
+ system_prompt = get_universal_system_prompt(
164
+ self.tool_manager, self.config, self.skill_manager, self.agent_manager
165
+ )
166
+ self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
167
+
168
+ if self.message_observer:
169
+ self.message_observer(self.messages[0])
170
+ self._last_observed_message_index = 1
171
+
172
+ self.stats = AgentStats()
173
+ try:
174
+ active_model = config.get_active_model()
175
+ self.stats.input_price_per_million = active_model.input_price
176
+ self.stats.output_price_per_million = active_model.output_price
177
+ except ValueError:
178
+ pass
179
+
180
+ self.approval_callback: ApprovalCallback | None = None
181
+ self.user_input_callback: UserInputCallback | None = None
182
+
183
+ self.session_id = str(uuid4())
184
+ self._current_user_message_id: str | None = None
185
+
186
+ self.telemetry_client = TelemetryClient(config_getter=lambda: self.config)
187
+ self.session_logger = SessionLogger(config.session_logging, self.session_id)
188
+ self._teleport_service: TeleportService | None = None
189
+
190
+ thread = Thread(
191
+ target=migrate_sessions_entrypoint,
192
+ args=(config.session_logging,),
193
+ daemon=True,
194
+ name="migrate_sessions",
195
+ )
196
+ thread.start()
197
+
198
+ @property
199
+ def agent_profile(self) -> AgentProfile:
200
+ return self.agent_manager.active_profile
201
+
202
+ @property
203
+ def config(self) -> VibeConfig:
204
+ return self.agent_manager.config
205
+
206
+ @property
207
+ def auto_approve(self) -> bool:
208
+ return self.config.auto_approve
209
+
210
+ def set_tool_permission(
211
+ self, tool_name: str, permission: ToolPermission, save_permanently: bool = False
212
+ ) -> None:
213
+ if save_permanently:
214
+ VibeConfig.save_updates({
215
+ "tools": {tool_name: {"permission": permission.value}}
216
+ })
217
+
218
+ if tool_name not in self.config.tools:
219
+ self.config.tools[tool_name] = BaseToolConfig()
220
+
221
+ self.config.tools[tool_name].permission = permission
222
+ self.tool_manager.invalidate_tool(tool_name)
223
+
224
+ def emit_new_session_telemetry(
225
+ self, entrypoint: Literal["cli", "acp", "programmatic"]
226
+ ) -> None:
227
+ has_agents_md = has_agents_md_file(Path.cwd())
228
+ nb_skills = len(self.skill_manager.available_skills)
229
+ nb_mcp_servers = len(self.config.mcp_servers)
230
+ nb_models = len(self.config.models)
231
+ self.telemetry_client.send_new_session(
232
+ has_agents_md=has_agents_md,
233
+ nb_skills=nb_skills,
234
+ nb_mcp_servers=nb_mcp_servers,
235
+ nb_models=nb_models,
236
+ entrypoint=entrypoint,
237
+ )
238
+
239
+ def _select_backend(self) -> BackendLike:
240
+ active_model = self.config.get_active_model()
241
+ provider = self.config.get_provider_for_model(active_model)
242
+ timeout = self.config.api_timeout
243
+ return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout)
244
+
245
+ def add_message(self, message: LLMMessage) -> None:
246
+ self.messages.append(message)
247
+
248
+ async def _save_messages(self) -> None:
249
+ await self.session_logger.save_interaction(
250
+ self.messages,
251
+ self.stats,
252
+ self._base_config,
253
+ self.tool_manager,
254
+ self.agent_profile,
255
+ )
256
+
257
+ async def _flush_new_messages(self) -> None:
258
+ await self._save_messages()
259
+
260
+ if not self.message_observer:
261
+ return
262
+
263
+ if self._last_observed_message_index >= len(self.messages):
264
+ return
265
+
266
+ for msg in self.messages[self._last_observed_message_index :]:
267
+ self.message_observer(msg)
268
+ self._last_observed_message_index = len(self.messages)
269
+
270
+ async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
271
+ self._clean_message_history()
272
+ async for event in self._conversation_loop(msg):
273
+ yield event
274
+
275
+ @property
276
+ def teleport_service(self) -> TeleportService:
277
+ if not _TELEPORT_AVAILABLE:
278
+ raise TeleportError(
279
+ "Teleport requires git to be installed. "
280
+ "Please install git and try again."
281
+ )
282
+
283
+ if self._teleport_service is None:
284
+ if _TeleportService is None:
285
+ raise TeleportError("_TeleportService is unexpectedly None")
286
+ self._teleport_service = _TeleportService(
287
+ session_logger=self.session_logger,
288
+ nuage_base_url=self.config.nuage_base_url,
289
+ nuage_workflow_id=self.config.nuage_workflow_id,
290
+ nuage_api_key=self.config.nuage_api_key,
291
+ )
292
+ return self._teleport_service
293
+
294
+ def teleport_to_vibe_nuage(
295
+ self, prompt: str | None
296
+ ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
297
+ from vibe.core.teleport.nuage import TeleportSession
298
+
299
+ session = TeleportSession(
300
+ metadata={
301
+ "agent": self.agent_profile.name,
302
+ "model": self.config.active_model,
303
+ "stats": self.stats.model_dump(),
304
+ },
305
+ messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]],
306
+ )
307
+ return self._teleport_generator(prompt, session)
308
+
309
+ async def _teleport_generator(
310
+ self, prompt: str | None, session: TeleportSession
311
+ ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
312
+ from vibe.core.teleport.errors import ServiceTeleportError
313
+
314
+ try:
315
+ async with self.teleport_service:
316
+ gen = self.teleport_service.execute(prompt=prompt, session=session)
317
+ response: TeleportPushResponseEvent | None = None
318
+ while True:
319
+ try:
320
+ event = await gen.asend(response)
321
+ response = yield event
322
+ except StopAsyncIteration:
323
+ break
324
+ except ServiceTeleportError as e:
325
+ raise TeleportError(str(e)) from e
326
+ finally:
327
+ self._teleport_service = None
328
+
329
+ def _setup_middleware(self) -> None:
330
+ """Configure middleware pipeline for this conversation."""
331
+ self.middleware_pipeline.clear()
332
+
333
+ if self._max_turns is not None:
334
+ self.middleware_pipeline.add(TurnLimitMiddleware(self._max_turns))
335
+
336
+ if self._max_price is not None:
337
+ self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
338
+
339
+ if self.config.auto_compact_threshold > 0:
340
+ self.middleware_pipeline.add(
341
+ AutoCompactMiddleware(self.config.auto_compact_threshold)
342
+ )
343
+ if self.config.context_warnings:
344
+ self.middleware_pipeline.add(
345
+ ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
346
+ )
347
+
348
+ self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
349
+
350
+ async def _handle_middleware_result(
351
+ self, result: MiddlewareResult
352
+ ) -> AsyncGenerator[BaseEvent]:
353
+ match result.action:
354
+ case MiddlewareAction.STOP:
355
+ yield AssistantEvent(
356
+ content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>",
357
+ stopped_by_middleware=True,
358
+ )
359
+
360
+ case MiddlewareAction.INJECT_MESSAGE:
361
+ if result.message:
362
+ injected_message = LLMMessage(
363
+ role=Role.user, content=result.message
364
+ )
365
+ self.messages.append(injected_message)
366
+
367
+ case MiddlewareAction.COMPACT:
368
+ old_tokens = result.metadata.get(
369
+ "old_tokens", self.stats.context_tokens
370
+ )
371
+ threshold = result.metadata.get(
372
+ "threshold", self.config.auto_compact_threshold
373
+ )
374
+ tool_call_id = str(uuid4())
375
+
376
+ yield CompactStartEvent(
377
+ tool_call_id=tool_call_id,
378
+ current_context_tokens=old_tokens,
379
+ threshold=threshold,
380
+ )
381
+ self.telemetry_client.send_auto_compact_triggered()
382
+
383
+ summary = await self.compact()
384
+
385
+ yield CompactEndEvent(
386
+ tool_call_id=tool_call_id,
387
+ old_context_tokens=old_tokens,
388
+ new_context_tokens=self.stats.context_tokens,
389
+ summary_length=len(summary),
390
+ )
391
+
392
+ case MiddlewareAction.CONTINUE:
393
+ pass
394
+
395
+ def _get_context(self) -> ConversationContext:
396
+ return ConversationContext(
397
+ messages=self.messages, stats=self.stats, config=self.config
398
+ )
399
+
400
+ def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
401
+ headers: dict[str, str] = {
402
+ "user-agent": get_user_agent(provider.backend),
403
+ "x-affinity": self.session_id,
404
+ }
405
+ if (
406
+ provider.backend == Backend.MISTRAL
407
+ and self._current_user_message_id is not None
408
+ ):
409
+ headers["metadata"] = json.dumps({
410
+ "message_id": self._current_user_message_id
411
+ })
412
+ return headers
413
+
414
+ async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
415
+ user_message = LLMMessage(role=Role.user, content=user_msg)
416
+ self.messages.append(user_message)
417
+ self.stats.steps += 1
418
+ self._current_user_message_id = user_message.message_id
419
+
420
+ if user_message.message_id is None:
421
+ raise AgentLoopError("User message must have a message_id")
422
+
423
+ yield UserMessageEvent(content=user_msg, message_id=user_message.message_id)
424
+
425
+ try:
426
+ should_break_loop = False
427
+ while not should_break_loop:
428
+ result = await self.middleware_pipeline.run_before_turn(
429
+ self._get_context()
430
+ )
431
+ async for event in self._handle_middleware_result(result):
432
+ yield event
433
+
434
+ if result.action == MiddlewareAction.STOP:
435
+ return
436
+
437
+ self.stats.steps += 1
438
+ user_cancelled = False
439
+ async for event in self._perform_llm_turn():
440
+ if is_user_cancellation_event(event):
441
+ user_cancelled = True
442
+ yield event
443
+ await self._flush_new_messages()
444
+
445
+ last_message = self.messages[-1]
446
+ should_break_loop = last_message.role != Role.tool
447
+
448
+ if user_cancelled:
449
+ return
450
+
451
+ finally:
452
+ await self._flush_new_messages()
453
+
454
+ async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
455
+ if self.enable_streaming:
456
+ async for event in self._stream_assistant_events():
457
+ yield event
458
+ else:
459
+ assistant_event = await self._get_assistant_event()
460
+ if assistant_event.content:
461
+ yield assistant_event
462
+
463
+ last_message = self.messages[-1]
464
+
465
+ parsed = self.format_handler.parse_message(last_message)
466
+ resolved = self.format_handler.resolve_tool_calls(parsed, self.tool_manager)
467
+
468
+ if not resolved.tool_calls and not resolved.failed_calls:
469
+ return
470
+
471
+ async for event in self._handle_tool_calls(resolved):
472
+ yield event
473
+
474
+ async def _stream_assistant_events(
475
+ self,
476
+ ) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
477
+ content_buffer = ""
478
+ reasoning_buffer = ""
479
+ chunks_with_content = 0
480
+ chunks_with_reasoning = 0
481
+ message_id: str | None = None
482
+ BATCH_SIZE = 5
483
+
484
+ async for chunk in self._chat_streaming():
485
+ if message_id is None:
486
+ message_id = chunk.message.message_id
487
+
488
+ if chunk.message.reasoning_content:
489
+ if content_buffer:
490
+ yield AssistantEvent(content=content_buffer, message_id=message_id)
491
+ content_buffer = ""
492
+ chunks_with_content = 0
493
+
494
+ reasoning_buffer += chunk.message.reasoning_content
495
+ chunks_with_reasoning += 1
496
+
497
+ if chunks_with_reasoning >= BATCH_SIZE:
498
+ yield ReasoningEvent(
499
+ content=reasoning_buffer, message_id=message_id
500
+ )
501
+ reasoning_buffer = ""
502
+ chunks_with_reasoning = 0
503
+
504
+ if chunk.message.content:
505
+ if reasoning_buffer:
506
+ yield ReasoningEvent(
507
+ content=reasoning_buffer, message_id=message_id
508
+ )
509
+ reasoning_buffer = ""
510
+ chunks_with_reasoning = 0
511
+
512
+ content_buffer += chunk.message.content
513
+ chunks_with_content += 1
514
+
515
+ if chunks_with_content >= BATCH_SIZE:
516
+ yield AssistantEvent(content=content_buffer, message_id=message_id)
517
+ content_buffer = ""
518
+ chunks_with_content = 0
519
+
520
+ if reasoning_buffer:
521
+ yield ReasoningEvent(content=reasoning_buffer, message_id=message_id)
522
+
523
+ if content_buffer:
524
+ yield AssistantEvent(content=content_buffer, message_id=message_id)
525
+
526
+ async def _get_assistant_event(self) -> AssistantEvent:
527
+ llm_result = await self._chat()
528
+ return AssistantEvent(
529
+ content=llm_result.message.content or "",
530
+ message_id=llm_result.message.message_id,
531
+ )
532
+
533
+ async def _emit_failed_tool_events(
534
+ self, failed_calls: list[FailedToolCall]
535
+ ) -> AsyncGenerator[ToolResultEvent]:
536
+ for failed in failed_calls:
537
+ error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}</{TOOL_ERROR_TAG}>"
538
+ yield ToolResultEvent(
539
+ tool_name=failed.tool_name,
540
+ tool_class=None,
541
+ error=error_msg,
542
+ tool_call_id=failed.call_id,
543
+ )
544
+ self.stats.tool_calls_failed += 1
545
+ self.messages.append(
546
+ self.format_handler.create_failed_tool_response_message(
547
+ failed, error_msg
548
+ )
549
+ )
550
+
551
+ async def _process_one_tool_call(
552
+ self, tool_call: ResolvedToolCall
553
+ ) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
554
+ try:
555
+ tool_instance = self.tool_manager.get(tool_call.tool_name)
556
+ except Exception as exc:
557
+ error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
558
+ yield ToolResultEvent(
559
+ tool_name=tool_call.tool_name,
560
+ tool_class=tool_call.tool_class,
561
+ error=error_msg,
562
+ tool_call_id=tool_call.call_id,
563
+ )
564
+ self._handle_tool_response(tool_call, error_msg, "failure")
565
+ return
566
+
567
+ decision = await self._should_execute_tool(
568
+ tool_instance, tool_call.validated_args, tool_call.call_id
569
+ )
570
+
571
+ if decision.verdict == ToolExecutionResponse.SKIP:
572
+ self.stats.tool_calls_rejected += 1
573
+ skip_reason = decision.feedback or str(
574
+ get_user_cancellation_message(
575
+ CancellationReason.TOOL_SKIPPED, tool_call.tool_name
576
+ )
577
+ )
578
+ yield ToolResultEvent(
579
+ tool_name=tool_call.tool_name,
580
+ tool_class=tool_call.tool_class,
581
+ skipped=True,
582
+ skip_reason=skip_reason,
583
+ tool_call_id=tool_call.call_id,
584
+ )
585
+ self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
586
+ return
587
+
588
+ self.stats.tool_calls_agreed += 1
589
+
590
+ try:
591
+ start_time = time.perf_counter()
592
+ result_model = None
593
+ async for item in tool_instance.invoke(
594
+ ctx=InvokeContext(
595
+ tool_call_id=tool_call.call_id,
596
+ approval_callback=self.approval_callback,
597
+ agent_manager=self.agent_manager,
598
+ user_input_callback=self.user_input_callback,
599
+ ),
600
+ **tool_call.args_dict,
601
+ ):
602
+ if isinstance(item, ToolStreamEvent):
603
+ yield item
604
+ else:
605
+ result_model = item
606
+
607
+ duration = time.perf_counter() - start_time
608
+ if result_model is None:
609
+ raise ToolError("Tool did not yield a result")
610
+
611
+ result_dict = result_model.model_dump()
612
+ text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
613
+ self._handle_tool_response(
614
+ tool_call, text, "success", decision, result_dict
615
+ )
616
+ yield ToolResultEvent(
617
+ tool_name=tool_call.tool_name,
618
+ tool_class=tool_call.tool_class,
619
+ result=result_model,
620
+ duration=duration,
621
+ tool_call_id=tool_call.call_id,
622
+ )
623
+ self.stats.tool_calls_succeeded += 1
624
+
625
+ except asyncio.CancelledError:
626
+ cancel = str(
627
+ get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
628
+ )
629
+ yield ToolResultEvent(
630
+ tool_name=tool_call.tool_name,
631
+ tool_class=tool_call.tool_class,
632
+ error=cancel,
633
+ tool_call_id=tool_call.call_id,
634
+ )
635
+ self._handle_tool_response(tool_call, cancel, "failure", decision)
636
+ raise
637
+
638
+ except (ToolError, ToolPermissionError) as exc:
639
+ error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
640
+ yield ToolResultEvent(
641
+ tool_name=tool_call.tool_name,
642
+ tool_class=tool_call.tool_class,
643
+ error=error_msg,
644
+ tool_call_id=tool_call.call_id,
645
+ )
646
+ if isinstance(exc, ToolPermissionError):
647
+ self.stats.tool_calls_agreed -= 1
648
+ self.stats.tool_calls_rejected += 1
649
+ else:
650
+ self.stats.tool_calls_failed += 1
651
+ self._handle_tool_response(tool_call, error_msg, "failure", decision)
652
+
653
+ async def _handle_tool_calls(
654
+ self, resolved: ResolvedMessage
655
+ ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
656
+ async for event in self._emit_failed_tool_events(resolved.failed_calls):
657
+ yield event
658
+ for tool_call in resolved.tool_calls:
659
+ yield ToolCallEvent(
660
+ tool_name=tool_call.tool_name,
661
+ tool_class=tool_call.tool_class,
662
+ args=tool_call.validated_args,
663
+ tool_call_id=tool_call.call_id,
664
+ )
665
+ async for event in self._process_one_tool_call(tool_call):
666
+ yield event
667
+
668
+ def _handle_tool_response(
669
+ self,
670
+ tool_call: ResolvedToolCall,
671
+ text: str,
672
+ status: Literal["success", "failure", "skipped"],
673
+ decision: ToolDecision | None = None,
674
+ result: dict[str, Any] | None = None,
675
+ ) -> None:
676
+ self.messages.append(
677
+ LLMMessage.model_validate(
678
+ self.format_handler.create_tool_response_message(tool_call, text)
679
+ )
680
+ )
681
+
682
+ self.telemetry_client.send_tool_call_finished(
683
+ tool_call=tool_call,
684
+ agent_profile_name=self.agent_profile.name,
685
+ status=status,
686
+ decision=decision,
687
+ result=result,
688
+ )
689
+
690
+ async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
691
+ active_model = self.config.get_active_model()
692
+ provider = self.config.get_provider_for_model(active_model)
693
+
694
+ available_tools = self.format_handler.get_available_tools(self.tool_manager)
695
+ tool_choice = self.format_handler.get_tool_choice()
696
+
697
+ try:
698
+ start_time = time.perf_counter()
699
+ result = await self.backend.complete(
700
+ model=active_model,
701
+ messages=self.messages,
702
+ temperature=active_model.temperature,
703
+ tools=available_tools,
704
+ tool_choice=tool_choice,
705
+ extra_headers=self._get_extra_headers(provider),
706
+ max_tokens=max_tokens,
707
+ )
708
+ end_time = time.perf_counter()
709
+
710
+ if result.usage is None:
711
+ raise AgentLoopLLMResponseError(
712
+ "Usage data missing in non-streaming completion response"
713
+ )
714
+ self._update_stats(usage=result.usage, time_seconds=end_time - start_time)
715
+
716
+ processed_message = self.format_handler.process_api_response_message(
717
+ result.message
718
+ )
719
+ self.messages.append(processed_message)
720
+ return LLMChunk(message=processed_message, usage=result.usage)
721
+
722
+ except Exception as e:
723
+ if _should_raise_rate_limit_error(e):
724
+ raise RateLimitError(provider.name, active_model.name) from e
725
+
726
+ raise RuntimeError(
727
+ f"API error from {provider.name} (model: {active_model.name}): {e}"
728
+ ) from e
729
+
730
+ async def _chat_streaming(
731
+ self, max_tokens: int | None = None
732
+ ) -> AsyncGenerator[LLMChunk]:
733
+ active_model = self.config.get_active_model()
734
+ provider = self.config.get_provider_for_model(active_model)
735
+
736
+ available_tools = self.format_handler.get_available_tools(self.tool_manager)
737
+ tool_choice = self.format_handler.get_tool_choice()
738
+ try:
739
+ start_time = time.perf_counter()
740
+ usage = LLMUsage()
741
+ chunk_agg = LLMChunk(message=LLMMessage(role=Role.assistant))
742
+ async for chunk in self.backend.complete_streaming(
743
+ model=active_model,
744
+ messages=self.messages,
745
+ temperature=active_model.temperature,
746
+ tools=available_tools,
747
+ tool_choice=tool_choice,
748
+ extra_headers=self._get_extra_headers(provider),
749
+ max_tokens=max_tokens,
750
+ ):
751
+ processed_message = self.format_handler.process_api_response_message(
752
+ chunk.message
753
+ )
754
+ processed_chunk = LLMChunk(message=processed_message, usage=chunk.usage)
755
+ chunk_agg += processed_chunk
756
+ usage += chunk.usage or LLMUsage()
757
+ yield processed_chunk
758
+ end_time = time.perf_counter()
759
+
760
+ if chunk_agg.usage is None:
761
+ raise AgentLoopLLMResponseError(
762
+ "Usage data missing in final chunk of streamed completion"
763
+ )
764
+ self._update_stats(usage=usage, time_seconds=end_time - start_time)
765
+
766
+ self.messages.append(chunk_agg.message)
767
+
768
+ except Exception as e:
769
+ if _should_raise_rate_limit_error(e):
770
+ raise RateLimitError(provider.name, active_model.name) from e
771
+
772
+ raise RuntimeError(
773
+ f"API error from {provider.name} (model: {active_model.name}): {e}"
774
+ ) from e
775
+
776
+ def _update_stats(self, usage: LLMUsage, time_seconds: float) -> None:
777
+ self.stats.last_turn_duration = time_seconds
778
+ self.stats.last_turn_prompt_tokens = usage.prompt_tokens
779
+ self.stats.last_turn_completion_tokens = usage.completion_tokens
780
+ self.stats.session_prompt_tokens += usage.prompt_tokens
781
+ self.stats.session_completion_tokens += usage.completion_tokens
782
+ self.stats.context_tokens = usage.prompt_tokens + usage.completion_tokens
783
+ if time_seconds > 0 and usage.completion_tokens > 0:
784
+ self.stats.tokens_per_second = usage.completion_tokens / time_seconds
785
+
786
+ async def _should_execute_tool(
787
+ self, tool: BaseTool, args: BaseModel, tool_call_id: str
788
+ ) -> ToolDecision:
789
+ if self.auto_approve:
790
+ return ToolDecision(
791
+ verdict=ToolExecutionResponse.EXECUTE,
792
+ approval_type=ToolPermission.ALWAYS,
793
+ )
794
+
795
+ allowlist_denylist_result = tool.check_allowlist_denylist(args)
796
+ if allowlist_denylist_result == ToolPermission.ALWAYS:
797
+ return ToolDecision(
798
+ verdict=ToolExecutionResponse.EXECUTE,
799
+ approval_type=ToolPermission.ALWAYS,
800
+ )
801
+ elif allowlist_denylist_result == ToolPermission.NEVER:
802
+ denylist_patterns = tool.config.denylist
803
+ denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns)
804
+ return ToolDecision(
805
+ verdict=ToolExecutionResponse.SKIP,
806
+ approval_type=ToolPermission.NEVER,
807
+ feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]",
808
+ )
809
+
810
+ tool_name = tool.get_name()
811
+ perm = self.tool_manager.get_tool_config(tool_name).permission
812
+
813
+ if perm is ToolPermission.ALWAYS:
814
+ return ToolDecision(
815
+ verdict=ToolExecutionResponse.EXECUTE,
816
+ approval_type=ToolPermission.ALWAYS,
817
+ )
818
+ if perm is ToolPermission.NEVER:
819
+ return ToolDecision(
820
+ verdict=ToolExecutionResponse.SKIP,
821
+ approval_type=ToolPermission.NEVER,
822
+ feedback=f"Tool '{tool_name}' is permanently disabled",
823
+ )
824
+
825
+ return await self._ask_approval(tool_name, args, tool_call_id)
826
+
827
+ async def _ask_approval(
828
+ self, tool_name: str, args: BaseModel, tool_call_id: str
829
+ ) -> ToolDecision:
830
+ if not self.approval_callback:
831
+ return ToolDecision(
832
+ verdict=ToolExecutionResponse.SKIP,
833
+ approval_type=ToolPermission.ASK,
834
+ feedback="Tool execution not permitted.",
835
+ )
836
+ if asyncio.iscoroutinefunction(self.approval_callback):
837
+ async_callback = cast(AsyncApprovalCallback, self.approval_callback)
838
+ response, feedback = await async_callback(tool_name, args, tool_call_id)
839
+ else:
840
+ sync_callback = cast(SyncApprovalCallback, self.approval_callback)
841
+ response, feedback = sync_callback(tool_name, args, tool_call_id)
842
+
843
+ match response:
844
+ case ApprovalResponse.YES:
845
+ return ToolDecision(
846
+ verdict=ToolExecutionResponse.EXECUTE,
847
+ approval_type=ToolPermission.ASK,
848
+ feedback=feedback,
849
+ )
850
+ case ApprovalResponse.NO:
851
+ return ToolDecision(
852
+ verdict=ToolExecutionResponse.SKIP,
853
+ approval_type=ToolPermission.ASK,
854
+ feedback=feedback,
855
+ )
856
+
857
+ def _clean_message_history(self) -> None:
858
+ ACCEPTABLE_HISTORY_SIZE = 2
859
+ if len(self.messages) < ACCEPTABLE_HISTORY_SIZE:
860
+ return
861
+ self._fill_missing_tool_responses()
862
+ self._ensure_assistant_after_tools()
863
+
864
+ def _fill_missing_tool_responses(self) -> None:
865
+ i = 1
866
+ while i < len(self.messages): # noqa: PLR1702
867
+ msg = self.messages[i]
868
+
869
+ if msg.role == "assistant" and msg.tool_calls:
870
+ expected_responses = len(msg.tool_calls)
871
+
872
+ if expected_responses > 0:
873
+ actual_responses = 0
874
+ j = i + 1
875
+ while j < len(self.messages) and self.messages[j].role == "tool":
876
+ actual_responses += 1
877
+ j += 1
878
+
879
+ if actual_responses < expected_responses:
880
+ insertion_point = i + 1 + actual_responses
881
+
882
+ for call_idx in range(actual_responses, expected_responses):
883
+ tool_call_data = msg.tool_calls[call_idx]
884
+
885
+ empty_response = LLMMessage(
886
+ role=Role.tool,
887
+ tool_call_id=tool_call_data.id or "",
888
+ name=(tool_call_data.function.name or "")
889
+ if tool_call_data.function
890
+ else "",
891
+ content=str(
892
+ get_user_cancellation_message(
893
+ CancellationReason.TOOL_NO_RESPONSE
894
+ )
895
+ ),
896
+ )
897
+
898
+ self.messages.insert(insertion_point, empty_response)
899
+ insertion_point += 1
900
+
901
+ i = i + 1 + expected_responses
902
+ continue
903
+
904
+ i += 1
905
+
906
+ def _ensure_assistant_after_tools(self) -> None:
907
+ MIN_MESSAGE_SIZE = 2
908
+ if len(self.messages) < MIN_MESSAGE_SIZE:
909
+ return
910
+
911
+ last_msg = self.messages[-1]
912
+ if last_msg.role is Role.tool:
913
+ empty_assistant_msg = LLMMessage(role=Role.assistant, content="Understood.")
914
+ self.messages.append(empty_assistant_msg)
915
+
916
+ def _reset_session(self) -> None:
917
+ self.session_id = str(uuid4())
918
+ self.session_logger.reset_session(self.session_id)
919
+
920
+ def set_approval_callback(self, callback: ApprovalCallback) -> None:
921
+ self.approval_callback = callback
922
+
923
+ def set_user_input_callback(self, callback: UserInputCallback) -> None:
924
+ self.user_input_callback = callback
925
+
926
+ async def clear_history(self) -> None:
927
+ await self.session_logger.save_interaction(
928
+ self.messages,
929
+ self.stats,
930
+ self._base_config,
931
+ self.tool_manager,
932
+ self.agent_profile,
933
+ )
934
+ self.messages = self.messages[:1]
935
+
936
+ self.stats = AgentStats()
937
+ self.stats.trigger_listeners()
938
+
939
+ try:
940
+ active_model = self.config.get_active_model()
941
+ self.stats.update_pricing(
942
+ active_model.input_price, active_model.output_price
943
+ )
944
+ except ValueError:
945
+ pass
946
+
947
+ self.middleware_pipeline.reset()
948
+ self.tool_manager.reset_all()
949
+ self._reset_session()
950
+
951
+ async def compact(self) -> str:
952
+ try:
953
+ self._clean_message_history()
954
+ await self.session_logger.save_interaction(
955
+ self.messages,
956
+ self.stats,
957
+ self._base_config,
958
+ self.tool_manager,
959
+ self.agent_profile,
960
+ )
961
+
962
+ summary_request = UtilityPrompt.COMPACT.read()
963
+ self.messages.append(LLMMessage(role=Role.user, content=summary_request))
964
+ self.stats.steps += 1
965
+
966
+ summary_result = await self._chat()
967
+ if summary_result.usage is None:
968
+ raise AgentLoopLLMResponseError(
969
+ "Usage data missing in compaction summary response"
970
+ )
971
+ summary_content = summary_result.message.content or ""
972
+
973
+ system_message = self.messages[0]
974
+ summary_message = LLMMessage(role=Role.user, content=summary_content)
975
+ self.messages = [system_message, summary_message]
976
+ self._last_observed_message_index = 1
977
+
978
+ active_model = self.config.get_active_model()
979
+ provider = self.config.get_provider_for_model(active_model)
980
+
981
+ actual_context_tokens = await self.backend.count_tokens(
982
+ model=active_model,
983
+ messages=self.messages,
984
+ tools=self.format_handler.get_available_tools(self.tool_manager),
985
+ extra_headers={"user-agent": get_user_agent(provider.backend)},
986
+ )
987
+
988
+ self.stats.context_tokens = actual_context_tokens
989
+
990
+ self._reset_session()
991
+ await self.session_logger.save_interaction(
992
+ self.messages,
993
+ self.stats,
994
+ self._base_config,
995
+ self.tool_manager,
996
+ self.agent_profile,
997
+ )
998
+
999
+ self.middleware_pipeline.reset(reset_reason=ResetReason.COMPACT)
1000
+
1001
+ return summary_content or ""
1002
+
1003
+ except Exception:
1004
+ await self.session_logger.save_interaction(
1005
+ self.messages,
1006
+ self.stats,
1007
+ self._base_config,
1008
+ self.tool_manager,
1009
+ self.agent_profile,
1010
+ )
1011
+ raise
1012
+
1013
+ async def switch_agent(self, agent_name: str) -> None:
1014
+ if agent_name == self.agent_profile.name:
1015
+ return
1016
+ self.agent_manager.switch_profile(agent_name)
1017
+ await self.reload_with_initial_messages(reset_middleware=False)
1018
+
1019
+ async def reload_with_initial_messages(
1020
+ self,
1021
+ base_config: VibeConfig | None = None,
1022
+ max_turns: int | None = None,
1023
+ max_price: float | None = None,
1024
+ reset_middleware: bool = True,
1025
+ ) -> None:
1026
+ # Force an immediate yield to allow the UI to update before heavy sync work.
1027
+ # When there are no messages, save_interaction returns early without any await,
1028
+ # so the coroutine would run synchronously through ToolManager, SkillManager,
1029
+ # and system prompt generation without yielding control to the event loop.
1030
+ await asyncio.sleep(0)
1031
+
1032
+ await self.session_logger.save_interaction(
1033
+ self.messages,
1034
+ self.stats,
1035
+ self._base_config,
1036
+ self.tool_manager,
1037
+ self.agent_profile,
1038
+ )
1039
+
1040
+ if base_config is not None:
1041
+ self._base_config = base_config
1042
+ self.agent_manager.invalidate_config()
1043
+
1044
+ self.backend = self.backend_factory()
1045
+
1046
+ if max_turns is not None:
1047
+ self._max_turns = max_turns
1048
+ if max_price is not None:
1049
+ self._max_price = max_price
1050
+
1051
+ self.tool_manager = ToolManager(lambda: self.config)
1052
+ self.skill_manager = SkillManager(lambda: self.config)
1053
+
1054
+ new_system_prompt = get_universal_system_prompt(
1055
+ self.tool_manager, self.config, self.skill_manager, self.agent_manager
1056
+ )
1057
+
1058
+ self.messages = [
1059
+ LLMMessage(role=Role.system, content=new_system_prompt),
1060
+ *[msg for msg in self.messages if msg.role != Role.system],
1061
+ ]
1062
+
1063
+ if len(self.messages) == 1:
1064
+ self.stats.reset_context_state()
1065
+
1066
+ try:
1067
+ active_model = self.config.get_active_model()
1068
+ self.stats.update_pricing(
1069
+ active_model.input_price, active_model.output_price
1070
+ )
1071
+ except ValueError:
1072
+ pass
1073
+
1074
+ if reset_middleware:
1075
+ self._setup_middleware()