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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. 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)