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,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
|