| 1 | from __future__ import annotations |
| 2 | |
| 3 | from typing import Any |
| 4 | |
| 5 | from agent import AgentContext |
| 6 | from api.stop import stop_context |
| 7 | from helpers import message_queue as mq |
| 8 | from helpers import plugins, projects, subagents |
| 9 | from helpers.integration_commands import try_handle_command |
| 10 | from helpers.state_monitor_integration import mark_dirty_for_context |
| 11 | |
| 12 | CLI_ONLY = { |
| 13 | "quit": "Quit is an A0 CLI shell command. Close this browser tab or stop the WebUI session when you are done.", |
| 14 | } |
| 15 | |
| 16 | |
| 17 | def run(payload: dict[str, Any]) -> dict[str, Any]: |
| 18 | invocation = payload.get("invocation") or {} |
| 19 | raw_name = str(invocation.get("command_name") or "").strip().lower() |
| 20 | command = raw_name |
| 21 | raw_args = str(invocation.get("raw_arguments") or "").strip() |
| 22 | arguments = invocation.get("arguments") if isinstance(invocation.get("arguments"), dict) else {} |
| 23 | context_id = str((payload.get("context") or {}).get("context_id") or "").strip() |
| 24 | context = _context(context_id) |
| 25 | |
| 26 | if command == "new": |
| 27 | return _effects(_toast("Created a new chat."), {"type": "new_chat"}) |
| 28 | if command == "chat": |
| 29 | return _handle_chat(arguments) |
| 30 | if command == "chats": |
| 31 | return _show_markdown("Chats", _chat_list(context, arguments)) |
| 32 | if command == "clear": |
| 33 | return _effects(_toast("Visible transcript cleared."), {"type": "clear_transcript"}) |
| 34 | if command == "project": |
| 35 | return _handle_project(context, raw_args) |
| 36 | if command == "profile": |
| 37 | return _handle_profile(context, raw_args, arguments) |
| 38 | if command == "permissions": |
| 39 | return _handle_permissions(context) |
| 40 | if command == "plugins": |
| 41 | return _effects({"type": "open_modal", "path": "/components/plugins/list/plugin-list.html"}) |
| 42 | if command == "compact": |
| 43 | return _effects({"type": "compact_chat"}) |
| 44 | if command == "pause": |
| 45 | return _effects(_toast("Pause requested."), {"type": "pause_agent", "paused": True}) |
| 46 | if command == "resume": |
| 47 | return _effects(_toast("Resume requested."), {"type": "pause_agent", "paused": False}) |
| 48 | if command == "nudge": |
| 49 | return _effects(_toast("Nudge sent."), {"type": "nudge_agent"}) |
| 50 | if command == "stop": |
| 51 | return _handle_stop(context) |
| 52 | if command == "send": |
| 53 | return _handle_queue(context, ["send"]) |
| 54 | if command == "queue": |
| 55 | return _handle_queue(context, list(arguments.get("tokens") or [])) |
| 56 | if command == "presets": |
| 57 | return _effects({"type": "open_modal", "path": "/plugins/_model_config/webui/main.html"}) |
| 58 | if command == "models": |
| 59 | return _handle_models(context, raw_args) |
| 60 | if command == "browser": |
| 61 | return _handle_browser(context, raw_args) |
| 62 | if command == "attach": |
| 63 | return _effects({"type": "attach_files"}) |
| 64 | if command == "computer-use": |
| 65 | return _handle_computer_use(context_id, raw_args) |
| 66 | if command == "copy": |
| 67 | return _effects({"type": "copy_transcript"}) |
| 68 | if command == "status": |
| 69 | return _show_markdown("Status", _status(context)) |
| 70 | if command in CLI_ONLY: |
| 71 | return _effects(_toast(CLI_ONLY[command], level="info")) |
| 72 | |
| 73 | return _effects(_toast(f"Unknown command: /{raw_name or command}", level="error")) |
| 74 | |
| 75 | |
| 76 | def _context(context_id: str) -> AgentContext | None: |
| 77 | if context_id: |
| 78 | return AgentContext.get(context_id) |
| 79 | return AgentContext.current() or AgentContext.first() |
| 80 | |
| 81 | |
| 82 | def _require_context(context: AgentContext | None) -> str | None: |
| 83 | if context: |
| 84 | return None |
| 85 | return "Open or create a chat context first." |
| 86 | |
| 87 | |
| 88 | def _effects(*effects: dict[str, Any]) -> dict[str, Any]: |
| 89 | return {"text": "", "effects": [effect for effect in effects if effect]} |
| 90 | |
| 91 | |
| 92 | def _toast(message: str, *, level: str = "success") -> dict[str, Any]: |
| 93 | return {"type": "toast", "message": message, "level": level} |
| 94 | |
| 95 | |
| 96 | def _show_markdown(title: str, content: str) -> dict[str, Any]: |
| 97 | return _effects({"type": "show_markdown", "title": title, "content": content}) |
| 98 | |
| 99 | |
| 100 | def _handle_chat(arguments: dict[str, Any]) -> dict[str, Any]: |
| 101 | selector = str((arguments.get("positional") or [""])[0] or "").strip() |
| 102 | if not selector: |
| 103 | return _effects(_toast("Usage: /chat <context_id>", level="error")) |
| 104 | if not AgentContext.get(selector): |
| 105 | return _effects(_toast(f"Chat context '{selector}' was not found.", level="error")) |
| 106 | return _effects(_toast(f"Switched to {selector}."), {"type": "select_chat", "context_id": selector}) |
| 107 | |
| 108 | |
| 109 | def _handle_project(context: AgentContext | None, raw_args: str) -> dict[str, Any]: |
| 110 | if not raw_args: |
| 111 | return _effects({"type": "open_modal", "path": "/components/projects/project-list.html"}) |
| 112 | error = _require_context(context) |
| 113 | if error: |
| 114 | return _effects(_toast(error, level="error")) |
| 115 | return _show_markdown("Project", try_handle_command(context, f"/project {raw_args}") or "") |
| 116 | |
| 117 | |
| 118 | def _handle_profile( |
| 119 | context: AgentContext | None, |
| 120 | raw_args: str, |
| 121 | arguments: dict[str, Any], |
| 122 | ) -> dict[str, Any]: |
| 123 | if not raw_args: |
| 124 | return _effects({"type": "open_agent_editor", "view": "manage"}) |
| 125 | error = _require_context(context) |
| 126 | if error: |
| 127 | return _effects(_toast(error, level="error")) |
| 128 | |
| 129 | positional = [str(value) for value in arguments.get("positional") or []] |
| 130 | project_name = projects.get_context_project_name(context) |
| 131 | desired = raw_args.strip().strip("\"'").casefold() |
| 132 | profiles = subagents.get_available_agents_dict(project_name or None) |
| 133 | if len(positional) < 2 or any( |
| 134 | desired in {profile_id.casefold(), (item.title or profile_id).casefold()} |
| 135 | for profile_id, item in profiles.items() |
| 136 | ): |
| 137 | return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "") |
| 138 | |
| 139 | from plugins._agent_editor.helpers import editor |
| 140 | |
| 141 | title = positional[0] |
| 142 | instructions = " ".join(positional[1:]) |
| 143 | try: |
| 144 | profile_id, _ = editor.save_easy_profile(title, instructions, context) |
| 145 | except ValueError as exc: |
| 146 | return _effects(_toast(str(exc), level="error")) |
| 147 | return _effects( |
| 148 | _toast(f"Created agent {title}."), |
| 149 | { |
| 150 | "type": "test_agent_profile", |
| 151 | "profile_id": profile_id, |
| 152 | "project_name": project_name or "", |
| 153 | }, |
| 154 | ) |
| 155 | |
| 156 | |
| 157 | def _handle_permissions(context: AgentContext | None) -> dict[str, Any]: |
| 158 | error = _require_context(context) |
| 159 | if error: |
| 160 | return _effects(_toast(error, level="error")) |
| 161 | profile_id = str(getattr(context.config, "profile", "") or "").strip() |
| 162 | if not profile_id: |
| 163 | return _effects(_toast("The current agent profile is unavailable.", level="error")) |
| 164 | if profile_id == "default": |
| 165 | return _effects( |
| 166 | _toast( |
| 167 | "The Default utility profile has no editable permissions.", |
| 168 | level="error", |
| 169 | ) |
| 170 | ) |
| 171 | return _effects( |
| 172 | { |
| 173 | "type": "open_agent_editor", |
| 174 | "view": "edit", |
| 175 | "profile_id": profile_id, |
| 176 | } |
| 177 | ) |
| 178 | |
| 179 | |
| 180 | def _handle_models(context: AgentContext | None, raw_args: str) -> dict[str, Any]: |
| 181 | return _effects({"type": "open_plugin_config", "plugin": "_model_config"}) |
| 182 | |
| 183 | |
| 184 | def _handle_browser(context: AgentContext | None, raw_args: str) -> dict[str, Any]: |
| 185 | args = raw_args.strip().lower().replace("-", "_").split() |
| 186 | action = args[0] if args else "" |
| 187 | if not action: |
| 188 | return _effects({"type": "open_modal", "path": "/plugins/_browser/webui/main.html"}) |
| 189 | if action in {"status", "state"}: |
| 190 | return _show_markdown("Browser", _browser_status(context)) |
| 191 | if action not in {"host", "container", "docker"}: |
| 192 | return _effects(_toast("Usage: /browser [host|container|status]", level="error")) |
| 193 | |
| 194 | project_name = projects.get_context_project_name(context) if context else "" |
| 195 | settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {} |
| 196 | settings["runtime_backend"] = "host_required" if action == "host" else "container" |
| 197 | plugins.save_plugin_config("_browser", project_name or "", "", settings) |
| 198 | if context: |
| 199 | mark_dirty_for_context(context.id, reason="plugins._commands.browser_runtime") |
| 200 | label = "Host browser through A0 CLI" if settings["runtime_backend"] == "host_required" else "Internal Docker browser" |
| 201 | return _effects(_toast(f"Browser runtime set to {label}.")) |
| 202 | |
| 203 | |
| 204 | def _handle_computer_use(context_id: str, raw_args: str) -> dict[str, Any]: |
| 205 | action = ( |
| 206 | "-".join( |
| 207 | part.strip().lower().replace("_", "-") for part in raw_args.split() |
| 208 | ) |
| 209 | or "status" |
| 210 | ) |
| 211 | enabled = action in {"on", "enable", "enabled", "true", "yes", "1"} |
| 212 | disabled = action in {"off", "disable", "disabled", "false", "no", "0"} |
| 213 | if enabled or disabled: |
| 214 | command = "on" if enabled else "off" |
| 215 | return _effects( |
| 216 | { |
| 217 | "type": "computer_use", |
| 218 | "enabled": enabled, |
| 219 | "fallback": ( |
| 220 | "Computer Use permissions are controlled on the connected host. " |
| 221 | "Use Host access in A0 Launcher, or run " |
| 222 | f"`/computer-use {command}` in the A0 CLI terminal." |
| 223 | ), |
| 224 | } |
| 225 | ) |
| 226 | return _show_markdown("Computer Use", _computer_use_status(context_id)) |
| 227 | |
| 228 | |
| 229 | def _browser_status(context: AgentContext | None) -> str: |
| 230 | project_name = projects.get_context_project_name(context) if context else "" |
| 231 | settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {} |
| 232 | runtime = str(settings.get("runtime_backend") or "container") |
| 233 | label = "Host browser through A0 CLI" if runtime == "host_required" else "Internal Docker browser" |
| 234 | return f"Browser runtime: {label}\n\nUse `/browser host` or `/browser container` to switch." |
| 235 | |
| 236 | |
| 237 | def _handle_queue(context: AgentContext | None, tokens: list[str]) -> dict[str, Any]: |
| 238 | error = _require_context(context) |
| 239 | if error: |
| 240 | return _effects(_toast(error, level="error")) |
| 241 | |
| 242 | queue = mq.get_queue(context) |
| 243 | if not tokens: |
| 244 | return _show_markdown("Queue", _queue_summary(queue)) |
| 245 | |
| 246 | action = str(tokens[0] or "").lower() |
| 247 | if action in {"send", "all", "flush"}: |
| 248 | if not queue: |
| 249 | return _effects(_toast("No queued messages.")) |
| 250 | sent_count = mq.send_all_aggregated(context) |
| 251 | mark_dirty_for_context(context.id, reason="plugins._commands.queue_send") |
| 252 | noun = "message" if sent_count == 1 else "messages" |
| 253 | return _effects(_toast(f"Sent {sent_count} queued {noun}.")) |
| 254 | |
| 255 | if action in {"clear", "delete"} and len(tokens) == 1: |
| 256 | mq.remove(context) |
| 257 | mark_dirty_for_context(context.id, reason="plugins._commands.queue_clear") |
| 258 | return _effects(_toast("Queue cleared.")) |
| 259 | |
| 260 | if action in {"remove", "rm", "delete"}: |
| 261 | if len(tokens) < 2: |
| 262 | return _effects(_toast("Usage: /queue remove <number|id>", level="error")) |
| 263 | item_id = _queue_selector_to_id(queue, str(tokens[1])) |
| 264 | if not item_id: |
| 265 | return _effects(_toast(f"No queued message matches '{tokens[1]}'.", level="error")) |
| 266 | mq.remove(context, item_id) |
| 267 | mark_dirty_for_context(context.id, reason="plugins._commands.queue_remove") |
| 268 | return _effects(_toast("Queued message removed.")) |
| 269 | |
| 270 | return _effects(_toast("Usage: /queue [send|clear|remove <number|id>]", level="error")) |
| 271 | |
| 272 | |
| 273 | def _handle_stop(context: AgentContext | None) -> dict[str, Any]: |
| 274 | error = _require_context(context) |
| 275 | if error: |
| 276 | return _effects(_toast(error, level="error")) |
| 277 | result = stop_context(context) |
| 278 | return _effects(_toast(str(result["message"]))) |
| 279 | |
| 280 | |
| 281 | def _queue_summary(queue: list[dict[str, Any]]) -> str: |
| 282 | if not queue: |
| 283 | return "No queued messages." |
| 284 | lines = [f"Queued messages ({len(queue)}):"] |
| 285 | for index, item in enumerate(queue, start=1): |
| 286 | text = str(item.get("text") or "").strip() or "(attachment only)" |
| 287 | if len(text) > 100: |
| 288 | text = text[:97].rstrip() + "..." |
| 289 | attachments = item.get("attachments") or [] |
| 290 | suffix = f" [{len(attachments)} files]" if attachments else "" |
| 291 | lines.append(f"{index}. {text}{suffix}") |
| 292 | return "\n".join(lines) |
| 293 | |
| 294 | |
| 295 | def _queue_selector_to_id(queue: list[dict[str, Any]], selector: str) -> str: |
| 296 | value = selector.strip() |
| 297 | if value.isdigit(): |
| 298 | index = int(value) - 1 |
| 299 | if 0 <= index < len(queue): |
| 300 | return str(queue[index].get("id") or "") |
| 301 | return "" |
| 302 | return value |
| 303 | |
| 304 | |
| 305 | def _chat_list(context: AgentContext | None, arguments: dict[str, Any]) -> str: |
| 306 | items = list(AgentContext.all()) |
| 307 | flags = arguments.get("flags") or {} |
| 308 | active_project_only = bool(flags.get("project") or flags.get("active_project") or flags.get("p")) |
| 309 | sort_by = str(flags.get("sort") or "").lower() |
| 310 | positional = [str(item).lower() for item in (arguments.get("positional") or [])] |
| 311 | if not sort_by: |
| 312 | sort_by = next((item for item in positional if item in {"updated", "created", "name"}), "updated") |
| 313 | if sort_by not in {"updated", "created", "name"}: |
| 314 | return "Usage: /chats [--project|--all-projects] [--sort=updated|created|name]" |
| 315 | |
| 316 | if active_project_only and context: |
| 317 | project_name = projects.get_context_project_name(context) or "" |
| 318 | items = [item for item in items if (projects.get_context_project_name(item) or "") == project_name] |
| 319 | |
| 320 | def sort_key(item: AgentContext) -> Any: |
| 321 | output = item.output() |
| 322 | if sort_by == "name": |
| 323 | return (item.name or item.id).casefold() |
| 324 | if sort_by == "created": |
| 325 | return str(output.get("created_at") or "") |
| 326 | return str(output.get("last_message") or output.get("created_at") or "") |
| 327 | |
| 328 | items = sorted(items, key=sort_key, reverse=sort_by != "name") |
| 329 | if not items: |
| 330 | return "No chats found." |
| 331 | |
| 332 | lines = ["| Chat | Context | State |", "| --- | --- | --- |"] |
| 333 | for item in items[:30]: |
| 334 | marker = "current" if context and item.id == context.id else ("running" if item.is_running() else "idle") |
| 335 | lines.append(f"| {_escape_cell(item.name or item.id)} | `{item.id}` | {marker} |") |
| 336 | if len(items) > 30: |
| 337 | lines.append(f"\nShowing 30 of {len(items)} chats.") |
| 338 | return "\n".join(lines) |
| 339 | |
| 340 | |
| 341 | def _status(context: AgentContext | None) -> str: |
| 342 | error = _require_context(context) |
| 343 | if error: |
| 344 | return error |
| 345 | project_name = projects.get_context_project_name(context) or "none" |
| 346 | profile = getattr(context.agent0.config, "profile", "default") if context.agent0 else "default" |
| 347 | running = "running" if context.is_running() else "idle" |
| 348 | if getattr(context, "paused", False): |
| 349 | running = "paused" |
| 350 | return "\n".join( |
| 351 | [ |
| 352 | f"Context: `{context.id}`", |
| 353 | f"State: {running}", |
| 354 | f"Project: {project_name}", |
| 355 | f"Agent profile: {profile}", |
| 356 | f"Queued messages: {len(mq.get_queue(context))}", |
| 357 | ] |
| 358 | ) |
| 359 | |
| 360 | |
| 361 | def _computer_use_status(context_id: str) -> str: |
| 362 | from plugins._a0_connector.helpers import ws_runtime |
| 363 | |
| 364 | sids = ( |
| 365 | ws_runtime.remote_tool_sids_for_context(context_id) |
| 366 | if context_id |
| 367 | else sorted(ws_runtime.connected_sids()) |
| 368 | ) |
| 369 | if not sids: |
| 370 | return ( |
| 371 | "No A0 CLI or Launcher host gateway is connected to this WebUI session.\n\n" |
| 372 | "Open this Instance in A0 Launcher with Host access, or connect A0 CLI, then run `/computer-use on`." |
| 373 | ) |
| 374 | |
| 375 | lines = ["Connected host-control sessions:"] |
| 376 | for sid in sids: |
| 377 | metadata = ws_runtime.computer_use_metadata_for_sid(sid) or {} |
| 378 | if not metadata: |
| 379 | lines.append(f"- `{sid}`: connected, but not advertising Computer Use metadata.") |
| 380 | continue |
| 381 | state = "enabled" if metadata.get("enabled") else "disabled" |
| 382 | supported = "supported" if metadata.get("supported") else "unsupported" |
| 383 | status = str(metadata.get("status") or "unknown") |
| 384 | detail = str(metadata.get("last_error") or metadata.get("support_reason") or "").strip() |
| 385 | suffix = f" ({detail})" if detail else "" |
| 386 | lines.append(f"- `{sid}`: {state}, {supported}, status: {status}{suffix}") |
| 387 | lines.append( |
| 388 | "\nUse `/computer-use on|off` in A0 Launcher or A0 CLI to change local Computer Use." |
| 389 | ) |
| 390 | return "\n".join(lines) |
| 391 | |
| 392 | |
| 393 | def _escape_cell(value: str) -> str: |
| 394 | return str(value).replace("|", "\\|") |