Time Travel workspace selector + LiteLLM globals

Add selectable workspace support to the Time Travel plugin and introduce normalized global LiteLLM configuration handling. models.py: add DEFAULT_LITELLM_GLOBAL_KWARGS and helpers to normalize, load, apply, and merge LiteLLM global kwargs; call configure_litellm/set per-call merges in LiteLLM wrappers so framework defaults, configured globals, and per-call overrides combine correctly. Add tests to assert merging and runtime application. plugins/_time_travel: update docs and plugin metadata to describe workdir/project selection. API handlers now accept workspace_id and a new history_workspaces endpoint lists selectable workspaces. helpers/time_travel: implement workspace listing, selection, and resolution logic (workdir/project options, availability/locking, default selection). UI: add workspace picker to time-travel panel, wire selection/load into time-travel store, pass workspace_id to API calls, and add click hook to open the panel. Update styles and refresh/load behavior accordingly. Tests: extend tests for LiteLLM global kwargs merging and adapt stream test to expect merged params; add tests for selectable workspaces, default selection, and locked external workdir handling.

frdel committed Jun 9, 2026 at 17:16 UTC 77f7aa0274605210d76002fa9d5260738e502e82
17 files changed +576 -75
AGENTS.md
+1
@@ -101,6 +101,7 @@ Key Files:
101 - helpers/plugins.py: Plugin discovery and configuration logic.
102 - webui/js/AlpineStore.js: Store factory for reactive frontend state.
103 - helpers/api.py: Base class for all API endpoints.
104 +- models.py: LLM provider configuration and LiteLLM wrappers; framework LiteLLM defaults such as `drop_params=True` are merged with `litellm_global_kwargs`, configured values override framework defaults, and the merged kwargs are applied at LiteLLM module and per-call boundaries.
105 - scripts/openrouter_release_notes_system_prompt.md: Editable system prompt used to generate GitHub release notes during Docker publishing.
106 - knowledge/main/about/: Agent self-knowledge files, indexed into the vector DB for runtime recall. Not user-facing docs - written for the agent's internal reference.
107 - webui/components/AGENTS.md: DOX contract for Alpine component architecture.
models.py
+97 -32
@@ -45,6 +45,48 @@ from sentence_transformers import SentenceTransformer
45 from pydantic import ConfigDict
46
47
48 +DEFAULT_LITELLM_GLOBAL_KWARGS: dict[str, Any] = {
49 + "drop_params": True,
50 +}
51 +
52 +
53 +def _normalize_litellm_kwargs(values: dict[str, Any]) -> dict[str, Any]:
54 + # Normalize .env/UI-style scalar strings into native types for LiteLLM.
55 + result: dict[str, Any] = {}
56 + for k, v in values.items():
57 + if isinstance(v, str):
58 + stripped = v.strip()
59 + lowered = stripped.lower()
60 + if lowered == "true":
61 + result[k] = True
62 + elif lowered == "false":
63 + result[k] = False
64 + elif lowered in ("none", "null"):
65 + result[k] = None
66 + else:
67 + try:
68 + result[k] = int(stripped)
69 + except ValueError:
70 + try:
71 + result[k] = float(stripped)
72 + except ValueError:
73 + result[k] = v
74 + else:
75 + result[k] = v
76 + return result
77 +
78 +
79 +def get_litellm_global_kwargs() -> dict[str, Any]:
80 + kwargs = _normalize_litellm_kwargs(DEFAULT_LITELLM_GLOBAL_KWARGS)
81 + try:
82 + configured = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr]
83 + except Exception:
84 + configured = {}
85 + if isinstance(configured, dict):
86 + kwargs.update(_normalize_litellm_kwargs(configured))
87 + return kwargs
88 +
89 +
90 # keep provider logging quiet in normal operation
91 def turn_off_logging():
92 os.environ["LITELLM_LOG"] = "ERROR" # only errors
@@ -55,9 +97,30 @@ def turn_off_logging():
97 logging.getLogger(name).setLevel(logging.ERROR)
98
99
100 +def set_litellm_params():
101 + global_kwargs = get_litellm_global_kwargs()
102 + for key, value in global_kwargs.items():
103 + setattr(litellm, key, value)
104 + return global_kwargs
105 +
106 +
107 +def configure_litellm():
108 + turn_off_logging()
109 + set_litellm_params()
110 +
111 +
112 +def _merge_litellm_call_kwargs(*overrides: dict[str, Any] | None) -> dict[str, Any]:
113 + kwargs = get_litellm_global_kwargs()
114 + for override in overrides:
115 + if isinstance(override, dict):
116 + kwargs.update(override)
117 + return kwargs
118 +
119 +
120 # init
121 load_dotenv()
60 -turn_off_logging()
122 +configure_litellm()
123 +
124
125 class ModelType(Enum):
126 CHAT = "Chat"
@@ -390,13 +453,16 @@ class LiteLLMChatWrapper(SimpleChatModel):
453 ) -> str:
454 import asyncio
455
456 + configure_litellm()
457 msgs = self._convert_messages(messages)
458
459 # Apply rate limiting if configured
460 apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
461
462 # Call the model
399 - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
463 + call_kwargs = _without_stream_kwarg(
464 + _merge_litellm_call_kwargs(self.kwargs, kwargs)
465 + )
466 resp = completion(
467 model=self.model_name, messages=msgs, stop=stop, **call_kwargs
468 )
@@ -415,13 +481,16 @@ class LiteLLMChatWrapper(SimpleChatModel):
481 ) -> Iterator[ChatGenerationChunk]:
482 import asyncio
483
484 + configure_litellm()
485 msgs = self._convert_messages(messages)
486
487 # Apply rate limiting if configured
488 apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
489
490 result = ChatGenerationResult()
424 - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
491 + call_kwargs = _without_stream_kwarg(
492 + _merge_litellm_call_kwargs(self.kwargs, kwargs)
493 + )
494
495 for chunk in completion(
496 model=self.model_name,
@@ -447,13 +516,16 @@ class LiteLLMChatWrapper(SimpleChatModel):
516 run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
517 **kwargs: Any,
518 ) -> AsyncIterator[ChatGenerationChunk]:
519 + configure_litellm()
520 msgs = self._convert_messages(messages)
521
522 # Apply rate limiting if configured
523 await apply_rate_limiter(self.a0_model_conf, str(msgs))
524
525 result = ChatGenerationResult()
456 - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
526 + call_kwargs = _without_stream_kwarg(
527 + _merge_litellm_call_kwargs(self.kwargs, kwargs)
528 + )
529
530 response = await acompletion(
531 model=self.model_name,
@@ -488,7 +560,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
560 **kwargs: Any,
561 ) -> Tuple[str, str]:
562
491 - turn_off_logging()
563 + configure_litellm()
564
565 if not messages:
566 messages = []
@@ -507,7 +579,9 @@ class LiteLLMChatWrapper(SimpleChatModel):
579 )
580
581 # Prepare call kwargs and retry config (strip A0-only params before calling LiteLLM)
510 - call_kwargs: dict[str, Any] = _without_stream_kwarg({**self.kwargs, **kwargs})
582 + call_kwargs: dict[str, Any] = _without_stream_kwarg(
583 + _merge_litellm_call_kwargs(self.kwargs, kwargs)
584 + )
585 max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2))
586 retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5))
587 stream = reasoning_callback is not None or response_callback is not None or tokens_callback is not None
@@ -610,20 +684,30 @@ class LiteLLMEmbeddingWrapper(Embeddings):
684 self.a0_model_conf = model_config
685
686 def embed_documents(self, texts: List[str]) -> List[List[float]]:
687 + configure_litellm()
688 # Apply rate limiting if configured
689 apply_rate_limiter_sync(self.a0_model_conf, " ".join(texts))
690
616 - resp = embedding(model=self.model_name, input=texts, **self.kwargs)
691 + resp = embedding(
692 + model=self.model_name,
693 + input=texts,
694 + **_merge_litellm_call_kwargs(self.kwargs),
695 + )
696 return [
697 item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore
698 for item in resp.data # type: ignore
699 ]
700
701 def embed_query(self, text: str) -> List[float]:
702 + configure_litellm()
703 # Apply rate limiting if configured
704 apply_rate_limiter_sync(self.a0_model_conf, text)
705
626 - resp = embedding(model=self.model_name, input=[text], **self.kwargs)
706 + resp = embedding(
707 + model=self.model_name,
708 + input=[text],
709 + **_merge_litellm_call_kwargs(self.kwargs),
710 + )
711 item = resp.data[0] # type: ignore
712 return item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore
713
@@ -781,22 +865,6 @@ def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict):
865 def _merge_provider_defaults(
866 provider_type: ProviderModelType, original_provider: str, kwargs: dict
867 ) -> tuple[str, dict]:
784 - # Normalize .env-style numeric strings (e.g., "timeout=30") into ints/floats for LiteLLM
785 - def _normalize_values(values: dict) -> dict:
786 - result: dict[str, Any] = {}
787 - for k, v in values.items():
788 - if isinstance(v, str):
789 - try:
790 - result[k] = int(v)
791 - except ValueError:
792 - try:
793 - result[k] = float(v)
794 - except ValueError:
795 - result[k] = v
796 - else:
797 - result[k] = v
798 - return result
799 -
868 provider_name = original_provider # default: unchanged
869 cfg = get_provider_config(provider_type, original_provider)
870 if cfg:
@@ -814,14 +882,11 @@ def _merge_provider_defaults(
882 if key and key not in ("None", "NA"):
883 kwargs["api_key"] = key
884
817 - # Merge LiteLLM global kwargs (timeouts, stream_timeout, etc.)
818 - try:
819 - global_kwargs = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr]
820 - except Exception:
821 - global_kwargs = {}
822 - if isinstance(global_kwargs, dict):
823 - for k, v in _normalize_values(global_kwargs).items():
824 - kwargs.setdefault(k, v)
885 + # Merge LiteLLM global kwargs. Framework defaults are merged first, then
886 + # configured global kwargs override those defaults; explicit provider/model
887 + # kwargs still keep priority via setdefault.
888 + for k, v in get_litellm_global_kwargs().items():
889 + kwargs.setdefault(k, v)
890
891 return provider_name, kwargs
892
plugins/_time_travel/AGENTS.md
+5 -5
@@ -2,18 +2,18 @@
2
3 ## Purpose
4
5 -- Own Agent Zero workspace history, diff inspection, travel, snapshots, and revert for active `/a0/usr` workspaces.
5 +- Own Agent Zero workspace history, diff inspection, travel, snapshots, and revert for selectable Agent Zero workdir/project workspaces under `/a0/usr`.
6
7 ## Ownership
8
9 - `helpers/time_travel.py` owns history storage, diff, travel, snapshot, preview, and revert mechanics.
10 -- `api/` owns history list, diff, preview, revert, snapshot, and travel endpoints.
11 -- `webui/` owns the time-travel panel, store, main surface, and thumbnail.
10 +- `api/` owns selectable workspace list, history list, diff, preview, revert, snapshot, and travel endpoints.
11 +- `webui/` owns the time-travel panel, workspace selector, store, main surface, and thumbnail.
12 - `plugin.yaml` and `extensions/` own metadata and hook contributions.
13
14 ## Local Contracts
15
16 -- Keep history operations scoped to Agent Zero-owned workspaces.
16 +- Keep history operations scoped to Agent Zero-owned workdir/project workspaces.
17 - Revert and travel operations must avoid unintended writes outside managed workspace paths.
18 - Preserve enough metadata for clear preview and diff inspection before destructive actions.
19
@@ -23,7 +23,7 @@
23
24 ## Verification
25
26 -- Smoke-test snapshot, list, diff, preview, travel, and revert flows after changes.
26 +- Smoke-test workspace selection, snapshot, list, diff, preview, travel, and revert flows after changes.
27
28 ## Child DOX Index
29
plugins/_time_travel/api/history_diff.py
+6 -1
@@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
12 class HistoryDiff(ApiHandler):
13 async def process(self, input: dict, request: Request) -> dict | Response:
14 context_id = str(input.get("context_id") or "").strip()
15 + workspace_id = str(input.get("workspace_id") or "").strip()
16 try:
16 - workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + workspace = resolve_workspace(
18 + context_id,
19 + workspace_id=workspace_id,
20 + context_loader=self.use_context,
21 + )
22 return TimeTravelService(workspace).history_diff(
23 commit_hash=str(input.get("commit_hash") or ""),
24 path=str(input.get("path") or ""),
plugins/_time_travel/api/history_list.py
+6 -1
@@ -13,8 +13,13 @@ from plugins._time_travel.helpers.time_travel import (
13 class HistoryList(ApiHandler):
14 async def process(self, input: dict, request: Request) -> dict | Response:
15 context_id = str(input.get("context_id") or "").strip()
16 + workspace_id = str(input.get("workspace_id") or "").strip()
17 try:
17 - workspace = resolve_workspace(context_id, context_loader=self.use_context)
18 + workspace = resolve_workspace(
19 + context_id,
20 + workspace_id=workspace_id,
21 + context_loader=self.use_context,
22 + )
23 return TimeTravelService(workspace).history_list(
24 limit=int(input.get("limit") or 100),
25 offset=int(input.get("offset") or 0),
plugins/_time_travel/api/history_preview.py
+6 -1
@@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
12 class HistoryPreview(ApiHandler):
13 async def process(self, input: dict, request: Request) -> dict | Response:
14 context_id = str(input.get("context_id") or "").strip()
15 + workspace_id = str(input.get("workspace_id") or "").strip()
16 try:
16 - workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + workspace = resolve_workspace(
18 + context_id,
19 + workspace_id=workspace_id,
20 + context_loader=self.use_context,
21 + )
22 return TimeTravelService(workspace).preview(
23 operation=str(input.get("operation") or ""),
24 commit_hash=str(input.get("commit_hash") or ""),
plugins/_time_travel/api/history_revert.py
+6 -1
@@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
12 class HistoryRevert(ApiHandler):
13 async def process(self, input: dict, request: Request) -> dict | Response:
14 context_id = str(input.get("context_id") or "").strip()
15 + workspace_id = str(input.get("workspace_id") or "").strip()
16 try:
16 - workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + workspace = resolve_workspace(
18 + context_id,
19 + workspace_id=workspace_id,
20 + context_loader=self.use_context,
21 + )
22 return TimeTravelService(workspace).revert(
23 commit_hash=str(input.get("commit_hash") or ""),
24 metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
plugins/_time_travel/api/history_snapshot.py
+6 -1
@@ -13,8 +13,13 @@ from plugins._time_travel.helpers.time_travel import (
13 class HistorySnapshot(ApiHandler):
14 async def process(self, input: dict, request: Request) -> dict | Response:
15 context_id = str(input.get("context_id") or "").strip()
16 + workspace_id = str(input.get("workspace_id") or "").strip()
17 try:
17 - workspace = resolve_workspace(context_id, context_loader=self.use_context)
18 + workspace = resolve_workspace(
19 + context_id,
20 + workspace_id=workspace_id,
21 + context_loader=self.use_context,
22 + )
23 snapshot = TimeTravelService(workspace).snapshot(
24 trigger=str(input.get("trigger") or "manual"),
25 message=str(input.get("message") or ""),
plugins/_time_travel/api/history_travel.py
+6 -1
@@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
12 class HistoryTravel(ApiHandler):
13 async def process(self, input: dict, request: Request) -> dict | Response:
14 context_id = str(input.get("context_id") or "").strip()
15 + workspace_id = str(input.get("workspace_id") or "").strip()
16 try:
16 - workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + workspace = resolve_workspace(
18 + context_id,
19 + workspace_id=workspace_id,
20 + context_loader=self.use_context,
21 + )
22 return TimeTravelService(workspace).travel(
23 commit_hash=str(input.get("commit_hash") or ""),
24 metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
plugins/_time_travel/api/history_workspaces.py new
+11
@@ -0,0 +1,11 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import list_selectable_workspaces
5 +
6 +
7 +class HistoryWorkspaces(ApiHandler):
8 + async def process(self, input: dict, request: Request) -> dict | Response:
9 + context_id = str(input.get("context_id") or "").strip()
10 + data = list_selectable_workspaces(context_id, context_loader=self.use_context)
11 + return {"ok": True, **data}
plugins/_time_travel/extensions/webui/sidebar-quick-actions-dropdown-start/time-travel-entry.html
+1 -1
@@ -5,7 +5,7 @@
5 class="dropdown-item"
6 id="time-travel-dropdown"
7 title="Time Travel"
8 - @click="ensureModalOpen('/plugins/_time_travel/webui/main.html'); $store.sidebar.menuClose()">
8 + @click="$store.timeTravel?.onOpen?.(); ensureModalOpen('/plugins/_time_travel/webui/main.html'); $store.sidebar.menuClose()">
9 <span class="material-symbols-outlined">history</span>
10 <span>Time Travel</span>
11 </button>
plugins/_time_travel/helpers/time_travel.py
+156 -5
@@ -210,7 +210,132 @@ def canonical_workspace_display_path(display_path: str) -> str:
210 return (canonical if canonical.startswith("/a0") else normalized).rstrip("/") or canonical
211
212
213 -def resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
213 +def configured_workdir_display_path() -> str:
214 + from helpers import settings
215 +
216 + configured = str(settings.get_settings().get("workdir_path") or "")
217 + fallback = files.normalize_a0_path(files.get_abs_path("usr/workdir"))
218 + return canonical_workspace_display_path(configured or fallback)
219 +
220 +
221 +def _workspace_option(
222 + *,
223 + kind: str,
224 + display_path: str,
225 + label: str,
226 + name: str = "",
227 + title: str = "",
228 + project_name: str = "",
229 + color: str = "",
230 +) -> dict[str, Any]:
231 + normalized = canonical_workspace_display_path(display_path)
232 + available = is_inside_usr_display(normalized)
233 + error = (
234 + "" if available else "Time Travel is only available for workspaces inside /a0/usr."
235 + )
236 + return {
237 + "id": workspace_id_for(normalized),
238 + "kind": kind,
239 + "name": name,
240 + "title": title or label,
241 + "label": label,
242 + "display_path": normalized.rstrip("/") or normalized,
243 + "path": normalized.rstrip("/") or normalized,
244 + "project_name": project_name,
245 + "color": color,
246 + "available": available,
247 + "locked": not available,
248 + "error": error,
249 + }
250 +
251 +
252 +def list_selectable_workspaces(
253 + context_id: str = "",
254 + *,
255 + context_loader=None,
256 +) -> dict[str, Any]:
257 + from helpers import projects
258 +
259 + context_id = str(context_id or "").strip()
260 + workspaces: list[dict[str, Any]] = []
261 + seen_ids: set[str] = set()
262 +
263 + def add_workspace(option: dict[str, Any]) -> None:
264 + option_id = str(option.get("id") or "")
265 + if not option_id or option_id in seen_ids:
266 + return
267 + seen_ids.add(option_id)
268 + workspaces.append(option)
269 +
270 + add_workspace(
271 + _workspace_option(
272 + kind="workdir",
273 + display_path=configured_workdir_display_path(),
274 + label="User working directory",
275 + name="workdir",
276 + title="User working directory",
277 + )
278 + )
279 +
280 + for project in projects.get_active_projects_list() or []:
281 + project_name = str(project.get("name") or "").strip()
282 + if not project_name:
283 + continue
284 + title = str(project.get("title") or project_name)
285 + add_workspace(
286 + _workspace_option(
287 + kind="project",
288 + display_path=files.normalize_a0_path(
289 + projects.get_project_folder(project_name)
290 + ),
291 + label=title,
292 + name=project_name,
293 + title=title,
294 + project_name=project_name,
295 + color=str(project.get("color") or ""),
296 + )
297 + )
298 +
299 + default_workspace_id = ""
300 + try:
301 + default_workspace_id = _resolve_context_workspace(
302 + context_id,
303 + context_loader=context_loader,
304 + ).id
305 + except TimeTravelError:
306 + default_workspace_id = ""
307 +
308 + if default_workspace_id not in seen_ids:
309 + default_workspace_id = ""
310 + if not default_workspace_id and workspaces:
311 + default_workspace_id = str(workspaces[0].get("id") or "")
312 +
313 + return {
314 + "context_id": context_id,
315 + "workspaces": workspaces,
316 + "default_workspace_id": default_workspace_id,
317 + }
318 +
319 +
320 +def _selectable_workspace_by_id(
321 + workspace_id: str,
322 + context_id: str = "",
323 + *,
324 + context_loader=None,
325 +) -> dict[str, Any] | None:
326 + wanted = str(workspace_id or "").strip()
327 + if not wanted:
328 + return None
329 + for workspace in list_selectable_workspaces(
330 + context_id,
331 + context_loader=context_loader,
332 + )["workspaces"]:
333 + if str(workspace.get("id") or "") == wanted:
334 + return workspace
335 + return None
336 +
337 +
338 +def _resolve_context_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
339 from helpers import projects, settings
340
341 context_id = str(context_id or "").strip()
@@ -246,9 +371,36 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace
371 )
372
373
249 -def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
250 - from helpers import settings
374 +def resolve_workspace(
375 + context_id: str = "",
376 + *,
377 + workspace_id: str = "",
378 + context_loader=None,
379 +) -> WorkspaceInfo:
380 + workspace_id = str(workspace_id or "").strip()
381 + if not workspace_id:
382 + return _resolve_context_workspace(context_id, context_loader=context_loader)
383 +
384 + selected = _selectable_workspace_by_id(
385 + workspace_id,
386 + context_id,
387 + context_loader=context_loader,
388 + )
389 + if not selected:
390 + raise WorkspaceRejectedError("Selected Time Travel workspace is not available.")
391 + if selected.get("locked") or selected.get("available") is False:
392 + raise WorkspaceRejectedError(
393 + str(selected.get("error") or "Selected Time Travel workspace is not available.")
394 + )
395
396 + return _workspace_from_display(
397 + str(selected.get("display_path") or selected.get("path") or ""),
398 + project_name=str(selected.get("project_name") or ""),
399 + context_id=str(context_id or "").strip(),
400 + )
401 +
402 +
403 +def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
404 normalized = canonical_workspace_display_path(path_hint)
405 if not is_inside_usr_display(normalized):
406 return None
@@ -258,8 +410,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
410 project_display = f"/a0/usr/projects/{parts[3]}"
411 return _workspace_from_display(project_display, project_name=parts[3])
412
261 - configured = str(settings.get_settings().get("workdir_path") or "")
262 - workdir_display = canonical_workspace_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
413 + workdir_display = configured_workdir_display_path()
414 if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"):
415 return _workspace_from_display(workdir_display)
416
plugins/_time_travel/plugin.yaml
+1 -1
@@ -1,6 +1,6 @@
1 name: _time_travel
2 title: Time Travel
3 -description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces.
3 +description: Agent Zero-owned workdir/project history, diff inspection, travel, and revert for /a0/usr workspaces.
4 version: 0.1.0
5 always_enabled: false
6 settings_sections: []
plugins/_time_travel/webui/time-travel-panel.html
+59 -19
@@ -9,16 +9,29 @@
9 <template x-if="$store.timeTravel">
10 <div class="time-travel-shell">
11 <div class="time-travel-toolbar">
12 - <div class="time-travel-title">
13 - <span class="material-symbols-outlined">history</span>
14 - <span>Time Travel</span>
12 + <div class="time-travel-workspace-picker" :title="$store.timeTravel.workspacePath || $store.timeTravel.workspaceOptionLabel($store.timeTravel.selectedWorkspace())">
13 + <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
14 + <select
15 + aria-label="Workspace"
16 + :value="$store.timeTravel.selectedWorkspaceId"
17 + @change="$store.timeTravel.selectWorkspace($event.target.value)"
18 + :disabled="$store.timeTravel.workspaceLoading || $store.timeTravel.loading || $store.timeTravel.busy"
19 + >
20 + <option value="" x-show="$store.timeTravel.workspaces.length === 0">Workspace</option>
21 + <template x-for="workspace in $store.timeTravel.workspaces" :key="workspace.id">
22 + <option
23 + :value="workspace.id"
24 + :selected="workspace.id === $store.timeTravel.selectedWorkspaceId"
25 + x-text="$store.timeTravel.workspaceOptionLabel(workspace)"
26 + ></option>
27 + </template>
28 + </select>
29 </div>
16 - <div class="time-travel-workspace" :title="$store.timeTravel.workspacePath" x-text="$store.timeTravel.workspacePath || 'workspace'"></div>
30 <span class="time-travel-spacer"></span>
31 <button type="button" class="time-travel-icon-button" title="Snapshot" aria-label="Snapshot" @click="$store.timeTravel.manualSnapshot()" :disabled="$store.timeTravel.busy || $store.timeTravel.loading || $store.timeTravel.isLocked()">
32 <span class="material-symbols-outlined">add_a_photo</span>
33 </button>
21 - <button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true })" :disabled="$store.timeTravel.loading">
34 + <button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true, reloadWorkspaces: true })" :disabled="$store.timeTravel.loading">
35 <span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading }">refresh</span>
36 </button>
37 </div>
@@ -270,7 +283,6 @@
283
284 .time-travel-toolbar,
285 .time-travel-status,
273 - .time-travel-title,
286 .time-travel-filter,
287 .time-travel-actions,
288 .time-travel-tool-button,
@@ -293,25 +305,48 @@
305 background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
306 }
307
296 - .time-travel-title {
297 - gap: 7px;
298 - font-weight: 750;
299 - font-size: 0.9rem;
308 + .time-travel-workspace-picker {
309 + display: flex;
310 + align-items: center;
311 + gap: 6px;
312 + flex: 0 1 520px;
313 + min-width: 0;
314 + max-width: 48%;
315 + height: 32px;
316 + padding: 0 8px;
317 + border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
318 + border-radius: 7px;
319 + background: color-mix(in srgb, var(--color-panel) 70%, transparent);
320 }
321
302 - .time-travel-title .material-symbols-outlined {
303 - font-size: 19px;
322 + .time-travel-workspace-picker .material-symbols-outlined {
323 + flex: 0 0 auto;
324 + color: var(--color-text-muted);
325 + font-size: 17px;
326 }
327
306 - .time-travel-workspace {
328 + .time-travel-workspace-picker select {
329 + width: 100%;
330 min-width: 0;
308 - max-width: 45%;
331 + border: 0;
332 + outline: 0;
333 + background: transparent;
334 + color: var(--color-text);
335 + cursor: pointer;
336 + font-size: 0.76rem;
337 overflow: hidden;
310 - color: var(--color-text-muted);
338 text-overflow: ellipsis;
339 white-space: nowrap;
313 - font-family: var(--font-family-code);
314 - font-size: 0.72rem;
340 + }
341 +
342 + .time-travel-workspace-picker select:disabled {
343 + cursor: not-allowed;
344 + opacity: 0.65;
345 + }
346 +
347 + .time-travel-workspace-picker option {
348 + background: var(--color-background);
349 + color: var(--color-text);
350 }
351
352 .time-travel-spacer {
@@ -877,12 +912,17 @@
912 justify-content: flex-start;
913 }
914
880 - .time-travel-workspace {
881 - display: none;
915 + .time-travel-workspace-picker {
916 + flex: 1 1 160px;
917 + max-width: none;
918 }
919 }
920
921 @container (max-width: 520px) {
922 + .time-travel-workspace-picker {
923 + flex-basis: 120px;
924 + }
925 +
926 .time-travel-tool-button span:last-child {
927 display: none;
928 }
plugins/_time_travel/webui/time-travel-store.js
+81 -6
@@ -29,10 +29,13 @@ function apiPath(name) {
29
30 const model = {
31 loading: false,
32 + workspaceLoading: false,
33 busy: false,
34 error: "",
35 payload: null,
36 contextId: "",
37 + workspaces: [],
38 + selectedWorkspaceId: "",
39 workspacePath: "",
40 fileFilter: "",
41 selectedHash: "",
@@ -63,15 +66,20 @@ const model = {
66 if (this._mode !== "modal") {
67 this.setupCanvasSurface(element);
68 }
66 - this.contextId = this.resolveContextId();
67 - if (!this.payload && !this.loading) {
68 - await this.refresh({ contextId: this.contextId });
69 + const nextContextId = this.resolveContextId();
70 + const resetWorkspace = this._mode === "modal" || this.contextId !== nextContextId || !this.selectedWorkspaceId;
71 + this.contextId = nextContextId;
72 + await this.loadWorkspaces({ contextId: this.contextId, reset: resetWorkspace });
73 + if (this._mode === "modal" || !this.payload || resetWorkspace) {
74 + await this.refresh({ contextId: this.contextId, keepSelection: !resetWorkspace, skipWorkspaceLoad: true });
75 }
76 },
77
78 async onOpen(payload = {}) {
79 const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
74 - await this.refresh({ contextId: nextContextId });
80 + this.contextId = nextContextId;
81 + await this.loadWorkspaces({ contextId: nextContextId, reset: true });
82 + await this.refresh({ contextId: nextContextId, skipWorkspaceLoad: true });
83 },
84
85 cleanup() {
@@ -106,18 +114,45 @@ const model = {
114 if (this._filterTimer) clearTimeout(this._filterTimer);
115 this._filterTimer = setTimeout(() => {
116 this._filterTimer = null;
109 - this.refresh({ keepSelection: false });
117 + this.refresh({ keepSelection: false, skipWorkspaceLoad: true });
118 }, 240);
119 },
120
121 + async loadWorkspaces(options = {}) {
122 + const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
123 + this.workspaceLoading = true;
124 + try {
125 + const response = await callJsonApi(apiPath("history_workspaces"), {
126 + context_id: contextId,
127 + });
128 + if (!response?.ok) throw new Error(response?.error || "Could not load workspaces.");
129 + this.workspaces = Array.isArray(response.workspaces) ? response.workspaces : [];
130 + const defaultWorkspaceId = String(response.default_workspace_id || "");
131 + const hasSelected = this.workspaces.some((workspace) => workspace?.id === this.selectedWorkspaceId);
132 + if (options.reset || !this.selectedWorkspaceId || !hasSelected) {
133 + this.selectedWorkspaceId = defaultWorkspaceId || String(this.workspaces[0]?.id || "");
134 + }
135 + } catch (error) {
136 + this.workspaces = [];
137 + this.selectedWorkspaceId = "";
138 + this.error = error instanceof Error ? error.message : String(error);
139 + } finally {
140 + this.workspaceLoading = false;
141 + }
142 + },
143 +
144 async refresh(options = {}) {
145 const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
146 + if (!options.skipWorkspaceLoad && (options.reloadWorkspaces || this.workspaces.length === 0)) {
147 + await this.loadWorkspaces({ contextId, reset: Boolean(options.resetWorkspace) });
148 + }
149 const seq = ++this._requestSeq;
150 this.loading = true;
151 this.error = "";
152 try {
153 const response = await callJsonApi(apiPath("history_list"), {
154 context_id: contextId,
155 + workspace_id: this.selectedWorkspaceId,
156 limit: 100,
157 offset: 0,
158 file_filter: this.fileFilter,
@@ -126,7 +161,17 @@ const model = {
161 if (!response?.ok) throw new Error(response?.error || "Could not load history.");
162 this.payload = response;
163 this.contextId = String(response.context_id || contextId || "");
129 - this.workspacePath = String(response.workspace?.display_path || response.workspace?.path || "");
164 + if (response.workspace?.id && !this.selectedWorkspaceId) {
165 + this.selectedWorkspaceId = String(response.workspace.id);
166 + }
167 + const selectedWorkspace = this.selectedWorkspace();
168 + this.workspacePath = String(
169 + response.workspace?.display_path
170 + || response.workspace?.path
171 + || selectedWorkspace?.display_path
172 + || selectedWorkspace?.path
173 + || ""
174 + );
175 this.reconcileSelection(Boolean(options.keepSelection));
176 } catch (error) {
177 if (seq !== this._requestSeq) return;
@@ -143,6 +188,7 @@ const model = {
188 try {
189 const response = await callJsonApi(apiPath("history_list"), {
190 context_id: this.contextId,
191 + workspace_id: this.selectedWorkspaceId,
192 limit: 100,
193 offset: this.commits().length,
194 file_filter: this.fileFilter,
@@ -190,6 +236,31 @@ const model = {
236 return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false);
237 },
238
239 + selectedWorkspace() {
240 + return (this.workspaces || []).find((workspace) => workspace?.id === this.selectedWorkspaceId) || null;
241 + },
242 +
243 + workspaceOptionLabel(workspace) {
244 + const label = String(workspace?.label || workspace?.title || workspace?.name || "Workspace");
245 + const path = String(workspace?.display_path || workspace?.path || "");
246 + const suffix = workspace?.locked || workspace?.available === false ? " (unavailable)" : "";
247 + return path ? `${label} - ${path}${suffix}` : `${label}${suffix}`;
248 + },
249 +
250 + async selectWorkspace(workspaceId) {
251 + const nextWorkspaceId = String(workspaceId || "");
252 + if (!nextWorkspaceId || nextWorkspaceId === this.selectedWorkspaceId || this.busy) return;
253 + this.selectedWorkspaceId = nextWorkspaceId;
254 + this.fileFilter = "";
255 + this.selectedHash = "";
256 + this.selectedPath = "";
257 + this.selectedDiff = null;
258 + this.diffError = "";
259 + this.previewOpen = false;
260 + this.preview = null;
261 + await this.refresh({ keepSelection: false, skipWorkspaceLoad: true });
262 + },
263 +
264 hasHistory() {
265 return this.commits().length > 0;
266 },
@@ -262,6 +333,7 @@ const model = {
333 try {
334 const response = await callJsonApi(apiPath("history_diff"), {
335 context_id: this.contextId,
336 + workspace_id: this.selectedWorkspaceId,
337 commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash,
338 path: file.path || file.old_path,
339 mode: row.kind === "present" ? "present" : "commit",
@@ -284,6 +356,7 @@ const model = {
356 try {
357 const response = await callJsonApi(apiPath("history_snapshot"), {
358 context_id: this.contextId,
359 + workspace_id: this.selectedWorkspaceId,
360 trigger: "manual",
361 });
362 if (!response?.ok) throw new Error(response?.error || "Snapshot failed.");
@@ -309,6 +382,7 @@ const model = {
382 try {
383 const response = await callJsonApi(apiPath("history_preview"), {
384 context_id: this.contextId,
385 + workspace_id: this.selectedWorkspaceId,
386 operation,
387 commit_hash: target.hash,
388 });
@@ -340,6 +414,7 @@ const model = {
414 try {
415 const response = await callJsonApi(apiPath(endpoint), {
416 context_id: this.contextId,
417 + workspace_id: this.selectedWorkspaceId,
418 commit_hash: this.preview.commit_hash,
419 metadata: { source: "time_travel_ui" },
420 });
tests/test_stream_tool_early_stop.py
+48
@@ -48,6 +48,48 @@ def test_extract_json_root_string_returns_canonical_snapshot():
48 assert extract_tools.extract_json_root_string('[{"tool_name":"response"}]') is None
49
50
51 +def test_litellm_global_kwargs_merge_defaults_and_config(monkeypatch):
52 + monkeypatch.setattr(
53 + models.settings,
54 + "get_settings",
55 + lambda: {"litellm_global_kwargs": {}},
56 + )
57 +
58 + assert models._merge_litellm_call_kwargs({})["drop_params"] is True
59 + assert models._merge_litellm_call_kwargs({"temperature": 0}) == {
60 + "drop_params": True,
61 + "temperature": 0,
62 + }
63 +
64 + monkeypatch.setattr(
65 + models.settings,
66 + "get_settings",
67 + lambda: {"litellm_global_kwargs": {"drop_params": "false", "timeout": "30"}},
68 + )
69 +
70 + assert models._merge_litellm_call_kwargs({}) == {
71 + "drop_params": False,
72 + "timeout": 30,
73 + }
74 +
75 + original_drop_params = getattr(models.litellm, "drop_params", None)
76 + had_timeout = hasattr(models.litellm, "timeout")
77 + original_timeout = getattr(models.litellm, "timeout", None)
78 + try:
79 + assert models.set_litellm_params() == {
80 + "drop_params": False,
81 + "timeout": 30,
82 + }
83 + assert models.litellm.drop_params is False
84 + assert models.litellm.timeout == 30
85 + finally:
86 + setattr(models.litellm, "drop_params", original_drop_params)
87 + if had_timeout:
88 + setattr(models.litellm, "timeout", original_timeout)
89 + elif hasattr(models.litellm, "timeout"):
90 + delattr(models.litellm, "timeout")
91 +
92 +
93 @pytest.mark.asyncio
94 async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
95 stream = _AsyncChunkStream(
@@ -61,6 +103,7 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
103
104 async def fake_acompletion(*args, **kwargs):
105 assert kwargs["stream"] is True
106 + assert kwargs["drop_params"] is True
107 return stream
108
109 async def fake_rate_limiter(*args, **kwargs):
@@ -68,6 +111,11 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
111
112 monkeypatch.setattr(models, "acompletion", fake_acompletion)
113 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
114 + monkeypatch.setattr(
115 + models.settings,
116 + "get_settings",
117 + lambda: {"litellm_global_kwargs": {}},
118 + )
119
120 wrapper = models.LiteLLMChatWrapper(
121 model="test-model",
tests/test_time_travel.py
+80
@@ -373,3 +373,83 @@ def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeyp
373 projects_mod.get_context_project_name = lambda _context: ""
374 with pytest.raises(WorkspaceRejectedError):
375 resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
376 +
377 +
378 +def test_selectable_workspaces_list_workdir_first_and_default_to_context_project(
379 + monkeypatch: pytest.MonkeyPatch,
380 + workspace,
381 +):
382 + root, _service = workspace
383 + (root / "workdir").mkdir()
384 + (root / "demo").mkdir()
385 + (root / "other").mkdir()
386 +
387 + projects_mod = ModuleType("helpers.projects")
388 + projects_mod.get_active_projects_list = lambda: [
389 + {"name": "demo", "title": "Demo Project", "color": "#336699"},
390 + {"name": "other", "title": "Other Project", "color": ""},
391 + ]
392 + projects_mod.get_context_project_name = lambda _context: "demo"
393 + projects_mod.get_project_folder = lambda name: str(root / name)
394 + settings_mod = ModuleType("helpers.settings")
395 + settings_mod.get_settings = lambda: {"workdir_path": str(root / "workdir")}
396 +
397 + import helpers
398 +
399 + monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
400 + monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
401 + monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
402 + monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
403 +
404 + data = tt.list_selectable_workspaces(
405 + "ctx",
406 + context_loader=lambda _ctxid: SimpleNamespace(id="ctx"),
407 + )
408 + workspaces = data["workspaces"]
409 + demo_workspace = next(item for item in workspaces if item["project_name"] == "demo")
410 +
411 + assert workspaces[0]["kind"] == "workdir"
412 + assert workspaces[0]["display_path"].endswith("/workdir")
413 + assert [item["project_name"] for item in workspaces[1:]] == ["demo", "other"]
414 + assert data["default_workspace_id"] == demo_workspace["id"]
415 +
416 + resolved = resolve_workspace(
417 + "ctx",
418 + workspace_id=demo_workspace["id"],
419 + context_loader=lambda _ctxid: SimpleNamespace(id="ctx"),
420 + )
421 +
422 + assert resolved.project_name == "demo"
423 + assert resolved.display_path == demo_workspace["display_path"]
424 +
425 + with pytest.raises(WorkspaceRejectedError):
426 + resolve_workspace(
427 + "ctx",
428 + workspace_id="missing",
429 + context_loader=lambda _ctxid: SimpleNamespace(id="ctx"),
430 + )
431 +
432 +
433 +def test_external_workdir_workspace_option_is_locked(monkeypatch: pytest.MonkeyPatch):
434 + projects_mod = ModuleType("helpers.projects")
435 + projects_mod.get_active_projects_list = lambda: []
436 + projects_mod.get_context_project_name = lambda _context: ""
437 + settings_mod = ModuleType("helpers.settings")
438 + settings_mod.get_settings = lambda: {"workdir_path": "/tmp/not-a0"}
439 +
440 + import helpers
441 +
442 + monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
443 + monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
444 + monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
445 + monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
446 +
447 + data = tt.list_selectable_workspaces("")
448 + workdir = data["workspaces"][0]
449 +
450 + assert workdir["kind"] == "workdir"
451 + assert workdir["locked"] is True
452 + assert data["default_workspace_id"] == workdir["id"]
453 +
454 + with pytest.raises(WorkspaceRejectedError):
455 + resolve_workspace("", workspace_id=workdir["id"])