main
py 223 lines 8.34 KB
Raw
1 """Authenticated ACP session metadata API for the host-side A0 CLI."""
2 from __future__ import annotations
3
4 from pathlib import Path
5 from typing import Any
6
7 from helpers.api import Request, Response
8 from plugins._a0_connector.api.v1.base import ProtectedConnectorApiHandler
9
10
11 PLUGIN_NAME = "_a0_acp"
12 CTX_IS_ACP = "acp_session"
13 CTX_CWD = "acp_cwd"
14 CTX_ADDITIONAL_DIRECTORIES = "acp_additional_directories"
15 CTX_MODE = "acp_mode"
16 CTX_MODEL_ID = "acp_model_id"
17 CTX_CONFIG_OPTIONS = "acp_config_options"
18 CTX_TRANSPORT = "acp_transport"
19 CTX_WORKDIR = "workdir_path"
20 _VALID_MODES = {"default", "plan", "act"}
21 _MAX_PATHS = 32
22 _MAX_PATH_LENGTH = 4096
23
24
25 def _config() -> dict[str, Any]:
26 from helpers.plugins import get_plugin_config
27
28 return dict(get_plugin_config(PLUGIN_NAME) or {})
29
30
31 def _paths(value: object) -> list[str]:
32 if not isinstance(value, list):
33 return []
34 return [
35 str(path).strip()
36 for path in value[:_MAX_PATHS]
37 if str(path).strip() and len(str(path).strip()) <= _MAX_PATH_LENGTH
38 ]
39
40
41 def _mode(value: object) -> str:
42 mode = str(value or "default").strip().lower()
43 return mode if mode in _VALID_MODES else "default"
44
45
46 def _timestamp(value: object) -> str:
47 if hasattr(value, "isoformat"):
48 return value.isoformat()
49 return str(value or "")
50
51
52 def _session_payload(context) -> dict[str, Any]:
53 return {
54 "session_id": context.id,
55 "title": context.name or "Agent Zero ACP",
56 "cwd": str(context.get_data(CTX_CWD) or ""),
57 "additional_directories": _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
58 "updated_at": _timestamp(context.last_message or context.created_at),
59 "mode": _mode(context.get_data(CTX_MODE)),
60 "model_id": str(context.get_data(CTX_MODEL_ID) or ""),
61 }
62
63
64 def _mark_dirty(context_id: str, reason: str) -> None:
65 try:
66 from helpers.state_monitor_integration import mark_dirty_for_context
67
68 mark_dirty_for_context(context_id, reason=reason)
69 except Exception:
70 return
71
72
73 class Session(ProtectedConnectorApiHandler):
74 async def process(self, input: dict, request: Request) -> dict | Response:
75 del request
76 action = str(input.get("action") or "config").strip().lower()
77 if action == "config":
78 return {"ok": True, "config": _config()}
79
80 if action == "list":
81 return self._list_sessions(input)
82 if action == "configure":
83 return self._configure(input)
84 if action == "fork":
85 return self._fork(input)
86 if action == "close":
87 return self._close(input)
88 if action == "set_mode":
89 return self._set_value(input, CTX_MODE, _mode(input.get("mode")))
90 if action == "set_model":
91 return self._set_value(input, CTX_MODEL_ID, str(input.get("model_id") or "").strip())
92 if action == "set_config_option":
93 return self._set_config_option(input)
94 return Response(status=400, response=f"Unknown ACP action: {action}")
95
96 def _context(self, input: dict):
97 from agent import AgentContext
98
99 context_id = str(input.get("context_id") or input.get("session_id") or "").strip()
100 if not context_id:
101 return None, Response(status=400, response="context_id is required")
102 context = AgentContext.get(context_id)
103 if context is None:
104 return None, Response(status=404, response="ACP session not found")
105 return context, None
106
107 def _list_sessions(self, input: dict) -> dict:
108 from agent import AgentContext
109 from helpers import persist_chat
110
111 persist_chat.load_tmp_chats()
112 cwd = str(input.get("cwd") or "").strip()
113 sessions = [
114 _session_payload(context)
115 for context in AgentContext.all()
116 if context.get_data(CTX_IS_ACP)
117 and (not cwd or str(context.get_data(CTX_CWD) or "") == cwd)
118 ]
119 sessions.sort(key=lambda session: str(session["updated_at"]), reverse=True)
120 return {"ok": True, "sessions": sessions}
121
122 def _configure(self, input: dict) -> dict | Response:
123 from helpers import persist_chat
124
125 config = _config()
126 if not bool(config.get("enabled", True)):
127 return Response(status=403, response="ACP is disabled in Agent Zero settings")
128 context, error = self._context(input)
129 if error:
130 return error
131
132 cwd = str(input.get("cwd") or "").strip()
133 if not cwd or len(cwd) > _MAX_PATH_LENGTH:
134 return Response(status=400, response="A valid ACP workspace path is required")
135 transport = str(config.get("transport") or "connector").strip().lower()
136 if transport not in {"connector", "container"}:
137 transport = "connector"
138
139 context.set_data(CTX_IS_ACP, True)
140 context.set_data(CTX_CWD, cwd)
141 context.set_data(CTX_ADDITIONAL_DIRECTORIES, _paths(input.get("additional_directories")))
142 context.set_data(CTX_MODE, _mode(input.get("mode")))
143 context.set_data(CTX_TRANSPORT, transport)
144 if transport == "container":
145 container_workspace = str(config.get("container_workspace_root") or "").strip()
146 if container_workspace:
147 context.set_data(CTX_WORKDIR, container_workspace)
148 if not context.name:
149 context.name = Path(cwd).name or "Agent Zero ACP"
150 persist_chat.save_tmp_chat(context)
151 _mark_dirty(context.id, "a0_acp.configure")
152 return {"ok": True, "session": _session_payload(context), "config": config}
153
154 def _fork(self, input: dict) -> dict | Response:
155 from agent import AgentContext
156 from helpers import persist_chat
157
158 context, error = self._context(input)
159 if error:
160 return error
161 if not context.get_data(CTX_IS_ACP):
162 return Response(status=400, response="Only ACP sessions can be forked through ACP")
163
164 new_ids = persist_chat.load_json_chats([persist_chat.export_json_chat(context)])
165 if not new_ids:
166 return Response(status=500, response="Could not fork ACP session")
167 fork = AgentContext.get(new_ids[0])
168 if fork is None:
169 return Response(status=500, response="Forked ACP session could not be loaded")
170
171 fork.name = f"{context.name or 'Agent Zero ACP'} (fork)"
172 fork.set_data(CTX_IS_ACP, True)
173 fork.set_data(CTX_CWD, str(input.get("cwd") or context.get_data(CTX_CWD) or ""))
174 fork.set_data(
175 CTX_ADDITIONAL_DIRECTORIES,
176 _paths(input.get("additional_directories"))
177 or _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
178 )
179 fork.set_data(CTX_MODE, _mode(context.get_data(CTX_MODE)))
180 fork.set_data(CTX_TRANSPORT, context.get_data(CTX_TRANSPORT) or "connector")
181 persist_chat.save_tmp_chat(fork)
182 _mark_dirty(fork.id, "a0_acp.fork")
183 return {"ok": True, "session": _session_payload(fork)}
184
185 def _close(self, input: dict) -> dict | Response:
186 from agent import AgentContext
187 from helpers import persist_chat
188
189 context, error = self._context(input)
190 if error:
191 return error
192 context.kill_process()
193 AgentContext.remove(context.id)
194 persist_chat.remove_chat(context.id)
195 return {"ok": True}
196
197 def _set_value(self, input: dict, key: str, value: object) -> dict | Response:
198 from helpers import persist_chat
199
200 context, error = self._context(input)
201 if error:
202 return error
203 context.set_data(key, value)
204 persist_chat.save_tmp_chat(context)
205 _mark_dirty(context.id, f"a0_acp.{key}")
206 return {"ok": True, "session": _session_payload(context)}
207
208 def _set_config_option(self, input: dict) -> dict | Response:
209 from helpers import persist_chat
210
211 context, error = self._context(input)
212 if error:
213 return error
214 config_id = str(input.get("config_id") or "").strip()
215 if not config_id:
216 return Response(status=400, response="config_id is required")
217 options = context.get_data(CTX_CONFIG_OPTIONS)
218 options = dict(options) if isinstance(options, dict) else {}
219 options[config_id] = input.get("value")
220 context.set_data(CTX_CONFIG_OPTIONS, options)
221 persist_chat.save_tmp_chat(context)
222 _mark_dirty(context.id, "a0_acp.config_option")
223 return {"ok": True, "config_options": options}