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.
- subactor_shell/__init__.py +3 -0
- subactor_shell/__main__.py +4 -0
- subactor_shell/acp_agent.py +363 -0
- subactor_shell/app.py +482 -0
- subactor_shell/artifacts.py +198 -0
- subactor_shell/catalog.py +416 -0
- subactor_shell/chat.py +517 -0
- subactor_shell/compiler.py +191 -0
- subactor_shell/config.py +374 -0
- subactor_shell/connectors.py +503 -0
- subactor_shell/context_builder.py +153 -0
- subactor_shell/control.py +141 -0
- subactor_shell/control_env.py +93 -0
- subactor_shell/intent_ir.py +187 -0
- subactor_shell/models.py +78 -0
- subactor_shell/operations.py +287 -0
- subactor_shell/orchestration.py +324 -0
- subactor_shell/policy.py +38 -0
- subactor_shell/providers/__init__.py +63 -0
- subactor_shell/providers/anthropic.py +101 -0
- subactor_shell/providers/base.py +81 -0
- subactor_shell/providers/mock.py +32 -0
- subactor_shell/providers/openai_compat.py +303 -0
- subactor_shell/providers/subactor_control.py +191 -0
- subactor_shell/redaction.py +62 -0
- subactor_shell/repl.py +480 -0
- subactor_shell/routing.py +334 -0
- subactor_shell/secret_refs.py +82 -0
- subactor_shell/store.py +857 -0
- subactor_shell/terminal.py +79 -0
- subactor_shell/token_budget.py +46 -0
- subactor_shell/vault.py +170 -0
- subactor_shell-0.2.2.dist-info/METADATA +449 -0
- subactor_shell-0.2.2.dist-info/RECORD +38 -0
- subactor_shell-0.2.2.dist-info/WHEEL +5 -0
- subactor_shell-0.2.2.dist-info/entry_points.txt +2 -0
- subactor_shell-0.2.2.dist-info/licenses/LICENSE +13 -0
- subactor_shell-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -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
|