vtx-coding-agent 0.1.1__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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/runtime.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import config as vtx_config
|
|
8
|
+
from .config import get_last_selected, set_last_selected
|
|
9
|
+
from .context import Context
|
|
10
|
+
from .core.compaction import generate_summary
|
|
11
|
+
from .core.handoff import generate_handoff_prompt
|
|
12
|
+
from .core.types import AssistantMessage, TextContent, UserMessage
|
|
13
|
+
from .llm import (
|
|
14
|
+
ApiType,
|
|
15
|
+
BaseProvider,
|
|
16
|
+
Model,
|
|
17
|
+
ProviderConfig,
|
|
18
|
+
get_max_tokens,
|
|
19
|
+
get_model,
|
|
20
|
+
get_provider_class,
|
|
21
|
+
resolve_provider_api_type,
|
|
22
|
+
)
|
|
23
|
+
from .llm.base import AuthMode
|
|
24
|
+
from .llm.dynamic_models import find_dynamic_model, get_dynamic_provider_headers
|
|
25
|
+
from .loop import Agent
|
|
26
|
+
from .prompts import build_system_prompt
|
|
27
|
+
from .session import CustomMessageEntry, MessageEntry, Session
|
|
28
|
+
from .tools import BaseTool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_base_url_for_api(api_type: ApiType) -> str | None:
|
|
32
|
+
if api_type == ApiType.OPENAI_COMPLETIONS or api_type == ApiType.OPENAI_SDK:
|
|
33
|
+
return os.environ.get("VTX_BASE_URL", "https://api.openai.com/v1")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_base_url_for_provider(provider: str | None) -> str | None:
|
|
38
|
+
"""Return the canonical base URL for a known provider, if any."""
|
|
39
|
+
if not provider:
|
|
40
|
+
return None
|
|
41
|
+
from .llm.dynamic_models import DYNAMIC_PROVIDERS
|
|
42
|
+
|
|
43
|
+
config = DYNAMIC_PROVIDERS.get(provider)
|
|
44
|
+
if config is not None:
|
|
45
|
+
return config.base_url
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_provider(api_type: ApiType, config: ProviderConfig) -> BaseProvider:
|
|
50
|
+
"""Instantiate a provider, attaching any dynamic-provider default headers."""
|
|
51
|
+
merged_headers = dict(config.default_headers or {})
|
|
52
|
+
merged_headers.update(get_dynamic_provider_headers(config.provider or ""))
|
|
53
|
+
final_config = (
|
|
54
|
+
config
|
|
55
|
+
if not merged_headers
|
|
56
|
+
else ProviderConfig(
|
|
57
|
+
api_key=config.api_key,
|
|
58
|
+
base_url=config.base_url,
|
|
59
|
+
model=config.model,
|
|
60
|
+
max_tokens=config.max_tokens,
|
|
61
|
+
temperature=config.temperature,
|
|
62
|
+
thinking_level=config.thinking_level,
|
|
63
|
+
provider=config.provider,
|
|
64
|
+
session_id=config.session_id,
|
|
65
|
+
openai_compat_auth_mode=config.openai_compat_auth_mode,
|
|
66
|
+
anthropic_compat_auth_mode=config.anthropic_compat_auth_mode,
|
|
67
|
+
default_headers=merged_headers,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return get_provider_class(api_type)(final_config)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RuntimeInitResult:
|
|
75
|
+
provider_error: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class CompactionResult:
|
|
80
|
+
tokens_before: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class HandoffResult:
|
|
85
|
+
prompt: str
|
|
86
|
+
source_session: Session
|
|
87
|
+
new_session: Session
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class TreeNavigationResult:
|
|
92
|
+
editor_text: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConversationRuntime:
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
cwd: str,
|
|
100
|
+
model: str | None = None,
|
|
101
|
+
model_provider: str | None = None,
|
|
102
|
+
api_key: str | None = None,
|
|
103
|
+
base_url: str | None = None,
|
|
104
|
+
thinking_level: str | None = None,
|
|
105
|
+
tools: list[BaseTool],
|
|
106
|
+
openai_compat_auth_mode: AuthMode = "auto",
|
|
107
|
+
anthropic_compat_auth_mode: AuthMode = "auto",
|
|
108
|
+
) -> None:
|
|
109
|
+
self.cwd = cwd
|
|
110
|
+
|
|
111
|
+
# Use last selected settings if not explicitly provided
|
|
112
|
+
if model is None or model_provider is None or thinking_level is None:
|
|
113
|
+
last_selected = get_last_selected()
|
|
114
|
+
if model is None:
|
|
115
|
+
self.model = last_selected.model_id or vtx_config.llm.default_model or ""
|
|
116
|
+
else:
|
|
117
|
+
self.model = model
|
|
118
|
+
|
|
119
|
+
if model_provider is None:
|
|
120
|
+
self.model_provider = last_selected.provider or (
|
|
121
|
+
vtx_config.llm.default_provider
|
|
122
|
+
if model is None and not last_selected.model_id
|
|
123
|
+
else None
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
self.model_provider = model_provider
|
|
127
|
+
|
|
128
|
+
if thinking_level is None:
|
|
129
|
+
self.thinking_level = (
|
|
130
|
+
last_selected.thinking_level or vtx_config.llm.default_thinking_level or "high"
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
self.thinking_level = thinking_level
|
|
134
|
+
else:
|
|
135
|
+
self.model = model
|
|
136
|
+
self.model_provider = model_provider
|
|
137
|
+
self.thinking_level = thinking_level
|
|
138
|
+
|
|
139
|
+
self.api_key = api_key
|
|
140
|
+
self.base_url = base_url
|
|
141
|
+
self.tools = tools
|
|
142
|
+
self.openai_compat_auth_mode: AuthMode = openai_compat_auth_mode
|
|
143
|
+
self.anthropic_compat_auth_mode: AuthMode = anthropic_compat_auth_mode
|
|
144
|
+
|
|
145
|
+
self.provider: BaseProvider | None = None
|
|
146
|
+
self.session: Session | None = None
|
|
147
|
+
self.agent: Agent | None = None
|
|
148
|
+
self.context: Context | None = None
|
|
149
|
+
|
|
150
|
+
def resolve_system_prompt(
|
|
151
|
+
self, session: Session | None = None, context: Context | None = None
|
|
152
|
+
) -> str:
|
|
153
|
+
return (session.system_prompt if session else None) or build_system_prompt(
|
|
154
|
+
self.cwd, context=context, tools=self.tools
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _provider_config(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
model: str,
|
|
161
|
+
provider: str | None,
|
|
162
|
+
base_url: str | None,
|
|
163
|
+
thinking_level: str | None = None,
|
|
164
|
+
session_id: str | None = None,
|
|
165
|
+
) -> ProviderConfig:
|
|
166
|
+
return ProviderConfig(
|
|
167
|
+
api_key=self.api_key,
|
|
168
|
+
base_url=base_url,
|
|
169
|
+
model=model,
|
|
170
|
+
max_tokens=get_max_tokens(model),
|
|
171
|
+
thinking_level=thinking_level or self.thinking_level,
|
|
172
|
+
provider=provider,
|
|
173
|
+
session_id=session_id,
|
|
174
|
+
openai_compat_auth_mode=self.openai_compat_auth_mode,
|
|
175
|
+
anthropic_compat_auth_mode=self.anthropic_compat_auth_mode,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _model_api_and_base_url(
|
|
179
|
+
self, model: str, provider: str | None
|
|
180
|
+
) -> tuple[ApiType, str | None]:
|
|
181
|
+
model_info = get_model(model, provider)
|
|
182
|
+
if model_info:
|
|
183
|
+
return model_info.api, self.base_url or model_info.base_url
|
|
184
|
+
# Fall back to the dynamic catalog (cache-only) so `--model foo --provider kilo`
|
|
185
|
+
# at startup resolves to the right endpoint without a network call.
|
|
186
|
+
dynamic = find_dynamic_model(model, provider)
|
|
187
|
+
if dynamic is not None:
|
|
188
|
+
return dynamic.api, self.base_url or dynamic.base_url
|
|
189
|
+
api_type = resolve_provider_api_type(provider)
|
|
190
|
+
provider_default = default_base_url_for_provider(provider)
|
|
191
|
+
return api_type, self.base_url or provider_default or default_base_url_for_api(api_type)
|
|
192
|
+
|
|
193
|
+
def _new_agent(
|
|
194
|
+
self, provider: BaseProvider, session: Session, context: Context | None = None
|
|
195
|
+
) -> Agent:
|
|
196
|
+
context = context or Context.load(self.cwd)
|
|
197
|
+
return Agent(
|
|
198
|
+
provider=provider,
|
|
199
|
+
tools=self.tools,
|
|
200
|
+
session=session,
|
|
201
|
+
cwd=self.cwd,
|
|
202
|
+
context=context,
|
|
203
|
+
system_prompt=self.resolve_system_prompt(session, context=context),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def initialize(
|
|
207
|
+
self, *, resume_session: str | None = None, continue_recent: bool = False
|
|
208
|
+
) -> RuntimeInitResult:
|
|
209
|
+
session: Session | None = None
|
|
210
|
+
context = Context.load(self.cwd)
|
|
211
|
+
self.context = context
|
|
212
|
+
model = self.model
|
|
213
|
+
model_provider = self.model_provider
|
|
214
|
+
base_url_override = self.base_url
|
|
215
|
+
thinking_level = self.thinking_level
|
|
216
|
+
|
|
217
|
+
if resume_session:
|
|
218
|
+
session = Session.continue_by_id(self.cwd, resume_session)
|
|
219
|
+
if session.entries:
|
|
220
|
+
model_info = session.model
|
|
221
|
+
if model_info:
|
|
222
|
+
model_provider, model, session_base_url = model_info
|
|
223
|
+
if base_url_override is None and session_base_url:
|
|
224
|
+
base_url_override = session_base_url
|
|
225
|
+
thinking_level = session.thinking_level
|
|
226
|
+
elif continue_recent:
|
|
227
|
+
session = Session.continue_recent(
|
|
228
|
+
self.cwd,
|
|
229
|
+
provider=model_provider,
|
|
230
|
+
model_id=model,
|
|
231
|
+
thinking_level=thinking_level,
|
|
232
|
+
system_prompt=self.resolve_system_prompt(None, context=context),
|
|
233
|
+
)
|
|
234
|
+
if session.entries:
|
|
235
|
+
model_info = session.model
|
|
236
|
+
if model_info:
|
|
237
|
+
model_provider, model, session_base_url = model_info
|
|
238
|
+
if base_url_override is None and session_base_url:
|
|
239
|
+
base_url_override = session_base_url
|
|
240
|
+
thinking_level = session.thinking_level
|
|
241
|
+
|
|
242
|
+
self.base_url = base_url_override
|
|
243
|
+
api_type, effective_base_url = self._model_api_and_base_url(model, model_provider)
|
|
244
|
+
provider_config = self._provider_config(
|
|
245
|
+
model=model,
|
|
246
|
+
provider=model_provider,
|
|
247
|
+
base_url=effective_base_url,
|
|
248
|
+
thinking_level=thinking_level,
|
|
249
|
+
session_id=session.id if session else None,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
provider: BaseProvider | None = None
|
|
253
|
+
provider_error: str | None = None
|
|
254
|
+
try:
|
|
255
|
+
provider = create_provider(api_type, provider_config)
|
|
256
|
+
except ValueError as e:
|
|
257
|
+
provider_error = str(e)
|
|
258
|
+
|
|
259
|
+
if provider:
|
|
260
|
+
valid_levels = provider.thinking_levels
|
|
261
|
+
if thinking_level not in valid_levels:
|
|
262
|
+
thinking_level = valid_levels[0] if valid_levels else "high"
|
|
263
|
+
provider.set_thinking_level(thinking_level)
|
|
264
|
+
|
|
265
|
+
if not continue_recent and not resume_session:
|
|
266
|
+
selected_model = get_model(model, model_provider) or find_dynamic_model(
|
|
267
|
+
model, model_provider
|
|
268
|
+
)
|
|
269
|
+
model_provider = (
|
|
270
|
+
selected_model.provider
|
|
271
|
+
if selected_model
|
|
272
|
+
else (provider.name if provider else model_provider)
|
|
273
|
+
)
|
|
274
|
+
session = Session.create(
|
|
275
|
+
self.cwd,
|
|
276
|
+
provider=model_provider,
|
|
277
|
+
model_id=model,
|
|
278
|
+
thinking_level=thinking_level,
|
|
279
|
+
system_prompt=self.resolve_system_prompt(None, context=context),
|
|
280
|
+
tools=[t.name for t in self.tools],
|
|
281
|
+
)
|
|
282
|
+
if model_provider:
|
|
283
|
+
session.append_model_change(model_provider, model, effective_base_url)
|
|
284
|
+
|
|
285
|
+
self.model = model
|
|
286
|
+
self.model_provider = model_provider
|
|
287
|
+
self.thinking_level = thinking_level
|
|
288
|
+
self.provider = provider
|
|
289
|
+
self.session = session
|
|
290
|
+
self.agent = self._new_agent(provider, session, context) if provider and session else None
|
|
291
|
+
self._sync_provider_session_id()
|
|
292
|
+
|
|
293
|
+
set_last_selected(self.model, self.model_provider, self.thinking_level)
|
|
294
|
+
|
|
295
|
+
return RuntimeInitResult(provider_error=provider_error)
|
|
296
|
+
|
|
297
|
+
def _sync_provider_session_id(self) -> None:
|
|
298
|
+
if self.provider and self.session:
|
|
299
|
+
self.provider.config.session_id = self.session.id
|
|
300
|
+
|
|
301
|
+
def _current_provider_api_type(self) -> ApiType | None:
|
|
302
|
+
if self.provider is None:
|
|
303
|
+
return None
|
|
304
|
+
if (model_info := get_model(self.model, self.model_provider)) is not None:
|
|
305
|
+
return model_info.api
|
|
306
|
+
try:
|
|
307
|
+
return resolve_provider_api_type(self.model_provider)
|
|
308
|
+
except ValueError:
|
|
309
|
+
return ApiType(ApiType.OPENAI_SDK)
|
|
310
|
+
|
|
311
|
+
def create_session(self) -> Session:
|
|
312
|
+
selected_model = get_model(self.model, self.model_provider)
|
|
313
|
+
model_provider = (
|
|
314
|
+
selected_model.provider
|
|
315
|
+
if selected_model
|
|
316
|
+
else (self.provider.name if self.provider else self.model_provider or "openai")
|
|
317
|
+
)
|
|
318
|
+
model_base_url = selected_model.base_url if selected_model else None
|
|
319
|
+
if model_base_url is None and self.provider:
|
|
320
|
+
model_base_url = self.provider.config.base_url
|
|
321
|
+
|
|
322
|
+
session = Session.create(
|
|
323
|
+
self.cwd,
|
|
324
|
+
provider=model_provider,
|
|
325
|
+
model_id=self.model,
|
|
326
|
+
thinking_level=self.thinking_level,
|
|
327
|
+
system_prompt=self.resolve_system_prompt(),
|
|
328
|
+
tools=[t.name for t in self.tools],
|
|
329
|
+
)
|
|
330
|
+
session.append_model_change(model_provider, self.model, model_base_url)
|
|
331
|
+
return session
|
|
332
|
+
|
|
333
|
+
def new_session(self, *, reload_context: bool = False) -> Session:
|
|
334
|
+
session = self.create_session()
|
|
335
|
+
self.session = session
|
|
336
|
+
self.model_provider = session.model[0] if session.model else self.model_provider
|
|
337
|
+
self._sync_provider_session_id()
|
|
338
|
+
if self.agent is not None:
|
|
339
|
+
self.agent.session = session
|
|
340
|
+
if reload_context:
|
|
341
|
+
self.agent.reload_context()
|
|
342
|
+
elif self.provider is not None:
|
|
343
|
+
self.agent = self._new_agent(self.provider, session)
|
|
344
|
+
return session
|
|
345
|
+
|
|
346
|
+
def switch_model(self, model: Model) -> None:
|
|
347
|
+
current_api_type = self._current_provider_api_type()
|
|
348
|
+
current_provider = (
|
|
349
|
+
self.provider.config.provider or self.model_provider
|
|
350
|
+
if self.provider
|
|
351
|
+
else self.model_provider
|
|
352
|
+
)
|
|
353
|
+
current_base_url = self.provider.config.base_url if self.provider else None
|
|
354
|
+
base_url_changed = (current_base_url or "").rstrip("/") != (model.base_url or "").rstrip(
|
|
355
|
+
"/"
|
|
356
|
+
)
|
|
357
|
+
provider_changed = current_provider != model.provider
|
|
358
|
+
replacement_provider: BaseProvider | None = None
|
|
359
|
+
|
|
360
|
+
if model.api != current_api_type or provider_changed or base_url_changed:
|
|
361
|
+
provider_config = self._provider_config(
|
|
362
|
+
model=model.id,
|
|
363
|
+
provider=model.provider,
|
|
364
|
+
base_url=model.base_url,
|
|
365
|
+
session_id=self.session.id if self.session else None,
|
|
366
|
+
)
|
|
367
|
+
replacement_provider = create_provider(model.api, provider_config)
|
|
368
|
+
|
|
369
|
+
if replacement_provider is not None:
|
|
370
|
+
self.provider = replacement_provider
|
|
371
|
+
elif self.provider:
|
|
372
|
+
self.provider.config.model = model.id
|
|
373
|
+
self.provider.config.base_url = model.base_url
|
|
374
|
+
self.provider.config.max_tokens = get_max_tokens(model.id)
|
|
375
|
+
self.provider.config.provider = model.provider
|
|
376
|
+
|
|
377
|
+
self.model = model.id
|
|
378
|
+
self.model_provider = model.provider
|
|
379
|
+
|
|
380
|
+
if self.session:
|
|
381
|
+
self.session.set_model(model.provider, model.id, model.base_url)
|
|
382
|
+
if self.agent and self.provider:
|
|
383
|
+
self.agent.provider = self.provider
|
|
384
|
+
|
|
385
|
+
set_last_selected(self.model, self.model_provider, self.thinking_level)
|
|
386
|
+
|
|
387
|
+
def set_thinking_level(self, level: str) -> None:
|
|
388
|
+
if self.provider is None:
|
|
389
|
+
return
|
|
390
|
+
self.provider.set_thinking_level(level)
|
|
391
|
+
self.thinking_level = level
|
|
392
|
+
if self.session:
|
|
393
|
+
self.session.set_thinking_level(level)
|
|
394
|
+
set_last_selected(self.model, self.model_provider, self.thinking_level)
|
|
395
|
+
|
|
396
|
+
def load_session(self, session_path: str | Path) -> Session:
|
|
397
|
+
session = Session.load(session_path)
|
|
398
|
+
model = self.model
|
|
399
|
+
model_provider = self.model_provider
|
|
400
|
+
provider = self.provider
|
|
401
|
+
thinking_level = session.thinking_level
|
|
402
|
+
|
|
403
|
+
model_info = session.model
|
|
404
|
+
if model_info:
|
|
405
|
+
model_provider, model, session_base_url = model_info
|
|
406
|
+
restored_model = get_model(model, model_provider)
|
|
407
|
+
restored_base_url = session_base_url or (
|
|
408
|
+
restored_model.base_url if restored_model else None
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if restored_model:
|
|
412
|
+
current_api_type = self._current_provider_api_type()
|
|
413
|
+
if provider is None or restored_model.api != current_api_type:
|
|
414
|
+
provider_config = self._provider_config(
|
|
415
|
+
model=model,
|
|
416
|
+
provider=model_provider,
|
|
417
|
+
base_url=restored_base_url,
|
|
418
|
+
thinking_level=thinking_level,
|
|
419
|
+
session_id=session.id,
|
|
420
|
+
)
|
|
421
|
+
provider = create_provider(restored_model.api, provider_config)
|
|
422
|
+
elif provider is None:
|
|
423
|
+
api_type = resolve_provider_api_type(model_provider)
|
|
424
|
+
provider_config = self._provider_config(
|
|
425
|
+
model=model,
|
|
426
|
+
provider=model_provider,
|
|
427
|
+
base_url=restored_base_url or default_base_url_for_api(api_type),
|
|
428
|
+
thinking_level=thinking_level,
|
|
429
|
+
session_id=session.id,
|
|
430
|
+
)
|
|
431
|
+
provider = create_provider(api_type, provider_config)
|
|
432
|
+
else:
|
|
433
|
+
restored_base_url = None
|
|
434
|
+
|
|
435
|
+
if provider:
|
|
436
|
+
valid_levels = provider.thinking_levels
|
|
437
|
+
if valid_levels and thinking_level not in valid_levels:
|
|
438
|
+
thinking_level = valid_levels[0]
|
|
439
|
+
|
|
440
|
+
# Commit only after all provider construction/validation above has succeeded.
|
|
441
|
+
self.session = session
|
|
442
|
+
self.model = model
|
|
443
|
+
self.model_provider = model_provider
|
|
444
|
+
self.thinking_level = thinking_level
|
|
445
|
+
self.provider = provider
|
|
446
|
+
|
|
447
|
+
if model_info and self.provider:
|
|
448
|
+
self.provider.config.model = model
|
|
449
|
+
if restored_base_url:
|
|
450
|
+
self.provider.config.base_url = restored_base_url
|
|
451
|
+
self.provider.config.max_tokens = get_max_tokens(model)
|
|
452
|
+
self.provider.config.provider = model_provider
|
|
453
|
+
self.provider.config.session_id = session.id
|
|
454
|
+
|
|
455
|
+
if self.provider:
|
|
456
|
+
self.provider.set_thinking_level(thinking_level)
|
|
457
|
+
self.agent = self._new_agent(self.provider, session)
|
|
458
|
+
elif self.agent is not None:
|
|
459
|
+
self.agent.session = session
|
|
460
|
+
|
|
461
|
+
set_last_selected(self.model, self.model_provider, self.thinking_level)
|
|
462
|
+
|
|
463
|
+
return session
|
|
464
|
+
|
|
465
|
+
def navigate_tree(self, entry_id: str) -> TreeNavigationResult:
|
|
466
|
+
if self.session is None:
|
|
467
|
+
raise RuntimeError("Agent not initialized")
|
|
468
|
+
entry = self.session.get_entry(entry_id)
|
|
469
|
+
if entry is None:
|
|
470
|
+
raise ValueError(f"Entry not found: {entry_id}")
|
|
471
|
+
|
|
472
|
+
editor_text: str | None = None
|
|
473
|
+
if isinstance(entry, MessageEntry) and isinstance(entry.message, UserMessage):
|
|
474
|
+
self.session.move_to(entry.parent_id)
|
|
475
|
+
content = entry.message.content
|
|
476
|
+
if isinstance(content, str):
|
|
477
|
+
editor_text = content
|
|
478
|
+
else:
|
|
479
|
+
editor_text = "".join(
|
|
480
|
+
part.text for part in content if isinstance(part, TextContent)
|
|
481
|
+
)
|
|
482
|
+
elif isinstance(entry, CustomMessageEntry):
|
|
483
|
+
self.session.move_to(entry.parent_id)
|
|
484
|
+
editor_text = entry.content
|
|
485
|
+
else:
|
|
486
|
+
self.session.move_to(entry_id)
|
|
487
|
+
|
|
488
|
+
if self.agent is not None:
|
|
489
|
+
self.agent.session = self.session
|
|
490
|
+
self._sync_provider_session_id()
|
|
491
|
+
return TreeNavigationResult(editor_text=editor_text)
|
|
492
|
+
|
|
493
|
+
def prepare_for_run(self) -> Agent | None:
|
|
494
|
+
if self.provider is None or self.session is None:
|
|
495
|
+
return None
|
|
496
|
+
if self.agent is None:
|
|
497
|
+
self.agent = self._new_agent(self.provider, self.session)
|
|
498
|
+
|
|
499
|
+
model_info = get_model(self.model, self.model_provider)
|
|
500
|
+
self.agent.provider = self.provider
|
|
501
|
+
self.agent.session = self.session
|
|
502
|
+
self.agent.tools = self.tools
|
|
503
|
+
self.agent.config.context_window = model_info.context_window if model_info else None
|
|
504
|
+
self.agent.config.max_output_tokens = model_info.max_tokens if model_info else None
|
|
505
|
+
return self.agent
|
|
506
|
+
|
|
507
|
+
def reload_context(self) -> None:
|
|
508
|
+
if self.agent is not None:
|
|
509
|
+
self.agent.reload_context()
|
|
510
|
+
self.context = self.agent.context
|
|
511
|
+
else:
|
|
512
|
+
self.context = Context.load(self.cwd)
|
|
513
|
+
|
|
514
|
+
def latest_assistant_usage_tokens(self) -> int:
|
|
515
|
+
if self.session is None:
|
|
516
|
+
return 0
|
|
517
|
+
for entry in reversed(self.session.active_entries):
|
|
518
|
+
if isinstance(entry, MessageEntry) and isinstance(entry.message, AssistantMessage):
|
|
519
|
+
usage = entry.message.usage
|
|
520
|
+
if usage is None:
|
|
521
|
+
continue
|
|
522
|
+
return (
|
|
523
|
+
usage.input_tokens
|
|
524
|
+
+ usage.output_tokens
|
|
525
|
+
+ usage.cache_read_tokens
|
|
526
|
+
+ usage.cache_write_tokens
|
|
527
|
+
)
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
async def compact_now(self) -> CompactionResult:
|
|
531
|
+
if self.provider is None or self.session is None or self.agent is None:
|
|
532
|
+
raise RuntimeError("Agent not initialized")
|
|
533
|
+
|
|
534
|
+
tokens_before = self.latest_assistant_usage_tokens()
|
|
535
|
+
summary = await generate_summary(
|
|
536
|
+
self.session.all_messages, self.provider, system_prompt=self.agent.system_prompt
|
|
537
|
+
)
|
|
538
|
+
self.session.append_compaction(
|
|
539
|
+
summary=summary,
|
|
540
|
+
first_kept_entry_id=self.session.leaf_id or "",
|
|
541
|
+
tokens_before=tokens_before,
|
|
542
|
+
)
|
|
543
|
+
return CompactionResult(tokens_before=tokens_before)
|
|
544
|
+
|
|
545
|
+
async def create_handoff(self, query: str) -> HandoffResult:
|
|
546
|
+
if self.provider is None or self.session is None or self.agent is None:
|
|
547
|
+
raise RuntimeError("Agent not initialized")
|
|
548
|
+
|
|
549
|
+
source_session = self.session
|
|
550
|
+
prompt = await generate_handoff_prompt(
|
|
551
|
+
source_session.all_messages,
|
|
552
|
+
self.provider,
|
|
553
|
+
system_prompt=self.agent.system_prompt,
|
|
554
|
+
query=query,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
source_session_id = source_session.id
|
|
558
|
+
new_session = self.create_session()
|
|
559
|
+
new_session.append_custom_message(
|
|
560
|
+
"handoff_backlink",
|
|
561
|
+
f"Handoff from {source_session_id[:8]}",
|
|
562
|
+
display=False,
|
|
563
|
+
details={"target_session_id": source_session_id, "query": query},
|
|
564
|
+
)
|
|
565
|
+
source_session.append_custom_message(
|
|
566
|
+
"handoff_forward_link",
|
|
567
|
+
f"Handoff to {new_session.id[:8]}",
|
|
568
|
+
display=False,
|
|
569
|
+
details={"target_session_id": new_session.id, "query": query},
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
new_session.ensure_persisted()
|
|
573
|
+
source_session.ensure_persisted()
|
|
574
|
+
|
|
575
|
+
self.session = new_session
|
|
576
|
+
self._sync_provider_session_id()
|
|
577
|
+
if self.agent is not None:
|
|
578
|
+
self.agent.session = new_session
|
|
579
|
+
|
|
580
|
+
return HandoffResult(prompt=prompt, source_session=source_session, new_session=new_session)
|