diff --git a/AGENTS.md b/AGENTS.md index 00a1570f9..e3bcd1d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ Key Files: - helpers/plugins.py: Plugin discovery and configuration logic. - webui/js/AlpineStore.js: Store factory for reactive frontend state. - helpers/api.py: Base class for all API endpoints. +- 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. - scripts/openrouter_release_notes_system_prompt.md: Editable system prompt used to generate GitHub release notes during Docker publishing. - 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. - webui/components/AGENTS.md: DOX contract for Alpine component architecture. diff --git a/models.py b/models.py index 8518f0d1e..ff2632a94 100644 --- a/models.py +++ b/models.py @@ -45,6 +45,48 @@ from sentence_transformers import SentenceTransformer from pydantic import ConfigDict +DEFAULT_LITELLM_GLOBAL_KWARGS: dict[str, Any] = { + "drop_params": True, +} + + +def _normalize_litellm_kwargs(values: dict[str, Any]) -> dict[str, Any]: + # Normalize .env/UI-style scalar strings into native types for LiteLLM. + result: dict[str, Any] = {} + for k, v in values.items(): + if isinstance(v, str): + stripped = v.strip() + lowered = stripped.lower() + if lowered == "true": + result[k] = True + elif lowered == "false": + result[k] = False + elif lowered in ("none", "null"): + result[k] = None + else: + try: + result[k] = int(stripped) + except ValueError: + try: + result[k] = float(stripped) + except ValueError: + result[k] = v + else: + result[k] = v + return result + + +def get_litellm_global_kwargs() -> dict[str, Any]: + kwargs = _normalize_litellm_kwargs(DEFAULT_LITELLM_GLOBAL_KWARGS) + try: + configured = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr] + except Exception: + configured = {} + if isinstance(configured, dict): + kwargs.update(_normalize_litellm_kwargs(configured)) + return kwargs + + # keep provider logging quiet in normal operation def turn_off_logging(): os.environ["LITELLM_LOG"] = "ERROR" # only errors @@ -55,9 +97,30 @@ def turn_off_logging(): logging.getLogger(name).setLevel(logging.ERROR) +def set_litellm_params(): + global_kwargs = get_litellm_global_kwargs() + for key, value in global_kwargs.items(): + setattr(litellm, key, value) + return global_kwargs + + +def configure_litellm(): + turn_off_logging() + set_litellm_params() + + +def _merge_litellm_call_kwargs(*overrides: dict[str, Any] | None) -> dict[str, Any]: + kwargs = get_litellm_global_kwargs() + for override in overrides: + if isinstance(override, dict): + kwargs.update(override) + return kwargs + + # init load_dotenv() -turn_off_logging() +configure_litellm() + class ModelType(Enum): CHAT = "Chat" @@ -390,13 +453,16 @@ class LiteLLMChatWrapper(SimpleChatModel): ) -> str: import asyncio + configure_litellm() msgs = self._convert_messages(messages) # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, str(msgs)) # Call the model - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs}) + call_kwargs = _without_stream_kwarg( + _merge_litellm_call_kwargs(self.kwargs, kwargs) + ) resp = completion( model=self.model_name, messages=msgs, stop=stop, **call_kwargs ) @@ -415,13 +481,16 @@ class LiteLLMChatWrapper(SimpleChatModel): ) -> Iterator[ChatGenerationChunk]: import asyncio + configure_litellm() msgs = self._convert_messages(messages) # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, str(msgs)) result = ChatGenerationResult() - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs}) + call_kwargs = _without_stream_kwarg( + _merge_litellm_call_kwargs(self.kwargs, kwargs) + ) for chunk in completion( model=self.model_name, @@ -447,13 +516,16 @@ class LiteLLMChatWrapper(SimpleChatModel): run_manager: Optional[AsyncCallbackManagerForLLMRun] = None, **kwargs: Any, ) -> AsyncIterator[ChatGenerationChunk]: + configure_litellm() msgs = self._convert_messages(messages) # Apply rate limiting if configured await apply_rate_limiter(self.a0_model_conf, str(msgs)) result = ChatGenerationResult() - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs}) + call_kwargs = _without_stream_kwarg( + _merge_litellm_call_kwargs(self.kwargs, kwargs) + ) response = await acompletion( model=self.model_name, @@ -488,7 +560,7 @@ class LiteLLMChatWrapper(SimpleChatModel): **kwargs: Any, ) -> Tuple[str, str]: - turn_off_logging() + configure_litellm() if not messages: messages = [] @@ -507,7 +579,9 @@ class LiteLLMChatWrapper(SimpleChatModel): ) # Prepare call kwargs and retry config (strip A0-only params before calling LiteLLM) - call_kwargs: dict[str, Any] = _without_stream_kwarg({**self.kwargs, **kwargs}) + call_kwargs: dict[str, Any] = _without_stream_kwarg( + _merge_litellm_call_kwargs(self.kwargs, kwargs) + ) max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2)) retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5)) 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): self.a0_model_conf = model_config def embed_documents(self, texts: List[str]) -> List[List[float]]: + configure_litellm() # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, " ".join(texts)) - resp = embedding(model=self.model_name, input=texts, **self.kwargs) + resp = embedding( + model=self.model_name, + input=texts, + **_merge_litellm_call_kwargs(self.kwargs), + ) return [ item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore for item in resp.data # type: ignore ] def embed_query(self, text: str) -> List[float]: + configure_litellm() # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, text) - resp = embedding(model=self.model_name, input=[text], **self.kwargs) + resp = embedding( + model=self.model_name, + input=[text], + **_merge_litellm_call_kwargs(self.kwargs), + ) item = resp.data[0] # type: ignore return item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore @@ -781,22 +865,6 @@ def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict): def _merge_provider_defaults( provider_type: ProviderModelType, original_provider: str, kwargs: dict ) -> tuple[str, dict]: - # Normalize .env-style numeric strings (e.g., "timeout=30") into ints/floats for LiteLLM - def _normalize_values(values: dict) -> dict: - result: dict[str, Any] = {} - for k, v in values.items(): - if isinstance(v, str): - try: - result[k] = int(v) - except ValueError: - try: - result[k] = float(v) - except ValueError: - result[k] = v - else: - result[k] = v - return result - provider_name = original_provider # default: unchanged cfg = get_provider_config(provider_type, original_provider) if cfg: @@ -814,14 +882,11 @@ def _merge_provider_defaults( if key and key not in ("None", "NA"): kwargs["api_key"] = key - # Merge LiteLLM global kwargs (timeouts, stream_timeout, etc.) - try: - global_kwargs = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr] - except Exception: - global_kwargs = {} - if isinstance(global_kwargs, dict): - for k, v in _normalize_values(global_kwargs).items(): - kwargs.setdefault(k, v) + # Merge LiteLLM global kwargs. Framework defaults are merged first, then + # configured global kwargs override those defaults; explicit provider/model + # kwargs still keep priority via setdefault. + for k, v in get_litellm_global_kwargs().items(): + kwargs.setdefault(k, v) return provider_name, kwargs diff --git a/plugins/_time_travel/AGENTS.md b/plugins/_time_travel/AGENTS.md index 0998d949f..c459cce47 100644 --- a/plugins/_time_travel/AGENTS.md +++ b/plugins/_time_travel/AGENTS.md @@ -2,18 +2,18 @@ ## Purpose -- Own Agent Zero workspace history, diff inspection, travel, snapshots, and revert for active `/a0/usr` workspaces. +- Own Agent Zero workspace history, diff inspection, travel, snapshots, and revert for selectable Agent Zero workdir/project workspaces under `/a0/usr`. ## Ownership - `helpers/time_travel.py` owns history storage, diff, travel, snapshot, preview, and revert mechanics. -- `api/` owns history list, diff, preview, revert, snapshot, and travel endpoints. -- `webui/` owns the time-travel panel, store, main surface, and thumbnail. +- `api/` owns selectable workspace list, history list, diff, preview, revert, snapshot, and travel endpoints. +- `webui/` owns the time-travel panel, workspace selector, store, main surface, and thumbnail. - `plugin.yaml` and `extensions/` own metadata and hook contributions. ## Local Contracts -- Keep history operations scoped to Agent Zero-owned workspaces. +- Keep history operations scoped to Agent Zero-owned workdir/project workspaces. - Revert and travel operations must avoid unintended writes outside managed workspace paths. - Preserve enough metadata for clear preview and diff inspection before destructive actions. @@ -23,7 +23,7 @@ ## Verification -- Smoke-test snapshot, list, diff, preview, travel, and revert flows after changes. +- Smoke-test workspace selection, snapshot, list, diff, preview, travel, and revert flows after changes. ## Child DOX Index diff --git a/plugins/_time_travel/api/history_diff.py b/plugins/_time_travel/api/history_diff.py index ca6217db7..ca5d82cf7 100644 --- a/plugins/_time_travel/api/history_diff.py +++ b/plugins/_time_travel/api/history_diff.py @@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import ( class HistoryDiff(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: context_id = str(input.get("context_id") or "").strip() + workspace_id = str(input.get("workspace_id") or "").strip() try: - workspace = resolve_workspace(context_id, context_loader=self.use_context) + workspace = resolve_workspace( + context_id, + workspace_id=workspace_id, + context_loader=self.use_context, + ) return TimeTravelService(workspace).history_diff( commit_hash=str(input.get("commit_hash") or ""), path=str(input.get("path") or ""), diff --git a/plugins/_time_travel/api/history_list.py b/plugins/_time_travel/api/history_list.py index cefcfdc9c..3ef73ba92 100644 --- a/plugins/_time_travel/api/history_list.py +++ b/plugins/_time_travel/api/history_list.py @@ -13,8 +13,13 @@ from plugins._time_travel.helpers.time_travel import ( class HistoryList(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: context_id = str(input.get("context_id") or "").strip() + workspace_id = str(input.get("workspace_id") or "").strip() try: - workspace = resolve_workspace(context_id, context_loader=self.use_context) + workspace = resolve_workspace( + context_id, + workspace_id=workspace_id, + context_loader=self.use_context, + ) return TimeTravelService(workspace).history_list( limit=int(input.get("limit") or 100), offset=int(input.get("offset") or 0), diff --git a/plugins/_time_travel/api/history_preview.py b/plugins/_time_travel/api/history_preview.py index 294505095..a44af1588 100644 --- a/plugins/_time_travel/api/history_preview.py +++ b/plugins/_time_travel/api/history_preview.py @@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import ( class HistoryPreview(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: context_id = str(input.get("context_id") or "").strip() + workspace_id = str(input.get("workspace_id") or "").strip() try: - workspace = resolve_workspace(context_id, context_loader=self.use_context) + workspace = resolve_workspace( + context_id, + workspace_id=workspace_id, + context_loader=self.use_context, + ) return TimeTravelService(workspace).preview( operation=str(input.get("operation") or ""), commit_hash=str(input.get("commit_hash") or ""), diff --git a/plugins/_time_travel/api/history_revert.py b/plugins/_time_travel/api/history_revert.py index 6c64d9da6..74200fc99 100644 --- a/plugins/_time_travel/api/history_revert.py +++ b/plugins/_time_travel/api/history_revert.py @@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import ( class HistoryRevert(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: context_id = str(input.get("context_id") or "").strip() + workspace_id = str(input.get("workspace_id") or "").strip() try: - workspace = resolve_workspace(context_id, context_loader=self.use_context) + workspace = resolve_workspace( + context_id, + workspace_id=workspace_id, + context_loader=self.use_context, + ) return TimeTravelService(workspace).revert( commit_hash=str(input.get("commit_hash") or ""), metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {}, diff --git a/plugins/_time_travel/api/history_snapshot.py b/plugins/_time_travel/api/history_snapshot.py index 2af4ec467..bc1d825cf 100644 --- a/plugins/_time_travel/api/history_snapshot.py +++ b/plugins/_time_travel/api/history_snapshot.py @@ -13,8 +13,13 @@ from plugins._time_travel.helpers.time_travel import ( class HistorySnapshot(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: context_id = str(input.get("context_id") or "").strip() + workspace_id = str(input.get("workspace_id") or "").strip() try: - workspace = resolve_workspace(context_id, context_loader=self.use_context) + workspace = resolve_workspace( + context_id, + workspace_id=workspace_id, + context_loader=self.use_context, + ) snapshot = TimeTravelService(workspace).snapshot( trigger=str(input.get("trigger") or "manual"), message=str(input.get("message") or ""), diff --git a/plugins/_time_travel/api/history_travel.py b/plugins/_time_travel/api/history_travel.py index c1ecac699..8dfd9428d 100644 --- a/plugins/_time_travel/api/history_travel.py +++ b/plugins/_time_travel/api/history_travel.py @@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import ( class HistoryTravel(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: context_id = str(input.get("context_id") or "").strip() + workspace_id = str(input.get("workspace_id") or "").strip() try: - workspace = resolve_workspace(context_id, context_loader=self.use_context) + workspace = resolve_workspace( + context_id, + workspace_id=workspace_id, + context_loader=self.use_context, + ) return TimeTravelService(workspace).travel( commit_hash=str(input.get("commit_hash") or ""), metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {}, diff --git a/plugins/_time_travel/api/history_workspaces.py b/plugins/_time_travel/api/history_workspaces.py new file mode 100644 index 000000000..43e3287ef --- /dev/null +++ b/plugins/_time_travel/api/history_workspaces.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import list_selectable_workspaces + + +class HistoryWorkspaces(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + data = list_selectable_workspaces(context_id, context_loader=self.use_context) + return {"ok": True, **data} diff --git a/plugins/_time_travel/extensions/webui/sidebar-quick-actions-dropdown-start/time-travel-entry.html b/plugins/_time_travel/extensions/webui/sidebar-quick-actions-dropdown-start/time-travel-entry.html index 75bb2f4a3..709771766 100644 --- a/plugins/_time_travel/extensions/webui/sidebar-quick-actions-dropdown-start/time-travel-entry.html +++ b/plugins/_time_travel/extensions/webui/sidebar-quick-actions-dropdown-start/time-travel-entry.html @@ -5,7 +5,7 @@ class="dropdown-item" id="time-travel-dropdown" title="Time Travel" - @click="ensureModalOpen('/plugins/_time_travel/webui/main.html'); $store.sidebar.menuClose()"> + @click="$store.timeTravel?.onOpen?.(); ensureModalOpen('/plugins/_time_travel/webui/main.html'); $store.sidebar.menuClose()"> history Time Travel diff --git a/plugins/_time_travel/helpers/time_travel.py b/plugins/_time_travel/helpers/time_travel.py index e75c15f92..6b9996a75 100644 --- a/plugins/_time_travel/helpers/time_travel.py +++ b/plugins/_time_travel/helpers/time_travel.py @@ -210,7 +210,132 @@ def canonical_workspace_display_path(display_path: str) -> str: return (canonical if canonical.startswith("/a0") else normalized).rstrip("/") or canonical -def resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo: +def configured_workdir_display_path() -> str: + from helpers import settings + + configured = str(settings.get_settings().get("workdir_path") or "") + fallback = files.normalize_a0_path(files.get_abs_path("usr/workdir")) + return canonical_workspace_display_path(configured or fallback) + + +def _workspace_option( + *, + kind: str, + display_path: str, + label: str, + name: str = "", + title: str = "", + project_name: str = "", + color: str = "", +) -> dict[str, Any]: + normalized = canonical_workspace_display_path(display_path) + available = is_inside_usr_display(normalized) + error = ( + "" if available else "Time Travel is only available for workspaces inside /a0/usr." + ) + return { + "id": workspace_id_for(normalized), + "kind": kind, + "name": name, + "title": title or label, + "label": label, + "display_path": normalized.rstrip("/") or normalized, + "path": normalized.rstrip("/") or normalized, + "project_name": project_name, + "color": color, + "available": available, + "locked": not available, + "error": error, + } + + +def list_selectable_workspaces( + context_id: str = "", + *, + context_loader=None, +) -> dict[str, Any]: + from helpers import projects + + context_id = str(context_id or "").strip() + workspaces: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + + def add_workspace(option: dict[str, Any]) -> None: + option_id = str(option.get("id") or "") + if not option_id or option_id in seen_ids: + return + seen_ids.add(option_id) + workspaces.append(option) + + add_workspace( + _workspace_option( + kind="workdir", + display_path=configured_workdir_display_path(), + label="User working directory", + name="workdir", + title="User working directory", + ) + ) + + for project in projects.get_active_projects_list() or []: + project_name = str(project.get("name") or "").strip() + if not project_name: + continue + title = str(project.get("title") or project_name) + add_workspace( + _workspace_option( + kind="project", + display_path=files.normalize_a0_path( + projects.get_project_folder(project_name) + ), + label=title, + name=project_name, + title=title, + project_name=project_name, + color=str(project.get("color") or ""), + ) + ) + + default_workspace_id = "" + try: + default_workspace_id = _resolve_context_workspace( + context_id, + context_loader=context_loader, + ).id + except TimeTravelError: + default_workspace_id = "" + + if default_workspace_id not in seen_ids: + default_workspace_id = "" + if not default_workspace_id and workspaces: + default_workspace_id = str(workspaces[0].get("id") or "") + + return { + "context_id": context_id, + "workspaces": workspaces, + "default_workspace_id": default_workspace_id, + } + + +def _selectable_workspace_by_id( + workspace_id: str, + context_id: str = "", + *, + context_loader=None, +) -> dict[str, Any] | None: + wanted = str(workspace_id or "").strip() + if not wanted: + return None + for workspace in list_selectable_workspaces( + context_id, + context_loader=context_loader, + )["workspaces"]: + if str(workspace.get("id") or "") == wanted: + return workspace + return None + + +def _resolve_context_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo: from helpers import projects, settings context_id = str(context_id or "").strip() @@ -246,9 +371,36 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace ) -def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None: - from helpers import settings +def resolve_workspace( + context_id: str = "", + *, + workspace_id: str = "", + context_loader=None, +) -> WorkspaceInfo: + workspace_id = str(workspace_id or "").strip() + if not workspace_id: + return _resolve_context_workspace(context_id, context_loader=context_loader) + selected = _selectable_workspace_by_id( + workspace_id, + context_id, + context_loader=context_loader, + ) + if not selected: + raise WorkspaceRejectedError("Selected Time Travel workspace is not available.") + if selected.get("locked") or selected.get("available") is False: + raise WorkspaceRejectedError( + str(selected.get("error") or "Selected Time Travel workspace is not available.") + ) + + return _workspace_from_display( + str(selected.get("display_path") or selected.get("path") or ""), + project_name=str(selected.get("project_name") or ""), + context_id=str(context_id or "").strip(), + ) + + +def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None: normalized = canonical_workspace_display_path(path_hint) if not is_inside_usr_display(normalized): return None @@ -258,8 +410,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None: project_display = f"/a0/usr/projects/{parts[3]}" return _workspace_from_display(project_display, project_name=parts[3]) - configured = str(settings.get_settings().get("workdir_path") or "") - workdir_display = canonical_workspace_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))) + workdir_display = configured_workdir_display_path() if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"): return _workspace_from_display(workdir_display) diff --git a/plugins/_time_travel/plugin.yaml b/plugins/_time_travel/plugin.yaml index 3afeda0ec..0c37f491f 100644 --- a/plugins/_time_travel/plugin.yaml +++ b/plugins/_time_travel/plugin.yaml @@ -1,6 +1,6 @@ name: _time_travel title: Time Travel -description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces. +description: Agent Zero-owned workdir/project history, diff inspection, travel, and revert for /a0/usr workspaces. version: 0.1.0 always_enabled: false settings_sections: [] diff --git a/plugins/_time_travel/webui/time-travel-panel.html b/plugins/_time_travel/webui/time-travel-panel.html index c2a46ea8c..b69e66613 100644 --- a/plugins/_time_travel/webui/time-travel-panel.html +++ b/plugins/_time_travel/webui/time-travel-panel.html @@ -9,16 +9,29 @@