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/chat.py ADDED
@@ -0,0 +1,517 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import threading
6
+ from collections.abc import AsyncIterator, Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .artifacts import ArtifactManager, select_relevant_text
11
+ from .config import AppConfig
12
+ from .context_builder import ContextBuilder
13
+ from .models import Artifact, PreparedPrompt, Session
14
+ from .orchestration import OrchestrationOutcome, OrchestrationService
15
+ from .providers import ProviderBundle, build_provider
16
+ from .redaction import StreamingRedactor
17
+ from .secret_refs import SecretResolver
18
+ from .store import Store
19
+ from .token_budget import TokenUsage, estimate_text_tokens
20
+
21
+
22
+ class ChatError(RuntimeError):
23
+ pass
24
+
25
+
26
+ _ALIAS = r"[A-Za-z][A-Za-z0-9_.-]{0,63}"
27
+ SECRET_PATTERN = re.compile(r"\{\{secret:(" + _ALIAS + r")\}\}")
28
+ DATA_PATTERN = re.compile(r"\{\{data:(" + _ALIAS + r")\}\}")
29
+ ALIAS_PATTERN = re.compile(r"^" + _ALIAS + r"$")
30
+
31
+
32
+ class OneTimeGrantRegistry:
33
+ """In-memory, process-local grants; intentionally never persisted."""
34
+
35
+ def __init__(self) -> None:
36
+ self._aliases: set[str] = set()
37
+ self._lock = threading.Lock()
38
+
39
+ def grant(self, alias: str) -> None:
40
+ validate_alias(alias)
41
+ with self._lock:
42
+ self._aliases.add(alias)
43
+
44
+ def consume(self, alias: str) -> bool:
45
+ with self._lock:
46
+ if alias not in self._aliases:
47
+ return False
48
+ self._aliases.remove(alias)
49
+ return True
50
+
51
+ def list(self) -> list[str]:
52
+ with self._lock:
53
+ return sorted(self._aliases)
54
+
55
+
56
+ def validate_alias(alias: str) -> str:
57
+ if not ALIAS_PATTERN.fullmatch(alias):
58
+ raise ValueError("Nazwa musi pasować do [A-Za-z][A-Za-z0-9_.-]{0,63}")
59
+ return alias
60
+
61
+
62
+ ProviderBuilder = Callable[[Any, SecretResolver], ProviderBundle]
63
+
64
+
65
+ class ChatService:
66
+ def __init__(
67
+ self,
68
+ config: AppConfig,
69
+ store: Store,
70
+ *,
71
+ resolver: SecretResolver | None = None,
72
+ provider_builder: ProviderBuilder = build_provider,
73
+ ):
74
+ self.config = config
75
+ self.store = store
76
+ self.resolver = resolver or SecretResolver(config.vault)
77
+ self.provider_builder = provider_builder
78
+ self.grants = OneTimeGrantRegistry()
79
+ self.artifacts = ArtifactManager(
80
+ config.data_dir / "artifacts",
81
+ max_bytes=config.max_attachment_bytes,
82
+ max_text_chars=config.max_attachment_text_chars,
83
+ )
84
+ self.context_builder = ContextBuilder(store, config.context)
85
+ self.orchestration = OrchestrationService(
86
+ config,
87
+ store,
88
+ self.resolver,
89
+ provider_builder,
90
+ )
91
+
92
+ def new_session(
93
+ self,
94
+ *,
95
+ name: str = "Nowa rozmowa",
96
+ provider: str | None = None,
97
+ model: str | None = None,
98
+ session_id: str | None = None,
99
+ ) -> Session:
100
+ provider = provider or self.config.default_provider
101
+ profile = self.config.provider(provider)
102
+ model = model or profile.model or self.config.default_model
103
+ return self.store.create_session(name, provider, model, session_id=session_id)
104
+
105
+ def get_or_create_session(
106
+ self,
107
+ session_id: str | None,
108
+ *,
109
+ provider: str | None = None,
110
+ model: str | None = None,
111
+ ) -> Session:
112
+ if session_id:
113
+ found = self.store.get_session(session_id)
114
+ if not found:
115
+ raise ChatError(f"Nie ma sesji {session_id}")
116
+ return found
117
+ return self.new_session(provider=provider, model=model)
118
+
119
+ def bind_secret(self, alias: str, reference: str) -> None:
120
+ validate_alias(alias)
121
+ if reference.startswith("vault://"):
122
+ from .vault import VaultRef
123
+
124
+ VaultRef.parse(reference)
125
+ elif not reference.startswith(("env://", "file://")):
126
+ raise ValueError("Sekret musi używać vault://, env:// albo file://")
127
+ self.store.bind_secret(alias, reference)
128
+
129
+ def grant_secret(self, alias: str) -> None:
130
+ validate_alias(alias)
131
+ if not self.store.get_secret_binding(alias):
132
+ raise ChatError(f"Brak bindingu sekretu '{alias}'")
133
+ self.grants.grant(alias)
134
+
135
+ def set_data_text(self, name: str, value: str) -> None:
136
+ validate_alias(name)
137
+ self.store.set_data(name, "text", value)
138
+
139
+ def set_data_file(self, name: str, source: Path, session_id: str | None = None) -> Artifact:
140
+ validate_alias(name)
141
+ artifact = self.artifacts.import_file(source)
142
+ if session_id:
143
+ self.store.add_artifact(artifact, session_id)
144
+ else:
145
+ raise ChatError("Do zapisu pliku danych wymagana jest aktywna sesja")
146
+ self.store.set_data(name, "artifact", artifact.id)
147
+ return artifact
148
+
149
+ def _expand_data(self, text: str, *, query: str) -> tuple[str, list[str]]:
150
+ names: list[str] = []
151
+ settings = self.config.context
152
+ max_chars = max(256, int(settings.get("max_data_chars", 6000)))
153
+ chunk_chars = max(256, int(settings.get("artifact_chunk_chars", 1800)))
154
+ max_chunks = max(1, int(settings.get("max_artifact_chunks", 4)))
155
+
156
+ def replace(match: re.Match[str]) -> str:
157
+ name = match.group(1)
158
+ item = self.store.get_data(name)
159
+ if not item:
160
+ raise ChatError(f"Brak danych '{{{{data:{name}}}}}'")
161
+ kind, value = item
162
+ names.append(name)
163
+ if kind == "text":
164
+ selected, truncated = select_relevant_text(
165
+ value,
166
+ query,
167
+ max_chars=max_chars,
168
+ chunk_chars=chunk_chars,
169
+ max_chunks=max_chunks,
170
+ )
171
+ return selected + ("\n[DATA_SELECTED_LOCALLY]" if truncated else "")
172
+ artifact = self.store.get_artifact(value)
173
+ if not artifact:
174
+ raise ChatError(f"Brak artefaktu danych '{name}' ({value})")
175
+ return self.artifacts.render_for_prompt(
176
+ artifact,
177
+ query=query,
178
+ max_chars=max_chars,
179
+ chunk_chars=chunk_chars,
180
+ max_chunks=max_chunks,
181
+ )
182
+
183
+ return DATA_PATTERN.sub(replace, text), sorted(set(names))
184
+
185
+ def _prepare_prompt(
186
+ self,
187
+ text: str,
188
+ attachments: list[Artifact],
189
+ additional_context_blocks: list[str] | None = None,
190
+ ) -> PreparedPrompt:
191
+ additional_context_blocks = additional_context_blocks or []
192
+
193
+ # Only placeholders typed in the current user message can consume a
194
+ # one-time grant. Data, files and ACP resources remain untrusted text.
195
+ aliases = list(dict.fromkeys(SECRET_PATTERN.findall(text)))
196
+ resolved: dict[str, str] = {}
197
+ for alias in aliases:
198
+ reference = self.store.get_secret_binding(alias)
199
+ if not reference:
200
+ raise ChatError(f"Brak bindingu sekretu '{{{{secret:{alias}}}}}'")
201
+ if not self.grants.consume(alias):
202
+ raise ChatError(
203
+ f"Sekret '{alias}' wymaga jednorazowego grantu: /vault grant {alias}"
204
+ )
205
+ resolved[alias] = self.resolver.resolve(reference)
206
+
207
+ safe_text, data_names = self._expand_data(text, query=text)
208
+ provider_text_with_secrets = SECRET_PATTERN.sub(
209
+ lambda match: resolved[match.group(1)], text
210
+ )
211
+ provider_text, provider_data_names = self._expand_data(
212
+ provider_text_with_secrets, query=text
213
+ )
214
+ data_names = sorted(set(data_names) | set(provider_data_names))
215
+
216
+ settings = self.config.context
217
+ embedded_limit = max(0, int(settings.get("max_embedded_context_chars", 8000)))
218
+ safe_blocks = self.context_builder.compact_blocks(
219
+ list(additional_context_blocks), total_limit=embedded_limit
220
+ )
221
+ provider_blocks = list(safe_blocks)
222
+ if attachments:
223
+ attachment_limit = max(256, int(settings.get("max_attachment_prompt_chars", 8000)))
224
+ chunk_chars = max(256, int(settings.get("artifact_chunk_chars", 1800)))
225
+ max_chunks = max(1, int(settings.get("max_artifact_chunks", 4)))
226
+ rendered = [
227
+ self.artifacts.render_for_prompt(
228
+ item,
229
+ query=text,
230
+ max_chars=attachment_limit,
231
+ chunk_chars=chunk_chars,
232
+ max_chunks=max_chunks,
233
+ )
234
+ for item in attachments
235
+ ]
236
+ rendered = self.context_builder.compact_blocks(
237
+ rendered, total_limit=attachment_limit
238
+ )
239
+ safe_blocks.extend(rendered)
240
+ provider_blocks.extend(rendered)
241
+
242
+ safe_context = safe_text
243
+ provider_content = provider_text
244
+ if safe_blocks:
245
+ safe_context += "\n\n" + "\n\n".join(safe_blocks)
246
+ provider_content += "\n\n" + "\n\n".join(provider_blocks)
247
+
248
+ display_content = text
249
+ if additional_context_blocks:
250
+ display_content += "\n\n" + "\n\n".join(additional_context_blocks)
251
+
252
+ return PreparedPrompt(
253
+ display_content=display_content,
254
+ safe_context_content=safe_context,
255
+ provider_content=provider_content,
256
+ resolved_secret_values=list(resolved.values()),
257
+ metadata={
258
+ "secret_aliases": aliases,
259
+ "data_names": data_names,
260
+ "artifact_ids": [item.id for item in attachments],
261
+ "embedded_context_blocks": len(additional_context_blocks),
262
+ },
263
+ )
264
+
265
+ def _prepare_local_message(
266
+ self,
267
+ text: str,
268
+ attachments: list[Artifact],
269
+ additional_context_blocks: list[str] | None,
270
+ ) -> PreparedPrompt:
271
+ """Persist a local route without reading or consuming any secret grant."""
272
+
273
+ additional_context_blocks = additional_context_blocks or []
274
+ safe_text, data_names = self._expand_data(text, query=text)
275
+ settings = self.config.context
276
+ blocks = self.context_builder.compact_blocks(
277
+ list(additional_context_blocks),
278
+ total_limit=max(0, int(settings.get("max_embedded_context_chars", 8000))),
279
+ )
280
+ if attachments:
281
+ attachment_limit = max(256, int(settings.get("max_attachment_prompt_chars", 8000)))
282
+ rendered = [
283
+ self.artifacts.render_for_prompt(
284
+ item,
285
+ query=text,
286
+ max_chars=attachment_limit,
287
+ chunk_chars=int(settings.get("artifact_chunk_chars", 1800)),
288
+ max_chunks=int(settings.get("max_artifact_chunks", 4)),
289
+ )
290
+ for item in attachments
291
+ ]
292
+ blocks.extend(
293
+ self.context_builder.compact_blocks(rendered, total_limit=attachment_limit)
294
+ )
295
+ safe_context = safe_text + ("\n\n" + "\n\n".join(blocks) if blocks else "")
296
+ display = text + (
297
+ "\n\n" + "\n\n".join(additional_context_blocks)
298
+ if additional_context_blocks
299
+ else ""
300
+ )
301
+ return PreparedPrompt(
302
+ display_content=display,
303
+ safe_context_content=safe_context,
304
+ provider_content=safe_context,
305
+ resolved_secret_values=[],
306
+ metadata={
307
+ "secret_aliases": [],
308
+ "data_names": data_names,
309
+ "artifact_ids": [item.id for item in attachments],
310
+ "embedded_context_blocks": len(additional_context_blocks),
311
+ "local_route": True,
312
+ },
313
+ )
314
+
315
+ async def stream_message(
316
+ self,
317
+ session_id: str,
318
+ text: str,
319
+ *,
320
+ attachment_paths: list[Path] | None = None,
321
+ additional_context_blocks: list[str] | None = None,
322
+ cancel_event: asyncio.Event | None = None,
323
+ ) -> AsyncIterator[str]:
324
+ session = self.store.get_session(session_id)
325
+ if not session:
326
+ raise ChatError(f"Nie ma sesji {session_id}")
327
+ if not text.strip() and not attachment_paths and not additional_context_blocks:
328
+ raise ChatError("Pusta wiadomość")
329
+
330
+ attachments: list[Artifact] = []
331
+ for path in attachment_paths or []:
332
+ artifact = self.artifacts.import_file(path)
333
+ self.store.add_artifact(artifact, session_id)
334
+ attachments.append(artifact)
335
+
336
+ outcome = await self.orchestration.prepare(
337
+ session,
338
+ text,
339
+ cancel_event=cancel_event,
340
+ )
341
+ route_metadata = {
342
+ "route": outcome.decision.route,
343
+ "intent_id": outcome.decision.intent_id,
344
+ "confidence": outcome.decision.confidence,
345
+ }
346
+
347
+ if outcome.direct_text:
348
+ prepared_local = self._prepare_local_message(
349
+ text,
350
+ attachments,
351
+ additional_context_blocks,
352
+ )
353
+ self.store.add_message(
354
+ session_id,
355
+ "user",
356
+ prepared_local.display_content,
357
+ prepared_local.safe_context_content,
358
+ prepared_local.metadata | {"routing": route_metadata},
359
+ )
360
+ self.store.add_message(
361
+ session_id,
362
+ "assistant",
363
+ outcome.direct_text,
364
+ outcome.direct_text,
365
+ {
366
+ "routing": route_metadata,
367
+ "plan_id": outcome.plan.id if outcome.plan else "",
368
+ "receipt_id": outcome.receipt.id if outcome.receipt else "",
369
+ "local": True,
370
+ },
371
+ )
372
+ intent = outcome.decision.intent
373
+ self.context_builder.update_state(
374
+ session_id,
375
+ user_text=text,
376
+ intent_id=intent.intent_id if intent else "",
377
+ constraints=intent.constraints if intent else None,
378
+ unresolved=intent.unresolved if intent else None,
379
+ receipt_id=outcome.receipt.id if outcome.receipt else "",
380
+ )
381
+ yield outcome.direct_text
382
+ return
383
+
384
+ prepared = self._prepare_prompt(
385
+ text,
386
+ attachments,
387
+ additional_context_blocks=additional_context_blocks,
388
+ )
389
+ context = self.context_builder.build(
390
+ session_id,
391
+ prepared.provider_content,
392
+ route_context=outcome.route_context,
393
+ )
394
+ user_metadata = prepared.metadata | {
395
+ "routing": route_metadata,
396
+ "context": {
397
+ "included_history_messages": context.included_history_messages,
398
+ "history_chars": context.history_chars,
399
+ "estimated_input_tokens": context.estimated_input_tokens,
400
+ },
401
+ }
402
+ self.store.add_message(
403
+ session_id,
404
+ "user",
405
+ prepared.display_content,
406
+ prepared.safe_context_content,
407
+ user_metadata,
408
+ )
409
+
410
+ provider_name = outcome.provider or session.provider
411
+ profile = self.config.provider(provider_name)
412
+ model = outcome.model or (session.model if provider_name == session.provider else profile.model)
413
+ bundle = self.provider_builder(profile, self.resolver)
414
+ redactor = StreamingRedactor(
415
+ [*bundle.sensitive_values, *prepared.resolved_secret_values]
416
+ )
417
+ parts: list[str] = []
418
+ cancelled = False
419
+ stream_error: Exception | None = None
420
+ try:
421
+ async for raw in bundle.provider.stream(
422
+ context.messages,
423
+ model=model,
424
+ cancel_event=cancel_event,
425
+ ):
426
+ safe = redactor.feed(raw)
427
+ if safe:
428
+ parts.append(safe)
429
+ yield safe
430
+ if cancel_event is not None and cancel_event.is_set():
431
+ cancelled = True
432
+ break
433
+ cancelled = cancelled or bool(cancel_event is not None and cancel_event.is_set())
434
+ tail = redactor.finish()
435
+ if tail:
436
+ parts.append(tail)
437
+ yield tail
438
+ except asyncio.CancelledError:
439
+ cancelled = True
440
+ tail = redactor.finish()
441
+ if tail:
442
+ parts.append(tail)
443
+ raise
444
+ except Exception as exc:
445
+ stream_error = exc
446
+ raise
447
+ finally:
448
+ answer = "".join(parts)
449
+ provider_usage = getattr(bundle.provider, "last_usage", None)
450
+ if not isinstance(provider_usage, TokenUsage):
451
+ provider_usage = TokenUsage(
452
+ input_tokens=context.estimated_input_tokens,
453
+ output_tokens=estimate_text_tokens(answer),
454
+ estimated=True,
455
+ )
456
+ else:
457
+ if provider_usage.input_tokens <= 0:
458
+ provider_usage.input_tokens = context.estimated_input_tokens
459
+ provider_usage.estimated = True
460
+ if provider_usage.output_tokens <= 0 and answer:
461
+ provider_usage.output_tokens = estimate_text_tokens(answer)
462
+ provider_usage.estimated = True
463
+ self.store.record_provider_usage(
464
+ session_id,
465
+ provider=provider_name,
466
+ model=model,
467
+ purpose="chat_response",
468
+ usage=provider_usage,
469
+ input_cost_per_million=profile.input_cost_per_million,
470
+ cached_input_cost_per_million=profile.cached_input_cost_per_million,
471
+ output_cost_per_million=profile.output_cost_per_million,
472
+ metadata={
473
+ "route": outcome.decision.route,
474
+ "cancelled": cancelled,
475
+ "error": type(stream_error).__name__ if stream_error else "",
476
+ },
477
+ )
478
+ if answer:
479
+ self.store.add_message(
480
+ session_id,
481
+ "assistant",
482
+ answer,
483
+ answer,
484
+ {
485
+ "cancelled": cancelled,
486
+ "routing": route_metadata,
487
+ "usage": provider_usage.to_dict(),
488
+ },
489
+ )
490
+ intent = outcome.decision.intent
491
+ self.context_builder.update_state(
492
+ session_id,
493
+ user_text=text,
494
+ intent_id=intent.intent_id if intent else "",
495
+ constraints=intent.constraints if intent else None,
496
+ unresolved=intent.unresolved if intent else None,
497
+ )
498
+
499
+ async def complete_message(
500
+ self,
501
+ session_id: str,
502
+ text: str,
503
+ *,
504
+ attachment_paths: list[Path] | None = None,
505
+ additional_context_blocks: list[str] | None = None,
506
+ cancel_event: asyncio.Event | None = None,
507
+ ) -> str:
508
+ parts: list[str] = []
509
+ async for chunk in self.stream_message(
510
+ session_id,
511
+ text,
512
+ attachment_paths=attachment_paths,
513
+ additional_context_blocks=additional_context_blocks,
514
+ cancel_event=cancel_event,
515
+ ):
516
+ parts.append(chunk)
517
+ return "".join(parts)