subactor-shell 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Subactor terminal shell."""
2
+
3
+ __version__ = "0.2.2"
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import os
7
+ import sys
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import __version__
13
+ from .chat import ChatError, ChatService
14
+ from .orchestration import OrchestrationError
15
+
16
+
17
+ class AcpProtocolError(RuntimeError):
18
+ def __init__(self, code: int, message: str):
19
+ super().__init__(message)
20
+ self.code = code
21
+ self.message = message
22
+
23
+
24
+ class AcpAgent:
25
+ """Minimal ACP v1 stdio agent backed by the same persistent ChatService."""
26
+
27
+ def __init__(self, chat: ChatService):
28
+ self.chat = chat
29
+ self.initialized = False
30
+ self._write_lock = asyncio.Lock()
31
+ self._active_turns: dict[str, asyncio.Event] = {}
32
+ self._tasks: set[asyncio.Task[Any]] = set()
33
+
34
+ async def _write(self, message: dict[str, Any]) -> None:
35
+ encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
36
+ async with self._write_lock:
37
+ sys.stdout.write(encoded + "\n")
38
+ sys.stdout.flush()
39
+
40
+ async def _response(self, request_id: Any, result: Any) -> None:
41
+ await self._write({"jsonrpc": "2.0", "id": request_id, "result": result})
42
+
43
+ async def _error(self, request_id: Any, code: int, message: str) -> None:
44
+ await self._write(
45
+ {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
46
+ )
47
+
48
+ async def _notify_update(self, session_id: str, update: dict[str, Any]) -> None:
49
+ await self._write(
50
+ {
51
+ "jsonrpc": "2.0",
52
+ "method": "session/update",
53
+ "params": {"sessionId": session_id, "update": update},
54
+ }
55
+ )
56
+
57
+ async def run_stdio(self) -> None:
58
+ while True:
59
+ line = await asyncio.to_thread(sys.stdin.buffer.readline)
60
+ if not line:
61
+ break
62
+ try:
63
+ message = json.loads(line)
64
+ except json.JSONDecodeError:
65
+ await self._error(None, -32700, "Parse error")
66
+ continue
67
+ if not isinstance(message, dict):
68
+ await self._error(None, -32600, "Invalid Request")
69
+ continue
70
+ method = message.get("method")
71
+ if method == "session/cancel" and "id" not in message:
72
+ params = message.get("params", {})
73
+ session_id = params.get("sessionId") if isinstance(params, dict) else None
74
+ event = self._active_turns.get(str(session_id))
75
+ if event:
76
+ event.set()
77
+ continue
78
+ task = asyncio.create_task(self._handle(message))
79
+ self._tasks.add(task)
80
+ task.add_done_callback(self._tasks.discard)
81
+ if self._tasks:
82
+ await asyncio.gather(*self._tasks, return_exceptions=True)
83
+
84
+ async def _handle(self, message: dict[str, Any]) -> None:
85
+ request_id = message.get("id")
86
+ method = message.get("method")
87
+ if not isinstance(method, str):
88
+ if "id" in message:
89
+ await self._error(request_id, -32600, "Invalid Request")
90
+ return
91
+ try:
92
+ result = await self._dispatch(method, message.get("params", {}))
93
+ if "id" in message:
94
+ await self._response(request_id, result)
95
+ except AcpProtocolError as exc:
96
+ if "id" in message:
97
+ await self._error(request_id, exc.code, exc.message)
98
+ except (ChatError, OrchestrationError, KeyError, ValueError) as exc:
99
+ if "id" in message:
100
+ await self._error(request_id, -32000, str(exc))
101
+ except Exception as exc: # pragma: no cover - defensive protocol boundary
102
+ print(f"subactor-shell ACP error: {type(exc).__name__}", file=sys.stderr)
103
+ if "id" in message:
104
+ await self._error(request_id, -32603, "Internal error")
105
+
106
+ async def _dispatch(self, method: str, params: Any) -> Any:
107
+ if not isinstance(params, dict):
108
+ raise AcpProtocolError(-32602, "Invalid params")
109
+ if method == "initialize":
110
+ requested = params.get("protocolVersion")
111
+ if not isinstance(requested, int):
112
+ raise AcpProtocolError(-32602, "protocolVersion is required")
113
+ self.initialized = True
114
+ return {
115
+ "protocolVersion": 1,
116
+ "agentCapabilities": {
117
+ "loadSession": True,
118
+ "promptCapabilities": {"embeddedContext": True},
119
+ "_meta": {
120
+ "com.subactor.secretReferences": True,
121
+ "com.subactor.persistentData": True,
122
+ "com.subactor.intentIR": "v1",
123
+ "com.subactor.tokenBudget": True,
124
+ "com.subactor.executionPlans": True,
125
+ "com.subactor.executionReceipts": True,
126
+ "com.subactor.namedConnectors": True,
127
+ },
128
+ },
129
+ "agentInfo": {
130
+ "name": "subactor-shell",
131
+ "title": "Subactor Shell Bridge",
132
+ "version": __version__,
133
+ },
134
+ "authMethods": [],
135
+ }
136
+ if not self.initialized:
137
+ raise AcpProtocolError(-32002, "Connection not initialized")
138
+ if method == "session/new":
139
+ cwd = str(params.get("cwd", ""))
140
+ name = f"ACP: {Path(cwd).name}" if cwd else "ACP conversation"
141
+ session = self.chat.new_session(name=name)
142
+ return {"sessionId": session.id}
143
+ if method == "session/load":
144
+ session_id = self._session_id(params)
145
+ if not self.chat.store.get_session(session_id):
146
+ raise AcpProtocolError(-32001, f"Unknown session: {session_id}")
147
+ for message in self.chat.store.list_messages(session_id):
148
+ kind = "user_message_chunk" if message.role == "user" else "agent_message_chunk"
149
+ if message.role == "system":
150
+ continue
151
+ await self._notify_update(
152
+ session_id,
153
+ {
154
+ "sessionUpdate": kind,
155
+ "messageId": f"msg_{message.id}",
156
+ "content": {"type": "text", "text": message.display_content},
157
+ },
158
+ )
159
+ return None
160
+ if method == "session/prompt":
161
+ return await self._prompt(params)
162
+ if method == "session/cancel":
163
+ session_id = self._session_id(params)
164
+ event = self._active_turns.get(session_id)
165
+ if event:
166
+ event.set()
167
+ return None
168
+ if method == "subactor/secret/bind":
169
+ alias = str(params.get("alias", ""))
170
+ reference = str(params.get("reference", ""))
171
+ self.chat.bind_secret(alias, reference)
172
+ return {}
173
+ if method == "subactor/secret/grant":
174
+ alias = str(params.get("alias", ""))
175
+ self.chat.grant_secret(alias)
176
+ return {"granted": True, "oneTime": True}
177
+ if method == "subactor/data/set":
178
+ name = str(params.get("name", ""))
179
+ value = str(params.get("value", ""))
180
+ self.chat.set_data_text(name, value)
181
+ return {}
182
+ if method == "subactor/data/list":
183
+ return {
184
+ "items": [
185
+ {
186
+ "name": name,
187
+ "kind": kind,
188
+ "value": value if kind == "artifact" else f"{len(value)} chars",
189
+ }
190
+ for name, kind, value in self.chat.store.list_data()
191
+ ]
192
+ }
193
+ if method == "subactor/secret/list":
194
+ return {
195
+ "bindings": [
196
+ {"alias": alias, "reference": reference}
197
+ for alias, reference in self.chat.store.list_secret_bindings()
198
+ ]
199
+ }
200
+ if method == "subactor/plan/list":
201
+ session_id = params.get("sessionId")
202
+ if session_id is not None and not isinstance(session_id, str):
203
+ raise AcpProtocolError(-32602, "sessionId must be a string")
204
+ limit = self._limit(params)
205
+ return {
206
+ "plans": self.chat.store.list_execution_plans(session_id or None, limit=limit)
207
+ }
208
+ if method == "subactor/plan/get":
209
+ plan_id = self._required_string(params, "planId")
210
+ plan = self.chat.store.get_execution_plan(plan_id)
211
+ if not plan:
212
+ raise AcpProtocolError(-32001, f"Unknown plan: {plan_id}")
213
+ return {"plan": plan}
214
+ if method == "subactor/plan/apply":
215
+ plan_id = self._required_string(params, "planId")
216
+ confirmation = str(params.get("confirmation", ""))
217
+ receipt = await self.chat.orchestration.apply_plan(
218
+ plan_id, confirmation=confirmation
219
+ )
220
+ return {"receipt": receipt.to_dict()}
221
+ if method == "subactor/receipt/list":
222
+ session_id = params.get("sessionId")
223
+ if session_id is not None and not isinstance(session_id, str):
224
+ raise AcpProtocolError(-32602, "sessionId must be a string")
225
+ limit = self._limit(params)
226
+ return {
227
+ "receipts": self.chat.store.list_execution_receipts(
228
+ session_id or None, limit=limit
229
+ )
230
+ }
231
+ if method == "subactor/receipt/get":
232
+ receipt_id = self._required_string(params, "receiptId")
233
+ receipt = self.chat.store.get_execution_receipt(receipt_id)
234
+ if not receipt:
235
+ raise AcpProtocolError(-32001, f"Unknown receipt: {receipt_id}")
236
+ return {"receipt": receipt}
237
+ if method == "subactor/metrics/get":
238
+ session_id = params.get("sessionId")
239
+ if session_id is not None and not isinstance(session_id, str):
240
+ raise AcpProtocolError(-32602, "sessionId must be a string")
241
+ return {"metrics": self.chat.store.usage_summary(session_id or None)}
242
+ if method == "subactor/catalog/list":
243
+ return {
244
+ "fingerprint": self.chat.orchestration.catalog.fingerprint,
245
+ "intents": [
246
+ item.to_summary() for item in self.chat.orchestration.catalog.list()
247
+ ],
248
+ }
249
+ if method == "subactor/connectors/list":
250
+ return {
251
+ "fingerprint": self.chat.orchestration.registry.fingerprint,
252
+ "connectors": [
253
+ item.public_dict() for item in self.chat.orchestration.registry.list()
254
+ ],
255
+ }
256
+ if method == "subactor/route/get":
257
+ session_id = self._session_id(params)
258
+ return {
259
+ "route": self.chat.store.last_routing_decision(session_id),
260
+ "workingState": self.chat.store.get_session_state(session_id),
261
+ }
262
+ raise AcpProtocolError(-32601, "Method not found")
263
+
264
+ @staticmethod
265
+ def _session_id(params: dict[str, Any]) -> str:
266
+ session_id = params.get("sessionId")
267
+ if not isinstance(session_id, str) or not session_id:
268
+ raise AcpProtocolError(-32602, "sessionId is required")
269
+ return session_id
270
+
271
+ @staticmethod
272
+ def _required_string(params: dict[str, Any], name: str) -> str:
273
+ value = params.get(name)
274
+ if not isinstance(value, str) or not value:
275
+ raise AcpProtocolError(-32602, f"{name} is required")
276
+ return value
277
+
278
+ @staticmethod
279
+ def _limit(params: dict[str, Any], default: int = 100) -> int:
280
+ value = params.get("limit", default)
281
+ if isinstance(value, bool) or not isinstance(value, int):
282
+ raise AcpProtocolError(-32602, "limit must be an integer")
283
+ if value < 1 or value > 500:
284
+ raise AcpProtocolError(-32602, "limit must be between 1 and 500")
285
+ return value
286
+
287
+ async def _prompt(self, params: dict[str, Any]) -> dict[str, str]:
288
+ session_id = self._session_id(params)
289
+ if session_id in self._active_turns:
290
+ raise AcpProtocolError(-32003, "A prompt is already active for this session")
291
+ prompt = params.get("prompt")
292
+ if not isinstance(prompt, list):
293
+ raise AcpProtocolError(-32602, "prompt must be a ContentBlock array")
294
+ text, context_blocks = self._content_to_text(prompt)
295
+ cancel_event = asyncio.Event()
296
+ self._active_turns[session_id] = cancel_event
297
+ message_id = "msg_agent_" + uuid.uuid4().hex
298
+ try:
299
+ async for chunk in self.chat.stream_message(
300
+ session_id,
301
+ text,
302
+ additional_context_blocks=context_blocks,
303
+ cancel_event=cancel_event,
304
+ ):
305
+ await self._notify_update(
306
+ session_id,
307
+ {
308
+ "sessionUpdate": "agent_message_chunk",
309
+ "messageId": message_id,
310
+ "content": {"type": "text", "text": chunk},
311
+ },
312
+ )
313
+ return {"stopReason": "cancelled" if cancel_event.is_set() else "end_turn"}
314
+ finally:
315
+ self._active_turns.pop(session_id, None)
316
+
317
+ @staticmethod
318
+ def _content_to_text(blocks: list[Any]) -> tuple[str, list[str]]:
319
+ text_parts: list[str] = []
320
+ context_blocks: list[str] = []
321
+ for block in blocks:
322
+ if not isinstance(block, dict):
323
+ raise AcpProtocolError(-32602, "Invalid content block")
324
+ kind = block.get("type")
325
+ if kind == "text":
326
+ value = block.get("text")
327
+ if not isinstance(value, str):
328
+ raise AcpProtocolError(-32602, "Text block requires text")
329
+ text_parts.append(value)
330
+ elif kind == "resource":
331
+ resource = block.get("resource")
332
+ if not isinstance(resource, dict):
333
+ raise AcpProtocolError(-32602, "Resource block requires resource")
334
+ uri = str(resource.get("uri", "embedded://resource"))
335
+ mime = str(resource.get("mimeType", "application/octet-stream"))
336
+ if isinstance(resource.get("text"), str):
337
+ content = resource["text"]
338
+ elif isinstance(resource.get("blob"), str):
339
+ try:
340
+ raw = base64.b64decode(resource["blob"], validate=True)
341
+ except ValueError as exc:
342
+ raise AcpProtocolError(-32602, "Invalid base64 resource") from exc
343
+ if len(raw) > 5 * 1024 * 1024:
344
+ raise AcpProtocolError(-32602, "Embedded resource is too large")
345
+ content = raw.decode("utf-8", errors="replace")
346
+ else:
347
+ raise AcpProtocolError(-32602, "Resource requires text or blob")
348
+ context_blocks.append(
349
+ f'<resource uri="{uri}" mime="{mime}">\n{content}\n</resource>'
350
+ )
351
+ elif kind == "resource_link":
352
+ uri = block.get("uri")
353
+ name = block.get("name")
354
+ if not isinstance(uri, str) or not isinstance(name, str):
355
+ raise AcpProtocolError(-32602, "Resource link requires uri and name")
356
+ context_blocks.append(
357
+ f'<resource-link uri="{uri}" name="{name}">'
358
+ "Treść nie została automatycznie odczytana przez agenta."
359
+ "</resource-link>"
360
+ )
361
+ else:
362
+ raise AcpProtocolError(-32602, f"Unsupported content type: {kind}")
363
+ return "\n\n".join(text_parts), context_blocks