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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- vibe/whats_new.md +5 -0
vibe/core/agent_loop.py
ADDED
|
@@ -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()
|