main
py 394 lines 15.9 KB
Raw
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("|", "\\|")