add built-in A0 CLI Connector plugin

Introduce the builtin `_a0_connector` plugin that lets the host-side A0 CLI connect to Agent Zero over authenticated HTTP and `/ws`. This adds connector capability discovery, chat/context lifecycle endpoints, log streaming, and the remote text editing, code execution, and file tree bridge used by the CLI workflow.

Alessandro committed Apr 11, 2026 at 18:56 UTC 8c5cf1f69fa95ef5c4b2bbf67be630b80002811d
36 files changed +2702
plugins/_a0_connector/api/__init__.py
plugins/_a0_connector/api/v1/__init__.py
plugins/_a0_connector/api/v1/agents_list.py new
+15
@@ -0,0 +1,15 @@
1 +"""POST /api/plugins/_a0_connector/v1/agents_list."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class AgentsList(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from helpers import subagents
11 +
12 + return {
13 + "ok": True,
14 + "data": subagents.get_all_agents_list(),
15 + }
plugins/_a0_connector/api/v1/base.py new
+29
@@ -0,0 +1,29 @@
1 +from helpers.api import ApiHandler
2 +
3 +
4 +class PublicConnectorApiHandler(ApiHandler):
5 + @classmethod
6 + def requires_auth(cls) -> bool:
7 + return False
8 +
9 + @classmethod
10 + def requires_csrf(cls) -> bool:
11 + return False
12 +
13 + @classmethod
14 + def requires_api_key(cls) -> bool:
15 + return False
16 +
17 +
18 +class ProtectedConnectorApiHandler(ApiHandler):
19 + @classmethod
20 + def requires_auth(cls) -> bool:
21 + return True
22 +
23 + @classmethod
24 + def requires_csrf(cls) -> bool:
25 + return False
26 +
27 + @classmethod
28 + def requires_api_key(cls) -> bool:
29 + return False
plugins/_a0_connector/api/v1/capabilities.py new
+86
@@ -0,0 +1,86 @@
1 +"""POST /api/plugins/_a0_connector/v1/capabilities."""
2 +from __future__ import annotations
3 +
4 +import importlib.util
5 +import sys
6 +
7 +from helpers.api import Request, Response
8 +import plugins._a0_connector.api.v1.base as connector_base
9 +
10 +
11 +_BASE_FEATURES = [
12 + "chat_create",
13 + "chats_list",
14 + "chat_get",
15 + "chat_reset",
16 + "chat_delete",
17 + "pause",
18 + "nudge",
19 + "message_send",
20 + "log_tail",
21 + "projects",
22 + "text_editor_remote",
23 + "code_execution_remote",
24 + "remote_file_tree",
25 + "token_status",
26 +]
27 +
28 +_OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
29 + "settings_get": ("helpers.settings", "helpers.subagents"),
30 + "settings_set": ("helpers.settings", "helpers.subagents"),
31 + "agents_list": ("helpers.subagents",),
32 + "skills_list": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
33 + "skills_delete": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
34 + "model_presets": ("plugins._model_config.helpers.model_config",),
35 + "model_switcher": ("plugins._model_config.helpers.model_config",),
36 + "compact_chat": (
37 + "plugins._chat_compaction.helpers.compactor",
38 + "plugins._model_config.helpers.model_config",
39 + ),
40 +}
41 +
42 +
43 +def _module_available(module_name: str) -> bool:
44 + if module_name in sys.modules:
45 + return True
46 +
47 + try:
48 + return importlib.util.find_spec(module_name) is not None
49 + except (AttributeError, ModuleNotFoundError, ValueError):
50 + return False
51 +
52 +
53 +def _feature_available(feature: str) -> bool:
54 + required = _OPTIONAL_FEATURES.get(feature, ())
55 + return all(_module_available(module_name) for module_name in required)
56 +
57 +
58 +def _feature_list() -> list[str]:
59 + features = list(_BASE_FEATURES)
60 + for feature in _OPTIONAL_FEATURES:
61 + if _feature_available(feature):
62 + features.append(feature)
63 + return features
64 +
65 +
66 +class Capabilities(connector_base.PublicConnectorApiHandler):
67 + """Return the connector discovery contract for current Agent Zero."""
68 +
69 + async def process(self, input: dict, request: Request) -> dict | Response:
70 + from helpers import login
71 +
72 + return {
73 + "protocol": "a0-connector.v1",
74 + "version": "0.1.0",
75 + "auth": ["session"],
76 + "auth_required": bool(login.is_login_required()),
77 + "transports": ["http", "websocket"],
78 + "streaming": True,
79 + "websocket_namespace": "/ws",
80 + "websocket_handlers": ["plugins/_a0_connector/ws_connector"],
81 + "attachments": {
82 + "mode": "base64",
83 + "max_files": 20,
84 + },
85 + "features": _feature_list(),
86 + }
plugins/_a0_connector/api/v1/chat_create.py new
+40
@@ -0,0 +1,40 @@
1 +"""POST /api/plugins/_a0_connector/v1/chat_create."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class ChatCreate(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from helpers import projects
11 + from plugins._a0_connector.helpers.chat_context import create_context
12 +
13 + current_context_id = (
14 + str(input.get("current_context", input.get("current_context_id", ""))).strip()
15 + or None
16 + )
17 + project_name = str(input.get("project_name", "")).strip() or None
18 + agent_profile = str(input.get("agent_profile", "")).strip() or None
19 +
20 + try:
21 + context = create_context(
22 + lock=self.thread_lock,
23 + current_context_id=current_context_id,
24 + agent_profile=agent_profile,
25 + project_name=project_name,
26 + )
27 + except Exception as exc:
28 + return Response(
29 + response=f'{{"error": "Failed to activate project: {str(exc)}"}}',
30 + status=400,
31 + mimetype="application/json",
32 + )
33 +
34 + context_data = context.output()
35 + return {
36 + "context_id": context.id,
37 + "created_at": context_data.get("created_at"),
38 + "agent_profile": agent_profile or getattr(context.agent0.config, "profile", "default"),
39 + "project_name": context.get_data(projects.CONTEXT_DATA_KEY_PROJECT),
40 + }
plugins/_a0_connector/api/v1/chat_delete.py new
+39
@@ -0,0 +1,39 @@
1 +"""POST /api/plugins/_a0_connector/v1/chat_delete."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class ChatDelete(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import AgentContext
11 + from api.chat_remove import RemoveChat
12 +
13 + context_id = str(input.get("context_id", "")).strip()
14 + if not context_id:
15 + return Response(
16 + response='{"error": "context_id is required"}',
17 + status=400,
18 + mimetype="application/json",
19 + )
20 +
21 + context = AgentContext.get(context_id)
22 + if context is None:
23 + return Response(
24 + response='{"error": "Context not found"}',
25 + status=404,
26 + mimetype="application/json",
27 + )
28 +
29 + try:
30 + handler = RemoveChat(self.app, self.thread_lock)
31 + await handler.process({"context": context_id}, request)
32 + except Exception as exc:
33 + return Response(
34 + response=f'{{"error": "{str(exc)}"}}',
35 + status=500,
36 + mimetype="application/json",
37 + )
38 +
39 + return {"context_id": context_id, "status": "deleted"}
plugins/_a0_connector/api/v1/chat_get.py new
+46
@@ -0,0 +1,46 @@
1 +"""POST /api/plugins/_a0_connector/v1/chat_get."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class ChatGet(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import AgentContext
11 + from plugins._a0_connector.helpers.event_bridge import (
12 + get_context_log_entries,
13 + )
14 +
15 + context_id = str(input.get("context_id", "")).strip()
16 + if not context_id:
17 + return Response(
18 + response='{"error": "context_id is required"}',
19 + status=400,
20 + mimetype="application/json",
21 + )
22 +
23 + context = AgentContext.get(context_id)
24 + if context is None:
25 + return Response(
26 + response='{"error": "Context not found"}',
27 + status=404,
28 + mimetype="application/json",
29 + )
30 +
31 + context_data = context.output()
32 + events, last_sequence = get_context_log_entries(context_id)
33 +
34 + return {
35 + "context_id": context.id,
36 + "id": context.id,
37 + "name": context_data.get("name") or context.id,
38 + "created_at": context_data.get("created_at"),
39 + "last_message": context_data.get("last_message"),
40 + "running": context_data.get("running", False),
41 + "agent_profile": getattr(context.agent0.config, "profile", "default")
42 + if context.agent0
43 + else "default",
44 + "log_entries": len(events),
45 + "last_sequence": last_sequence,
46 + }
plugins/_a0_connector/api/v1/chat_reset.py new
+31
@@ -0,0 +1,31 @@
1 +"""POST /api/plugins/_a0_connector/v1/chat_reset."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class ChatReset(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import AgentContext
11 + from api.chat_reset import Reset
12 +
13 + context_id = str(input.get("context_id", "")).strip()
14 + if not context_id:
15 + return Response(
16 + response='{"error": "context_id is required"}',
17 + status=400,
18 + mimetype="application/json",
19 + )
20 +
21 + context = AgentContext.get(context_id)
22 + if context is None:
23 + return Response(
24 + response='{"error": "Context not found"}',
25 + status=404,
26 + mimetype="application/json",
27 + )
28 +
29 + handler = Reset(self.app, self.thread_lock)
30 + await handler.process({"context": context_id}, request)
31 + return {"context_id": context_id, "status": "reset"}
plugins/_a0_connector/api/v1/chats_list.py new
+31
@@ -0,0 +1,31 @@
1 +"""POST /api/plugins/_a0_connector/v1/chats_list."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class ChatsList(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import AgentContext
11 +
12 + contexts: list[dict[str, object]] = []
13 + for context in AgentContext.all():
14 + data = context.output()
15 + contexts.append(
16 + {
17 + "id": context.id,
18 + "name": data.get("name") or context.name or context.id,
19 + "created_at": data.get("created_at"),
20 + "last_message": data.get("last_message"),
21 + "running": data.get("running", False),
22 + "agent_profile": getattr(context.agent0.config, "profile", "default")
23 + if context.agent0
24 + else "default",
25 + }
26 + )
27 +
28 + return {
29 + "contexts": contexts,
30 + "chats": contexts,
31 + }
plugins/_a0_connector/api/v1/compact_chat.py new
+75
@@ -0,0 +1,75 @@
1 +"""POST /api/plugins/_a0_connector/v1/compact_chat."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +def _coerce_bool(value: object, default: bool = False) -> bool:
9 + if isinstance(value, bool):
10 + return value
11 + if isinstance(value, str):
12 + normalized = value.strip().lower()
13 + if not normalized:
14 + return default
15 + return normalized in {"1", "true", "yes", "on"}
16 + if value is None:
17 + return default
18 + return bool(value)
19 +
20 +
21 +async def _run_compaction_task(context, use_chat_model: bool, preset_name: str | None) -> None:
22 + from helpers.state_monitor_integration import mark_dirty_all
23 + from plugins._chat_compaction.helpers.compactor import run_compaction
24 +
25 + try:
26 + await run_compaction(context, use_chat_model, preset_name)
27 + except Exception as exc:
28 + context.log.log(
29 + type="error",
30 + heading="Compaction Failed",
31 + content=str(exc),
32 + )
33 + mark_dirty_all(reason="plugins._a0_connector.compact_chat_error")
34 +
35 +
36 +class CompactChat(connector_base.ProtectedConnectorApiHandler):
37 + async def process(self, input: dict, request: Request) -> dict | Response:
38 + from agent import AgentContext
39 + from plugins._chat_compaction.helpers.compactor import (
40 + MIN_COMPACTION_TOKENS,
41 + get_compaction_stats,
42 + )
43 +
44 + action = str(input.get("action", "compact")).strip() or "compact"
45 + context_id = str(
46 + input.get("context", input.get("context_id", input.get("ctxid", "")))
47 + ).strip()
48 +
49 + if not context_id:
50 + return Response("Missing context id", 400)
51 +
52 + context = AgentContext.get(context_id)
53 + if not context:
54 + return Response("Context not found", 404)
55 +
56 + if context.is_running():
57 + return Response("Cannot compact while agent is running", 409)
58 +
59 + stats = await get_compaction_stats(context)
60 + if stats["token_count"] < MIN_COMPACTION_TOKENS:
61 + return {
62 + "ok": False,
63 + "message": f"Not enough content to compact (minimum {MIN_COMPACTION_TOKENS:,} tokens)",
64 + }
65 +
66 + if action == "stats":
67 + return {"ok": True, "stats": stats}
68 +
69 + if action == "compact":
70 + use_chat_model = _coerce_bool(input.get("use_chat_model", True), default=True)
71 + preset_name = str(input.get("preset_name", "")).strip() or None
72 + context.run_task(_run_compaction_task, context, use_chat_model, preset_name)
73 + return {"ok": True, "message": "Compaction started"}
74 +
75 + return Response(f"Unknown action: {action}", 400)
plugins/_a0_connector/api/v1/log_tail.py new
+32
@@ -0,0 +1,32 @@
1 +"""POST /api/plugins/_a0_connector/v1/log_tail."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class LogTail(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from plugins._a0_connector.helpers.event_bridge import (
11 + get_context_log_entries,
12 + )
13 +
14 + context_id = str(input.get("context_id", "")).strip()
15 + if not context_id:
16 + return Response(
17 + response='{"error": "context_id is required"}',
18 + status=400,
19 + mimetype="application/json",
20 + )
21 +
22 + after = int(input.get("after", 0) or 0)
23 + limit = min(int(input.get("limit", 50) or 50), 250)
24 +
25 + events, last_sequence = get_context_log_entries(context_id, after=after)
26 + limited_events = events[:limit]
27 + return {
28 + "context_id": context_id,
29 + "events": limited_events,
30 + "last_sequence": last_sequence,
31 + "has_more": len(events) > len(limited_events),
32 + }
plugins/_a0_connector/api/v1/message_send.py new
+120
@@ -0,0 +1,120 @@
1 +"""POST /api/plugins/_a0_connector/v1/message_send."""
2 +from __future__ import annotations
3 +
4 +import base64
5 +import os
6 +import uuid
7 +
8 +from helpers.api import Request, Response
9 +from helpers.print_style import PrintStyle
10 +from helpers.security import safe_filename
11 +import plugins._a0_connector.api.v1.base as connector_base
12 +
13 +
14 +class MessageSend(connector_base.ProtectedConnectorApiHandler):
15 + async def process(self, input: dict, request: Request) -> dict | Response:
16 + from agent import UserMessage
17 + from helpers import files
18 + from plugins._a0_connector.helpers.chat_context import (
19 + ConnectorContextError,
20 + create_context,
21 + get_existing_context,
22 + )
23 +
24 + message = str(input.get("message", "")).strip()
25 + if not message:
26 + return Response(
27 + response='{"error": "message is required"}',
28 + status=400,
29 + mimetype="application/json",
30 + )
31 +
32 + context_id = str(input.get("context_id", "")).strip() or None
33 + current_context_id = (
34 + str(input.get("current_context", input.get("current_context_id", ""))).strip()
35 + or None
36 + )
37 + project_name = str(input.get("project_name", "")).strip() or None
38 + agent_profile = str(input.get("agent_profile", "")).strip() or None
39 + attachments_data = input.get("attachments", [])
40 +
41 + attachment_paths: list[str] = []
42 + if isinstance(attachments_data, list) and attachments_data:
43 + upload_folder_ext = files.get_abs_path("usr/uploads")
44 + upload_folder_int = "/a0/usr/uploads"
45 + os.makedirs(upload_folder_ext, exist_ok=True)
46 +
47 + for attachment in attachments_data:
48 + if not isinstance(attachment, dict):
49 + continue
50 + filename = str(attachment.get("filename", "")).strip()
51 + b64_content = str(attachment.get("base64", "")).strip()
52 + if not filename or not b64_content:
53 + continue
54 +
55 + try:
56 + safe_name = safe_filename(filename)
57 + if not safe_name:
58 + continue
59 + save_path = os.path.join(upload_folder_ext, safe_name)
60 + with open(save_path, "wb") as handle:
61 + handle.write(base64.b64decode(b64_content))
62 + attachment_paths.append(os.path.join(upload_folder_int, safe_name))
63 + except Exception as exc:
64 + PrintStyle.error(f"[a0-connector] attachment error: {exc}")
65 +
66 + try:
67 + if context_id:
68 + context = get_existing_context(
69 + context_id,
70 + agent_profile=agent_profile,
71 + project_name=project_name,
72 + )
73 + else:
74 + context = create_context(
75 + lock=self.thread_lock,
76 + current_context_id=current_context_id,
77 + agent_profile=agent_profile,
78 + project_name=project_name,
79 + )
80 + context_id = context.id
81 + except ConnectorContextError as exc:
82 + return Response(
83 + response=f'{{"error": "{str(exc)}"}}',
84 + status=exc.status_code,
85 + mimetype="application/json",
86 + )
87 + except Exception as exc:
88 + return Response(
89 + response=f'{{"error": "Failed to activate project: {str(exc)}"}}',
90 + status=400,
91 + mimetype="application/json",
92 + )
93 +
94 + attachment_names = [os.path.basename(path) for path in attachment_paths]
95 + message_id = str(uuid.uuid4())
96 + context.log.log(
97 + type="user",
98 + heading="",
99 + content=message,
100 + kvps={"attachments": attachment_names},
101 + id=message_id,
102 + )
103 +
104 + try:
105 + task = context.communicate(
106 + UserMessage(message=message, attachments=attachment_paths, id=message_id)
107 + )
108 + result = await task.result()
109 + return {
110 + "context_id": context_id,
111 + "status": "completed",
112 + "response": result,
113 + }
114 + except Exception as exc:
115 + PrintStyle.error(f"[a0-connector] message_send error: {exc}")
116 + return Response(
117 + response=f'{{"error": "{str(exc)}"}}',
118 + status=500,
119 + mimetype="application/json",
120 + )
plugins/_a0_connector/api/v1/model_presets.py new
+29
@@ -0,0 +1,29 @@
1 +"""POST /api/plugins/_a0_connector/v1/model_presets."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class ModelPresets(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from plugins._model_config.helpers import model_config
11 +
12 + action = str(input.get("action", "get")).strip() or "get"
13 +
14 + if action == "get":
15 + presets = model_config.get_presets()
16 + return {"ok": True, "presets": presets}
17 +
18 + if action == "save":
19 + presets = input.get("presets")
20 + if not isinstance(presets, list):
21 + return Response(status=400, response="presets must be an array")
22 + model_config.save_presets(presets)
23 + return {"ok": True, "presets": presets}
24 +
25 + if action == "reset":
26 + presets = model_config.reset_presets()
27 + return {"ok": True, "presets": presets}
28 +
29 + return Response(status=400, response=f"Unknown action: {action}")
plugins/_a0_connector/api/v1/model_switcher.py new
+167
@@ -0,0 +1,167 @@
1 +"""POST /api/plugins/_a0_connector/v1/model_switcher."""
2 +from __future__ import annotations
3 +
4 +from typing import Callable
5 +
6 +from helpers.api import Request, Response
7 +import plugins._a0_connector.api.v1.base as connector_base
8 +
9 +
10 +def _model_payload(config: dict | None, *, has_api_key: bool = False) -> dict[str, object]:
11 + config = config or {}
12 + provider = str(config.get("provider") or "").strip()
13 + name = str(config.get("name") or "").strip()
14 + return {
15 + "provider": provider,
16 + "name": name,
17 + "label": f"{provider}/{name}" if provider and name else (name or provider or "—"),
18 + "has_api_key": bool(has_api_key),
19 + }
20 +
21 +
22 +def _coerce_override_model(value: object) -> dict[str, str]:
23 + if not isinstance(value, dict):
24 + return {}
25 +
26 + payload: dict[str, str] = {}
27 + provider = str(value.get("provider") or "").strip()
28 + name = str(value.get("name") or "").strip()
29 + api_key = str(value.get("api_key") or "").strip()
30 + api_base = str(value.get("api_base") or value.get("base_url") or "").strip()
31 +
32 + if provider:
33 + payload["provider"] = provider
34 + if name:
35 + payload["name"] = name
36 + if api_key:
37 + payload["api_key"] = api_key
38 + if api_base:
39 + payload["api_base"] = api_base
40 +
41 + return payload
42 +
43 +
44 +def _provider_payload(
45 + value: object,
46 + *,
47 + has_api_key_lookup: Callable[[str], bool] | None = None,
48 +) -> list[dict[str, object]]:
49 + if not isinstance(value, list):
50 + return []
51 +
52 + options: list[dict[str, object]] = []
53 + seen: set[str] = set()
54 + for item in value:
55 + if isinstance(item, dict):
56 + provider = str(item.get("value") or item.get("id") or "").strip().lower()
57 + label = str(item.get("label") or item.get("name") or provider).strip()
58 + else:
59 + provider = str(item or "").strip().lower()
60 + label = provider.replace("_", " ").title()
61 +
62 + if not provider or provider in seen:
63 + continue
64 + seen.add(provider)
65 + has_api_key = False
66 + if callable(has_api_key_lookup):
67 + try:
68 + has_api_key = bool(has_api_key_lookup(provider))
69 + except Exception:
70 + has_api_key = False
71 + elif isinstance(item, dict):
72 + has_api_key = bool(item.get("has_api_key"))
73 +
74 + options.append({"value": provider, "label": label or provider, "has_api_key": has_api_key})
75 +
76 + return options
77 +
78 +
79 +class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
80 + async def process(self, input: dict, request: Request) -> dict | Response:
81 + from agent import AgentContext
82 + from helpers.persist_chat import save_tmp_chat
83 + from plugins._model_config.helpers import model_config
84 +
85 + action = str(input.get("action", "get")).strip() or "get"
86 + context_id = str(input.get("context_id", "")).strip()
87 + context = AgentContext.get(context_id) if context_id else None
88 + agent = getattr(context, "agent0", None) if context is not None else None
89 +
90 + def build_state() -> dict[str, object]:
91 + override = context.get_data("chat_model_override") if context is not None else None
92 + try:
93 + chat_providers = _provider_payload(
94 + model_config.get_chat_providers(),
95 + has_api_key_lookup=lambda provider: model_config.has_provider_api_key(provider, ""),
96 + )
97 + except Exception:
98 + chat_providers = []
99 + chat_model = model_config.get_chat_model_config(agent)
100 + utility_model = model_config.get_utility_model_config(agent)
101 +
102 + def _has_api_key(config: object) -> bool:
103 + if not isinstance(config, dict):
104 + return False
105 + provider = str(config.get("provider") or "").strip().lower()
106 + api_key = str(config.get("api_key") or "").strip()
107 + if not provider:
108 + return bool(api_key)
109 + try:
110 + return bool(model_config.has_provider_api_key(provider, api_key))
111 + except Exception:
112 + return bool(api_key)
113 +
114 + return {
115 + "ok": True,
116 + "allowed": bool(model_config.is_chat_override_allowed(agent)),
117 + "override": override,
118 + "presets": model_config.get_presets(),
119 + "chat_providers": chat_providers,
120 + "main_model": _model_payload(chat_model, has_api_key=_has_api_key(chat_model)),
121 + "utility_model": _model_payload(utility_model, has_api_key=_has_api_key(utility_model)),
122 + }
123 +
124 + if action == "get":
125 + return build_state()
126 +
127 + if not context_id:
128 + return Response(status=400, response="Missing context_id")
129 +
130 + if context is None:
131 + return Response(status=404, response="Context not found")
132 +
133 + if not model_config.is_chat_override_allowed(agent):
134 + return Response(status=403, response="Per-chat override is disabled")
135 +
136 + if action == "set_preset":
137 + preset_name = str(input.get("preset_name", "")).strip()
138 + if not preset_name:
139 + return Response(status=400, response="Missing preset_name")
140 + preset = model_config.get_preset_by_name(preset_name)
141 + if not preset:
142 + return Response(status=404, response=f"Preset '{preset_name}' not found")
143 + context.set_data("chat_model_override", {"preset_name": preset_name})
144 + save_tmp_chat(context)
145 + return build_state()
146 +
147 + if action == "clear":
148 + context.set_data("chat_model_override", None)
149 + save_tmp_chat(context)
150 + return build_state()
151 +
152 + if action == "set_override":
153 + main_model = _coerce_override_model(input.get("main_model"))
154 + utility_model = _coerce_override_model(input.get("utility_model"))
155 + if not main_model and not utility_model:
156 + return Response(status=400, response="Missing model override payload")
157 +
158 + override: dict[str, dict[str, str]] = {}
159 + if main_model:
160 + override["chat"] = main_model
161 + if utility_model:
162 + override["utility"] = utility_model
163 + context.set_data("chat_model_override", override)
164 + save_tmp_chat(context)
165 + return build_state()
166 +
167 + return Response(status=400, response=f"Unknown action: {action}")
plugins/_a0_connector/api/v1/nudge.py new
+36
@@ -0,0 +1,36 @@
1 +"""POST /api/plugins/_a0_connector/v1/nudge."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class Nudge(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import AgentContext
11 +
12 + context_id = str(input.get("context_id", "")).strip()
13 + if not context_id:
14 + return Response(
15 + response='{"error": "context_id is required"}',
16 + status=400,
17 + mimetype="application/json",
18 + )
19 +
20 + context = AgentContext.get(context_id)
21 + if context is None:
22 + return Response(
23 + response='{"error": "Context not found"}',
24 + status=404,
25 + mimetype="application/json",
26 + )
27 +
28 + context.nudge()
29 + message = "Process reset, agent nudged."
30 + context.log.log(type="info", content=message)
31 + return {
32 + "ok": True,
33 + "context_id": context_id,
34 + "status": "nudged",
35 + "message": message,
36 + }
plugins/_a0_connector/api/v1/pause.py new
+48
@@ -0,0 +1,48 @@
1 +"""POST /api/plugins/_a0_connector/v1/pause."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class Pause(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import AgentContext
11 +
12 + context_id = str(input.get("context_id", "")).strip()
13 + raw_paused = input.get("paused", True)
14 + if isinstance(raw_paused, str):
15 + paused = raw_paused.strip().lower() not in {"", "0", "false", "no", "off"}
16 + else:
17 + paused = bool(raw_paused)
18 +
19 + if not context_id:
20 + return Response(
21 + response='{"error": "context_id is required"}',
22 + status=400,
23 + mimetype="application/json",
24 + )
25 +
26 + context = AgentContext.get(context_id)
27 + if context is None:
28 + return Response(
29 + response='{"error": "Context not found"}',
30 + status=404,
31 + mimetype="application/json",
32 + )
33 +
34 + if paused and not context.is_running():
35 + return Response(
36 + response='{"error": "Context is not currently running"}',
37 + status=409,
38 + mimetype="application/json",
39 + )
40 +
41 + context.paused = paused
42 + return {
43 + "ok": True,
44 + "context_id": context_id,
45 + "paused": paused,
46 + "status": "paused" if paused else "running",
47 + "message": "Agent paused." if paused else "Agent unpaused.",
48 + }
plugins/_a0_connector/api/v1/projects.py new
+108
@@ -0,0 +1,108 @@
1 +"""POST /api/plugins/_a0_connector/v1/projects."""
2 +from __future__ import annotations
3 +
4 +from typing import Any, Mapping
5 +
6 +from helpers.api import Request, Response
7 +import plugins._a0_connector.api.v1.base as connector_base
8 +
9 +
10 +def _string(value: object) -> str:
11 + if value is None:
12 + return ""
13 + return str(value).strip()
14 +
15 +
16 +def _normalize_project_summary(value: object) -> dict[str, str] | None:
17 + if not isinstance(value, Mapping):
18 + return None
19 +
20 + name = _string(value.get("name"))
21 + if not name:
22 + return None
23 +
24 + return {
25 + "name": name,
26 + "title": _string(value.get("title")),
27 + "description": _string(value.get("description")),
28 + "color": _string(value.get("color")),
29 + }
30 +
31 +
32 +class Projects(connector_base.ProtectedConnectorApiHandler):
33 + """Thin connector proxy around the core `api.projects.Projects` surface."""
34 +
35 + async def process(self, input: dict, request: Request) -> dict | Response:
36 + action = _string(input.get("action")).lower() or "list"
37 + if action not in {"list", "load", "update", "activate", "deactivate"}:
38 + return {"ok": False, "error": f"Unsupported action: {action or '<missing>'}"}
39 +
40 + core_response = await self._call_core(
41 + {
42 + "action": action,
43 + "context_id": _string(input.get("context_id")),
44 + "name": _string(input.get("name")),
45 + "project": input.get("project"),
46 + },
47 + request,
48 + )
49 + if isinstance(core_response, Response):
50 + return core_response
51 + if not isinstance(core_response, Mapping):
52 + return {"ok": False, "error": "Invalid response from core projects handler"}
53 + if not core_response.get("ok"):
54 + return {"ok": False, "error": _string(core_response.get("error")) or "Project request failed"}
55 +
56 + if action in {"activate", "deactivate", "list"}:
57 + return await self._normalized_list_state(_string(input.get("context_id")), request)
58 +
59 + project = core_response.get("data")
60 + return {
61 + "ok": True,
62 + "project": dict(project) if isinstance(project, Mapping) else {},
63 + }
64 +
65 + async def _normalized_list_state(self, context_id: str, request: Request) -> dict[str, Any] | Response:
66 + core_response = await self._call_core(
67 + {
68 + "action": "list",
69 + "context_id": context_id,
70 + },
71 + request,
72 + )
73 + if isinstance(core_response, Response):
74 + return core_response
75 + if not isinstance(core_response, Mapping):
76 + return {"ok": False, "error": "Invalid response from core projects handler"}
77 + if not core_response.get("ok"):
78 + return {"ok": False, "error": _string(core_response.get("error")) or "Project request failed"}
79 +
80 + projects: list[dict[str, str]] = []
81 + for item in core_response.get("data") or []:
82 + normalized = _normalize_project_summary(item)
83 + if normalized is not None:
84 + projects.append(normalized)
85 +
86 + return {
87 + "ok": True,
88 + "projects": projects,
89 + "current_project": self._load_current_project(context_id),
90 + }
91 +
92 + async def _call_core(self, payload: dict[str, Any], request: Request) -> dict | Response:
93 + from api.projects import Projects as CoreProjects
94 +
95 + handler = CoreProjects(self.app, self.thread_lock)
96 + return await handler.process(payload, request)
97 +
98 + def _load_current_project(self, context_id: str) -> dict[str, str] | None:
99 + if not context_id:
100 + return None
101 +
102 + from agent import AgentContext
103 +
104 + context = AgentContext.get(context_id)
105 + if context is None:
106 + return None
107 +
108 + return _normalize_project_summary(context.get_output_data("project"))
plugins/_a0_connector/api/v1/settings_get.py new
+12
@@ -0,0 +1,12 @@
1 +"""POST /api/plugins/_a0_connector/v1/settings_get."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class SettingsGet(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from helpers import settings
11 +
12 + return dict(settings.convert_out(settings.get_settings()))
plugins/_a0_connector/api/v1/settings_set.py new
+22
@@ -0,0 +1,22 @@
1 +"""POST /api/plugins/_a0_connector/v1/settings_set."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class SettingsSet(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from helpers import settings
11 +
12 + payload = input.get("settings", input)
13 + if not isinstance(payload, dict):
14 + return Response(
15 + response='{"error":"settings must be an object"}',
16 + status=400,
17 + mimetype="application/json",
18 + )
19 +
20 + backend = settings.convert_in(settings.Settings(**payload))
21 + backend = settings.set_settings(backend)
22 + return dict(settings.convert_out(backend))
plugins/_a0_connector/api/v1/skills_delete.py new
+26
@@ -0,0 +1,26 @@
1 +"""POST /api/plugins/_a0_connector/v1/skills_delete."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class SkillsDelete(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from helpers import skills
11 +
12 + skill_path = str(input.get("skill_path") or "").strip()
13 + if not skill_path:
14 + return Response(
15 + response='{"error":"skill_path is required"}',
16 + status=400,
17 + mimetype="application/json",
18 + )
19 +
20 + skills.delete_skill(skill_path)
21 + return {
22 + "ok": True,
23 + "data": {
24 + "skill_path": skill_path,
25 + },
26 + }
plugins/_a0_connector/api/v1/skills_list.py new
+55
@@ -0,0 +1,55 @@
1 +"""POST /api/plugins/_a0_connector/v1/skills_list."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class SkillsList(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from helpers import files, projects, runtime, skills
11 +
12 + skill_list = skills.list_skills()
13 + project_name = str(input.get("project_name", "")).strip() or None
14 +
15 + if project_name:
16 + project_folder = projects.get_project_folder(project_name)
17 + if runtime.is_development():
18 + project_folder = files.normalize_a0_path(project_folder)
19 + skill_list = [
20 + item
21 + for item in skill_list
22 + if files.is_in_dir(str(item.path), project_folder)
23 + ]
24 +
25 + agent_profile = str(input.get("agent_profile", "")).strip() or None
26 + if agent_profile:
27 + roots: list[str] = [
28 + files.get_abs_path("agents", agent_profile, "skills"),
29 + files.get_abs_path("usr", "agents", agent_profile, "skills"),
30 + ]
31 + if project_name:
32 + roots.append(
33 + projects.get_project_meta(project_name, "agents", agent_profile, "skills")
34 + )
35 +
36 + skill_list = [
37 + item
38 + for item in skill_list
39 + if any(files.is_in_dir(str(item.path), root) for root in roots)
40 + ]
41 +
42 + result = [
43 + {
44 + "name": skill.name,
45 + "description": skill.description,
46 + "path": str(skill.path),
47 + }
48 + for skill in skill_list
49 + ]
50 + result.sort(key=lambda item: (item["name"], item["path"]))
51 +
52 + return {
53 + "ok": True,
54 + "data": result,
55 + }
plugins/_a0_connector/api/v1/token_status.py new
+57
@@ -0,0 +1,57 @@
1 +"""POST /api/plugins/_a0_connector/v1/token_status."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class TokenStatus(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from agent import Agent, AgentContext
11 +
12 + context_id = str(
13 + input.get("context", input.get("context_id", input.get("ctxid", "")))
14 + ).strip()
15 + if not context_id:
16 + return Response(
17 + response='{"error":"context_id required"}',
18 + status=400,
19 + mimetype="application/json",
20 + )
21 +
22 + context = AgentContext.get(context_id)
23 + if context is None:
24 + return Response(
25 + response='{"error":"Context not found"}',
26 + status=404,
27 + mimetype="application/json",
28 + )
29 +
30 + agent = context.streaming_agent or context.agent0
31 + window = agent.get_data(Agent.DATA_NAME_CTX_WINDOW) if agent is not None else None
32 + token_count: int | None = None
33 + if isinstance(window, dict):
34 + raw_tokens = window.get("tokens")
35 + try:
36 + token_count = int(raw_tokens)
37 + except (TypeError, ValueError):
38 + token_count = None
39 +
40 + context_window: int | None = None
41 + try:
42 + from plugins._model_config.helpers.model_config import get_chat_model_config
43 +
44 + chat_config = get_chat_model_config(agent)
45 + if isinstance(chat_config, dict):
46 + raw_context_window = int(chat_config.get("ctx_length", 0))
47 + if raw_context_window > 0:
48 + context_window = raw_context_window
49 + except Exception:
50 + context_window = None
51 +
52 + return {
53 + "ok": True,
54 + "context_id": context_id,
55 + "token_count": token_count,
56 + "context_window": context_window,
57 + }
plugins/_a0_connector/api/ws_connector.py new
+490
@@ -0,0 +1,490 @@
1 +"""Connector WebSocket handler for the shared `/ws` namespace."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +from typing import TYPE_CHECKING, Any, ClassVar
6 +
7 +from helpers.print_style import PrintStyle
8 +from helpers.ws import WsHandler
9 +from helpers.ws_manager import WsResult
10 +
11 +from plugins._a0_connector.helpers.event_bridge import get_context_log_entries
12 +from plugins._a0_connector.helpers.ws_runtime import (
13 + clear_remote_tree_snapshot,
14 + fail_pending_file_ops_for_sid,
15 + fail_pending_exec_ops_for_sid,
16 + register_sid,
17 + resolve_pending_file_op,
18 + resolve_pending_exec_op,
19 + store_remote_tree_snapshot,
20 + subscribe_sid_to_context,
21 + subscribed_contexts_for_sid,
22 + subscribed_sids_for_context,
23 + unsubscribe_sid_from_context,
24 + unregister_sid,
25 +)
26 +
27 +if TYPE_CHECKING:
28 + from agent import AgentContext, AgentContextType, UserMessage
29 +
30 +
31 +PROTOCOL_VERSION = "a0-connector.v1"
32 +WS_FEATURES = [
33 + "connector_subscribe_context",
34 + "connector_send_message",
35 + "text_editor_remote",
36 + "remote_file_tree",
37 + "code_execution_remote",
38 +]
39 +
40 +
41 +class WsConnector(WsHandler):
42 + _streaming_tasks: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
43 +
44 + @classmethod
45 + def requires_auth(cls) -> bool:
46 + return True
47 +
48 + @classmethod
49 + def requires_csrf(cls) -> bool:
50 + return False
51 +
52 + @classmethod
53 + def requires_api_key(cls) -> bool:
54 + return False
55 +
56 + async def on_connect(self, sid: str) -> None:
57 + register_sid(sid)
58 + PrintStyle.debug(f"[a0-connector] /ws connected: {sid}")
59 +
60 + async def on_disconnect(self, sid: str) -> None:
61 + contexts = unregister_sid(sid)
62 + for context_id in contexts:
63 + self._cancel_streaming(sid, context_id)
64 + clear_remote_tree_snapshot(sid)
65 + fail_pending_file_ops_for_sid(
66 + sid,
67 + error="CLI disconnected before completing the requested file operation",
68 + )
69 + fail_pending_exec_ops_for_sid(
70 + sid,
71 + error="CLI disconnected before completing the requested remote execution",
72 + )
73 + PrintStyle.debug(f"[a0-connector] /ws disconnected: {sid}")
74 +
75 + async def process(
76 + self,
77 + event: str,
78 + data: dict[str, Any],
79 + sid: str,
80 + ) -> dict[str, Any] | WsResult | None:
81 + if event == "connector_hello":
82 + return {
83 + "protocol": PROTOCOL_VERSION,
84 + "features": WS_FEATURES,
85 + }
86 +
87 + if event == "connector_subscribe_context":
88 + return await self._handle_subscribe_context(data, sid)
89 +
90 + if event == "connector_unsubscribe_context":
91 + return self._handle_unsubscribe_context(data, sid)
92 +
93 + if event == "connector_send_message":
94 + return await self._handle_send_message(data, sid)
95 +
96 + if event == "connector_file_op_result":
97 + return self._handle_file_op_result(data, sid)
98 +
99 + if event == "connector_remote_tree_update":
100 + return self._handle_remote_tree_update(data, sid)
101 +
102 + if event == "connector_exec_op_result":
103 + return self._handle_exec_op_result(data, sid)
104 +
105 + if event.startswith("connector_"):
106 + return WsResult.error(
107 + code="UNKNOWN_EVENT",
108 + message=f"Unknown connector event: {event}",
109 + correlation_id=data.get("correlationId"),
110 + )
111 +
112 + return None
113 +
114 + async def _handle_subscribe_context(
115 + self,
116 + data: dict[str, Any],
117 + sid: str,
118 + ) -> dict[str, Any] | WsResult:
119 + from agent import AgentContext
120 +
121 + context_id = str(data.get("context_id", "")).strip()
122 + from_sequence = int(data.get("from", 0) or 0)
123 +
124 + if not context_id:
125 + return WsResult.error(
126 + code="MISSING_CONTEXT_ID",
127 + message="context_id is required",
128 + correlation_id=data.get("correlationId"),
129 + )
130 +
131 + context = AgentContext.get(context_id)
132 + if context is None:
133 + return WsResult.error(
134 + code="CONTEXT_NOT_FOUND",
135 + message=f"Context '{context_id}' not found",
136 + correlation_id=data.get("correlationId"),
137 + )
138 +
139 + subscribe_sid_to_context(sid, context_id)
140 + events, last_sequence = get_context_log_entries(context_id, after=from_sequence)
141 + await self.emit_to(
142 + sid,
143 + "connector_context_snapshot",
144 + {
145 + "context_id": context_id,
146 + "events": events,
147 + "last_sequence": last_sequence,
148 + },
149 + correlation_id=data.get("correlationId"),
150 + )
151 + self._start_streaming(sid, context_id, from_sequence=last_sequence)
152 +
153 + return {
154 + "context_id": context_id,
155 + "subscribed": True,
156 + "last_sequence": last_sequence,
157 + }
158 +
159 + def _handle_unsubscribe_context(
160 + self,
161 + data: dict[str, Any],
162 + sid: str,
163 + ) -> dict[str, Any] | WsResult:
164 + context_id = str(data.get("context_id", "")).strip()
165 + if not context_id:
166 + return WsResult.error(
167 + code="MISSING_CONTEXT_ID",
168 + message="context_id is required",
169 + correlation_id=data.get("correlationId"),
170 + )
171 +
172 + self._cancel_streaming(sid, context_id)
173 + unsubscribe_sid_from_context(sid, context_id)
174 + return {"context_id": context_id, "unsubscribed": True}
175 +
176 + async def _handle_send_message(
177 + self,
178 + data: dict[str, Any],
179 + sid: str,
180 + ) -> dict[str, Any] | WsResult:
181 + from plugins._a0_connector.helpers.chat_context import ConnectorContextError
182 +
183 + message = str(data.get("message", "")).strip()
184 + if not message:
185 + return WsResult.error(
186 + code="MISSING_MESSAGE",
187 + message="message is required",
188 + correlation_id=data.get("correlationId"),
189 + )
190 +
191 + context_id = str(data.get("context_id", "")).strip() or None
192 + current_context_id = (
193 + str(data.get("current_context", data.get("current_context_id", ""))).strip()
194 + or None
195 + )
196 + client_message_id = str(data.get("client_message_id", "")).strip()
197 + attachments = list(data.get("attachments", [])) if isinstance(data.get("attachments"), list) else []
198 + project_name = str(data.get("project_name", "")).strip() or None
199 + agent_profile = str(data.get("agent_profile", "")).strip() or None
200 +
201 + try:
202 + context, context_id = await self._resolve_context(
203 + context_id=context_id,
204 + current_context_id=current_context_id,
205 + agent_profile=agent_profile,
206 + project_name=project_name,
207 + )
208 + except ConnectorContextError as exc:
209 + return WsResult.error(
210 + code=exc.code,
211 + message=str(exc),
212 + correlation_id=data.get("correlationId"),
213 + )
214 + except Exception as exc:
215 + return WsResult.error(
216 + code="BAD_REQUEST",
217 + message=str(exc),
218 + correlation_id=data.get("correlationId"),
219 + )
220 + if context is None or context_id is None:
221 + return WsResult.error(
222 + code="CONTEXT_NOT_FOUND",
223 + message="Unable to resolve or create the requested context",
224 + correlation_id=data.get("correlationId"),
225 + )
226 +
227 + if context_id not in subscribed_contexts_for_sid(sid):
228 + subscribe_sid_to_context(sid, context_id)
229 + events, last_sequence = get_context_log_entries(context_id, after=0)
230 + await self.emit_to(
231 + sid,
232 + "connector_context_snapshot",
233 + {
234 + "context_id": context_id,
235 + "events": events,
236 + "last_sequence": last_sequence,
237 + },
238 + correlation_id=data.get("correlationId"),
239 + )
240 + self._start_streaming(sid, context_id, from_sequence=last_sequence)
241 +
242 + message_id = client_message_id or data.get("correlationId") or ""
243 + context.log.log(
244 + type="user",
245 + heading="",
246 + content=message,
247 + kvps={},
248 + id=message_id,
249 + )
250 +
251 + asyncio.create_task(
252 + self._run_message(
253 + context=context,
254 + context_id=context_id,
255 + message=message,
256 + attachments=attachments,
257 + )
258 + )
259 +
260 + return {
261 + "context_id": context_id,
262 + "status": "accepted",
263 + "client_message_id": client_message_id or None,
264 + }
265 +
266 + def _handle_file_op_result(
267 + self,
268 + data: dict[str, Any],
269 + sid: str,
270 + ) -> dict[str, Any] | WsResult:
271 + op_id = str(data.get("op_id", "")).strip()
272 + if not op_id:
273 + return WsResult.error(
274 + code="MISSING_OP_ID",
275 + message="op_id is required",
276 + correlation_id=data.get("correlationId"),
277 + )
278 +
279 + if not resolve_pending_file_op(op_id, sid=sid, payload=data):
280 + return WsResult.error(
281 + code="UNKNOWN_OP_ID",
282 + message=f"No pending file operation for op_id '{op_id}'",
283 + correlation_id=data.get("correlationId"),
284 + )
285 +
286 + return {"op_id": op_id, "accepted": True}
287 +
288 + def _handle_remote_tree_update(
289 + self,
290 + data: dict[str, Any],
291 + sid: str,
292 + ) -> dict[str, Any] | WsResult:
293 + tree = data.get("tree")
294 + root_path = data.get("root_path")
295 + tree_hash = data.get("tree_hash")
296 +
297 + if not isinstance(tree, str) or not tree.strip():
298 + return WsResult.error(
299 + code="INVALID_TREE_PAYLOAD",
300 + message="tree is required",
301 + correlation_id=data.get("correlationId"),
302 + )
303 +
304 + if not isinstance(root_path, str) or not root_path.strip():
305 + return WsResult.error(
306 + code="INVALID_TREE_PAYLOAD",
307 + message="root_path is required",
308 + correlation_id=data.get("correlationId"),
309 + )
310 +
311 + if not isinstance(tree_hash, str) or not tree_hash.strip():
312 + return WsResult.error(
313 + code="INVALID_TREE_PAYLOAD",
314 + message="tree_hash is required",
315 + correlation_id=data.get("correlationId"),
316 + )
317 +
318 + snapshot = store_remote_tree_snapshot(sid, data)
319 + return {
320 + "accepted": True,
321 + "sid": sid,
322 + "tree_hash": tree_hash,
323 + "updated_at": snapshot.updated_at,
324 + }
325 +
326 + def _handle_exec_op_result(
327 + self,
328 + data: dict[str, Any],
329 + sid: str,
330 + ) -> dict[str, Any] | WsResult:
331 + op_id = str(data.get("op_id", "")).strip()
332 + if not op_id:
333 + return WsResult.error(
334 + code="MISSING_OP_ID",
335 + message="op_id is required",
336 + correlation_id=data.get("correlationId"),
337 + )
338 +
339 + if not resolve_pending_exec_op(op_id, sid=sid, payload=data):
340 + return WsResult.error(
341 + code="UNKNOWN_OP_ID",
342 + message=f"No pending exec operation for op_id '{op_id}'",
343 + correlation_id=data.get("correlationId"),
344 + )
345 +
346 + return {"op_id": op_id, "accepted": True}
347 +
348 + async def _resolve_context(
349 + self,
350 + *,
351 + context_id: str | None,
352 + current_context_id: str | None,
353 + agent_profile: str | None,
354 + project_name: str | None,
355 + ) -> tuple[AgentContext | None, str | None]:
356 + from plugins._a0_connector.helpers.chat_context import (
357 + create_context,
358 + get_existing_context,
359 + )
360 +
361 + if context_id:
362 + context = get_existing_context(
363 + context_id,
364 + agent_profile=agent_profile,
365 + project_name=project_name,
366 + )
367 + return context, context_id
368 +
369 + context = create_context(
370 + lock=self.lock,
371 + current_context_id=current_context_id,
372 + agent_profile=agent_profile,
373 + project_name=project_name,
374 + )
375 + context_id = context.id
376 + return context, context_id
377 +
378 + async def _run_message(
379 + self,
380 + *,
381 + context: AgentContext,
382 + context_id: str,
383 + message: str,
384 + attachments: list[Any],
385 + ) -> None:
386 + from agent import AgentContext, UserMessage
387 +
388 + try:
389 + AgentContext.use(context_id)
390 + task = context.communicate(
391 + UserMessage(message=message, attachments=attachments)
392 + )
393 + result = await task.result()
394 + except Exception as exc:
395 + PrintStyle.error(f"[a0-connector] connector_send_message error: {exc}")
396 + await self._emit_context_error(
397 + context_id=context_id,
398 + code="AGENT_ERROR",
399 + message=str(exc),
400 + )
401 + await self._emit_context_complete(
402 + context_id=context_id,
403 + payload={"status": "error", "error": str(exc)},
404 + )
405 + return
406 +
407 + await self._emit_context_complete(
408 + context_id=context_id,
409 + payload={"status": "completed", "response": result},
410 + )
411 +
412 + async def _emit_context_error(
413 + self,
414 + *,
415 + context_id: str,
416 + code: str,
417 + message: str,
418 + ) -> None:
419 + payload = {
420 + "context_id": context_id,
421 + "code": code,
422 + "message": message,
423 + }
424 + for target_sid in subscribed_sids_for_context(context_id):
425 + try:
426 + await self.emit_to(target_sid, "connector_error", payload)
427 + except Exception as exc:
428 + PrintStyle.error(
429 + f"[a0-connector] failed to emit connector_error to {target_sid}: {exc}"
430 + )
431 +
432 + async def _emit_context_complete(
433 + self,
434 + *,
435 + context_id: str,
436 + payload: dict[str, Any],
437 + ) -> None:
438 + event_payload = {"context_id": context_id, **payload}
439 + for target_sid in subscribed_sids_for_context(context_id):
440 + try:
441 + await self.emit_to(
442 + target_sid,
443 + "connector_context_complete",
444 + event_payload,
445 + )
446 + except Exception as exc:
447 + PrintStyle.error(
448 + f"[a0-connector] failed to emit connector_context_complete to {target_sid}: {exc}"
449 + )
450 +
451 + def _start_streaming(self, sid: str, context_id: str, *, from_sequence: int) -> None:
452 + key = (sid, context_id)
453 + task = self._streaming_tasks.get(key)
454 + if task is not None and not task.done():
455 + return
456 +
457 + task = asyncio.create_task(
458 + self._stream_events(sid, context_id, from_sequence=from_sequence)
459 + )
460 + self._streaming_tasks[key] = task
461 +
462 + def _cancel_streaming(self, sid: str, context_id: str) -> None:
463 + task = self._streaming_tasks.pop((sid, context_id), None)
464 + if task is not None and not task.done():
465 + task.get_loop().call_soon_threadsafe(task.cancel)
466 +
467 + async def _stream_events(
468 + self,
469 + sid: str,
470 + context_id: str,
471 + *,
472 + from_sequence: int,
473 + ) -> None:
474 + # `from_sequence` is a log-output cursor (not an event sequence number).
475 + cursor = max(int(from_sequence or 0), 0)
476 + try:
477 + while context_id in subscribed_contexts_for_sid(sid):
478 + events, next_cursor = get_context_log_entries(context_id, after=cursor)
479 + for event in events:
480 + await self.emit_to(sid, "connector_context_event", event)
481 + cursor = max(cursor, int(next_cursor or cursor))
482 + await asyncio.sleep(0.5)
483 + except asyncio.CancelledError:
484 + raise
485 + except Exception as exc:
486 + PrintStyle.error(
487 + f"[a0-connector] stream error sid={sid} context={context_id}: {exc}"
488 + )
489 + finally:
490 + self._streaming_tasks.pop((sid, context_id), None)
plugins/_a0_connector/extensions/python/message_loop_prompts_after/_76_include_remote_file_structure.py new
+40
@@ -0,0 +1,40 @@
1 +from __future__ import annotations
2 +
3 +import time
4 +
5 +from agent import LoopData
6 +from helpers.extension import Extension
7 +
8 +from plugins._a0_connector.helpers.ws_runtime import latest_remote_tree_for_context
9 +
10 +
11 +class IncludeRemoteFileStructure(Extension):
12 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
13 + if not self.agent:
14 + return
15 +
16 + context_id = getattr(self.agent.context, "id", "")
17 + if not context_id:
18 + return
19 +
20 + snapshot = latest_remote_tree_for_context(context_id, max_age_seconds=90.0)
21 + if not snapshot:
22 + return
23 +
24 + file_structure = str(snapshot.get("tree") or "").strip()
25 + if not file_structure:
26 + return
27 +
28 + folder = str(snapshot.get("root_path") or "").strip() or "unknown"
29 + generated_at = str(snapshot.get("generated_at") or "unknown")
30 + updated_at = float(snapshot.get("updated_at") or 0.0)
31 + age_seconds = max(0, int(time.time() - updated_at))
32 +
33 + prompt = self.agent.read_prompt(
34 + "agent.extras.remote_file_structure.md",
35 + folder=folder,
36 + generated_at=generated_at,
37 + age_seconds=age_seconds,
38 + file_structure=file_structure,
39 + )
40 + loop_data.extras_temporary["remote_file_structure"] = prompt
plugins/_a0_connector/helpers/__init__.py
plugins/_a0_connector/helpers/chat_context.py new
+115
@@ -0,0 +1,115 @@
1 +"""Shared chat-context helpers for connector handlers."""
2 +
3 +from __future__ import annotations
4 +
5 +from contextlib import nullcontext
6 +from typing import Any
7 +
8 +
9 +class ConnectorContextError(Exception):
10 + def __init__(
11 + self,
12 + message: str,
13 + *,
14 + status_code: int = 400,
15 + code: str = "BAD_REQUEST",
16 + ) -> None:
17 + super().__init__(message)
18 + self.status_code = status_code
19 + self.code = code
20 +
21 +
22 +def get_existing_context(
23 + context_id: str,
24 + *,
25 + agent_profile: str | None = None,
26 + project_name: str | None = None,
27 +):
28 + from agent import AgentContext
29 + from helpers import projects
30 +
31 + context = AgentContext.get(context_id)
32 + if context is None:
33 + raise ConnectorContextError(
34 + "Context not found",
35 + status_code=404,
36 + code="CONTEXT_NOT_FOUND",
37 + )
38 +
39 + if agent_profile and getattr(context.agent0.config, "profile", None) != agent_profile:
40 + raise ConnectorContextError(
41 + "Cannot change agent_profile on existing context",
42 + status_code=400,
43 + code="INVALID_AGENT_PROFILE",
44 + )
45 +
46 + existing_project = context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
47 + if project_name and existing_project and existing_project != project_name:
48 + raise ConnectorContextError(
49 + "Project can only be set on first message",
50 + status_code=400,
51 + code="PROJECT_CONFLICT",
52 + )
53 +
54 + return context
55 +
56 +
57 +def create_context(
58 + *,
59 + lock: Any | None = None,
60 + current_context_id: str | None = None,
61 + agent_profile: str | None = None,
62 + project_name: str | None = None,
63 +):
64 + from agent import AgentContext, AgentContextType
65 + from helpers import projects, settings
66 + from helpers.state_monitor_integration import mark_dirty_all
67 + from initialize import initialize_agent
68 + from plugins._model_config.helpers.model_config import is_chat_override_allowed
69 +
70 + override_settings: dict[str, str] = {}
71 + if agent_profile:
72 + override_settings["agent_profile"] = agent_profile
73 +
74 + with lock if lock is not None else nullcontext():
75 + current_context = AgentContext.get(current_context_id or "") if current_context_id else None
76 +
77 + context = AgentContext(
78 + config=initialize_agent(override_settings=override_settings),
79 + type=AgentContextType.USER,
80 + set_current=True,
81 + )
82 +
83 + if current_context and settings.get_settings().get("chat_inherit_project", True):
84 + current_project = current_context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
85 + if current_project:
86 + context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, current_project)
87 +
88 + current_project_output = current_context.get_output_data(
89 + projects.CONTEXT_DATA_KEY_PROJECT
90 + )
91 + if current_project_output:
92 + context.set_output_data(
93 + projects.CONTEXT_DATA_KEY_PROJECT,
94 + current_project_output,
95 + )
96 +
97 + if current_context:
98 + model_override = current_context.get_data("chat_model_override")
99 + if model_override and is_chat_override_allowed(context.agent0):
100 + context.set_data("chat_model_override", model_override)
101 +
102 + if project_name:
103 + try:
104 + try:
105 + projects.activate_project(context.id, project_name, mark_dirty=False)
106 + except TypeError as exc:
107 + if "mark_dirty" not in str(exc):
108 + raise
109 + projects.activate_project(context.id, project_name)
110 + except Exception:
111 + AgentContext.remove(context.id)
112 + raise
113 +
114 + mark_dirty_all(reason="plugins._a0_connector.chat_context.create_context")
115 + return context
plugins/_a0_connector/helpers/event_bridge.py new
+128
@@ -0,0 +1,128 @@
1 +"""Context event streaming bridge for the a0-connector plugin."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +import time
6 +from typing import Any, AsyncIterator, Callable
7 +
8 +from helpers.print_style import PrintStyle
9 +
10 +
11 +EVENT_USER_MESSAGE = "user_message"
12 +EVENT_ASSISTANT_DELTA = "assistant_delta"
13 +EVENT_ASSISTANT_MESSAGE = "assistant_message"
14 +EVENT_TOOL_START = "tool_start"
15 +EVENT_TOOL_OUTPUT = "tool_output"
16 +EVENT_TOOL_END = "tool_end"
17 +EVENT_CODE_START = "code_start"
18 +EVENT_CODE_OUTPUT = "code_output"
19 +EVENT_WARNING = "warning"
20 +EVENT_ERROR = "error"
21 +EVENT_INFO = "info"
22 +EVENT_STATUS = "status"
23 +EVENT_UTIL_MESSAGE = "util_message"
24 +EVENT_MESSAGE_COMPLETE = "message_complete"
25 +EVENT_CONTEXT_UPDATED = "context_updated"
26 +
27 +_LOG_TYPE_MAP: dict[str, str] = {
28 + "agent": EVENT_STATUS,
29 + "ai_response": EVENT_ASSISTANT_MESSAGE,
30 + "browser": EVENT_TOOL_OUTPUT,
31 + "code": EVENT_CODE_START,
32 + "code_exe": EVENT_CODE_OUTPUT,
33 + "code_output": EVENT_CODE_OUTPUT,
34 + "error": EVENT_ERROR,
35 + "hint": EVENT_STATUS,
36 + "info": EVENT_INFO,
37 + "input": EVENT_USER_MESSAGE,
38 + "mcp": EVENT_TOOL_START,
39 + "progress": EVENT_STATUS,
40 + "response": EVENT_ASSISTANT_MESSAGE,
41 + "subagent": EVENT_STATUS,
42 + "tool": EVENT_TOOL_START,
43 + "tool_output": EVENT_TOOL_OUTPUT,
44 + "user": EVENT_USER_MESSAGE,
45 + "util": EVENT_UTIL_MESSAGE,
46 + "warning": EVENT_WARNING,
47 +}
48 +
49 +
50 +def log_entry_to_connector_event(
51 + log_entry: dict[str, Any],
52 + context_id: str,
53 +) -> dict[str, Any]:
54 + entry_type = str(log_entry.get("type", "")).strip()
55 + event_type = _LOG_TYPE_MAP.get(entry_type, EVENT_STATUS)
56 + item_no = int(log_entry.get("no", 0) or 0)
57 +
58 + data: dict[str, Any] = {}
59 + content = log_entry.get("content")
60 + heading = log_entry.get("heading")
61 + kvps = log_entry.get("kvps")
62 +
63 + if isinstance(content, str) and content:
64 + data["text"] = content
65 + if isinstance(heading, str) and heading:
66 + data["heading"] = heading
67 + if isinstance(kvps, dict) and kvps:
68 + data["meta"] = kvps
69 +
70 + return {
71 + "context_id": context_id,
72 + "sequence": item_no + 1,
73 + "event": event_type,
74 + "timestamp": log_entry.get("timestamp", ""),
75 + "data": data,
76 + }
77 +
78 +
79 +def get_context_log_entries(
80 + context_id: str,
81 + after: int = 0,
82 +) -> tuple[list[dict[str, Any]], int]:
83 + """Return connector events plus the next log cursor for the context."""
84 + try:
85 + from agent import AgentContext
86 +
87 + context = AgentContext.get(context_id)
88 + if context is None:
89 + return [], 0
90 +
91 + log_output = context.log.output(start=max(int(after or 0), 0))
92 + events = [
93 + log_entry_to_connector_event(entry, context_id)
94 + for entry in log_output.items
95 + if isinstance(entry, dict)
96 + ]
97 + return events, int(log_output.end)
98 + except Exception as exc:
99 + PrintStyle.error(
100 + f"[a0-connector] event_bridge error for context {context_id}: {exc}"
101 + )
102 + return [], max(int(after or 0), 0)
103 +
104 +
105 +async def stream_context_events(
106 + context_id: str,
107 + from_sequence: int = 0,
108 + poll_interval: float = 0.5,
109 + timeout: float = 300.0,
110 + emit_fn: Callable[[dict[str, Any]], Any] | None = None,
111 +) -> AsyncIterator[dict[str, Any]]:
112 + cursor = max(int(from_sequence or 0), 0)
113 + deadline = time.monotonic() + timeout
114 +
115 + while time.monotonic() < deadline:
116 + events, next_cursor = get_context_log_entries(context_id, after=cursor)
117 + for event in events:
118 + if emit_fn is not None:
119 + try:
120 + result = emit_fn(event)
121 + if asyncio.iscoroutine(result):
122 + await result
123 + except Exception as exc:
124 + PrintStyle.error(f"[a0-connector] emit_fn error: {exc}")
125 + yield event
126 +
127 + cursor = max(cursor, next_cursor)
128 + await asyncio.sleep(poll_interval)
plugins/_a0_connector/helpers/ws_runtime.py new
+295
@@ -0,0 +1,295 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import threading
5 +import time
6 +from dataclasses import dataclass
7 +from typing import Any
8 +
9 +
10 +@dataclass
11 +class PendingFileOperation:
12 + sid: str
13 + loop: asyncio.AbstractEventLoop
14 + future: asyncio.Future[dict[str, Any]]
15 + context_id: str | None = None
16 +
17 +
18 +@dataclass
19 +class PendingExecOperation:
20 + sid: str
21 + loop: asyncio.AbstractEventLoop
22 + future: asyncio.Future[dict[str, Any]]
23 + context_id: str | None = None
24 +
25 +
26 +@dataclass(frozen=True)
27 +class RemoteTreeSnapshot:
28 + sid: str
29 + payload: dict[str, Any]
30 + updated_at: float
31 +
32 +
33 +_context_subscriptions: dict[str, set[str]] = {}
34 +_sid_contexts: dict[str, set[str]] = {}
35 +_pending_file_ops: dict[str, PendingFileOperation] = {}
36 +_pending_exec_ops: dict[str, PendingExecOperation] = {}
37 +_remote_tree_snapshots: dict[str, RemoteTreeSnapshot] = {}
38 +_state_lock = threading.RLock()
39 +
40 +
41 +def register_sid(sid: str) -> None:
42 + with _state_lock:
43 + _sid_contexts.setdefault(sid, set())
44 +
45 +
46 +def unregister_sid(sid: str) -> set[str]:
47 + with _state_lock:
48 + contexts = _sid_contexts.pop(sid, set())
49 + _remote_tree_snapshots.pop(sid, None)
50 + for context_id in contexts:
51 + subscribers = _context_subscriptions.get(context_id)
52 + if not subscribers:
53 + continue
54 + subscribers.discard(sid)
55 + if not subscribers:
56 + _context_subscriptions.pop(context_id, None)
57 + return contexts
58 +
59 +
60 +def subscribe_sid_to_context(sid: str, context_id: str) -> None:
61 + with _state_lock:
62 + _sid_contexts.setdefault(sid, set()).add(context_id)
63 + _context_subscriptions.setdefault(context_id, set()).add(sid)
64 +
65 +
66 +def unsubscribe_sid_from_context(sid: str, context_id: str) -> None:
67 + with _state_lock:
68 + contexts = _sid_contexts.get(sid)
69 + if contexts is not None:
70 + contexts.discard(context_id)
71 + if not contexts:
72 + _sid_contexts.pop(sid, None)
73 +
74 + subscribers = _context_subscriptions.get(context_id)
75 + if subscribers is not None:
76 + subscribers.discard(sid)
77 + if not subscribers:
78 + _context_subscriptions.pop(context_id, None)
79 +
80 +
81 +def subscribed_contexts_for_sid(sid: str) -> set[str]:
82 + with _state_lock:
83 + return set(_sid_contexts.get(sid, set()))
84 +
85 +
86 +def subscribed_sids_for_context(context_id: str) -> set[str]:
87 + with _state_lock:
88 + return set(_context_subscriptions.get(context_id, set()))
89 +
90 +
91 +def store_remote_tree_snapshot(
92 + sid: str,
93 + payload: dict[str, Any],
94 +) -> RemoteTreeSnapshot:
95 + snapshot = RemoteTreeSnapshot(
96 + sid=sid,
97 + payload=dict(payload),
98 + updated_at=time.time(),
99 + )
100 + with _state_lock:
101 + _remote_tree_snapshots[sid] = snapshot
102 + return snapshot
103 +
104 +
105 +def clear_remote_tree_snapshot(sid: str) -> None:
106 + with _state_lock:
107 + _remote_tree_snapshots.pop(sid, None)
108 +
109 +
110 +def latest_remote_tree_for_context(
111 + context_id: str,
112 + *,
113 + max_age_seconds: float = 90.0,
114 +) -> dict[str, Any] | None:
115 + now = time.time()
116 + with _state_lock:
117 + subscribers = _context_subscriptions.get(context_id, set())
118 + snapshots = [
119 + _remote_tree_snapshots[sid]
120 + for sid in subscribers
121 + if sid in _remote_tree_snapshots
122 + ]
123 +
124 + if not snapshots:
125 + return None
126 +
127 + snapshots.sort(key=lambda item: item.updated_at, reverse=True)
128 + for snapshot in snapshots:
129 + if max_age_seconds > 0 and now - snapshot.updated_at > max_age_seconds:
130 + continue
131 + payload = dict(snapshot.payload)
132 + payload["sid"] = snapshot.sid
133 + payload["updated_at"] = snapshot.updated_at
134 + return payload
135 + return None
136 +
137 +
138 +def select_target_sid(context_id: str) -> str | None:
139 + with _state_lock:
140 + subscribers = _context_subscriptions.get(context_id, set())
141 + if not subscribers:
142 + return None
143 + return sorted(subscribers)[0]
144 +
145 +
146 +def store_pending_file_op(
147 + op_id: str,
148 + *,
149 + sid: str,
150 + future: asyncio.Future[dict[str, Any]],
151 + loop: asyncio.AbstractEventLoop,
152 + context_id: str | None = None,
153 +) -> None:
154 + with _state_lock:
155 + _pending_file_ops[op_id] = PendingFileOperation(
156 + sid=sid,
157 + loop=loop,
158 + future=future,
159 + context_id=context_id,
160 + )
161 +
162 +
163 +def clear_pending_file_op(op_id: str) -> None:
164 + with _state_lock:
165 + _pending_file_ops.pop(op_id, None)
166 +
167 +
168 +def resolve_pending_file_op(
169 + op_id: str,
170 + *,
171 + sid: str,
172 + payload: dict[str, Any],
173 +) -> bool:
174 + with _state_lock:
175 + pending = _pending_file_ops.get(op_id)
176 + if pending is None or pending.sid != sid:
177 + return False
178 + _pending_file_ops.pop(op_id, None)
179 +
180 + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
181 + return True
182 +
183 +
184 +def fail_pending_file_op(
185 + op_id: str,
186 + *,
187 + sid: str | None = None,
188 + error: str,
189 +) -> bool:
190 + with _state_lock:
191 + pending = _pending_file_ops.get(op_id)
192 + if pending is None:
193 + return False
194 + if sid is not None and pending.sid != sid:
195 + return False
196 + _pending_file_ops.pop(op_id, None)
197 +
198 + payload = {"op_id": op_id, "ok": False, "error": error}
199 + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
200 + return True
201 +
202 +
203 +def fail_pending_file_ops_for_sid(sid: str, *, error: str) -> None:
204 + with _state_lock:
205 + matches = [
206 + (op_id, pending)
207 + for op_id, pending in _pending_file_ops.items()
208 + if pending.sid == sid
209 + ]
210 + for op_id, _pending in matches:
211 + _pending_file_ops.pop(op_id, None)
212 +
213 + for op_id, pending in matches:
214 + payload = {"op_id": op_id, "ok": False, "error": error}
215 + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
216 +
217 +
218 +def store_pending_exec_op(
219 + op_id: str,
220 + *,
221 + sid: str,
222 + future: asyncio.Future[dict[str, Any]],
223 + loop: asyncio.AbstractEventLoop,
224 + context_id: str | None = None,
225 +) -> None:
226 + with _state_lock:
227 + _pending_exec_ops[op_id] = PendingExecOperation(
228 + sid=sid,
229 + loop=loop,
230 + future=future,
231 + context_id=context_id,
232 + )
233 +
234 +
235 +def clear_pending_exec_op(op_id: str) -> None:
236 + with _state_lock:
237 + _pending_exec_ops.pop(op_id, None)
238 +
239 +
240 +def resolve_pending_exec_op(
241 + op_id: str,
242 + *,
243 + sid: str,
244 + payload: dict[str, Any],
245 +) -> bool:
246 + with _state_lock:
247 + pending = _pending_exec_ops.get(op_id)
248 + if pending is None or pending.sid != sid:
249 + return False
250 + _pending_exec_ops.pop(op_id, None)
251 +
252 + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
253 + return True
254 +
255 +
256 +def fail_pending_exec_op(
257 + op_id: str,
258 + *,
259 + sid: str | None = None,
260 + error: str,
261 +) -> bool:
262 + with _state_lock:
263 + pending = _pending_exec_ops.get(op_id)
264 + if pending is None:
265 + return False
266 + if sid is not None and pending.sid != sid:
267 + return False
268 + _pending_exec_ops.pop(op_id, None)
269 +
270 + payload = {"op_id": op_id, "ok": False, "error": error}
271 + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
272 + return True
273 +
274 +
275 +def fail_pending_exec_ops_for_sid(sid: str, *, error: str) -> None:
276 + with _state_lock:
277 + matches = [
278 + (op_id, pending)
279 + for op_id, pending in _pending_exec_ops.items()
280 + if pending.sid == sid
281 + ]
282 + for op_id, _pending in matches:
283 + _pending_exec_ops.pop(op_id, None)
284 +
285 + for op_id, pending in matches:
286 + payload = {"op_id": op_id, "ok": False, "error": error}
287 + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
288 +
289 +
290 +def _set_future_result(
291 + future: asyncio.Future[dict[str, Any]],
292 + payload: dict[str, Any],
293 +) -> None:
294 + if not future.done():
295 + future.set_result(payload)
plugins/_a0_connector/plugin.yaml new
+9
@@ -0,0 +1,9 @@
1 +name: _a0_connector
2 +title: A0 Connector
3 +description: Current Agent Zero connector plugin for HTTP plus /ws integration, using session auth and handler activation through auth.handlers.
4 +version: 0.1.0
5 +settings_sections:
6 + - external
7 + - developer
8 +per_project_config: false
9 +per_agent_config: false
plugins/_a0_connector/prompts/agent.extras.remote_file_structure.md new
+7
@@ -0,0 +1,7 @@
1 +# Remote file structure of connected CLI workspace {{folder}}
2 +- this snapshot comes from the frontend machine, not the Agent Zero server filesystem
3 +- snapshot age (seconds): {{age_seconds}}
4 +- generated at: {{generated_at}}
5 +
6 +## file tree
7 +{{file_structure}}
plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md new
+102
@@ -0,0 +1,102 @@
1 +# code_execution_remote tool
2 +
3 +This tool runs shell-backed execution on the **remote machine where the CLI is running**.
4 +It converges onto Agent Zero Core's persistent local-shell model, so the frontend session
5 +can execute terminal commands and shell-launched `python` / `nodejs` snippets while keeping
6 +session ids stable across calls.
7 +
8 +## Requirements
9 +- A CLI client must be connected to this context via the shared `/ws` namespace.
10 +- The CLI client must support `connector_exec_op`.
11 +- Frontend execution may be locally disabled in the CLI session; in that case the result is
12 + a structured `{ok: false}` error and no fallback runtime is used.
13 +
14 +## Arguments
15 +- `runtime`: one of `terminal`, `python`, `nodejs`, `output`, `reset`
16 +- `runtime=input` is a temporary deprecated compatibility alias for sending one line of
17 + keyboard input into a running shell session
18 +- `session`: integer session id (default `0`)
19 +
20 +Runtime-specific fields:
21 +- `terminal`, `python`, `nodejs`: require `code`
22 +- `input`: requires `keyboard` (or `code` as fallback)
23 +- `reset`: optional `reason`
24 +
25 +## Usage
26 +
27 +### Execute a terminal command
28 +```json
29 +{
30 + "tool_name": "code_execution_remote",
31 + "tool_args": {
32 + "runtime": "terminal",
33 + "session": 0,
34 + "code": "pwd && ls -la"
35 + }
36 +}
37 +```
38 +
39 +### Execute Python through the shell-backed runtime
40 +```json
41 +{
42 + "tool_name": "code_execution_remote",
43 + "tool_args": {
44 + "runtime": "python",
45 + "session": 0,
46 + "code": "import os\nprint(os.getcwd())"
47 + }
48 +}
49 +```
50 +
51 +### Execute Node.js through the shell-backed runtime
52 +```json
53 +{
54 + "tool_name": "code_execution_remote",
55 + "tool_args": {
56 + "runtime": "nodejs",
57 + "session": 0,
58 + "code": "console.log(process.cwd())"
59 + }
60 +}
61 +```
62 +
63 +### Poll output from a running session
64 +```json
65 +{
66 + "tool_name": "code_execution_remote",
67 + "tool_args": {
68 + "runtime": "output",
69 + "session": 0
70 + }
71 +}
72 +```
73 +
74 +### Send keyboard input to a running session
75 +```json
76 +{
77 + "tool_name": "code_execution_remote",
78 + "tool_args": {
79 + "runtime": "input",
80 + "session": 0,
81 + "keyboard": "yes"
82 + }
83 +}
84 +```
85 +
86 +### Reset a session
87 +```json
88 +{
89 + "tool_name": "code_execution_remote",
90 + "tool_args": {
91 + "runtime": "reset",
92 + "session": 0,
93 + "reason": "stuck process"
94 + }
95 +}
96 +```
97 +
98 +## Notes
99 +- Session state is frontend-local and shell-backed.
100 +- `output` is for long-running operations where a prior call returned control before the
101 + shell reached a prompt.
102 +- The transport uses `connector_exec_op` and `connector_exec_op_result` with shared `op_id`.
plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md new
+60
@@ -0,0 +1,60 @@
1 +# text_editor_remote tool
2 +
3 +This tool allows you to read, write, and patch files on the **remote machine where the CLI is running**.
4 +This is different from `text_editor` which operates on the Agent Zero server's filesystem.
5 +
6 +Use `text_editor_remote` when the user asks you to edit files on their local machine while connected via the CLI.
7 +
8 +## Requirements
9 +- A CLI client must be connected to this context via the shared `/ws` namespace.
10 +- The CLI client must have enabled remote file editing support.
11 +
12 +## Operations
13 +
14 +### Read a file
15 +```json
16 +{
17 + "tool_name": "text_editor_remote",
18 + "tool_args": {
19 + "op": "read",
20 + "path": "/path/on/remote/machine/file.py",
21 + "line_from": 1,
22 + "line_to": 50
23 + }
24 +}
25 +```
26 +Returns file content with line numbers. `line_from` and `line_to` are optional.
27 +
28 +### Write a file
29 +```json
30 +{
31 + "tool_name": "text_editor_remote",
32 + "tool_args": {
33 + "op": "write",
34 + "path": "/path/on/remote/machine/file.py",
35 + "content": "import os\nprint('hello')\n"
36 + }
37 +}
38 +```
39 +Creates or overwrites the file on the remote machine.
40 +
41 +### Patch a file
42 +```json
43 +{
44 + "tool_name": "text_editor_remote",
45 + "tool_args": {
46 + "op": "patch",
47 + "path": "/path/on/remote/machine/file.py",
48 + "edits": [
49 + {"from": 5, "to": 5, "content": " if x == 2:\n"}
50 + ]
51 + }
52 +}
53 +```
54 +Applies line-range patches to the file. Use the same format as the standard `text_editor:patch` tool.
55 +
56 +## Notes
57 +- Always read the file first before patching to get current line numbers.
58 +- Paths are evaluated on the **remote machine's filesystem**, not the Agent Zero server.
59 +- If no CLI is connected, the tool will return an error message.
60 +- The transport uses `connector_file_op` and `connector_file_op_result` with a shared `op_id`.
plugins/_a0_connector/tools/__init__.py
plugins/_a0_connector/tools/code_execution_remote.py new
+196
@@ -0,0 +1,196 @@
1 +"""code_execution_remote tool — run shell-backed frontend operations on the CLI machine via `/ws`."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +import uuid
6 +from typing import Any
7 +
8 +from helpers.tool import Response, Tool
9 +from helpers.ws import NAMESPACE
10 +from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
11 +
12 +from plugins._a0_connector.helpers.ws_runtime import (
13 + clear_pending_exec_op,
14 + select_target_sid,
15 + store_pending_exec_op,
16 +)
17 +
18 +
19 +EXEC_OP_TIMEOUT = 120.0
20 +EXEC_OP_EVENT = "connector_exec_op"
21 +
22 +
23 +class CodeExecutionRemote(Tool):
24 + """Send shell-backed frontend execution operations to the connected CLI machine."""
25 +
26 + def get_log_object(self):
27 + import uuid
28 +
29 + return self.agent.context.log.log(
30 + type="code_exe",
31 + heading=self.get_heading(),
32 + content="",
33 + kvps=self.args,
34 + id=str(uuid.uuid4()),
35 + )
36 +
37 + def get_heading(self, text: str = "") -> str:
38 + if not text:
39 + name = str(getattr(self, "name", "code_execution_remote"))
40 + runtime = str(self.args.get("runtime", "unknown") or "unknown")
41 + text = f"{name} - {runtime}"
42 +
43 + normalized = " ".join(str(text).split())
44 + if len(normalized) > 200:
45 + normalized = normalized[:197].rstrip() + "..."
46 +
47 + session = self.args.get("session", None)
48 + session_text = f"[{session}] " if session or session == 0 else ""
49 + return f"icon://terminal {session_text}{normalized}"
50 +
51 + async def execute(self, **kwargs: Any) -> Response:
52 + runtime = str(self.args.get("runtime", "")).strip().lower()
53 + if runtime not in {"terminal", "python", "nodejs", "output", "input", "reset"}:
54 + return Response(
55 + message=(
56 + "runtime is required (terminal, python, nodejs, output, reset, "
57 + "or input [deprecated compatibility alias])"
58 + ),
59 + break_loop=False,
60 + )
61 +
62 + context_id = self.agent.context.id
63 + sid = select_target_sid(context_id)
64 + if not sid:
65 + return Response(
66 + message=(
67 + "code_execution_remote: no CLI client connected to this context. "
68 + "Make sure the CLI is connected and subscribed."
69 + ),
70 + break_loop=False,
71 + )
72 +
73 + try:
74 + session = int(self.args.get("session", 0) or 0)
75 + except (TypeError, ValueError):
76 + return Response(
77 + message="session must be an integer",
78 + break_loop=False,
79 + )
80 +
81 + op_id = str(uuid.uuid4())
82 + payload: dict[str, Any] = {
83 + "op_id": op_id,
84 + "runtime": runtime,
85 + "session": session,
86 + "context_id": context_id,
87 + }
88 +
89 + if runtime in {"terminal", "python", "nodejs"}:
90 + code = self.args.get("code")
91 + if code is None or not str(code).strip():
92 + return Response(
93 + message=f"code is required for runtime={runtime}",
94 + break_loop=False,
95 + )
96 + payload["code"] = str(code)
97 +
98 + elif runtime == "input":
99 + keyboard = self.args.get("keyboard")
100 + if keyboard is None:
101 + keyboard = self.args.get("code")
102 + if keyboard is None:
103 + return Response(
104 + message="keyboard is required for runtime=input",
105 + break_loop=False,
106 + )
107 + payload["keyboard"] = str(keyboard)
108 +
109 + elif runtime == "reset":
110 + reason = self.args.get("reason")
111 + if reason is not None:
112 + payload["reason"] = str(reason)
113 +
114 + loop = asyncio.get_running_loop()
115 + future: asyncio.Future[dict[str, Any]] = loop.create_future()
116 + store_pending_exec_op(
117 + op_id,
118 + sid=sid,
119 + future=future,
120 + loop=loop,
121 + context_id=context_id,
122 + )
123 +
124 + try:
125 + await get_shared_ws_manager().emit_to(
126 + NAMESPACE,
127 + sid,
128 + EXEC_OP_EVENT,
129 + payload,
130 + handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
131 + )
132 + result = await asyncio.wait_for(future, timeout=EXEC_OP_TIMEOUT)
133 + except ConnectionNotFoundError:
134 + clear_pending_exec_op(op_id)
135 + return Response(
136 + message=(
137 + "code_execution_remote: the selected CLI client disconnected before "
138 + "the execution request could be delivered"
139 + ),
140 + break_loop=False,
141 + )
142 + except asyncio.TimeoutError:
143 + clear_pending_exec_op(op_id)
144 + return Response(
145 + message=(
146 + "code_execution_remote: timed out waiting for CLI to respond "
147 + f"to runtime={runtime!r} in session {session}"
148 + ),
149 + break_loop=False,
150 + )
151 + except Exception as exc:
152 + clear_pending_exec_op(op_id)
153 + return Response(
154 + message=f"code_execution_remote: error sending exec_op: {exc}",
155 + break_loop=False,
156 + )
157 + finally:
158 + clear_pending_exec_op(op_id)
159 +
160 + return Response(
161 + message=self._extract_result(result, runtime, session),
162 + break_loop=False,
163 + )
164 +
165 + def _extract_result(self, result: Any, runtime: str, session: int) -> str:
166 + if not isinstance(result, dict):
167 + return f"Unexpected response format from CLI: {result!r}"
168 +
169 + ok = bool(result.get("ok"))
170 + data = result.get("result")
171 + error = result.get("error")
172 +
173 + if not ok:
174 + return (
175 + f"Error (runtime={runtime!r}, session={session}): "
176 + f"{error or 'Unknown error'}"
177 + )
178 +
179 + if not isinstance(data, dict):
180 + data = {}
181 +
182 + output = str(data.get("output") or data.get("text") or "").strip()
183 + message = str(data.get("message") or "").strip()
184 + running = bool(data.get("running"))
185 +
186 + parts: list[str] = []
187 + if message:
188 + parts.append(message)
189 + if output:
190 + parts.append(output)
191 +
192 + if not parts:
193 + state = "running" if running else "completed"
194 + parts.append(f"Session {session} {state}.")
195 +
196 + return "\n\n".join(parts)
plugins/_a0_connector/tools/text_editor_remote.py new
+156
@@ -0,0 +1,156 @@
1 +"""text_editor_remote tool — edit files on the CLI machine via `/ws`."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +import uuid
6 +from typing import Any
7 +
8 +from helpers.tool import Response, Tool
9 +from helpers.ws import NAMESPACE
10 +from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
11 +
12 +from plugins._a0_connector.helpers.ws_runtime import (
13 + clear_pending_file_op,
14 + select_target_sid,
15 + store_pending_file_op,
16 +)
17 +
18 +
19 +FILE_OP_TIMEOUT = 30.0
20 +FILE_OP_EVENT = "connector_file_op"
21 +
22 +
23 +class TextEditorRemote(Tool):
24 + """Send file-editing operations to the connected CLI machine."""
25 +
26 + async def execute(self, **kwargs: Any) -> Response:
27 + op = str(self.args.get("op") or self.args.get("operation", "")).strip().lower()
28 + if not op:
29 + return Response(
30 + message="op is required (read, write, or patch)",
31 + break_loop=False,
32 + )
33 + if op not in {"read", "write", "patch"}:
34 + return Response(
35 + message=f"Unknown operation: {op!r}. Use read, write, or patch.",
36 + break_loop=False,
37 + )
38 +
39 + path = str(self.args.get("path", "")).strip()
40 + if not path:
41 + return Response(message="path is required", break_loop=False)
42 +
43 + context_id = self.agent.context.id
44 + sid = select_target_sid(context_id)
45 + if not sid:
46 + return Response(
47 + message=(
48 + "text_editor_remote: no CLI client connected to this context. "
49 + "Make sure the CLI is connected and subscribed."
50 + ),
51 + break_loop=False,
52 + )
53 +
54 + op_id = str(uuid.uuid4())
55 + payload: dict[str, Any] = {
56 + "op_id": op_id,
57 + "op": op,
58 + "path": path,
59 + "context_id": context_id,
60 + }
61 + if op == "read":
62 + if self.args.get("line_from"):
63 + payload["line_from"] = int(self.args["line_from"])
64 + if self.args.get("line_to"):
65 + payload["line_to"] = int(self.args["line_to"])
66 + elif op == "write":
67 + content = self.args.get("content")
68 + if content is None:
69 + return Response(
70 + message="content is required for write",
71 + break_loop=False,
72 + )
73 + payload["content"] = content
74 + else:
75 + edits = self.args.get("edits")
76 + if not edits:
77 + return Response(
78 + message="edits is required for patch",
79 + break_loop=False,
80 + )
81 + payload["edits"] = edits
82 +
83 + loop = asyncio.get_running_loop()
84 + future: asyncio.Future[dict[str, Any]] = loop.create_future()
85 + store_pending_file_op(
86 + op_id,
87 + sid=sid,
88 + future=future,
89 + loop=loop,
90 + context_id=context_id,
91 + )
92 +
93 + try:
94 + await get_shared_ws_manager().emit_to(
95 + NAMESPACE,
96 + sid,
97 + FILE_OP_EVENT,
98 + payload,
99 + handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
100 + )
101 + result = await asyncio.wait_for(future, timeout=FILE_OP_TIMEOUT)
102 + except ConnectionNotFoundError:
103 + clear_pending_file_op(op_id)
104 + return Response(
105 + message=(
106 + "text_editor_remote: the selected CLI client disconnected before "
107 + "the file operation could be delivered"
108 + ),
109 + break_loop=False,
110 + )
111 + except asyncio.TimeoutError:
112 + clear_pending_file_op(op_id)
113 + return Response(
114 + message=(
115 + f"text_editor_remote: timed out waiting for CLI to respond "
116 + f"to {op} on {path!r}"
117 + ),
118 + break_loop=False,
119 + )
120 + except Exception as exc:
121 + clear_pending_file_op(op_id)
122 + return Response(
123 + message=f"text_editor_remote: error sending file_op: {exc}",
124 + break_loop=False,
125 + )
126 + finally:
127 + clear_pending_file_op(op_id)
128 +
129 + return Response(
130 + message=self._extract_result(result, op, path),
131 + break_loop=False,
132 + )
133 +
134 + def _extract_result(self, result: Any, op: str, path: str) -> str:
135 + if not isinstance(result, dict):
136 + return f"Unexpected response format from CLI: {result!r}"
137 +
138 + ok = bool(result.get("ok"))
139 + data = result.get("result")
140 + error = result.get("error")
141 +
142 + if not ok:
143 + return f"Error ({op} {path!r}): {error or 'Unknown error'}"
144 +
145 + if not isinstance(data, dict):
146 + data = {}
147 +
148 + if op == "read":
149 + content = data.get("content", "")
150 + total_lines = data.get("total_lines", "?")
151 + return f"{path} {total_lines} lines\n>>>\n{content}\n<<<"
152 + if op == "write":
153 + return data.get("message") or f"{path} written successfully"
154 + if op == "patch":
155 + return data.get("message") or f"{path} patched successfully"
156 + return str(data)