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()