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,191 @@
1
+ """Provider adapter for the canonical Subactor Founder conversation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any
9
+ from urllib.parse import parse_qsl, urlsplit
10
+
11
+ import httpx
12
+
13
+ from ..token_budget import TokenUsage, estimate_messages_tokens, estimate_text_tokens
14
+ from .base import ChatProvider, ProviderError
15
+
16
+
17
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
18
+ _SENSITIVE_QUERY_KEY = re.compile(r"(?:token|secret|password|api[_-]?key|authorization)", re.IGNORECASE)
19
+ _MAX_MESSAGE_CHARS = 2_000
20
+ _MAX_HISTORY_ITEMS = 8
21
+
22
+
23
+ def _bounded_text(value: Any, limit: int = _MAX_MESSAGE_CHARS) -> str:
24
+ return _CONTROL_CHARS.sub("", str(value or "")).strip()[:limit]
25
+
26
+
27
+ def _safe_http_url(value: Any) -> str | None:
28
+ candidate = _bounded_text(value, 2_048)
29
+ if not candidate:
30
+ return None
31
+ parsed = urlsplit(candidate)
32
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
33
+ return None
34
+ if parsed.username or parsed.password:
35
+ return None
36
+ if any(_SENSITIVE_QUERY_KEY.search(key) for key, _value in parse_qsl(parsed.query, keep_blank_values=True)):
37
+ return None
38
+ return candidate
39
+
40
+
41
+ class SubactorControlProvider(ChatProvider):
42
+ """Stream grounded Founder answers from Subactor Control."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ base_url: str,
48
+ endpoint: str,
49
+ api_key: str,
50
+ timeout_seconds: float = 90.0,
51
+ transport: httpx.AsyncBaseTransport | None = None,
52
+ ) -> None:
53
+ parsed = urlsplit(base_url)
54
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
55
+ raise ProviderError("Nieprawidłowy adres Subactor Control")
56
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
57
+ raise ProviderError("Adres Subactor Control nie może zawierać poświadczeń ani parametrów")
58
+ if not endpoint.startswith("/"):
59
+ raise ProviderError("Endpoint Subactor Control musi być ścieżką bezwzględną")
60
+ if not api_key:
61
+ raise ProviderError("Brak tokenu Subactor Control")
62
+ self._base_url = base_url.rstrip("/")
63
+ self._endpoint = endpoint
64
+ self._api_key = api_key
65
+ self._timeout_seconds = timeout_seconds
66
+ self._transport = transport
67
+ self.last_usage: TokenUsage | None = None
68
+
69
+ @staticmethod
70
+ def _request_payload(messages: list[dict[str, Any]], model: str) -> dict[str, Any]:
71
+ conversation: list[dict[str, str]] = []
72
+ for message in messages:
73
+ role = str(message.get("role") or "")
74
+ content = _bounded_text(message.get("content"))
75
+ if role in {"user", "assistant"} and content:
76
+ conversation.append({"role": role, "content": content})
77
+ conversation = conversation[-(_MAX_HISTORY_ITEMS + 1) :]
78
+ user_index = next(
79
+ (index for index in range(len(conversation) - 1, -1, -1) if conversation[index]["role"] == "user"),
80
+ -1,
81
+ )
82
+ if user_index < 0:
83
+ raise ProviderError("Brak wiadomości użytkownika dla Subactor Control")
84
+ payload: dict[str, Any] = {
85
+ "surface": "founder_autonomy",
86
+ "text": conversation[user_index]["content"],
87
+ "history": conversation[max(0, user_index - _MAX_HISTORY_ITEMS) : user_index],
88
+ }
89
+ preferred_model = _bounded_text(model, 120)
90
+ if preferred_model and preferred_model not in {"control", "mock"}:
91
+ payload["preferred_model"] = preferred_model
92
+ return payload
93
+
94
+ @staticmethod
95
+ def _format_response(body: dict[str, Any]) -> str:
96
+ data = body.get("data") if isinstance(body.get("data"), dict) else {}
97
+ summary = ""
98
+ for value in (
99
+ data.get("summary"),
100
+ body.get("summary"),
101
+ data.get("answer"),
102
+ body.get("answer"),
103
+ body.get("message"),
104
+ ):
105
+ summary = _bounded_text(value, 20_000)
106
+ if summary:
107
+ break
108
+ if not summary:
109
+ raise ProviderError("Subactor Control nie zwrócił odpowiedzi")
110
+
111
+ lines = [summary]
112
+ actions = body.get("actions") if isinstance(body.get("actions"), list) else data.get("actions", [])
113
+ for action in (actions[:8] if isinstance(actions, list) else []):
114
+ if not isinstance(action, dict):
115
+ continue
116
+ url = _safe_http_url(action.get("url"))
117
+ if not url:
118
+ continue
119
+ label = _bounded_text(action.get("label") or action.get("title") or "Otwórz", 160)
120
+ lines.append(f" → {label}: {url}")
121
+
122
+ diagnostics = body.get("diagnostics") if isinstance(body.get("diagnostics"), dict) else data.get("diagnostics")
123
+ if isinstance(diagnostics, dict):
124
+ observed = _bounded_text(diagnostics.get("observedLocal") or diagnostics.get("observed_at"), 120)
125
+ duration = diagnostics.get("durationMs") or diagnostics.get("duration_ms")
126
+ correlation = _bounded_text(diagnostics.get("correlationId") or diagnostics.get("correlation_id"), 160)
127
+ details = [
128
+ value
129
+ for value in (
130
+ observed,
131
+ f"{duration} ms" if isinstance(duration, (int, float)) else "",
132
+ f"cid: {correlation}" if correlation else "",
133
+ )
134
+ if value
135
+ ]
136
+ if details:
137
+ lines.append(f"\n [diagnostyka] {' | '.join(details)}")
138
+ markdown_url = _safe_http_url(
139
+ diagnostics.get("markdownDownloadUrl") or diagnostics.get("markdown_url")
140
+ )
141
+ if markdown_url:
142
+ lines.append(f" markdown (uwierzytelnienie wymagane): {markdown_url}")
143
+ return "\n".join(lines)
144
+
145
+ async def stream(
146
+ self,
147
+ messages: list[dict[str, Any]],
148
+ *,
149
+ model: str,
150
+ cancel_event=None,
151
+ ) -> AsyncIterator[str]:
152
+ payload = self._request_payload(messages, model)
153
+ headers = {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json"}
154
+ try:
155
+ async with httpx.AsyncClient(
156
+ base_url=self._base_url,
157
+ timeout=self._timeout_seconds,
158
+ transport=self._transport,
159
+ ) as client:
160
+ response = await client.post(self._endpoint, json=payload, headers=headers)
161
+ except httpx.HTTPError as exc:
162
+ raise ProviderError(f"Subactor Control jest niedostępny ({exc.__class__.__name__})") from exc
163
+
164
+ if response.status_code >= 400:
165
+ code = "request_failed"
166
+ try:
167
+ problem = response.json()
168
+ if isinstance(problem, dict):
169
+ code = _bounded_text(problem.get("code") or problem.get("type") or code, 160)
170
+ except ValueError:
171
+ pass
172
+ raise ProviderError(f"Subactor Control odrzucił żądanie: HTTP {response.status_code} ({code})")
173
+
174
+ try:
175
+ body = response.json()
176
+ except ValueError as exc:
177
+ raise ProviderError("Subactor Control zwrócił nieprawidłowy JSON") from exc
178
+ if not isinstance(body, dict) or body.get("ok") is False:
179
+ raise ProviderError("Subactor Control nie wykonał żądania")
180
+
181
+ answer = self._format_response(body)
182
+ self.last_usage = TokenUsage(
183
+ input_tokens=estimate_messages_tokens(messages),
184
+ output_tokens=estimate_text_tokens(answer),
185
+ estimated=True,
186
+ )
187
+ for offset in range(0, len(answer), 256):
188
+ if cancel_event is not None and cancel_event.is_set():
189
+ return
190
+ yield answer[offset : offset + 256]
191
+ await asyncio.sleep(0)
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+
6
+ REDACTED = "[REDACTED]"
7
+
8
+
9
+ class ExactRedactor:
10
+ def __init__(self, values: Iterable[str], replacement: str = REDACTED):
11
+ self.replacement = replacement
12
+ self.values = sorted({value for value in values if value}, key=len, reverse=True)
13
+
14
+ def redact(self, text: str) -> str:
15
+ for value in self.values:
16
+ text = text.replace(value, self.replacement)
17
+ return text
18
+
19
+
20
+ class StreamingRedactor:
21
+ """Redact exact values without leaking a secret split across stream chunks."""
22
+
23
+ def __init__(self, values: Iterable[str], replacement: str = REDACTED):
24
+ self._exact = ExactRedactor(values, replacement)
25
+ self._buffer = ""
26
+ self._max_secret_length = max((len(value) for value in self._exact.values), default=1)
27
+
28
+ def feed(self, chunk: str) -> str:
29
+ if not chunk:
30
+ return ""
31
+ self._buffer += chunk
32
+ hold = self._max_secret_length - 1
33
+ safe_cut = len(self._buffer) - hold
34
+ if safe_cut <= 0:
35
+ return ""
36
+
37
+ # Gdy pełny sekret przecina granicę safe_cut, cofamy granicę do
38
+ # początku sekretu. Powtarzamy skan aż granica się ustabilizuje, bo
39
+ # cofnięcie przez jeden sekret może odsłonić przecięcie przez inny.
40
+ while True:
41
+ previous_cut = safe_cut
42
+ for value in self._exact.values:
43
+ start = self._buffer.find(value)
44
+ while start != -1:
45
+ end = start + len(value)
46
+ if start < safe_cut < end:
47
+ safe_cut = min(safe_cut, start)
48
+ break
49
+ start = self._buffer.find(value, start + 1)
50
+ if safe_cut == previous_cut:
51
+ break
52
+
53
+ if safe_cut <= 0:
54
+ return ""
55
+ ready = self._buffer[:safe_cut]
56
+ self._buffer = self._buffer[safe_cut:]
57
+ return self._exact.redact(ready)
58
+
59
+ def finish(self) -> str:
60
+ ready = self._exact.redact(self._buffer)
61
+ self._buffer = ""
62
+ return ready