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,79 @@
|
|
|
1
|
+
"""Safe terminal presentation for canonical Subactor resource links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_TICKET = re.compile(r"\bPLF-[0-9]{1,20}\b", re.IGNORECASE)
|
|
14
|
+
_FALSE = frozenset({"0", "false", "no", "off", "never"})
|
|
15
|
+
_TRUE = frozenset({"1", "true", "yes", "on", "always"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def terminal_hyperlinks_enabled(
|
|
19
|
+
*,
|
|
20
|
+
is_terminal: bool,
|
|
21
|
+
env: Mapping[str, str] | None = None,
|
|
22
|
+
) -> bool:
|
|
23
|
+
values = os.environ if env is None else env
|
|
24
|
+
if not is_terminal:
|
|
25
|
+
return False
|
|
26
|
+
preference = values.get("SUBACTOR_TERMINAL_HYPERLINKS", "auto").strip().lower()
|
|
27
|
+
if preference in _FALSE:
|
|
28
|
+
return False
|
|
29
|
+
if preference in _TRUE:
|
|
30
|
+
return True
|
|
31
|
+
if values.get("TERM", "").lower() == "dumb":
|
|
32
|
+
return False
|
|
33
|
+
return bool(
|
|
34
|
+
values.get("WT_SESSION")
|
|
35
|
+
or values.get("VTE_VERSION")
|
|
36
|
+
or values.get("KONSOLE_VERSION")
|
|
37
|
+
or values.get("KITTY_WINDOW_ID")
|
|
38
|
+
or re.search(r"^(vscode|wezterm|iterm\.app|ghostty)$", values.get("TERM_PROGRAM", ""), re.I)
|
|
39
|
+
or re.search(r"jetbrains|jediterm", values.get("TERMINAL_EMULATOR", ""), re.I)
|
|
40
|
+
or re.search(r"kitty|wezterm|foot|contour", values.get("TERM", ""), re.I)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def canonical_ticket_links(text: str, control_url: str, *, limit: int = 20) -> list[tuple[str, str]]:
|
|
45
|
+
parsed = urlsplit(control_url)
|
|
46
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
47
|
+
return []
|
|
48
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
result: list[tuple[str, str]] = []
|
|
52
|
+
seen: set[str] = set()
|
|
53
|
+
for match in _TICKET.finditer(text):
|
|
54
|
+
ticket = match.group(0).upper()
|
|
55
|
+
if ticket in seen:
|
|
56
|
+
continue
|
|
57
|
+
seen.add(ticket)
|
|
58
|
+
query = urlencode({"tab": "delegation", "action": "view", "ticket": ticket, "filter": ticket})
|
|
59
|
+
url = urlunsplit((parsed.scheme, parsed.netloc, "/", query, ""))
|
|
60
|
+
result.append((ticket, url))
|
|
61
|
+
if len(result) >= limit:
|
|
62
|
+
break
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ticket_link_lines(
|
|
67
|
+
text: str,
|
|
68
|
+
control_url: str,
|
|
69
|
+
*,
|
|
70
|
+
hyperlinks: bool,
|
|
71
|
+
) -> list[Text]:
|
|
72
|
+
lines: list[Text] = []
|
|
73
|
+
for ticket, url in canonical_ticket_links(text, control_url):
|
|
74
|
+
line = Text(" ")
|
|
75
|
+
line.append(ticket, style="cyan")
|
|
76
|
+
line.append(": ")
|
|
77
|
+
line.append(url, style=f"link {url}" if hyperlinks else None)
|
|
78
|
+
lines.append(line)
|
|
79
|
+
return lines
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class TokenUsage:
|
|
10
|
+
input_tokens: int = 0
|
|
11
|
+
cached_input_tokens: int = 0
|
|
12
|
+
output_tokens: int = 0
|
|
13
|
+
estimated: bool = False
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> dict[str, Any]:
|
|
16
|
+
return {
|
|
17
|
+
"input_tokens": max(0, int(self.input_tokens)),
|
|
18
|
+
"cached_input_tokens": max(0, int(self.cached_input_tokens)),
|
|
19
|
+
"output_tokens": max(0, int(self.output_tokens)),
|
|
20
|
+
"estimated": bool(self.estimated),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def add(self, other: "TokenUsage") -> "TokenUsage":
|
|
24
|
+
return TokenUsage(
|
|
25
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
26
|
+
cached_input_tokens=self.cached_input_tokens + other.cached_input_tokens,
|
|
27
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
28
|
+
estimated=self.estimated or other.estimated,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def estimate_text_tokens(text: str) -> int:
|
|
33
|
+
"""Tokenizer-independent estimate used only when an API returns no usage."""
|
|
34
|
+
|
|
35
|
+
if not text:
|
|
36
|
+
return 0
|
|
37
|
+
return max(1, math.ceil(len(text) / 3.6))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
|
|
41
|
+
total = 2
|
|
42
|
+
for item in messages:
|
|
43
|
+
total += 4
|
|
44
|
+
total += estimate_text_tokens(str(item.get("role", "")))
|
|
45
|
+
total += estimate_text_tokens(str(item.get("content", "")))
|
|
46
|
+
return total
|
subactor_shell/vault.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Any
|
|
5
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VaultError(RuntimeError):
|
|
11
|
+
"""Vault error that never includes request bodies or secret values."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class VaultRef:
|
|
16
|
+
mount: str
|
|
17
|
+
path: str
|
|
18
|
+
field: str
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def parse(cls, value: str) -> "VaultRef":
|
|
22
|
+
parsed = urlsplit(value)
|
|
23
|
+
if parsed.scheme != "vault":
|
|
24
|
+
raise ValueError("Referencja Vault musi zaczynać się od vault://")
|
|
25
|
+
if parsed.username or parsed.password or parsed.query:
|
|
26
|
+
raise ValueError("Referencja Vault nie może zawierać danych logowania ani query")
|
|
27
|
+
mount = unquote(parsed.netloc).strip()
|
|
28
|
+
path = unquote(parsed.path).strip("/")
|
|
29
|
+
field = unquote(parsed.fragment).strip()
|
|
30
|
+
if not mount or not path or not field:
|
|
31
|
+
raise ValueError("Użyj formatu vault://MOUNT/SCIEZKA#POLE")
|
|
32
|
+
segments = [mount, *path.split("/"), field]
|
|
33
|
+
if any(not segment or segment in {".", ".."} for segment in segments):
|
|
34
|
+
raise ValueError("Nieprawidłowa ścieżka Vault")
|
|
35
|
+
return cls(mount=mount, path=path, field=field)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def api_path(self) -> str:
|
|
39
|
+
encoded_mount = quote(self.mount, safe="")
|
|
40
|
+
encoded_path = "/".join(quote(part, safe="") for part in self.path.split("/"))
|
|
41
|
+
return f"/v1/{encoded_mount}/data/{encoded_path}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VaultClient:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
address: str,
|
|
48
|
+
token_loader: Callable[[], str],
|
|
49
|
+
*,
|
|
50
|
+
namespace: str = "",
|
|
51
|
+
verify_tls: bool = True,
|
|
52
|
+
timeout_seconds: float = 10.0,
|
|
53
|
+
transport: httpx.BaseTransport | None = None,
|
|
54
|
+
):
|
|
55
|
+
self.address = address.rstrip("/")
|
|
56
|
+
if not self.address.startswith(("http://", "https://")):
|
|
57
|
+
raise ValueError("vault.address musi być adresem http:// lub https://")
|
|
58
|
+
self._token_loader = token_loader
|
|
59
|
+
self.namespace = namespace.strip()
|
|
60
|
+
self.verify_tls = verify_tls
|
|
61
|
+
self.timeout_seconds = timeout_seconds
|
|
62
|
+
self.transport = transport
|
|
63
|
+
|
|
64
|
+
def _headers(self, *, wrap_ttl: str = "") -> dict[str, str]:
|
|
65
|
+
token = self._token_loader().strip()
|
|
66
|
+
if not token:
|
|
67
|
+
raise VaultError("Token Vault jest pusty")
|
|
68
|
+
headers = {"X-Vault-Token": token}
|
|
69
|
+
if self.namespace:
|
|
70
|
+
headers["X-Vault-Namespace"] = self.namespace
|
|
71
|
+
if wrap_ttl:
|
|
72
|
+
headers["X-Vault-Wrap-TTL"] = wrap_ttl
|
|
73
|
+
return headers
|
|
74
|
+
|
|
75
|
+
def _client(self) -> httpx.Client:
|
|
76
|
+
return httpx.Client(
|
|
77
|
+
base_url=self.address,
|
|
78
|
+
verify=self.verify_tls,
|
|
79
|
+
timeout=self.timeout_seconds,
|
|
80
|
+
transport=self.transport,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _raise_status(response: httpx.Response, operation: str) -> None:
|
|
85
|
+
request_id = response.headers.get("x-vault-request-id", "")
|
|
86
|
+
suffix = f", request_id={request_id}" if request_id else ""
|
|
87
|
+
raise VaultError(f"Vault: {operation} zakończone HTTP {response.status_code}{suffix}")
|
|
88
|
+
|
|
89
|
+
def read_field(self, reference: str | VaultRef) -> str:
|
|
90
|
+
ref = reference if isinstance(reference, VaultRef) else VaultRef.parse(reference)
|
|
91
|
+
try:
|
|
92
|
+
with self._client() as client:
|
|
93
|
+
response = client.get(ref.api_path, headers=self._headers())
|
|
94
|
+
except httpx.HTTPError as exc:
|
|
95
|
+
raise VaultError(f"Vault: błąd połączenia podczas odczytu ({type(exc).__name__})") from exc
|
|
96
|
+
if response.status_code != 200:
|
|
97
|
+
self._raise_status(response, "odczyt")
|
|
98
|
+
try:
|
|
99
|
+
value = response.json()["data"]["data"][ref.field]
|
|
100
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
101
|
+
raise VaultError(f"Vault: brak pola '{ref.field}' w odpowiedzi KV v2") from exc
|
|
102
|
+
if not isinstance(value, (str, int, float, bool)):
|
|
103
|
+
raise VaultError(f"Vault: pole '{ref.field}' nie jest wartością skalarną")
|
|
104
|
+
return str(value)
|
|
105
|
+
|
|
106
|
+
def write_field(self, reference: str | VaultRef, value: str) -> None:
|
|
107
|
+
ref = reference if isinstance(reference, VaultRef) else VaultRef.parse(reference)
|
|
108
|
+
headers = self._headers()
|
|
109
|
+
headers["Content-Type"] = "application/merge-patch+json"
|
|
110
|
+
try:
|
|
111
|
+
with self._client() as client:
|
|
112
|
+
patch = client.patch(ref.api_path, headers=headers, json={"data": {ref.field: value}})
|
|
113
|
+
if patch.status_code in {200, 204}:
|
|
114
|
+
return
|
|
115
|
+
if patch.status_code not in {400, 403, 404, 405}:
|
|
116
|
+
self._raise_status(patch, "zapis PATCH")
|
|
117
|
+
|
|
118
|
+
# Bezpieczny fallback: odczyt i zapis z CAS. Nie nadpisujemy
|
|
119
|
+
# pozostałych pól na ślepo, gdy PATCH jest wyłączony.
|
|
120
|
+
read = client.get(ref.api_path, headers=self._headers())
|
|
121
|
+
if read.status_code == 404:
|
|
122
|
+
current: dict[str, Any] = {}
|
|
123
|
+
version = 0
|
|
124
|
+
elif read.status_code == 200:
|
|
125
|
+
payload = read.json().get("data", {})
|
|
126
|
+
current = dict(payload.get("data", {}))
|
|
127
|
+
version = int(payload.get("metadata", {}).get("version", 0))
|
|
128
|
+
else:
|
|
129
|
+
self._raise_status(read, "odczyt przed zapisem CAS")
|
|
130
|
+
current[ref.field] = value
|
|
131
|
+
post = client.post(
|
|
132
|
+
ref.api_path,
|
|
133
|
+
headers=self._headers(),
|
|
134
|
+
json={"options": {"cas": version}, "data": current},
|
|
135
|
+
)
|
|
136
|
+
if post.status_code not in {200, 204}:
|
|
137
|
+
self._raise_status(post, "zapis CAS")
|
|
138
|
+
except httpx.HTTPError as exc:
|
|
139
|
+
raise VaultError(f"Vault: błąd połączenia podczas zapisu ({type(exc).__name__})") from exc
|
|
140
|
+
|
|
141
|
+
def wrap_read(self, reference: str | VaultRef, ttl: str = "5m") -> str:
|
|
142
|
+
"""Return a one-time Vault wrapping token for the whole KV response."""
|
|
143
|
+
ref = reference if isinstance(reference, VaultRef) else VaultRef.parse(reference)
|
|
144
|
+
if not ttl or len(ttl) > 16:
|
|
145
|
+
raise ValueError("Nieprawidłowy TTL wrappingu")
|
|
146
|
+
try:
|
|
147
|
+
with self._client() as client:
|
|
148
|
+
response = client.get(ref.api_path, headers=self._headers(wrap_ttl=ttl))
|
|
149
|
+
except httpx.HTTPError as exc:
|
|
150
|
+
raise VaultError(f"Vault: błąd połączenia podczas wrappingu ({type(exc).__name__})") from exc
|
|
151
|
+
if response.status_code != 200:
|
|
152
|
+
self._raise_status(response, "response wrapping")
|
|
153
|
+
try:
|
|
154
|
+
token = response.json()["wrap_info"]["token"]
|
|
155
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
156
|
+
raise VaultError("Vault: odpowiedź nie zawiera wrapping tokenu") from exc
|
|
157
|
+
if not isinstance(token, str) or not token:
|
|
158
|
+
raise VaultError("Vault: pusty wrapping token")
|
|
159
|
+
return token
|
|
160
|
+
|
|
161
|
+
def health(self) -> tuple[bool, str]:
|
|
162
|
+
try:
|
|
163
|
+
with self._client() as client:
|
|
164
|
+
response = client.get("/v1/sys/health")
|
|
165
|
+
except httpx.HTTPError as exc:
|
|
166
|
+
return False, f"błąd połączenia ({type(exc).__name__})"
|
|
167
|
+
# 200 active, 429 standby, 472/473 DR/performance standby są osiągalne.
|
|
168
|
+
if response.status_code in {200, 429, 472, 473}:
|
|
169
|
+
return True, f"HTTP {response.status_code}"
|
|
170
|
+
return False, f"HTTP {response.status_code}"
|