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/repl.py ADDED
@@ -0,0 +1,480 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import getpass
5
+ import json
6
+ import shlex
7
+ import signal
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.patch_stdout import patch_stdout
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from .chat import ChatError, ChatService
18
+ from .control import ControlError, SubactorControlClient
19
+ from .models import Session
20
+ from .orchestration import OrchestrationError
21
+ from .terminal import terminal_hyperlinks_enabled, ticket_link_lines
22
+
23
+
24
+ HELP = """[bold]Rozmowa[/bold]
25
+ /new [nazwa] nowa sesja
26
+ /sessions lista sesji
27
+ /resume ID wznowienie sesji
28
+ /provider NAZWA zmiana profilu providera
29
+ /model MODEL zmiana modelu w sesji
30
+ /attach PLIK dołącz plik do następnej wiadomości
31
+ /info aktywna sesja
32
+
33
+ [bold]Dane[/bold]
34
+ /data set NAZWA WARTOŚĆ zapisz jawne dane tekstowe
35
+ /data put NAZWA PLIK zapisz plik jako artefakt
36
+ /data list lista danych
37
+ /data del NAZWA usuń dane
38
+ W wiadomości użyj: {{data:NAZWA}}
39
+
40
+ [bold]Sekrety[/bold]
41
+ /vault bind ALIAS REF zapisz wyłącznie referencję vault://, env:// lub file://
42
+ /vault put ALIAS VAULT_REF wczytaj wartość bez echa, zapisz do KV v2 i utwórz binding
43
+ /vault grant ALIAS jednorazowo zezwól na {{secret:ALIAS}}
44
+ /vault list lista aliasów i referencji (bez wartości)
45
+ /vault unbind ALIAS usuń binding
46
+ /vault wrap ALIAS [TTL] utwórz jednorazowy wrapping token Vault
47
+
48
+ [bold]Orkiestracja i tokeny[/bold]
49
+ /plans lista planów sesji
50
+ /plan ID pokaż plan JSON
51
+ /apply ID zastosuj plan; zmiany wymagają EXECUTE
52
+ /receipts lista receipts sesji
53
+ /receipt ID pokaż receipt JSON
54
+ /route ostatnia decyzja routera
55
+ /metrics tokeny, koszt i udział fast path
56
+ /catalog lokalny katalog intentów
57
+ /connectors allowlista connectorów
58
+
59
+ [bold]Subactor Control[/bold]
60
+ /status wywołaj cli.status
61
+ /control tools sprawdź zamkniętą granicę MCP
62
+ /control call TOOL JSON wywołaj cli.status/plan/execute
63
+
64
+ [bold]Pozostałe[/bold]
65
+ /export PLIK eksport rozmowy do JSON (bez rozwiniętych sekretów)
66
+ /help ta pomoc
67
+ /q | /quit | /exit wyjście; działa też q, quit, exit i Ctrl-C
68
+ """
69
+
70
+
71
+ EXIT_COMMANDS = frozenset({"/q", "/quit", "/exit", "q", "quit", "exit"})
72
+
73
+
74
+ def is_exit_command(value: str) -> bool:
75
+ return value.strip().lower() in EXIT_COMMANDS
76
+
77
+
78
+ class ShellRepl:
79
+ def __init__(self, chat: ChatService, session: Session, console: Console | None = None):
80
+ self.chat = chat
81
+ self.session = session
82
+ self.console = console or Console()
83
+ self.prompt = PromptSession()
84
+ self.pending_attachments: list[Path] = []
85
+ self.control = SubactorControlClient(chat.config.control, chat.resolver)
86
+
87
+ async def run(self) -> None:
88
+ self.console.print(
89
+ f"[bold]Subactor Shell[/bold] — sesja [cyan]{self.session.id}[/cyan], "
90
+ f"provider [green]{self.session.provider}[/green], model [green]{self.session.model}[/green]"
91
+ )
92
+ self.console.print("Wpisz /help, aby zobaczyć komendy. Sekrety podawaj jako {{secret:ALIAS}}.")
93
+ while True:
94
+ try:
95
+ with patch_stdout(raw=True):
96
+ line = await self.prompt.prompt_async(self._prompt_text())
97
+ except EOFError:
98
+ self.console.print()
99
+ return
100
+ except KeyboardInterrupt:
101
+ self.console.print()
102
+ return
103
+ line = line.strip()
104
+ if not line:
105
+ continue
106
+ if is_exit_command(line):
107
+ return
108
+ try:
109
+ if line.startswith("/"):
110
+ keep_running = await self._command(line)
111
+ if not keep_running:
112
+ return
113
+ else:
114
+ await self._send(line)
115
+ except (ChatError, ControlError, OrchestrationError, ValueError, KeyError, OSError, json.JSONDecodeError) as exc:
116
+ self.console.print(f"[red]Błąd:[/red] {exc}")
117
+
118
+ def _prompt_text(self) -> str:
119
+ marker = f" +{len(self.pending_attachments)} plik" if self.pending_attachments else ""
120
+ return f"subactor:{self.session.id[:8]}{marker}> "
121
+
122
+ async def _command(self, line: str) -> bool:
123
+ try:
124
+ parts = shlex.split(line)
125
+ except ValueError as exc:
126
+ raise ValueError(f"Nieprawidłowe cudzysłowy: {exc}") from exc
127
+ command = parts[0].lower()
128
+ args = parts[1:]
129
+ if is_exit_command(command):
130
+ return False
131
+ if command == "/help":
132
+ self.console.print(HELP)
133
+ return True
134
+ if command == "/new":
135
+ name = " ".join(args) or "Nowa rozmowa"
136
+ self.session = self.chat.new_session(name=name)
137
+ self.pending_attachments.clear()
138
+ self.console.print(f"Nowa sesja: [cyan]{self.session.id}[/cyan]")
139
+ return True
140
+ if command == "/sessions":
141
+ self._print_sessions()
142
+ return True
143
+ if command == "/resume":
144
+ self._require(args, 1, "/resume ID")
145
+ self.session = self._resolve_session(args[0])
146
+ self.pending_attachments.clear()
147
+ self.console.print(f"Wznowiono [cyan]{self.session.id}[/cyan]")
148
+ return True
149
+ if command == "/provider":
150
+ self._require(args, 1, "/provider NAZWA")
151
+ profile = self.chat.config.provider(args[0])
152
+ self.session = self.chat.store.update_session(
153
+ self.session.id, provider=profile.name, model=profile.model
154
+ )
155
+ self.console.print(
156
+ f"Provider: [green]{self.session.provider}[/green], model: [green]{self.session.model}[/green]"
157
+ )
158
+ return True
159
+ if command == "/model":
160
+ self._require(args, 1, "/model MODEL")
161
+ self.session = self.chat.store.update_session(self.session.id, model=args[0])
162
+ self.console.print(f"Model: [green]{self.session.model}[/green]")
163
+ return True
164
+ if command == "/attach":
165
+ self._require(args, 1, "/attach PLIK")
166
+ path = Path(args[0]).expanduser()
167
+ if not path.is_file():
168
+ raise ValueError(f"Brak pliku: {path}")
169
+ self.pending_attachments.append(path)
170
+ self.console.print(f"Do następnej wiadomości: {path}")
171
+ return True
172
+ if command == "/info":
173
+ self._print_info()
174
+ return True
175
+ if command == "/data":
176
+ self._handle_data(args)
177
+ return True
178
+ if command == "/vault":
179
+ self._handle_vault(args)
180
+ return True
181
+ if command == "/plans":
182
+ self._print_plans()
183
+ return True
184
+ if command == "/plan":
185
+ self._require(args, 1, "/plan ID")
186
+ payload = self.chat.store.get_execution_plan(args[0])
187
+ if not payload:
188
+ raise ValueError(f"Nie ma planu {args[0]}")
189
+ self._print_json(payload)
190
+ return True
191
+ if command == "/apply":
192
+ self._require(args, 1, "/apply ID")
193
+ payload = self.chat.store.get_execution_plan(args[0])
194
+ if not payload:
195
+ raise ValueError(f"Nie ma planu {args[0]}")
196
+ confirmation = ""
197
+ if str(payload.get("effect", "read")) != "read":
198
+ with patch_stdout(raw=True):
199
+ confirmation = await self.prompt.prompt_async(
200
+ "Plan może zmienić system. Wpisz dokładnie EXECUTE: "
201
+ )
202
+ if confirmation != "EXECUTE":
203
+ raise OrchestrationError("Anulowano apply")
204
+ receipt = await self.chat.orchestration.apply_plan(
205
+ args[0], confirmation=confirmation
206
+ )
207
+ self.console.print(self.chat.orchestration.format_receipt(receipt), markup=False)
208
+ self.chat.context_builder.update_state(
209
+ self.session.id,
210
+ user_text=f"apply {args[0]}",
211
+ receipt_id=receipt.id,
212
+ )
213
+ return True
214
+ if command == "/receipts":
215
+ self._print_receipts()
216
+ return True
217
+ if command == "/receipt":
218
+ self._require(args, 1, "/receipt ID")
219
+ payload = self.chat.store.get_execution_receipt(args[0])
220
+ if not payload:
221
+ raise ValueError(f"Nie ma receiptu {args[0]}")
222
+ self._print_json(payload)
223
+ return True
224
+ if command == "/route":
225
+ payload = self.chat.store.last_routing_decision(self.session.id)
226
+ self._print_json(payload or {})
227
+ return True
228
+ if command == "/metrics":
229
+ self._print_json(self.chat.store.usage_summary(self.session.id))
230
+ return True
231
+ if command == "/catalog":
232
+ table = Table("Intent", "Execution", "Risk", "Źródło")
233
+ for item in self.chat.orchestration.catalog.list():
234
+ table.add_row(
235
+ item.id, str(item.execution.get("kind", "chat")), item.risk, item.source
236
+ )
237
+ self.console.print(table)
238
+ return True
239
+ if command == "/connectors":
240
+ table = Table("Nazwa", "Kind", "Effect", "Operations")
241
+ for item in self.chat.orchestration.registry.list():
242
+ table.add_row(
243
+ item.name, item.kind, item.effect, ", ".join(item.allowed_operations)
244
+ )
245
+ self.console.print(table)
246
+ return True
247
+ if command == "/status":
248
+ await self._send("pokaż status subactora")
249
+ return True
250
+ if command == "/control":
251
+ await self._handle_control(args)
252
+ return True
253
+ if command == "/export":
254
+ self._require(args, 1, "/export PLIK")
255
+ target = Path(args[0]).expanduser()
256
+ target.parent.mkdir(parents=True, exist_ok=True)
257
+ payload = self.chat.store.export_session(self.session.id)
258
+ target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
259
+ self.console.print(f"Zapisano: {target}")
260
+ return True
261
+ raise ValueError(f"Nieznana komenda: {command}. Użyj /help")
262
+
263
+ async def _send(self, text: str) -> None:
264
+ cancel_event = asyncio.Event()
265
+ loop = asyncio.get_running_loop()
266
+ installed_handler = False
267
+ previous_sigint = signal.getsignal(signal.SIGINT)
268
+ try:
269
+ try:
270
+ loop.add_signal_handler(signal.SIGINT, cancel_event.set)
271
+ installed_handler = True
272
+ except (NotImplementedError, RuntimeError):
273
+ pass
274
+ self.console.print("[bold cyan]agent>[/bold cyan] ", end="")
275
+ answer_parts: list[str] = []
276
+ async for chunk in self.chat.stream_message(
277
+ self.session.id,
278
+ text,
279
+ attachment_paths=list(self.pending_attachments),
280
+ cancel_event=cancel_event,
281
+ ):
282
+ answer_parts.append(chunk)
283
+ self.console.print(Text(chunk), end="", soft_wrap=True)
284
+ self.console.print()
285
+ profile = self.chat.config.provider(self.session.provider)
286
+ if profile.kind == "subactor_control":
287
+ lines = ticket_link_lines(
288
+ "".join(answer_parts),
289
+ profile.base_url,
290
+ hyperlinks=terminal_hyperlinks_enabled(is_terminal=self.console.is_terminal),
291
+ )
292
+ if lines:
293
+ self.console.print(" [dim][tickety][/dim]")
294
+ for line in lines:
295
+ self.console.print(line)
296
+ if cancel_event.is_set():
297
+ self.console.print("[yellow]Anulowano.[/yellow]")
298
+ if bool(self.chat.config.orchestration.get("show_route", False)):
299
+ route = self.chat.store.last_routing_decision(self.session.id)
300
+ if route:
301
+ self.console.print(
302
+ f"[dim]route={route['route']} intent={route['intent_id']} "
303
+ f"confidence={route['confidence']:.3f}[/dim]"
304
+ )
305
+ self.pending_attachments.clear()
306
+ except KeyboardInterrupt:
307
+ cancel_event.set()
308
+ self.console.print("\n[yellow]Anulowano.[/yellow]")
309
+ finally:
310
+ if installed_handler:
311
+ loop.remove_signal_handler(signal.SIGINT)
312
+ signal.signal(signal.SIGINT, previous_sigint)
313
+
314
+ def _handle_data(self, args: list[str]) -> None:
315
+ self._require(args, 1, "/data set|put|list|del ...")
316
+ action = args[0].lower()
317
+ if action == "set":
318
+ self._require(args, 3, "/data set NAZWA WARTOŚĆ")
319
+ self.chat.set_data_text(args[1], " ".join(args[2:]))
320
+ self.console.print(f"Zapisano dane [cyan]{args[1]}[/cyan].")
321
+ elif action == "put":
322
+ self._require(args, 3, "/data put NAZWA PLIK")
323
+ artifact = self.chat.set_data_file(args[1], Path(args[2]), self.session.id)
324
+ self.console.print(f"Zapisano [cyan]{args[1]}[/cyan] jako sha256:{artifact.id}")
325
+ elif action == "list":
326
+ table = Table("Nazwa", "Typ", "Wartość/ID")
327
+ for name, kind, value in self.chat.store.list_data():
328
+ shown = value if kind == "artifact" else f"{len(value)} znaków"
329
+ table.add_row(name, kind, shown)
330
+ self.console.print(table)
331
+ elif action == "del":
332
+ self._require(args, 2, "/data del NAZWA")
333
+ removed = self.chat.store.delete_data(args[1])
334
+ self.console.print("Usunięto." if removed else "Nie znaleziono.")
335
+ else:
336
+ raise ValueError("Użyj /data set|put|list|del")
337
+
338
+ def _handle_vault(self, args: list[str]) -> None:
339
+ self._require(args, 1, "/vault bind|put|grant|list|unbind|wrap ...")
340
+ action = args[0].lower()
341
+ if action == "bind":
342
+ self._require(args, 3, "/vault bind ALIAS REF")
343
+ self.chat.bind_secret(args[1], args[2])
344
+ self.console.print(f"Binding [cyan]{args[1]}[/cyan] zapisany; wartość nie została odczytana.")
345
+ elif action == "put":
346
+ self._require(args, 3, "/vault put ALIAS vault://MOUNT/SCIEZKA#POLE")
347
+ reference = args[2]
348
+ if not reference.startswith("vault://"):
349
+ raise ValueError("/vault put wymaga referencji vault://")
350
+ value = getpass.getpass("Wartość sekretu (bez echa): ")
351
+ if not value:
352
+ raise ValueError("Pusta wartość sekretu")
353
+ self.chat.resolver.vault.write_field(reference, value)
354
+ self.chat.bind_secret(args[1], reference)
355
+ self.console.print(f"Zapisano sekret i binding [cyan]{args[1]}[/cyan].")
356
+ elif action == "grant":
357
+ self._require(args, 2, "/vault grant ALIAS")
358
+ self.chat.grant_secret(args[1])
359
+ self.console.print(
360
+ f"Jednorazowy grant dla [cyan]{args[1]}[/cyan]. Zostanie zużyty przez następną wiadomość."
361
+ )
362
+ elif action == "list":
363
+ table = Table("Alias", "Referencja", "Grant w pamięci")
364
+ granted = set(self.chat.grants.list())
365
+ for alias, reference in self.chat.store.list_secret_bindings():
366
+ table.add_row(alias, reference, "tak" if alias in granted else "nie")
367
+ self.console.print(table)
368
+ elif action == "unbind":
369
+ self._require(args, 2, "/vault unbind ALIAS")
370
+ removed = self.chat.store.unbind_secret(args[1])
371
+ self.console.print("Usunięto binding." if removed else "Nie znaleziono bindingu.")
372
+ elif action == "wrap":
373
+ self._require(args, 2, "/vault wrap ALIAS [TTL]")
374
+ reference = self.chat.store.get_secret_binding(args[1])
375
+ if not reference:
376
+ raise ValueError(f"Brak bindingu {args[1]}")
377
+ if not reference.startswith("vault://"):
378
+ raise ValueError("Response wrapping działa tylko dla vault://")
379
+ token = self.chat.resolver.vault.wrap_read(reference, args[2] if len(args) > 2 else "5m")
380
+ self.console.print("[yellow]Wrapping token (jednorazowy; obejmuje całą odpowiedź ścieżki KV):[/yellow]")
381
+ self.console.print(Text(token))
382
+ else:
383
+ raise ValueError("Użyj /vault bind|put|grant|list|unbind|wrap")
384
+
385
+ async def _handle_control(self, args: list[str]) -> None:
386
+ self._require(args, 1, "/control tools|call ...")
387
+ action = args[0].lower()
388
+ if action == "tools":
389
+ tools = await asyncio.to_thread(self.control.list_tools, strict=True)
390
+ table = Table("Narzędzie", "Opis")
391
+ for item in tools:
392
+ table.add_row(str(item.get("name", "")), str(item.get("description", "")))
393
+ self.console.print(table)
394
+ return
395
+ if action == "call":
396
+ self._require(args, 3, "/control call TOOL JSON")
397
+ name = args[1]
398
+ arguments = json.loads(" ".join(args[2:]))
399
+ if not isinstance(arguments, dict):
400
+ raise ValueError("Argumenty narzędzia muszą być obiektem JSON")
401
+ allow_execute = False
402
+ if name == "cli.execute":
403
+ with patch_stdout(raw=True):
404
+ confirmation = await self.prompt.prompt_async(
405
+ "cli.execute może zmienić system. Wpisz dokładnie EXECUTE: "
406
+ )
407
+ allow_execute = confirmation == "EXECUTE"
408
+ if not allow_execute:
409
+ raise ControlError("Anulowano cli.execute")
410
+ result = await asyncio.to_thread(
411
+ self.control.call_tool,
412
+ name,
413
+ arguments,
414
+ allow_execute=allow_execute,
415
+ )
416
+ self._print_json(result)
417
+ return
418
+ raise ValueError("Użyj /control tools|call")
419
+
420
+ def _print_plans(self) -> None:
421
+ table = Table("ID", "Intent", "Effect", "Status", "Utworzono")
422
+ for item in self.chat.store.list_execution_plans(self.session.id):
423
+ table.add_row(
424
+ str(item.get("id", "")),
425
+ str(item.get("intent_id", "")),
426
+ str(item.get("effect", "")),
427
+ str(item.get("status", "")),
428
+ str(item.get("created_at", "")),
429
+ )
430
+ self.console.print(table)
431
+
432
+ def _print_receipts(self) -> None:
433
+ table = Table("ID", "Plan", "OK", "Utworzono")
434
+ for item in self.chat.store.list_execution_receipts(self.session.id):
435
+ table.add_row(
436
+ str(item.get("id", "")),
437
+ str(item.get("plan_id", "")),
438
+ str(bool(item.get("ok"))),
439
+ str(item.get("created_at", "")),
440
+ )
441
+ self.console.print(table)
442
+
443
+ def _resolve_session(self, value: str) -> Session:
444
+ exact = self.chat.store.get_session(value)
445
+ if exact:
446
+ return exact
447
+ matches = [item for item in self.chat.store.list_sessions() if item.id.startswith(value)]
448
+ if len(matches) == 1:
449
+ return matches[0]
450
+ if not matches:
451
+ raise ValueError(f"Nie ma sesji pasującej do {value}")
452
+ raise ValueError(f"Prefiks {value} jest niejednoznaczny")
453
+
454
+ def _print_sessions(self) -> None:
455
+ table = Table("ID", "Nazwa", "Provider", "Model", "Aktualizacja")
456
+ for item in self.chat.store.list_sessions():
457
+ table.add_row(item.id, item.name, item.provider, item.model, item.updated_at)
458
+ self.console.print(table)
459
+
460
+ def _print_info(self) -> None:
461
+ self.console.print(
462
+ {
463
+ "id": self.session.id,
464
+ "name": self.session.name,
465
+ "provider": self.session.provider,
466
+ "model": self.session.model,
467
+ "pending_attachments": [str(path) for path in self.pending_attachments],
468
+ "working_state": self.chat.store.get_session_state(self.session.id),
469
+ "last_route": self.chat.store.last_routing_decision(self.session.id),
470
+ "usage": self.chat.store.usage_summary(self.session.id),
471
+ }
472
+ )
473
+
474
+ def _print_json(self, payload: Any) -> None:
475
+ self.console.print_json(json.dumps(payload, ensure_ascii=False, default=str))
476
+
477
+ @staticmethod
478
+ def _require(args: list[str], count: int, usage: str) -> None:
479
+ if len(args) < count:
480
+ raise ValueError(f"Użycie: {usage}")