Add builtin ACP plugin

Expose Agent Zero over the Agent Client Protocol as a builtin stdio plugin with session lifecycle support, streaming bridges, registry metadata, and editor workspace handling. Add lazy dependency installation through the ACP plugin hook using the root requirements pin so self-updated instances can recover without a fresh Docker image. Pin agent-client-protocol, add focused static coverage, and allow ACP sessions to override the per-context workdir for code execution and workdir prompts.

Alessandro committed Jun 1, 2026 at 02:38 UTC 46112c9750d486f684e047c668262e4d1ace2200
20 files changed +1773 -3
extensions/python/message_loop_prompts_after/_75_include_workdir_extras.py
+1 -1
@@ -50,7 +50,7 @@ class IncludeWorkdirExtras(Extension):
50 max_lines = set["workdir_max_lines"]
51 gitignore_raw = set["workdir_gitignore"]
52
53 - folder = set["workdir_path"]
53 + folder = self.agent.context.get_data("workdir_path") or set["workdir_path"]
54 scan_path = files.get_abs_path_development(folder)
55
56 files.create_dir(scan_path)
plugins/_acp/README.md new
+34
@@ -0,0 +1,34 @@
1 +# Agent Client Protocol
2 +
3 +This builtin plugin exposes Agent Zero through the Agent Client Protocol (ACP)
4 +over stdio so ACP-capable editors can create, load, resume, and prompt Agent
5 +Zero sessions directly from a workspace.
6 +
7 +## Usage
8 +
9 +From the Agent Zero repository or runtime container:
10 +
11 +```bash
12 +python -m plugins._acp
13 +```
14 +
15 +For the Dockerized Agent Zero runtime, point the editor command at the
16 +framework interpreter inside the container, for example:
17 +
18 +```bash
19 +docker exec -i agent-zero /opt/venv-a0/bin/python -m plugins._acp
20 +```
21 +
22 +ACP reserves stdout for JSON-RPC, so the adapter writes diagnostics to stderr.
23 +
24 +## Checks
25 +
26 +```bash
27 +python -m plugins._acp --check
28 +python -m plugins._acp --registry
29 +```
30 +
31 +The ACP Python SDK is provided by `agent-client-protocol`.
32 +Fresh Docker images install it through the root `requirements.txt`; self-updated
33 +instances lazy-install the same root pin through `plugins/_acp/hooks.py` the
34 +first time the ACP stdio entrypoint starts.
plugins/_acp/__init__.py new
+4
@@ -0,0 +1,4 @@
1 +PLUGIN_NAME = "_acp"
2 +PLUGIN_TITLE = "Agent Client Protocol"
3 +AGENT_ZERO_ACP_VERSION = "1.19"
4 +PLUGIN_VERSION = AGENT_ZERO_ACP_VERSION
plugins/_acp/__main__.py new
+5
@@ -0,0 +1,5 @@
1 +from .entry import main
2 +
3 +
4 +if __name__ == "__main__":
5 + main()
plugins/_acp/acp_registry/agent.json new
+15
@@ -0,0 +1,15 @@
1 +{
2 + "id": "agent-zero",
3 + "name": "Agent Zero",
4 + "version": "1.19",
5 + "description": "Agent Zero ACP adapter for editor-native agent sessions.",
6 + "repository": "https://github.com/agent0ai/agent-zero",
7 + "authors": ["Agent Zero"],
8 + "license": "MIT",
9 + "distribution": {
10 + "stdio": {
11 + "command": "python",
12 + "args": ["-m", "plugins._acp"]
13 + }
14 + }
15 +}
plugins/_acp/entry.py new
+149
@@ -0,0 +1,149 @@
1 +from __future__ import annotations
2 +
3 +import argparse
4 +import asyncio
5 +import logging
6 +import sys
7 +from pathlib import Path
8 +
9 +from plugins._acp import PLUGIN_VERSION
10 +
11 +
12 +_BENIGN_PROBE_METHODS = {"ping", "health", "healthcheck"}
13 +
14 +
15 +class _BenignProbeFilter(logging.Filter):
16 + def filter(self, record: logging.LogRecord) -> bool:
17 + if record.getMessage() != "Background task failed":
18 + return True
19 + exc_info = record.exc_info
20 + if not exc_info:
21 + return True
22 + try:
23 + from acp.exceptions import RequestError
24 + except ImportError:
25 + return True
26 + exc = exc_info[1]
27 + if not isinstance(exc, RequestError):
28 + return True
29 + if getattr(exc, "code", None) != -32601:
30 + return True
31 + data = getattr(exc, "data", None)
32 + method = data.get("method") if isinstance(data, dict) else None
33 + return method not in _BENIGN_PROBE_METHODS
34 +
35 +
36 +def main(argv: list[str] | None = None) -> None:
37 + args = _parse_args(argv)
38 +
39 + if args.version:
40 + print(PLUGIN_VERSION)
41 + return
42 +
43 + if args.registry:
44 + registry = Path(__file__).resolve().parent / "acp_registry" / "agent.json"
45 + print(registry.read_text(encoding="utf-8"))
46 + return
47 +
48 + if args.check:
49 + _run_check()
50 + return
51 +
52 + _setup_logging(debug=args.debug)
53 + _ensure_project_root()
54 +
55 + acp = _import_acp_or_install()
56 +
57 + from helpers import persist_chat
58 + from plugins._acp.helpers.server import AgentZeroACPAgent
59 +
60 + persist_chat.load_tmp_chats()
61 + logger = logging.getLogger(__name__)
62 + logger.info("Starting Agent Zero ACP adapter")
63 +
64 + try:
65 + asyncio.run(acp.run_agent(AgentZeroACPAgent(), use_unstable_protocol=True))
66 + except KeyboardInterrupt:
67 + logger.info("ACP adapter stopped")
68 + except Exception:
69 + logger.exception("ACP adapter crashed")
70 + sys.exit(1)
71 +
72 +
73 +def _parse_args(argv: list[str] | None) -> argparse.Namespace:
74 + parser = argparse.ArgumentParser(
75 + prog="agent-zero-acp",
76 + description="Run Agent Zero as an Agent Client Protocol stdio server.",
77 + )
78 + parser.add_argument("--check", action="store_true", help="Verify ACP imports and exit")
79 + parser.add_argument("--registry", action="store_true", help="Print ACP registry metadata")
80 + parser.add_argument("--version", action="store_true", help="Print ACP plugin version")
81 + parser.add_argument("--debug", action="store_true", help="Enable debug logging to stderr")
82 + return parser.parse_args(argv)
83 +
84 +
85 +def _setup_logging(*, debug: bool = False) -> None:
86 + handler = logging.StreamHandler(sys.stderr)
87 + handler.setFormatter(
88 + logging.Formatter(
89 + "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
90 + datefmt="%Y-%m-%d %H:%M:%S",
91 + )
92 + )
93 + handler.addFilter(_BenignProbeFilter())
94 + root = logging.getLogger()
95 + root.handlers.clear()
96 + root.addHandler(handler)
97 + root.setLevel(logging.DEBUG if debug else logging.INFO)
98 + logging.getLogger("httpx").setLevel(logging.WARNING)
99 + logging.getLogger("httpcore").setLevel(logging.WARNING)
100 + logging.getLogger("openai").setLevel(logging.WARNING)
101 +
102 +
103 +def _ensure_project_root() -> None:
104 + project_root = str(Path(__file__).resolve().parents[2])
105 + if project_root not in sys.path:
106 + sys.path.insert(0, project_root)
107 +
108 +
109 +def _run_check() -> None:
110 + _ensure_project_root()
111 + _import_acp_or_install()
112 + from plugins._acp.helpers.server import AgentZeroACPAgent # noqa: F401
113 +
114 + print("Agent Zero ACP check OK")
115 +
116 +
117 +def _import_acp_or_install():
118 + try:
119 + import acp
120 +
121 + return acp
122 + except ImportError:
123 + pass
124 +
125 + from plugins._acp import hooks
126 +
127 + if hooks.ensure_dependencies(raise_on_error=False):
128 + try:
129 + import acp
130 +
131 + return acp
132 + except ImportError:
133 + pass
134 +
135 + _missing_dependency()
136 +
137 +
138 +def _missing_dependency() -> None:
139 + print(
140 + "Agent Zero ACP requires agent-client-protocol. "
141 + "The plugin tried to install the root requirements pin automatically; "
142 + "manual fallback: pip install agent-client-protocol==0.10.1",
143 + file=sys.stderr,
144 + )
145 + sys.exit(1)
146 +
147 +
148 +if __name__ == "__main__":
149 + main()
plugins/_acp/extensions/python/reasoning_stream_chunk/_50_acp_stream.py new
+9
@@ -0,0 +1,9 @@
1 +from helpers.extension import Extension
2 +from plugins._acp.helpers import bridge
3 +
4 +
5 +class ACPReasoningStream(Extension):
6 + async def execute(self, stream_data=None, **kwargs):
7 + if not self.agent or not stream_data:
8 + return
9 + bridge.send_agent_thought_delta(self.agent.context.id, stream_data.get("full", ""))
plugins/_acp/extensions/python/response_stream/_50_acp_response.py new
+16
@@ -0,0 +1,16 @@
1 +from helpers.extension import Extension
2 +from plugins._acp.helpers import bridge
3 +
4 +
5 +class ACPResponseStream(Extension):
6 + async def execute(self, parsed=None, **kwargs):
7 + if not self.agent or not isinstance(parsed, dict):
8 + return
9 + tool_name = parsed.get("tool_name") or parsed.get("tool")
10 + if tool_name != "response":
11 + return
12 + tool_args = parsed.get("tool_args") if isinstance(parsed.get("tool_args"), dict) else parsed.get("args")
13 + if not isinstance(tool_args, dict):
14 + return
15 + text = tool_args.get("text", tool_args.get("message", ""))
16 + bridge.send_agent_delta(self.agent.context.id, str(text or ""))
plugins/_acp/extensions/python/tool_execute_after/_50_acp_tool.py new
+9
@@ -0,0 +1,9 @@
1 +from helpers.extension import Extension
2 +from plugins._acp.helpers import bridge
3 +
4 +
5 +class ACPToolFinish(Extension):
6 + async def execute(self, response=None, tool_name="", **kwargs):
7 + if not self.agent:
8 + return
9 + bridge.finish_tool(self.agent.context.id, str(tool_name or ""), response)
plugins/_acp/extensions/python/tool_execute_before/_50_acp_tool.py new
+9
@@ -0,0 +1,9 @@
1 +from helpers.extension import Extension
2 +from plugins._acp.helpers import bridge
3 +
4 +
5 +class ACPToolStart(Extension):
6 + async def execute(self, tool_name="", tool_args=None, **kwargs):
7 + if not self.agent:
8 + return
9 + bridge.start_tool(self.agent.context.id, str(tool_name or ""), tool_args or {})
plugins/_acp/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_acp/helpers/bridge.py new
+303
@@ -0,0 +1,303 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import json
5 +import logging
6 +import threading
7 +import uuid
8 +from collections import defaultdict, deque
9 +from dataclasses import dataclass, field
10 +from typing import Any, Deque
11 +
12 +
13 +logger = logging.getLogger(__name__)
14 +
15 +CTX_IS_ACP = "acp_session"
16 +CTX_CWD = "acp_cwd"
17 +CTX_ADDITIONAL_DIRECTORIES = "acp_additional_directories"
18 +CTX_MODE = "acp_mode"
19 +CTX_CONFIG_OPTIONS = "acp_config_options"
20 +CTX_MODEL_ID = "acp_model_id"
21 +CTX_WORKDIR = "workdir_path"
22 +
23 +DEFAULT_MODE = "default"
24 +
25 +_MAX_RAW_OUTPUT = 12000
26 +
27 +
28 +@dataclass
29 +class SessionBridge:
30 + context_id: str
31 + session_id: str
32 + conn: Any
33 + loop: asyncio.AbstractEventLoop
34 + response_text_sent: str = ""
35 + thought_text_sent: str = ""
36 + message_id: str = ""
37 + tool_queues: dict[str, Deque[str]] = field(default_factory=lambda: defaultdict(deque))
38 + lock: threading.RLock = field(default_factory=threading.RLock)
39 +
40 +
41 +_bridges_by_context: dict[str, SessionBridge] = {}
42 +_bridges_by_session: dict[str, SessionBridge] = {}
43 +_registry_lock = threading.RLock()
44 +
45 +
46 +def register_bridge(
47 + *,
48 + context_id: str,
49 + session_id: str,
50 + conn: Any,
51 + loop: asyncio.AbstractEventLoop,
52 +) -> SessionBridge:
53 + bridge = SessionBridge(
54 + context_id=context_id,
55 + session_id=session_id,
56 + conn=conn,
57 + loop=loop,
58 + )
59 + with _registry_lock:
60 + _bridges_by_context[context_id] = bridge
61 + _bridges_by_session[session_id] = bridge
62 + return bridge
63 +
64 +
65 +def unregister_bridge(context_id: str | None = None, session_id: str | None = None) -> None:
66 + with _registry_lock:
67 + bridge = None
68 + if context_id:
69 + bridge = _bridges_by_context.pop(context_id, None)
70 + if session_id:
71 + bridge = _bridges_by_session.pop(session_id, None) or bridge
72 + if bridge:
73 + _bridges_by_context.pop(bridge.context_id, None)
74 + _bridges_by_session.pop(bridge.session_id, None)
75 +
76 +
77 +def get_bridge_for_context(context_id: str) -> SessionBridge | None:
78 + with _registry_lock:
79 + return _bridges_by_context.get(context_id)
80 +
81 +
82 +def get_bridge_for_session(session_id: str) -> SessionBridge | None:
83 + with _registry_lock:
84 + return _bridges_by_session.get(session_id)
85 +
86 +
87 +def reset_turn(context_id: str, message_id: str = "") -> None:
88 + bridge = get_bridge_for_context(context_id)
89 + if not bridge:
90 + return
91 + with bridge.lock:
92 + bridge.response_text_sent = ""
93 + bridge.thought_text_sent = ""
94 + bridge.message_id = message_id
95 + bridge.tool_queues.clear()
96 +
97 +
98 +def send_agent_delta(context_id: str, full_text: str) -> bool:
99 + bridge = get_bridge_for_context(context_id)
100 + if not bridge:
101 + return False
102 + full_text = str(full_text or "")
103 + with bridge.lock:
104 + previous = bridge.response_text_sent
105 + if full_text == previous:
106 + return False
107 + if full_text.startswith(previous):
108 + delta = full_text[len(previous) :]
109 + else:
110 + delta = full_text
111 + bridge.response_text_sent = full_text
112 + if not delta:
113 + return False
114 + return _send_acp_helper_update(bridge, "update_agent_message_text", delta)
115 +
116 +
117 +def send_agent_text(context_id: str, text: str) -> bool:
118 + bridge = get_bridge_for_context(context_id)
119 + if not bridge:
120 + return False
121 + text = str(text or "")
122 + if not text:
123 + return False
124 + with bridge.lock:
125 + bridge.response_text_sent += text
126 + return _send_acp_helper_update(bridge, "update_agent_message_text", text)
127 +
128 +
129 +def send_agent_thought_delta(context_id: str, full_text: str) -> bool:
130 + bridge = get_bridge_for_context(context_id)
131 + if not bridge:
132 + return False
133 + full_text = str(full_text or "")
134 + with bridge.lock:
135 + previous = bridge.thought_text_sent
136 + if full_text == previous:
137 + return False
138 + if full_text.startswith(previous):
139 + delta = full_text[len(previous) :]
140 + else:
141 + delta = full_text
142 + bridge.thought_text_sent = full_text
143 + if not delta:
144 + return False
145 + return _send_acp_helper_update(bridge, "update_agent_thought_text", delta)
146 +
147 +
148 +def start_tool(context_id: str, tool_name: str, tool_args: dict[str, Any] | None = None) -> str:
149 + bridge = get_bridge_for_context(context_id)
150 + if not bridge:
151 + return ""
152 +
153 + normalized_name = str(tool_name or "tool")
154 + if normalized_name == "response":
155 + return ""
156 +
157 + tool_call_id = f"a0-{uuid.uuid4().hex}"
158 + title = _tool_title(normalized_name, tool_args or {})
159 + kind = _tool_kind(normalized_name, tool_args or {})
160 + raw_input = _json_safe(tool_args or {})
161 +
162 + try:
163 + import acp
164 +
165 + update = acp.start_tool_call(
166 + tool_call_id,
167 + title,
168 + kind=kind,
169 + status="in_progress",
170 + raw_input=raw_input,
171 + )
172 + except Exception:
173 + logger.debug("Could not build ACP tool start", exc_info=True)
174 + return ""
175 +
176 + with bridge.lock:
177 + bridge.tool_queues[normalized_name].append(tool_call_id)
178 + _send_update(bridge, update)
179 + return tool_call_id
180 +
181 +
182 +def finish_tool(context_id: str, tool_name: str, response: Any) -> bool:
183 + bridge = get_bridge_for_context(context_id)
184 + if not bridge:
185 + return False
186 + normalized_name = str(tool_name or "tool")
187 + if normalized_name == "response":
188 + return False
189 +
190 + with bridge.lock:
191 + queue = bridge.tool_queues.get(normalized_name)
192 + if not queue:
193 + return False
194 + tool_call_id = queue.popleft()
195 + if not queue:
196 + bridge.tool_queues.pop(normalized_name, None)
197 +
198 + message = getattr(response, "message", response)
199 + raw_output = _truncate_raw_output(message)
200 + try:
201 + import acp
202 +
203 + update = acp.update_tool_call(
204 + tool_call_id,
205 + status="completed",
206 + raw_output=raw_output,
207 + )
208 + except Exception:
209 + logger.debug("Could not build ACP tool completion", exc_info=True)
210 + return False
211 + _send_update(bridge, update)
212 + return True
213 +
214 +
215 +async def send_update_async(session_id: str, update: Any) -> bool:
216 + bridge = get_bridge_for_session(session_id)
217 + if not bridge:
218 + return False
219 + try:
220 + await bridge.conn.session_update(session_id, update)
221 + return True
222 + except Exception:
223 + logger.debug("Could not send ACP update", exc_info=True)
224 + return False
225 +
226 +
227 +def _send_acp_helper_update(bridge: SessionBridge, helper_name: str, text: str) -> bool:
228 + try:
229 + import acp
230 +
231 + helper = getattr(acp, helper_name)
232 + update = helper(text)
233 + except Exception:
234 + logger.debug("Could not build ACP text update", exc_info=True)
235 + return False
236 + _send_update(bridge, update)
237 + return True
238 +
239 +
240 +def _send_update(bridge: SessionBridge, update: Any) -> None:
241 + if bridge.loop.is_closed():
242 + return
243 +
244 + async def _deliver() -> None:
245 + await bridge.conn.session_update(bridge.session_id, update)
246 +
247 + future = asyncio.run_coroutine_threadsafe(_deliver(), bridge.loop)
248 +
249 + def _log_failure(done_future: Any) -> None:
250 + try:
251 + done_future.result()
252 + except Exception:
253 + logger.debug("Failed to send ACP session update", exc_info=True)
254 +
255 + future.add_done_callback(_log_failure)
256 +
257 +
258 +def _tool_title(tool_name: str, tool_args: dict[str, Any]) -> str:
259 + action = tool_args.get("action") or tool_args.get("method") or ""
260 + if action:
261 + return f"{tool_name}: {action}"
262 + return tool_name.replace("_", " ").strip().title() or "Tool"
263 +
264 +
265 +def _tool_kind(tool_name: str, tool_args: dict[str, Any]) -> str:
266 + probe = " ".join(
267 + str(value).lower()
268 + for value in (tool_name, tool_args.get("action", ""), tool_args.get("method", ""))
269 + )
270 + if any(token in probe for token in ("read", "list", "show", "inspect")):
271 + return "read"
272 + if any(token in probe for token in ("write", "edit", "patch", "replace", "create")):
273 + return "edit"
274 + if any(token in probe for token in ("delete", "remove")):
275 + return "delete"
276 + if any(token in probe for token in ("move", "rename")):
277 + return "move"
278 + if any(token in probe for token in ("search", "find", "grep")):
279 + return "search"
280 + if any(token in probe for token in ("terminal", "shell", "python", "node", "code_execution")):
281 + return "execute"
282 + if any(token in probe for token in ("browser", "fetch", "http")):
283 + return "fetch"
284 + return "other"
285 +
286 +
287 +def _json_safe(value: Any) -> Any:
288 + try:
289 + json.dumps(value)
290 + return value
291 + except Exception:
292 + return str(value)
293 +
294 +
295 +def _truncate_raw_output(value: Any) -> Any:
296 + if value is None:
297 + return None
298 + if isinstance(value, str):
299 + if len(value) <= _MAX_RAW_OUTPUT:
300 + return value
301 + hidden = len(value) - _MAX_RAW_OUTPUT
302 + return value[:_MAX_RAW_OUTPUT] + f"\n\n[ACP output truncated: {hidden} characters hidden]"
303 + return _json_safe(value)
plugins/_acp/helpers/content.py new
+370
@@ -0,0 +1,370 @@
1 +from __future__ import annotations
2 +
3 +import base64
4 +import json
5 +import mimetypes
6 +import os
7 +import re
8 +from dataclasses import dataclass, field
9 +from pathlib import Path
10 +from typing import Any
11 +from urllib.parse import unquote, urlparse
12 +
13 +from helpers import files
14 +from helpers.security import safe_filename
15 +
16 +
17 +MAX_INLINE_RESOURCE_BYTES = 512 * 1024
18 +MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024
19 +
20 +_TEXT_MIME_PREFIXES = ("text/",)
21 +_TEXT_MIME_TYPES = {
22 + "application/json",
23 + "application/javascript",
24 + "application/typescript",
25 + "application/xml",
26 + "application/x-yaml",
27 + "application/yaml",
28 + "application/toml",
29 + "application/sql",
30 +}
31 +
32 +
33 +@dataclass
34 +class PromptParts:
35 + text: str = ""
36 + attachments: list[str] = field(default_factory=list)
37 +
38 +
39 +def normalize_cwd(cwd: str | None) -> str:
40 + raw = str(cwd or "").strip() or os.getcwd()
41 + raw = os.path.expanduser(raw)
42 + translated = translate_windows_drive_path(raw)
43 + if translated:
44 + raw = translated
45 + return os.path.abspath(raw)
46 +
47 +
48 +def normalize_path_for_compare(path: str | None) -> str:
49 + return os.path.normcase(os.path.normpath(normalize_cwd(path)))
50 +
51 +
52 +def translate_windows_drive_path(path: str) -> str | None:
53 + match = re.match(r"^/?([A-Za-z]):[\\/](.*)$", str(path or ""))
54 + if not match:
55 + return None
56 + drive = match.group(1).lower()
57 + tail = match.group(2).replace("\\", "/").lstrip("/")
58 + return f"/mnt/{drive}/{tail}"
59 +
60 +
61 +def path_from_file_uri(uri: str) -> Path | None:
62 + raw = str(uri or "").strip()
63 + if not raw:
64 + return None
65 +
66 + parsed = urlparse(raw)
67 + if parsed.scheme and parsed.scheme != "file":
68 + return None
69 +
70 + if parsed.scheme == "file":
71 + if parsed.netloc and parsed.netloc not in {"", "localhost"}:
72 + return None
73 + path_text = unquote(parsed.path or "")
74 + else:
75 + path_text = unquote(raw)
76 +
77 + translated = translate_windows_drive_path(path_text)
78 + if translated:
79 + return Path(translated)
80 + return Path(path_text)
81 +
82 +
83 +def stringify_message_content(content: Any) -> str:
84 + if isinstance(content, str):
85 + return content
86 + if isinstance(content, dict):
87 + preview = content.get("preview")
88 + if isinstance(preview, str) and preview.strip():
89 + return preview
90 + raw = content.get("raw_content")
91 + if raw is not None:
92 + return stringify_message_content(raw)
93 + if isinstance(content, list):
94 + parts = [stringify_message_content(item) for item in content]
95 + return "\n".join(part for part in parts if part)
96 + try:
97 + return json.dumps(content, ensure_ascii=False)
98 + except Exception:
99 + return str(content)
100 +
101 +
102 +def prompt_blocks_to_user_message(
103 + prompt: list[Any],
104 + *,
105 + context_id: str,
106 + message_id: str,
107 +) -> PromptParts:
108 + text_parts: list[str] = []
109 + attachments: list[str] = []
110 +
111 + for index, block in enumerate(prompt or []):
112 + block_type = str(getattr(block, "type", "") or "").strip()
113 + if block_type == "text" or hasattr(block, "text"):
114 + text = str(getattr(block, "text", "") or "")
115 + if text:
116 + text_parts.append(text)
117 + continue
118 +
119 + if block_type == "image":
120 + _append_media_block(
121 + block,
122 + context_id=context_id,
123 + message_id=message_id,
124 + index=index,
125 + kind="image",
126 + text_parts=text_parts,
127 + attachments=attachments,
128 + )
129 + continue
130 +
131 + if block_type == "audio":
132 + _append_media_block(
133 + block,
134 + context_id=context_id,
135 + message_id=message_id,
136 + index=index,
137 + kind="audio",
138 + text_parts=text_parts,
139 + attachments=attachments,
140 + )
141 + continue
142 +
143 + if block_type == "resource_link":
144 + _append_resource_link(block, text_parts=text_parts, attachments=attachments)
145 + continue
146 +
147 + if block_type == "resource":
148 + _append_embedded_resource(
149 + block,
150 + context_id=context_id,
151 + message_id=message_id,
152 + index=index,
153 + text_parts=text_parts,
154 + attachments=attachments,
155 + )
156 +
157 + text = "\n\n".join(part for part in text_parts if part).strip()
158 + if not text and attachments:
159 + text = "Please inspect the attached file(s)."
160 + return PromptParts(text=text, attachments=attachments)
161 +
162 +
163 +def _append_media_block(
164 + block: Any,
165 + *,
166 + context_id: str,
167 + message_id: str,
168 + index: int,
169 + kind: str,
170 + text_parts: list[str],
171 + attachments: list[str],
172 +) -> None:
173 + uri = str(getattr(block, "uri", "") or "").strip()
174 + mime_type = str(getattr(block, "mime_type", "") or "").strip() or None
175 + data = str(getattr(block, "data", "") or "").strip()
176 +
177 + if uri:
178 + path = path_from_file_uri(uri)
179 + if path and path.exists():
180 + attachments.append(str(path))
181 + text_parts.append(f"[Attached {kind}: {path}]")
182 + return
183 + text_parts.append(f"[Attached {kind} reference: {uri}]")
184 + return
185 +
186 + if not data:
187 + return
188 +
189 + try:
190 + raw = base64.b64decode(data.split(",", 1)[-1], validate=False)
191 + except Exception:
192 + raw = data.encode("utf-8", errors="replace")
193 +
194 + saved = _save_attachment_bytes(
195 + context_id=context_id,
196 + message_id=message_id,
197 + index=index,
198 + label=kind,
199 + data=raw,
200 + mime_type=mime_type,
201 + )
202 + attachments.append(saved)
203 + text_parts.append(f"[Attached {kind}: {saved}]")
204 +
205 +
206 +def _append_resource_link(
207 + block: Any,
208 + *,
209 + text_parts: list[str],
210 + attachments: list[str],
211 +) -> None:
212 + uri = str(getattr(block, "uri", "") or "").strip()
213 + if not uri:
214 + return
215 + name = _resource_display_name(block, uri)
216 + mime_type = str(getattr(block, "mime_type", "") or "").strip() or None
217 + path = path_from_file_uri(uri)
218 +
219 + if path is None:
220 + text_parts.append(f"[Attached resource: {name}]\nURI: {uri}")
221 + return
222 +
223 + if not path.exists():
224 + text_parts.append(f"[Attached resource unavailable: {name}]\nURI: {uri}\nPath: {path}")
225 + return
226 +
227 + if _is_probably_text(path, mime_type):
228 + try:
229 + size = path.stat().st_size
230 + data = path.read_bytes()[:MAX_INLINE_RESOURCE_BYTES]
231 + body = decode_text_bytes(data, mime_type) or ""
232 + if size > MAX_INLINE_RESOURCE_BYTES:
233 + body += f"\n\n[Truncated to {MAX_INLINE_RESOURCE_BYTES} of {size} bytes]"
234 + text_parts.append(f"[Attached file: {name}]\nURI: {uri}\n\n{body}")
235 + return
236 + except OSError as exc:
237 + text_parts.append(f"[Attached file unreadable: {name}]\nURI: {uri}\nError: {exc}")
238 + return
239 +
240 + attachments.append(str(path))
241 + text_parts.append(f"[Attached file: {path}]")
242 +
243 +
244 +def _append_embedded_resource(
245 + block: Any,
246 + *,
247 + context_id: str,
248 + message_id: str,
249 + index: int,
250 + text_parts: list[str],
251 + attachments: list[str],
252 +) -> None:
253 + resource = getattr(block, "resource", None)
254 + if resource is None:
255 + return
256 +
257 + uri = str(getattr(resource, "uri", "") or "").strip()
258 + mime_type = str(getattr(resource, "mime_type", "") or "").strip() or None
259 + if hasattr(resource, "text"):
260 + body = str(getattr(resource, "text", "") or "")
261 + text_parts.append(f"[Attached resource: {uri or 'embedded text'}]\n\n{body}")
262 + return
263 +
264 + blob = str(getattr(resource, "blob", "") or "")
265 + if not blob:
266 + return
267 +
268 + try:
269 + data = base64.b64decode(blob, validate=True)
270 + except Exception:
271 + data = blob.encode("utf-8", errors="replace")
272 +
273 + text = decode_text_bytes(data[:MAX_INLINE_RESOURCE_BYTES], mime_type)
274 + if text is not None and _is_text_mime(mime_type):
275 + if len(data) > MAX_INLINE_RESOURCE_BYTES:
276 + text += f"\n\n[Truncated to {MAX_INLINE_RESOURCE_BYTES} of {len(data)} bytes]"
277 + text_parts.append(f"[Attached resource: {uri or 'embedded text'}]\n\n{text}")
278 + return
279 +
280 + saved = _save_attachment_bytes(
281 + context_id=context_id,
282 + message_id=message_id,
283 + index=index,
284 + label="resource",
285 + data=data,
286 + mime_type=mime_type,
287 + )
288 + attachments.append(saved)
289 + text_parts.append(f"[Attached resource: {saved}]")
290 +
291 +
292 +def _save_attachment_bytes(
293 + *,
294 + context_id: str,
295 + message_id: str,
296 + index: int,
297 + label: str,
298 + data: bytes,
299 + mime_type: str | None,
300 +) -> str:
301 + if len(data) > MAX_ATTACHMENT_BYTES:
302 + data = data[:MAX_ATTACHMENT_BYTES]
303 +
304 + extension = mimetypes.guess_extension((mime_type or "").split(";", 1)[0]) or ".bin"
305 + base_name = safe_filename(f"{label}-{message_id}-{index}{extension}") or f"{label}-{index}{extension}"
306 + from helpers import persist_chat
307 +
308 + attach_dir = files.get_abs_path(persist_chat.get_chat_folder_path(context_id), "acp")
309 + os.makedirs(attach_dir, exist_ok=True)
310 + path = os.path.join(attach_dir, base_name)
311 + with open(path, "wb") as handle:
312 + handle.write(data)
313 + return files.normalize_a0_path(path)
314 +
315 +
316 +def _resource_display_name(block: Any, uri: str) -> str:
317 + title = str(getattr(block, "title", "") or "").strip()
318 + name = str(getattr(block, "name", "") or "").strip()
319 + if title and name and title != name:
320 + return f"{title} ({name})"
321 + if title:
322 + return title
323 + if name:
324 + return name
325 + parsed = urlparse(uri)
326 + return Path(unquote(parsed.path or uri)).name or uri or "resource"
327 +
328 +
329 +def _is_text_mime(mime_type: str | None) -> bool:
330 + mime = (mime_type or "").split(";", 1)[0].strip().lower()
331 + if not mime:
332 + return False
333 + return mime.startswith(_TEXT_MIME_PREFIXES) or mime in _TEXT_MIME_TYPES
334 +
335 +
336 +def _is_probably_text(path: Path, mime_type: str | None) -> bool:
337 + if _is_text_mime(mime_type):
338 + return True
339 + guessed, _encoding = mimetypes.guess_type(str(path))
340 + if _is_text_mime(guessed):
341 + return True
342 + return path.suffix.lower() in {
343 + ".md",
344 + ".txt",
345 + ".py",
346 + ".js",
347 + ".ts",
348 + ".tsx",
349 + ".jsx",
350 + ".json",
351 + ".yaml",
352 + ".yml",
353 + ".toml",
354 + ".xml",
355 + ".html",
356 + ".css",
357 + ".sql",
358 + ".sh",
359 + }
360 +
361 +
362 +def decode_text_bytes(data: bytes, mime_type: str | None = None) -> str | None:
363 + if b"\x00" in data and not _is_text_mime(mime_type):
364 + return None
365 + for encoding in ("utf-8-sig", "utf-8", "latin-1"):
366 + try:
367 + return data.decode(encoding)
368 + except UnicodeDecodeError:
369 + continue
370 + return data.decode("utf-8", errors="replace")
plugins/_acp/helpers/server.py new
+627
@@ -0,0 +1,627 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import logging
5 +import uuid
6 +from concurrent.futures import CancelledError
7 +from pathlib import Path
8 +from typing import Any
9 +
10 +import acp
11 +from acp.schema import (
12 + AgentCapabilities,
13 + AvailableCommand,
14 + AvailableCommandsUpdate,
15 + ClientCapabilities,
16 + CloseSessionResponse,
17 + CurrentModeUpdate,
18 + ForkSessionResponse,
19 + Implementation,
20 + InitializeResponse,
21 + ListSessionsResponse,
22 + LoadSessionResponse,
23 + NewSessionResponse,
24 + PromptCapabilities,
25 + PromptResponse,
26 + ResumeSessionResponse,
27 + SessionCapabilities,
28 + SessionForkCapabilities,
29 + SessionInfo,
30 + SessionListCapabilities,
31 + SessionMode,
32 + SessionModeState,
33 + SessionResumeCapabilities,
34 + SetSessionConfigOptionResponse,
35 + SetSessionModelResponse,
36 + SetSessionModeResponse,
37 + UnstructuredCommandInput,
38 + Usage,
39 +)
40 +
41 +from agent import Agent, AgentContext, AgentContextType, UserMessage
42 +from helpers import git, message_queue as mq, persist_chat, tokens
43 +from helpers.localization import Localization
44 +from initialize import initialize_agent
45 +from plugins._acp import AGENT_ZERO_ACP_VERSION, PLUGIN_VERSION
46 +from plugins._acp.helpers import bridge
47 +from plugins._acp.helpers.content import (
48 + normalize_cwd,
49 + normalize_path_for_compare,
50 + prompt_blocks_to_user_message,
51 + stringify_message_content,
52 +)
53 +
54 +
55 +logger = logging.getLogger(__name__)
56 +
57 +
58 +class AgentZeroACPAgent(acp.Agent):
59 + _ADVERTISED_COMMANDS = (
60 + {
61 + "name": "help",
62 + "description": "List Agent Zero ACP commands",
63 + },
64 + {
65 + "name": "context",
66 + "description": "Show conversation and context-window status",
67 + },
68 + {
69 + "name": "reset",
70 + "description": "Clear the current Agent Zero conversation",
71 + },
72 + {
73 + "name": "version",
74 + "description": "Show Agent Zero and ACP plugin versions",
75 + },
76 + )
77 +
78 + _MODE_DEFAULT = bridge.DEFAULT_MODE
79 + _MODE_PLAN = "plan"
80 + _MODE_ACT = "act"
81 +
82 + _MODE_INSTRUCTIONS = {
83 + _MODE_PLAN: (
84 + "ACP session mode: plan first. Prefer analysis, architecture, and tradeoffs. "
85 + "Do not modify files unless the user explicitly asks you to proceed."
86 + ),
87 + _MODE_ACT: (
88 + "ACP session mode: act. Complete the requested work end-to-end with focused "
89 + "implementation and validation."
90 + ),
91 + }
92 +
93 + def __init__(self) -> None:
94 + super().__init__()
95 + self._conn: acp.Client | None = None
96 +
97 + def on_connect(self, conn: acp.Client) -> None:
98 + self._conn = conn
99 + logger.info("ACP client connected")
100 +
101 + async def initialize(
102 + self,
103 + protocol_version: int | None = None,
104 + client_capabilities: ClientCapabilities | None = None,
105 + client_info: Implementation | None = None,
106 + **kwargs: Any,
107 + ) -> InitializeResponse:
108 + client_name = client_info.name if client_info else "unknown"
109 + logger.info("ACP initialize from %s (protocol v%s)", client_name, protocol_version)
110 + return InitializeResponse(
111 + protocol_version=acp.PROTOCOL_VERSION,
112 + agent_info=Implementation(
113 + name="agent-zero",
114 + title="Agent Zero",
115 + version=AGENT_ZERO_ACP_VERSION,
116 + ),
117 + agent_capabilities=AgentCapabilities(
118 + load_session=True,
119 + prompt_capabilities=PromptCapabilities(
120 + embedded_context=True,
121 + image=True,
122 + audio=True,
123 + ),
124 + session_capabilities=SessionCapabilities(
125 + fork=SessionForkCapabilities(),
126 + list=SessionListCapabilities(),
127 + resume=SessionResumeCapabilities(),
128 + ),
129 + ),
130 + auth_methods=[],
131 + )
132 +
133 + async def new_session(
134 + self,
135 + cwd: str,
136 + additional_directories: list[str] | None = None,
137 + **kwargs: Any,
138 + ) -> NewSessionResponse:
139 + context = self._create_context(cwd=cwd, additional_directories=additional_directories)
140 + self._register(context)
141 + await self._send_session_start_updates(context)
142 + persist_chat.save_tmp_chat(context)
143 + return NewSessionResponse(session_id=context.id, modes=self._session_modes(context))
144 +
145 + async def load_session(
146 + self,
147 + cwd: str,
148 + session_id: str,
149 + additional_directories: list[str] | None = None,
150 + **kwargs: Any,
151 + ) -> LoadSessionResponse | None:
152 + context = self._get_context(session_id)
153 + if context is None:
154 + logger.warning("ACP load_session: missing session %s", session_id)
155 + return None
156 + self._apply_workspace(context, cwd=cwd, additional_directories=additional_directories)
157 + self._register(context)
158 + await self._replay_history(context)
159 + await self._send_session_start_updates(context)
160 + persist_chat.save_tmp_chat(context)
161 + return LoadSessionResponse(modes=self._session_modes(context))
162 +
163 + async def resume_session(
164 + self,
165 + cwd: str,
166 + session_id: str,
167 + additional_directories: list[str] | None = None,
168 + **kwargs: Any,
169 + ) -> ResumeSessionResponse:
170 + context = self._get_context(session_id)
171 + if context is None:
172 + context = self._create_context(
173 + cwd=cwd,
174 + additional_directories=additional_directories,
175 + context_id=session_id,
176 + )
177 + else:
178 + self._apply_workspace(context, cwd=cwd, additional_directories=additional_directories)
179 + self._register(context)
180 + await self._replay_history(context)
181 + await self._send_session_start_updates(context)
182 + persist_chat.save_tmp_chat(context)
183 + return ResumeSessionResponse(modes=self._session_modes(context))
184 +
185 + async def fork_session(
186 + self,
187 + cwd: str,
188 + session_id: str,
189 + additional_directories: list[str] | None = None,
190 + **kwargs: Any,
191 + ) -> ForkSessionResponse:
192 + original = self._get_context(session_id)
193 + if original is None:
194 + return ForkSessionResponse(session_id="")
195 +
196 + new_ids = persist_chat.load_json_chats([persist_chat.export_json_chat(original)])
197 + new_id = new_ids[0] if new_ids else ""
198 + context = self._get_context(new_id) if new_id else None
199 + if context is None:
200 + return ForkSessionResponse(session_id="")
201 +
202 + context.name = f"{original.name or 'ACP session'} (fork)"
203 + self._apply_workspace(context, cwd=cwd, additional_directories=additional_directories)
204 + self._register(context)
205 + await self._send_session_start_updates(context)
206 + persist_chat.save_tmp_chat(context)
207 + return ForkSessionResponse(session_id=context.id, modes=self._session_modes(context))
208 +
209 + async def list_sessions(
210 + self,
211 + cursor: str | None = None,
212 + cwd: str | None = None,
213 + **kwargs: Any,
214 + ) -> ListSessionsResponse:
215 + persist_chat.load_tmp_chats()
216 + contexts = [ctx for ctx in AgentContext.all() if ctx.get_data(bridge.CTX_IS_ACP)]
217 + if cwd:
218 + normalized = normalize_path_for_compare(cwd)
219 + contexts = [
220 + ctx
221 + for ctx in contexts
222 + if normalize_path_for_compare(ctx.get_data(bridge.CTX_CWD) or ctx.get_data(bridge.CTX_WORKDIR))
223 + == normalized
224 + ]
225 +
226 + contexts.sort(key=lambda ctx: ctx.last_message or ctx.created_at, reverse=True)
227 + if cursor:
228 + for idx, context in enumerate(contexts):
229 + if context.id == cursor:
230 + contexts = contexts[idx + 1 :]
231 + break
232 + else:
233 + contexts = []
234 +
235 + page = contexts[:50]
236 + next_cursor = contexts[50].id if len(contexts) > 50 else None
237 + sessions = [self._session_info(context) for context in page]
238 + return ListSessionsResponse(sessions=sessions, next_cursor=next_cursor)
239 +
240 + async def prompt(
241 + self,
242 + prompt: list[Any],
243 + session_id: str,
244 + message_id: str | None = None,
245 + **kwargs: Any,
246 + ) -> PromptResponse:
247 + context = self._get_context(session_id)
248 + if context is None:
249 + logger.warning("ACP prompt: missing session %s", session_id)
250 + return PromptResponse(stop_reason="refusal", user_message_id=message_id)
251 +
252 + self._register(context)
253 + msg_id = message_id or str(uuid.uuid4())
254 + parts = prompt_blocks_to_user_message(
255 + prompt,
256 + context_id=context.id,
257 + message_id=msg_id,
258 + )
259 + user_text = parts.text.strip()
260 + if not user_text and not parts.attachments:
261 + return PromptResponse(stop_reason="end_turn", user_message_id=msg_id)
262 +
263 + context.last_message = Localization.get().now()
264 +
265 + text_only = bool(prompt) and all(str(getattr(block, "type", "") or "") == "text" for block in prompt)
266 + if text_only and not parts.attachments and user_text.startswith("/"):
267 + response_text = await self._handle_slash_command(user_text, context)
268 + if response_text is not None:
269 + await self._send_agent_message(context.id, response_text)
270 + persist_chat.save_tmp_chat(context)
271 + return PromptResponse(stop_reason="end_turn", user_message_id=msg_id)
272 +
273 + if context.is_running():
274 + await self._send_agent_message(
275 + context.id,
276 + "A turn is already running. Wait for it to finish or send cancel from the ACP client.",
277 + )
278 + return PromptResponse(stop_reason="end_turn", user_message_id=msg_id)
279 +
280 + mode_instruction = self._mode_instruction(context)
281 + system_message = [mode_instruction] if mode_instruction else []
282 + mq.log_user_message(
283 + context,
284 + user_text,
285 + parts.attachments,
286 + message_id=msg_id,
287 + source=" (ACP)",
288 + )
289 + bridge.reset_turn(context.id, msg_id)
290 +
291 + task = context.communicate(
292 + UserMessage(
293 + message=user_text,
294 + attachments=parts.attachments,
295 + system_message=system_message,
296 + id=msg_id,
297 + )
298 + )
299 +
300 + stop_reason = "end_turn"
301 + final_text = ""
302 + try:
303 + result = await task.result()
304 + final_text = "" if result is None else str(result)
305 + except (asyncio.CancelledError, CancelledError):
306 + stop_reason = "cancelled"
307 + except Exception as exc:
308 + logger.exception("ACP prompt failed for session %s", session_id)
309 + final_text = f"Error: {exc}"
310 + stop_reason = "end_turn"
311 +
312 + session_bridge = bridge.get_bridge_for_context(context.id)
313 + if final_text and session_bridge and not session_bridge.response_text_sent.strip():
314 + await self._send_agent_message(context.id, final_text)
315 + elif final_text and session_bridge is None:
316 + await self._send_agent_message(context.id, final_text)
317 +
318 + persist_chat.save_tmp_chat(context)
319 + return PromptResponse(
320 + stop_reason=stop_reason,
321 + usage=self._usage(context, final_text),
322 + user_message_id=msg_id,
323 + )
324 +
325 + async def cancel(self, session_id: str, **kwargs: Any) -> None:
326 + context = self._get_context(session_id)
327 + if context:
328 + context.kill_process()
329 + bridge.reset_turn(context.id)
330 + logger.info("ACP cancelled session %s", session_id)
331 +
332 + async def close_session(self, session_id: str, **kwargs: Any) -> CloseSessionResponse | None:
333 + context = self._get_context(session_id)
334 + if context:
335 + context.kill_process()
336 + AgentContext.remove(context.id)
337 + persist_chat.remove_chat(context.id)
338 + bridge.unregister_bridge(session_id=session_id)
339 + return CloseSessionResponse()
340 +
341 + async def set_session_mode(
342 + self,
343 + mode_id: str,
344 + session_id: str,
345 + **kwargs: Any,
346 + ) -> SetSessionModeResponse | None:
347 + context = self._get_context(session_id)
348 + if context is None:
349 + return None
350 + normalized = str(mode_id or self._MODE_DEFAULT).strip()
351 + if normalized not in {self._MODE_DEFAULT, self._MODE_PLAN, self._MODE_ACT}:
352 + normalized = self._MODE_DEFAULT
353 + context.set_data(bridge.CTX_MODE, normalized)
354 + persist_chat.save_tmp_chat(context)
355 + await self._send_current_mode(context)
356 + return SetSessionModeResponse()
357 +
358 + async def set_session_model(
359 + self,
360 + model_id: str,
361 + session_id: str,
362 + **kwargs: Any,
363 + ) -> SetSessionModelResponse | None:
364 + context = self._get_context(session_id)
365 + if context is None:
366 + return None
367 + context.set_data(bridge.CTX_MODEL_ID, str(model_id or ""))
368 + persist_chat.save_tmp_chat(context)
369 + return SetSessionModelResponse()
370 +
371 + async def set_config_option(
372 + self,
373 + config_id: str,
374 + session_id: str,
375 + value: str | bool,
376 + **kwargs: Any,
377 + ) -> SetSessionConfigOptionResponse | None:
378 + context = self._get_context(session_id)
379 + if context is None:
380 + return None
381 + options = context.get_data(bridge.CTX_CONFIG_OPTIONS) or {}
382 + if not isinstance(options, dict):
383 + options = {}
384 + options[str(config_id)] = value
385 + context.set_data(bridge.CTX_CONFIG_OPTIONS, options)
386 + persist_chat.save_tmp_chat(context)
387 + return SetSessionConfigOptionResponse(config_options=[])
388 +
389 + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
390 + if method in {"ping", "health", "healthcheck"}:
391 + return {"ok": True}
392 + return {}
393 +
394 + async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
395 + return None
396 +
397 + def _create_context(
398 + self,
399 + *,
400 + cwd: str,
401 + additional_directories: list[str] | None = None,
402 + context_id: str | None = None,
403 + ) -> AgentContext:
404 + resolved_cwd = normalize_cwd(cwd)
405 + context = AgentContext(
406 + config=initialize_agent(),
407 + id=context_id or None,
408 + name=self._title_for_cwd(resolved_cwd),
409 + type=AgentContextType.USER,
410 + )
411 + self._apply_workspace(context, cwd=resolved_cwd, additional_directories=additional_directories)
412 + return context
413 +
414 + def _get_context(self, session_id: str) -> AgentContext | None:
415 + context = AgentContext.get(session_id)
416 + if context is not None:
417 + return context
418 + persist_chat.load_tmp_chats()
419 + return AgentContext.get(session_id)
420 +
421 + def _apply_workspace(
422 + self,
423 + context: AgentContext,
424 + *,
425 + cwd: str,
426 + additional_directories: list[str] | None = None,
427 + ) -> None:
428 + resolved_cwd = normalize_cwd(cwd)
429 + context.set_data(bridge.CTX_IS_ACP, True)
430 + context.set_data(bridge.CTX_CWD, resolved_cwd)
431 + context.set_data(bridge.CTX_WORKDIR, resolved_cwd)
432 + context.set_data(
433 + bridge.CTX_ADDITIONAL_DIRECTORIES,
434 + [normalize_cwd(path) for path in (additional_directories or []) if path],
435 + )
436 + context.set_data(bridge.CTX_MODE, context.get_data(bridge.CTX_MODE) or self._MODE_DEFAULT)
437 + if not context.name:
438 + context.name = self._title_for_cwd(resolved_cwd)
439 +
440 + def _register(self, context: AgentContext) -> None:
441 + if not self._conn:
442 + return
443 + bridge.register_bridge(
444 + context_id=context.id,
445 + session_id=context.id,
446 + conn=self._conn,
447 + loop=asyncio.get_running_loop(),
448 + )
449 +
450 + def _session_modes(self, context: AgentContext) -> SessionModeState:
451 + current = str(context.get_data(bridge.CTX_MODE) or self._MODE_DEFAULT)
452 + if current not in {self._MODE_DEFAULT, self._MODE_PLAN, self._MODE_ACT}:
453 + current = self._MODE_DEFAULT
454 + return SessionModeState(
455 + current_mode_id=current,
456 + available_modes=[
457 + SessionMode(
458 + id=self._MODE_DEFAULT,
459 + name="Default",
460 + description="Use the normal Agent Zero behavior.",
461 + ),
462 + SessionMode(
463 + id=self._MODE_PLAN,
464 + name="Plan",
465 + description="Prefer planning and analysis before changing files.",
466 + ),
467 + SessionMode(
468 + id=self._MODE_ACT,
469 + name="Act",
470 + description="Proceed end-to-end when the request is actionable.",
471 + ),
472 + ],
473 + )
474 +
475 + def _mode_instruction(self, context: AgentContext) -> str:
476 + return self._MODE_INSTRUCTIONS.get(str(context.get_data(bridge.CTX_MODE) or ""), "")
477 +
478 + async def _send_session_start_updates(self, context: AgentContext) -> None:
479 + await self._send_available_commands(context)
480 + await self._send_current_mode(context)
481 +
482 + async def _send_available_commands(self, context: AgentContext) -> None:
483 + if not self._conn:
484 + return
485 + try:
486 + await self._conn.session_update(
487 + context.id,
488 + AvailableCommandsUpdate(
489 + session_update="available_commands_update",
490 + available_commands=self._available_commands(),
491 + ),
492 + )
493 + except Exception:
494 + logger.debug("Could not advertise ACP commands", exc_info=True)
495 +
496 + async def _send_current_mode(self, context: AgentContext) -> None:
497 + if not self._conn:
498 + return
499 + try:
500 + update = CurrentModeUpdate(
501 + session_update="current_mode_update",
502 + current_mode_id=str(context.get_data(bridge.CTX_MODE) or self._MODE_DEFAULT),
503 + )
504 + await self._conn.session_update(context.id, update)
505 + except Exception:
506 + logger.debug("Could not send ACP current mode", exc_info=True)
507 +
508 + @classmethod
509 + def _available_commands(cls) -> list[AvailableCommand]:
510 + commands: list[AvailableCommand] = []
511 + for spec in cls._ADVERTISED_COMMANDS:
512 + input_hint = spec.get("input_hint")
513 + commands.append(
514 + AvailableCommand(
515 + name=spec["name"],
516 + description=spec["description"],
517 + input=UnstructuredCommandInput(hint=input_hint) if input_hint else None,
518 + )
519 + )
520 + return commands
521 +
522 + async def _replay_history(self, context: AgentContext) -> None:
523 + if not self._conn:
524 + return
525 + try:
526 + outputs = context.agent0.history.output()
527 + except Exception:
528 + logger.debug("Could not read Agent Zero history for ACP replay", exc_info=True)
529 + return
530 + for item in outputs:
531 + text = stringify_message_content(item.get("content")).strip()
532 + if not text:
533 + continue
534 + update = (
535 + acp.update_agent_message_text(text)
536 + if item.get("ai")
537 + else acp.update_user_message_text(text)
538 + )
539 + try:
540 + await self._conn.session_update(context.id, update)
541 + except Exception:
542 + logger.debug("Could not replay ACP history", exc_info=True)
543 + return
544 +
545 + async def _send_agent_message(self, session_id: str, text: str) -> None:
546 + if not self._conn or not text:
547 + return
548 + try:
549 + await self._conn.session_update(session_id, acp.update_agent_message_text(text))
550 + except Exception:
551 + logger.debug("Could not send ACP agent message", exc_info=True)
552 +
553 + async def _handle_slash_command(self, text: str, context: AgentContext) -> str | None:
554 + command, _, args = text.partition(" ")
555 + command = command.lstrip("/").lower().strip()
556 + args = args.strip()
557 + if command == "help":
558 + lines = ["Available commands:", ""]
559 + for spec in self._ADVERTISED_COMMANDS:
560 + lines.append(f"/{spec['name']}: {spec['description']}")
561 + return "\n".join(lines)
562 + if command == "context":
563 + return self._context_summary(context)
564 + if command == "reset":
565 + context.reset()
566 + self._apply_workspace(
567 + context,
568 + cwd=context.get_data(bridge.CTX_CWD) or context.get_data(bridge.CTX_WORKDIR),
569 + additional_directories=context.get_data(bridge.CTX_ADDITIONAL_DIRECTORIES) or [],
570 + )
571 + return "Conversation history cleared."
572 + if command == "version":
573 + return f"Agent Zero {git.get_version()}\nACP plugin {PLUGIN_VERSION}\nACP protocol {acp.PROTOCOL_VERSION}"
574 + return None
575 +
576 + def _context_summary(self, context: AgentContext) -> str:
577 + outputs = context.agent0.history.output()
578 + user_count = sum(1 for item in outputs if not item.get("ai"))
579 + assistant_count = sum(1 for item in outputs if item.get("ai"))
580 + history_tokens = context.agent0.history.get_tokens()
581 + window = context.agent0.get_data(Agent.DATA_NAME_CTX_WINDOW) or {}
582 + window_tokens = window.get("tokens", 0) if isinstance(window, dict) else 0
583 + cwd = context.get_data(bridge.CTX_CWD) or context.get_data(bridge.CTX_WORKDIR) or ""
584 + lines = [
585 + f"Session: {context.id}",
586 + f"Workspace: {cwd}",
587 + f"Messages: {user_count} user, {assistant_count} assistant",
588 + f"History tokens: ~{history_tokens:,}",
589 + ]
590 + if window_tokens:
591 + lines.append(f"Last context window: ~{int(window_tokens):,} tokens")
592 + mode = context.get_data(bridge.CTX_MODE) or self._MODE_DEFAULT
593 + lines.append(f"Mode: {mode}")
594 + return "\n".join(lines)
595 +
596 + def _usage(self, context: AgentContext, final_text: str) -> Usage | None:
597 + window = context.agent0.get_data(Agent.DATA_NAME_CTX_WINDOW) or {}
598 + input_tokens = int(window.get("tokens", 0) or 0) if isinstance(window, dict) else 0
599 + output_tokens = tokens.approximate_tokens(final_text or "")
600 + total = input_tokens + output_tokens
601 + if total <= 0:
602 + return None
603 + return Usage(
604 + input_tokens=max(input_tokens, 0),
605 + output_tokens=max(output_tokens, 0),
606 + total_tokens=max(total, 0),
607 + )
608 +
609 + def _session_info(self, context: AgentContext) -> SessionInfo:
610 + cwd = context.get_data(bridge.CTX_CWD) or context.get_data(bridge.CTX_WORKDIR) or "."
611 + updated_at = context.last_message or context.created_at or Localization.get().now()
612 + if hasattr(updated_at, "isoformat"):
613 + updated_at_str = updated_at.isoformat()
614 + else:
615 + updated_at_str = str(updated_at)
616 + return SessionInfo(
617 + session_id=context.id,
618 + cwd=normalize_cwd(cwd),
619 + title=context.name or self._title_for_cwd(cwd),
620 + updated_at=updated_at_str,
621 + additional_directories=context.get_data(bridge.CTX_ADDITIONAL_DIRECTORIES) or None,
622 + )
623 +
624 + @staticmethod
625 + def _title_for_cwd(cwd: str) -> str:
626 + name = Path(str(cwd or "")).name
627 + return f"ACP: {name or 'workspace'}"
plugins/_acp/hooks.py new
+103
@@ -0,0 +1,103 @@
1 +from __future__ import annotations
2 +
3 +import importlib
4 +import importlib.util
5 +import re
6 +import shutil
7 +import subprocess
8 +import sys
9 +import threading
10 +from pathlib import Path
11 +
12 +from helpers.errors import format_error
13 +from helpers.print_style import PrintStyle
14 +
15 +
16 +PLUGIN_NAME = "_acp"
17 +IMPORT_NAME = "acp"
18 +DISTRIBUTION_NAME = "agent-client-protocol"
19 +FALLBACK_REQUIREMENT = "agent-client-protocol==0.10.1"
20 +
21 +_LOCK = threading.Lock()
22 +_CHECKED = False
23 +_PLUGIN_DIR = Path(__file__).resolve().parent
24 +_PROJECT_ROOT = _PLUGIN_DIR.parents[1]
25 +_ROOT_REQUIREMENTS_FILE = _PROJECT_ROOT / "requirements.txt"
26 +
27 +
28 +def has_acp() -> bool:
29 + return importlib.util.find_spec(IMPORT_NAME) is not None
30 +
31 +
32 +def get_acp_requirement() -> str:
33 + if not _ROOT_REQUIREMENTS_FILE.is_file():
34 + return FALLBACK_REQUIREMENT
35 +
36 + pattern = re.compile(rf"^\s*{re.escape(DISTRIBUTION_NAME)}\s*(?:[<>=!~]=?|===).*$")
37 + for raw_line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
38 + line = raw_line.split("#", 1)[0].strip()
39 + if line and pattern.match(line):
40 + return line
41 + return FALLBACK_REQUIREMENT
42 +
43 +
44 +def ensure_dependencies(raise_on_error: bool = True) -> bool:
45 + """Install the ACP SDK into the framework runtime when self-updates need it."""
46 + global _CHECKED
47 +
48 + if _CHECKED and has_acp():
49 + return True
50 +
51 + with _LOCK:
52 + if _CHECKED and has_acp():
53 + return True
54 + if has_acp():
55 + _CHECKED = True
56 + return True
57 +
58 + requirement = get_acp_requirement()
59 + try:
60 + _install_requirement(requirement)
61 + importlib.invalidate_caches()
62 + if not has_acp():
63 + raise RuntimeError(
64 + f"ACP dependency '{requirement}' is still unavailable after installation"
65 + )
66 + _CHECKED = True
67 + return True
68 + except Exception as exc:
69 + message = f"Agent Zero ACP: failed to install {requirement}: {format_error(exc)}"
70 + if raise_on_error:
71 + raise RuntimeError(message) from exc
72 + PrintStyle.error(message)
73 + return False
74 +
75 +
76 +def install() -> bool:
77 + return ensure_dependencies(raise_on_error=True)
78 +
79 +
80 +def _install_requirement(requirement: str) -> None:
81 + cmd = _install_command(requirement)
82 + PrintStyle.info(f"Agent Zero ACP: installing {requirement}")
83 + subprocess.check_call(cmd, cwd=str(_PROJECT_ROOT))
84 +
85 +
86 +def _install_command(requirement: str) -> list[str]:
87 + uv = shutil.which("uv")
88 + if uv:
89 + return [
90 + uv,
91 + "pip",
92 + "install",
93 + "--python",
94 + sys.executable,
95 + requirement,
96 + ]
97 + return [
98 + sys.executable,
99 + "-m",
100 + "pip",
101 + "install",
102 + requirement,
103 + ]
plugins/_acp/plugin.yaml new
+9
@@ -0,0 +1,9 @@
1 +name: _acp
2 +title: Agent Client Protocol
3 +description: Exposes Agent Zero as an ACP stdio agent for editor clients.
4 +version: "1.19"
5 +settings_sections:
6 + - external
7 +per_project_config: false
8 +per_agent_config: false
9 +always_enabled: true
plugins/_acp/webui/config.html new
+8
@@ -0,0 +1,8 @@
1 +<div class="space-y-4">
2 + <div>
3 + <h3 class="text-lg font-semibold">Agent Client Protocol</h3>
4 + <p class="text-sm opacity-70">Expose Agent Zero to ACP-capable editors over stdio.</p>
5 + </div>
6 + <pre class="rounded bg-base-300 p-3 text-xs overflow-auto">python -m plugins._acp</pre>
7 + <pre class="rounded bg-base-300 p-3 text-xs overflow-auto">docker exec -i agent-zero /opt/venv-a0/bin/python -m plugins._acp</pre>
8 +</div>
plugins/_code_execution/tools/code_execution_tool.py
+4 -2
@@ -480,8 +480,10 @@ class CodeExecution(Tool):
480 if project_name:
481 path = projects.get_project_folder(project_name)
482 else:
483 - set = settings.get_settings()
484 - path = set.get("workdir_path")
483 + path = self.agent.context.get_data("workdir_path")
484 + if not path:
485 + set = settings.get_settings()
486 + path = set.get("workdir_path")
487
488 if not path:
489 return None
requirements.txt
+1
@@ -1,5 +1,6 @@
1 a2wsgi==1.10.8
2 ansio==0.0.1
3 +agent-client-protocol==0.10.1
4 docker==7.1.0
5 duckduckgo-search==6.1.12
6 pyreqwest-impersonate==0.5.3 # freeze nearest wheel-backed release; 0.5.5 is source-only
tests/test_acp_plugin_static.py new
+96
@@ -0,0 +1,96 @@
1 +from __future__ import annotations
2 +
3 +from pathlib import Path
4 +import sys
5 +from types import SimpleNamespace
6 +
7 +ROOT = Path(__file__).resolve().parents[1]
8 +if str(ROOT) not in sys.path:
9 + sys.path.insert(0, str(ROOT))
10 +
11 +from plugins._acp.helpers.content import (
12 + normalize_cwd,
13 + path_from_file_uri,
14 + prompt_blocks_to_user_message,
15 +)
16 +from plugins._acp import hooks
17 +
18 +
19 +def test_acp_manifest_declares_builtin_plugin() -> None:
20 + manifest = (ROOT / "plugins" / "_acp" / "plugin.yaml").read_text(encoding="utf-8")
21 + assert "name: _acp" in manifest
22 + assert "always_enabled: true" in manifest
23 + assert 'version: "1.19"' in manifest
24 +
25 +
26 +def test_acp_registry_metadata_matches_release() -> None:
27 + registry = (ROOT / "plugins" / "_acp" / "acp_registry" / "agent.json").read_text(
28 + encoding="utf-8"
29 + )
30 + assert '"version": "1.19"' in registry
31 + assert '"license": "MIT"' in registry
32 +
33 +
34 +def test_acp_dependency_is_pinned() -> None:
35 + requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
36 + assert "agent-client-protocol==0.10.1" in requirements
37 +
38 +
39 +def test_acp_hook_uses_root_requirement_pin() -> None:
40 + assert hooks.get_acp_requirement() == "agent-client-protocol==0.10.1"
41 + assert not (ROOT / "plugins" / "_acp" / "requirements.txt").exists()
42 +
43 +
44 +def test_acp_entrypoint_attempts_lazy_dependency_install() -> None:
45 + entry = (ROOT / "plugins" / "_acp" / "entry.py").read_text(encoding="utf-8")
46 + assert "_import_acp_or_install()" in entry
47 + assert "hooks.ensure_dependencies(raise_on_error=False)" in entry
48 +
49 +
50 +def test_acp_initialize_metadata_uses_release_version_constant() -> None:
51 + server = (ROOT / "plugins" / "_acp" / "helpers" / "server.py").read_text(
52 + encoding="utf-8"
53 + )
54 + assert "version=AGENT_ZERO_ACP_VERSION" in server
55 +
56 +
57 +def test_context_workdir_override_is_wired() -> None:
58 + code_execution = (
59 + ROOT / "plugins" / "_code_execution" / "tools" / "code_execution_tool.py"
60 + ).read_text(encoding="utf-8")
61 + workdir_prompt = (
62 + ROOT / "extensions" / "python" / "message_loop_prompts_after" / "_75_include_workdir_extras.py"
63 + ).read_text(encoding="utf-8")
64 + assert 'get_data("workdir_path")' in code_execution
65 + assert 'get_data("workdir_path")' in workdir_prompt
66 +
67 +
68 +def test_file_uri_path_translation() -> None:
69 + assert str(path_from_file_uri("file:///tmp/example.py")) == "/tmp/example.py"
70 + assert str(path_from_file_uri("file:///C:/Users/Ada/work/app.py")) == "/mnt/c/Users/Ada/work/app.py"
71 + assert path_from_file_uri("https://example.com/app.py") is None
72 +
73 +
74 +def test_prompt_blocks_inline_text_resource(tmp_path: Path) -> None:
75 + source = tmp_path / "notes.md"
76 + source.write_text("hello ACP", encoding="utf-8")
77 + block = SimpleNamespace(
78 + type="resource_link",
79 + uri=source.as_uri(),
80 + name="notes.md",
81 + mime_type="text/markdown",
82 + )
83 +
84 + parts = prompt_blocks_to_user_message(
85 + [SimpleNamespace(type="text", text="Read this"), block],
86 + context_id="static-test",
87 + message_id="msg",
88 + )
89 +
90 + assert "Read this" in parts.text
91 + assert "hello ACP" in parts.text
92 + assert parts.attachments == []
93 +
94 +
95 +def test_normalize_cwd_returns_absolute_path() -> None:
96 + assert Path(normalize_cwd(".")).is_absolute()