Revert "Add builtin ACP plugin"

This reverts commit 46112c9750d486f684e047c668262e4d1ace2200.

Alessandro committed Jun 2, 2026 at 01:05 UTC b52b4220d928f695b225acf5e7c175eacf409d55
20 files changed +3 -1773
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 = self.agent.context.get_data("workdir_path") or set["workdir_path"]
53 + folder = set["workdir_path"]
54 scan_path = files.get_abs_path_development(folder)
55
56 files.create_dir(scan_path)
plugins/_acp/README.md deleted
-34
@@ -1,34 +0,0 @@
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 deleted
-4
@@ -1,4 +0,0 @@
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 deleted
-5
@@ -1,5 +0,0 @@
1 -from .entry import main
2 -
3 -
4 -if __name__ == "__main__":
5 - main()
plugins/_acp/acp_registry/agent.json deleted
-15
@@ -1,15 +0,0 @@
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 deleted
-149
@@ -1,149 +0,0 @@
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 deleted
-9
@@ -1,9 +0,0 @@
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 deleted
-16
@@ -1,16 +0,0 @@
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 deleted
-9
@@ -1,9 +0,0 @@
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 deleted
-9
@@ -1,9 +0,0 @@
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 deleted
-1
@@ -1 +0,0 @@
1 -
plugins/_acp/helpers/bridge.py deleted
-303
@@ -1,303 +0,0 @@
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 deleted
-370
@@ -1,370 +0,0 @@
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 deleted
-627
@@ -1,627 +0,0 @@
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 deleted
-103
@@ -1,103 +0,0 @@
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 deleted
-9
@@ -1,9 +0,0 @@
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 deleted
-8
@@ -1,8 +0,0 @@
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
+2 -4
@@ -480,10 +480,8 @@ class CodeExecution(Tool):
480 if project_name:
481 path = projects.get_project_folder(project_name)
482 else:
483 - path = self.agent.context.get_data("workdir_path")
484 - if not path:
485 - set = settings.get_settings()
486 - path = set.get("workdir_path")
483 + set = settings.get_settings()
484 + path = set.get("workdir_path")
485
486 if not path:
487 return None
requirements.txt
-1
@@ -1,6 +1,5 @@
1 a2wsgi==1.10.8
2 ansio==0.0.1
3 -agent-client-protocol==0.10.1
3 docker==7.1.0
4 duckduckgo-search==6.1.12
5 pyreqwest-impersonate==0.5.3 # freeze nearest wheel-backed release; 0.5.5 is source-only
tests/test_acp_plugin_static.py deleted
-96
@@ -1,96 +0,0 @@
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()