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,287 @@
|
|
|
1
|
+
"""Bounded operational HTTP commands exposed by Subactor Shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from .control_env import apply_control_environment
|
|
20
|
+
from .terminal import canonical_ticket_links, terminal_hyperlinks_enabled
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_TERMINAL_TICKET_STATES = frozenset({"done", "completed", "closed", "rejected", "cancelled"})
|
|
24
|
+
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
25
|
+
_URI = re.compile(r"^[a-z][a-z0-9+.-]*://[^\s]+$", re.IGNORECASE)
|
|
26
|
+
_ENDPOINTS = """# Subactor Control API
|
|
27
|
+
GET /health
|
|
28
|
+
GET /api/system/dashboard
|
|
29
|
+
GET /api/plans
|
|
30
|
+
GET /api/integrations
|
|
31
|
+
GET /api/delegation/manager
|
|
32
|
+
GET /api/llm/status
|
|
33
|
+
POST /api/delegation/dispatch
|
|
34
|
+
POST /api/processes/run
|
|
35
|
+
|
|
36
|
+
# Canonical shell
|
|
37
|
+
subactor health
|
|
38
|
+
subactor status
|
|
39
|
+
subactor tickets --open
|
|
40
|
+
subactor plans remote
|
|
41
|
+
subactor uri <uri-process> [json-payload]
|
|
42
|
+
subactor get /api/system/dashboard
|
|
43
|
+
subactor post /api/delegation/dispatch '{}' --confirm EXECUTE
|
|
44
|
+
subactor api GET|POST|PUT|PATCH|DELETE <path> [json-body]
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OperationsError(RuntimeError):
|
|
49
|
+
"""Safe user-facing operational command failure."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validated_origin(value: str, label: str) -> str:
|
|
53
|
+
parsed = urlsplit(value.strip())
|
|
54
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
55
|
+
raise OperationsError(f"{label} musi być adresem http(s)")
|
|
56
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
57
|
+
raise OperationsError(f"{label} nie może zawierać credentials, query ani fragmentu")
|
|
58
|
+
path = parsed.path.rstrip("/")
|
|
59
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_token(values: Mapping[str, str]) -> str:
|
|
63
|
+
token = values.get("SUBACTOR_ADMIN_TOKEN", "").strip()
|
|
64
|
+
token_file = values.get("SUBACTOR_ADMIN_TOKEN_FILE", "").strip()
|
|
65
|
+
if token or not token_file:
|
|
66
|
+
return token
|
|
67
|
+
path = Path(token_file).expanduser()
|
|
68
|
+
try:
|
|
69
|
+
if path.stat().st_size > 16_384:
|
|
70
|
+
raise OperationsError("Plik tokenu jest zbyt duży")
|
|
71
|
+
return path.read_text(encoding="utf-8").strip()
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise OperationsError("Nie można odczytać pliku tokenu") from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class OperationSettings:
|
|
78
|
+
control_url: str
|
|
79
|
+
planfile_url: str
|
|
80
|
+
token: str
|
|
81
|
+
timeout_seconds: float = 20.0
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_environment(cls, env: Mapping[str, str] | None = None) -> "OperationSettings":
|
|
85
|
+
if env is None:
|
|
86
|
+
apply_control_environment()
|
|
87
|
+
values = os.environ
|
|
88
|
+
else:
|
|
89
|
+
values = env
|
|
90
|
+
control_url = _validated_origin(
|
|
91
|
+
values.get("SUBACTOR_CONTROL_URL", "http://127.0.0.1:8091"), "SUBACTOR_CONTROL_URL"
|
|
92
|
+
)
|
|
93
|
+
planfile_url = _validated_origin(
|
|
94
|
+
values.get("SUBACTOR_PLANFILE_URL", values.get("PLANFILE_URL", "http://127.0.0.1:8765")),
|
|
95
|
+
"SUBACTOR_PLANFILE_URL",
|
|
96
|
+
)
|
|
97
|
+
return cls(control_url, planfile_url, _read_token(values))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OperationsClient:
|
|
101
|
+
def __init__(self, settings: OperationSettings, *, transport: httpx.BaseTransport | None = None) -> None:
|
|
102
|
+
self.settings = settings
|
|
103
|
+
self._transport = transport
|
|
104
|
+
|
|
105
|
+
def request(
|
|
106
|
+
self,
|
|
107
|
+
method: str,
|
|
108
|
+
path: str,
|
|
109
|
+
*,
|
|
110
|
+
body: Any = None,
|
|
111
|
+
service: str = "control",
|
|
112
|
+
authenticated: bool = True,
|
|
113
|
+
) -> tuple[Any, str]:
|
|
114
|
+
verb = method.upper()
|
|
115
|
+
if verb not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
|
116
|
+
raise OperationsError(f"Niedozwolona metoda HTTP: {verb}")
|
|
117
|
+
if not path.startswith("/") or path.startswith("//") or "\n" in path or "\r" in path:
|
|
118
|
+
raise OperationsError("Ścieżka API musi być względna wobec skonfigurowanej usługi")
|
|
119
|
+
if authenticated and not self.settings.token:
|
|
120
|
+
raise OperationsError(
|
|
121
|
+
"Brak SUBACTOR_ADMIN_TOKEN lub SUBACTOR_ADMIN_TOKEN_FILE; operacja nie została wysłana"
|
|
122
|
+
)
|
|
123
|
+
base = self.settings.planfile_url if service == "planfile" else self.settings.control_url
|
|
124
|
+
headers = {"Accept": "application/json"}
|
|
125
|
+
if authenticated:
|
|
126
|
+
headers["Authorization"] = f"Bearer {self.settings.token}"
|
|
127
|
+
try:
|
|
128
|
+
with httpx.Client(timeout=self.settings.timeout_seconds, transport=self._transport) as client:
|
|
129
|
+
response = client.request(verb, f"{base}{path}", json=body, headers=headers)
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
raise OperationsError(f"Usługa {service} jest niedostępna ({exc.__class__.__name__})") from exc
|
|
132
|
+
if response.status_code >= 400:
|
|
133
|
+
code = "request_failed"
|
|
134
|
+
try:
|
|
135
|
+
problem = response.json()
|
|
136
|
+
if isinstance(problem, dict):
|
|
137
|
+
code = str(problem.get("code") or problem.get("type") or code)[:160]
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
raise OperationsError(f"{service} odrzucił żądanie: HTTP {response.status_code} ({code})")
|
|
141
|
+
try:
|
|
142
|
+
return response.json(), response.text
|
|
143
|
+
except ValueError:
|
|
144
|
+
return response.text, response.text
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ticket_open(ticket: Mapping[str, Any]) -> bool:
|
|
148
|
+
return str(ticket.get("status", "")).lower() not in _TERMINAL_TICKET_STATES
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ticket_urgent(ticket: Mapping[str, Any]) -> bool:
|
|
152
|
+
priority = str(ticket.get("priority", "")).lower()
|
|
153
|
+
labels = {str(value).lower() for value in ticket.get("labels", []) if isinstance(value, str)}
|
|
154
|
+
name = str(ticket.get("name") or ticket.get("title") or "").upper()
|
|
155
|
+
return priority in {"urgent", "critical", "high"} or "urgent" in labels or name.startswith(("PILNE", "URGENT"))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def filter_tickets(rows: Sequence[Mapping[str, Any]], args: Any) -> list[Mapping[str, Any]]:
|
|
159
|
+
result = list(rows)
|
|
160
|
+
if args.open or args.urgent:
|
|
161
|
+
result = [item for item in result if _ticket_open(item)]
|
|
162
|
+
if args.urgent:
|
|
163
|
+
result = [item for item in result if _ticket_urgent(item)]
|
|
164
|
+
filters = {
|
|
165
|
+
"queue": args.queue,
|
|
166
|
+
"state": args.state,
|
|
167
|
+
"priority": args.priority,
|
|
168
|
+
"project": args.project,
|
|
169
|
+
}
|
|
170
|
+
for field, expected in filters.items():
|
|
171
|
+
if not expected:
|
|
172
|
+
continue
|
|
173
|
+
needle = expected.lower()
|
|
174
|
+
if field in {"queue", "state"}:
|
|
175
|
+
result = [item for item in result if needle in str(item.get("execution", {}).get(field, "")).lower()]
|
|
176
|
+
else:
|
|
177
|
+
result = [item for item in result if needle in str(item.get(field, "")).lower()]
|
|
178
|
+
if args.text:
|
|
179
|
+
needle = args.text.lower()
|
|
180
|
+
result = [item for item in result if needle in json.dumps(item, ensure_ascii=False).lower()]
|
|
181
|
+
return sorted(
|
|
182
|
+
result,
|
|
183
|
+
key=lambda item: (_ticket_urgent(item), str(item.get("updated_at") or item.get("created_at") or "")),
|
|
184
|
+
reverse=True,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _json_payload(raw: str | None) -> Any:
|
|
189
|
+
if raw is None:
|
|
190
|
+
return None
|
|
191
|
+
if len(raw) > 131_072:
|
|
192
|
+
raise OperationsError("Payload JSON jest zbyt duży")
|
|
193
|
+
try:
|
|
194
|
+
return json.loads(raw)
|
|
195
|
+
except json.JSONDecodeError as exc:
|
|
196
|
+
raise OperationsError("Payload musi być poprawnym JSON") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _require_execute(confirmation: str, operation: str) -> None:
|
|
200
|
+
if confirmation != "EXECUTE":
|
|
201
|
+
raise OperationsError(f"{operation} wymaga --confirm EXECUTE")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _print_json(console: Console, payload: Any) -> None:
|
|
205
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _print_tickets(console: Console, rows: Sequence[Mapping[str, Any]], control_url: str) -> None:
|
|
209
|
+
table = Table("Ticket", "Priorytet", "Status", "Kolejka", "Stan", "Nazwa")
|
|
210
|
+
hyperlinks = terminal_hyperlinks_enabled(is_terminal=console.is_terminal)
|
|
211
|
+
for item in rows[:50]:
|
|
212
|
+
ticket = str(item.get("id", "?"))
|
|
213
|
+
links = canonical_ticket_links(ticket, control_url, limit=1)
|
|
214
|
+
ticket_text = Text(ticket)
|
|
215
|
+
if links and hyperlinks:
|
|
216
|
+
ticket_text.stylize(f"link {links[0][1]}")
|
|
217
|
+
execution = item.get("execution") if isinstance(item.get("execution"), dict) else {}
|
|
218
|
+
table.add_row(
|
|
219
|
+
ticket_text,
|
|
220
|
+
str(item.get("priority", "normal")),
|
|
221
|
+
str(item.get("status", "?")),
|
|
222
|
+
str(execution.get("queue", "?")),
|
|
223
|
+
str(execution.get("state", "?")),
|
|
224
|
+
str(item.get("name") or item.get("title") or "")[:80],
|
|
225
|
+
)
|
|
226
|
+
console.print(f"{len(rows)} ticket(s)")
|
|
227
|
+
console.print(table)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_operational_command(args: Any, console: Console, client: OperationsClient) -> int:
|
|
231
|
+
command = args.command
|
|
232
|
+
if command == "health":
|
|
233
|
+
payload, text = client.request("GET", "/health", authenticated=False)
|
|
234
|
+
_print_json(console, payload) if not isinstance(payload, str) else console.print(text, markup=False)
|
|
235
|
+
return 0
|
|
236
|
+
if command == "status":
|
|
237
|
+
payload, _ = client.request("GET", "/api/system/dashboard")
|
|
238
|
+
_print_json(console, payload)
|
|
239
|
+
return 0
|
|
240
|
+
if command == "tickets":
|
|
241
|
+
payload, _ = client.request("GET", "/tickets?sprint=all", service="planfile")
|
|
242
|
+
rows = payload if isinstance(payload, list) else payload.get("tickets", [])
|
|
243
|
+
filtered = filter_tickets([item for item in rows if isinstance(item, dict)], args)
|
|
244
|
+
_print_json(console, filtered) if args.json else _print_tickets(console, filtered, client.settings.control_url)
|
|
245
|
+
return 0
|
|
246
|
+
if command == "plans":
|
|
247
|
+
payload, _ = client.request("GET", "/api/plans?view=summary")
|
|
248
|
+
plans = payload.get("plans", []) if isinstance(payload, dict) else []
|
|
249
|
+
if args.status:
|
|
250
|
+
plans = [item for item in plans if str(item.get("status", "")) == args.status]
|
|
251
|
+
_print_json(console, plans)
|
|
252
|
+
return 0
|
|
253
|
+
if command == "dispatch":
|
|
254
|
+
_require_execute(args.confirm, "dispatch")
|
|
255
|
+
payload, _ = client.request("POST", "/api/delegation/dispatch", body={})
|
|
256
|
+
_print_json(console, payload)
|
|
257
|
+
return 0
|
|
258
|
+
if command == "uri":
|
|
259
|
+
if not _URI.fullmatch(args.uri):
|
|
260
|
+
raise OperationsError("Nieprawidłowy URI procesu")
|
|
261
|
+
body = _json_payload(args.payload) or {}
|
|
262
|
+
if not isinstance(body, dict):
|
|
263
|
+
raise OperationsError("Payload URI musi być obiektem JSON")
|
|
264
|
+
if body.get("apply") is True:
|
|
265
|
+
_require_execute(args.confirm, "URI apply")
|
|
266
|
+
elif "/command/" in args.uri and "apply" not in body:
|
|
267
|
+
body["apply"] = False
|
|
268
|
+
payload, _ = client.request(
|
|
269
|
+
"POST",
|
|
270
|
+
"/api/processes/run",
|
|
271
|
+
body={"uri": args.uri, "payload": body, "reason": f"Canonical Subactor shell: {args.uri}"},
|
|
272
|
+
)
|
|
273
|
+
_print_json(console, payload)
|
|
274
|
+
return 0
|
|
275
|
+
if command in {"api", "get", "post"}:
|
|
276
|
+
method = args.method.upper() if command == "api" else command.upper()
|
|
277
|
+
path = args.path
|
|
278
|
+
raw_body = getattr(args, "payload", None)
|
|
279
|
+
if method in _WRITE_METHODS:
|
|
280
|
+
_require_execute(args.confirm, f"HTTP {method}")
|
|
281
|
+
payload, text = client.request(method, path, body=_json_payload(raw_body))
|
|
282
|
+
_print_json(console, payload) if not isinstance(payload, str) else console.print(text, markup=False)
|
|
283
|
+
return 0
|
|
284
|
+
if command == "endpoints":
|
|
285
|
+
console.print(_ENDPOINTS, markup=False)
|
|
286
|
+
return 0
|
|
287
|
+
raise OperationsError(f"Nieobsługiwana komenda operacyjna: {command}")
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .catalog import IntentCatalog
|
|
9
|
+
from .compiler import CompileError, ExecutionPlan, PlanCompiler, compute_plan_hash
|
|
10
|
+
from .config import AppConfig
|
|
11
|
+
from .connectors import (
|
|
12
|
+
ConnectorError,
|
|
13
|
+
ConnectorExecutor,
|
|
14
|
+
ConnectorRegistry,
|
|
15
|
+
ExecutionReceipt,
|
|
16
|
+
)
|
|
17
|
+
from .models import Session
|
|
18
|
+
from .policy import PolicyEngine
|
|
19
|
+
from .providers import ProviderBundle
|
|
20
|
+
from .routing import Router, RoutingDecision
|
|
21
|
+
from .secret_refs import SecretResolver
|
|
22
|
+
from .store import Store
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OrchestrationError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ProviderBuilder = Callable[[Any, SecretResolver], ProviderBundle]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class OrchestrationOutcome:
|
|
34
|
+
decision: RoutingDecision
|
|
35
|
+
direct_text: str = ""
|
|
36
|
+
provider: str = ""
|
|
37
|
+
model: str = ""
|
|
38
|
+
route_context: dict[str, Any] | None = None
|
|
39
|
+
plan: ExecutionPlan | None = None
|
|
40
|
+
receipt: ExecutionReceipt | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OrchestrationService:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
config: AppConfig,
|
|
47
|
+
store: Store,
|
|
48
|
+
resolver: SecretResolver,
|
|
49
|
+
provider_builder: ProviderBuilder,
|
|
50
|
+
):
|
|
51
|
+
self.config = config
|
|
52
|
+
self.store = store
|
|
53
|
+
self.resolver = resolver
|
|
54
|
+
self.provider_builder = provider_builder
|
|
55
|
+
self.catalog = IntentCatalog.load(config.intent_catalog_paths())
|
|
56
|
+
self.registry = ConnectorRegistry(config)
|
|
57
|
+
self.router = Router(
|
|
58
|
+
config,
|
|
59
|
+
store,
|
|
60
|
+
resolver,
|
|
61
|
+
provider_builder,
|
|
62
|
+
self.catalog,
|
|
63
|
+
)
|
|
64
|
+
self.compiler = PlanCompiler()
|
|
65
|
+
self.policy = PolicyEngine(
|
|
66
|
+
allow_destructive=bool(config.orchestration.get("allow_destructive", False))
|
|
67
|
+
)
|
|
68
|
+
self.executor = ConnectorExecutor(config, store, resolver, self.registry)
|
|
69
|
+
self.mode = str(config.orchestration.get("mode", "active")).strip().lower()
|
|
70
|
+
|
|
71
|
+
async def prepare(
|
|
72
|
+
self,
|
|
73
|
+
session: Session,
|
|
74
|
+
text: str,
|
|
75
|
+
*,
|
|
76
|
+
cancel_event=None,
|
|
77
|
+
) -> OrchestrationOutcome:
|
|
78
|
+
decision = await self.router.route(session, text, cancel_event=cancel_event)
|
|
79
|
+
for record in decision.parser_usage:
|
|
80
|
+
try:
|
|
81
|
+
profile = self.config.provider(record.provider)
|
|
82
|
+
pricing = {
|
|
83
|
+
"input_cost_per_million": profile.input_cost_per_million,
|
|
84
|
+
"cached_input_cost_per_million": profile.cached_input_cost_per_million,
|
|
85
|
+
"output_cost_per_million": profile.output_cost_per_million,
|
|
86
|
+
}
|
|
87
|
+
except (KeyError, ValueError):
|
|
88
|
+
pricing = {}
|
|
89
|
+
self.store.record_provider_usage(
|
|
90
|
+
session.id,
|
|
91
|
+
provider=record.provider,
|
|
92
|
+
model=record.model,
|
|
93
|
+
purpose=record.purpose,
|
|
94
|
+
usage=record.usage,
|
|
95
|
+
metadata={"latency_ms": record.latency_ms},
|
|
96
|
+
**pricing,
|
|
97
|
+
)
|
|
98
|
+
self.store.record_routing_decision(
|
|
99
|
+
session.id,
|
|
100
|
+
route=decision.route,
|
|
101
|
+
reason=decision.reason,
|
|
102
|
+
intent_id=decision.intent_id,
|
|
103
|
+
confidence=decision.confidence,
|
|
104
|
+
provider=decision.provider,
|
|
105
|
+
model=decision.model,
|
|
106
|
+
candidates=[item.to_dict() for item in decision.candidates],
|
|
107
|
+
metadata={
|
|
108
|
+
"parser_errors": decision.parser_errors,
|
|
109
|
+
"cache_hit": decision.cache_hit,
|
|
110
|
+
"orchestration_mode": self.mode,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if self.mode == "shadow":
|
|
115
|
+
return OrchestrationOutcome(
|
|
116
|
+
decision=decision,
|
|
117
|
+
provider=self.router.large_provider or session.provider,
|
|
118
|
+
model=self.router.large_model or session.model,
|
|
119
|
+
route_context=decision.route_context() | {"shadow_mode": True},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not decision.intent:
|
|
123
|
+
return OrchestrationOutcome(
|
|
124
|
+
decision=decision,
|
|
125
|
+
provider=decision.provider or session.provider,
|
|
126
|
+
model=decision.model or session.model,
|
|
127
|
+
route_context=decision.route_context(),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
definition = self.catalog.get(decision.intent.intent_id)
|
|
131
|
+
if not definition:
|
|
132
|
+
return self._fallback(session, decision, "Intent nie istnieje już w katalogu")
|
|
133
|
+
if decision.intent.unresolved:
|
|
134
|
+
missing = ", ".join(decision.intent.unresolved)
|
|
135
|
+
return OrchestrationOutcome(
|
|
136
|
+
decision=decision,
|
|
137
|
+
direct_text=(
|
|
138
|
+
f"Rozpoznałem intent `{decision.intent.intent_id}`, ale brakuje pól: {missing}. "
|
|
139
|
+
"Doprecyzuj je w kolejnej wiadomości."
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
if str(definition.execution.get("kind", "chat")) == "chat":
|
|
143
|
+
return self._fallback(session, decision, "Intent wymaga odpowiedzi konwersacyjnej")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
plan = self.compiler.compile(
|
|
147
|
+
session_id=session.id,
|
|
148
|
+
intent=decision.intent,
|
|
149
|
+
definition=definition,
|
|
150
|
+
state_fingerprint=self.current_state_fingerprint(),
|
|
151
|
+
catalog_fingerprint=self.catalog.fingerprint,
|
|
152
|
+
)
|
|
153
|
+
except CompileError as exc:
|
|
154
|
+
return OrchestrationOutcome(
|
|
155
|
+
decision=decision,
|
|
156
|
+
direct_text=f"Nie udało się skompilować IntentIR: {exc}",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
connector_error = self._preflight_connectors(plan)
|
|
160
|
+
policy = self.policy.evaluate(plan)
|
|
161
|
+
if connector_error:
|
|
162
|
+
plan.status = "blocked"
|
|
163
|
+
self.store.save_execution_plan(plan.to_dict())
|
|
164
|
+
return OrchestrationOutcome(
|
|
165
|
+
decision=decision,
|
|
166
|
+
plan=plan,
|
|
167
|
+
direct_text=(
|
|
168
|
+
self.format_plan(plan)
|
|
169
|
+
+ "\n\nPlan jest zablokowany: "
|
|
170
|
+
+ connector_error
|
|
171
|
+
+ ". Skonfiguruj nazwany connector/operation; bridge nie uruchomi arbitralnego shella."
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
if not policy.allowed:
|
|
175
|
+
plan.status = "blocked"
|
|
176
|
+
self.store.save_execution_plan(plan.to_dict())
|
|
177
|
+
return OrchestrationOutcome(
|
|
178
|
+
decision=decision,
|
|
179
|
+
plan=plan,
|
|
180
|
+
direct_text=self.format_plan(plan) + f"\n\nPlan zablokowany przez politykę: {policy.reason}",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if policy.auto_execute:
|
|
184
|
+
plan.status = "running"
|
|
185
|
+
self.store.save_execution_plan(plan.to_dict())
|
|
186
|
+
receipt = await self.executor.execute(plan, approved=False)
|
|
187
|
+
self.store.save_execution_receipt(receipt.to_dict())
|
|
188
|
+
plan.status = "executed" if receipt.ok else "failed"
|
|
189
|
+
self.store.update_plan_status(plan.id, plan.status)
|
|
190
|
+
self.store.record_router_feedback(
|
|
191
|
+
session.id,
|
|
192
|
+
intent_id=plan.intent_id,
|
|
193
|
+
route=decision.route,
|
|
194
|
+
success=receipt.ok,
|
|
195
|
+
metadata={"plan_id": plan.id, "receipt_id": receipt.id},
|
|
196
|
+
)
|
|
197
|
+
return OrchestrationOutcome(
|
|
198
|
+
decision=decision,
|
|
199
|
+
plan=plan,
|
|
200
|
+
receipt=receipt,
|
|
201
|
+
direct_text=self.format_receipt(receipt),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
plan.status = "pending_approval" if policy.requires_approval else "planned"
|
|
205
|
+
self.store.save_execution_plan(plan.to_dict())
|
|
206
|
+
suffix = (
|
|
207
|
+
f"\n\nAby wykonać plan, użyj `/apply {plan.id}` i wpisz dokładnie `EXECUTE`."
|
|
208
|
+
if policy.requires_approval
|
|
209
|
+
else f"\n\nPlan zapisano. Możesz użyć `/apply {plan.id}`."
|
|
210
|
+
)
|
|
211
|
+
return OrchestrationOutcome(
|
|
212
|
+
decision=decision,
|
|
213
|
+
plan=plan,
|
|
214
|
+
direct_text=self.format_plan(plan) + suffix,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def apply_plan(self, plan_id: str, *, confirmation: str = "") -> ExecutionReceipt:
|
|
218
|
+
payload = self.store.get_execution_plan(plan_id)
|
|
219
|
+
if not payload:
|
|
220
|
+
raise OrchestrationError(f"Nie ma planu {plan_id}")
|
|
221
|
+
plan = ExecutionPlan.from_dict(payload)
|
|
222
|
+
if plan.status not in {"planned", "pending_approval", "failed"}:
|
|
223
|
+
raise OrchestrationError(f"Plan ma status {plan.status} i nie może zostać zastosowany")
|
|
224
|
+
if plan.plan_hash != compute_plan_hash(plan):
|
|
225
|
+
raise OrchestrationError("Plan hash nie zgadza się z zapisaną treścią planu")
|
|
226
|
+
if plan.effect != "read" and confirmation != "EXECUTE":
|
|
227
|
+
raise OrchestrationError("Operacja zmienia stan; wymagane jest dokładne potwierdzenie EXECUTE")
|
|
228
|
+
current = self.current_state_fingerprint()
|
|
229
|
+
if current != plan.state_fingerprint:
|
|
230
|
+
raise OrchestrationError(
|
|
231
|
+
"Lokalny stan/katalog/connector registry zmienił się od utworzenia planu; utwórz nowy plan"
|
|
232
|
+
)
|
|
233
|
+
connector_error = self._preflight_connectors(plan)
|
|
234
|
+
if connector_error:
|
|
235
|
+
raise OrchestrationError(connector_error)
|
|
236
|
+
policy = self.policy.evaluate(plan)
|
|
237
|
+
if not policy.allowed:
|
|
238
|
+
raise OrchestrationError(policy.reason)
|
|
239
|
+
self.store.update_plan_status(plan.id, "running")
|
|
240
|
+
receipt = await self.executor.execute(plan, approved=True)
|
|
241
|
+
self.store.save_execution_receipt(receipt.to_dict())
|
|
242
|
+
self.store.update_plan_status(plan.id, "executed" if receipt.ok else "failed")
|
|
243
|
+
self.store.record_router_feedback(
|
|
244
|
+
plan.session_id,
|
|
245
|
+
intent_id=plan.intent_id,
|
|
246
|
+
route="approved_plan",
|
|
247
|
+
success=receipt.ok,
|
|
248
|
+
metadata={"plan_id": plan.id, "receipt_id": receipt.id},
|
|
249
|
+
)
|
|
250
|
+
return receipt
|
|
251
|
+
|
|
252
|
+
def current_state_fingerprint(self) -> str:
|
|
253
|
+
encoded = ":".join(
|
|
254
|
+
[self.store.state_fingerprint(), self.catalog.fingerprint, self.registry.fingerprint]
|
|
255
|
+
)
|
|
256
|
+
return hashlib.sha256(encoded.encode("ascii")).hexdigest()
|
|
257
|
+
|
|
258
|
+
def _preflight_connectors(self, plan: ExecutionPlan) -> str:
|
|
259
|
+
try:
|
|
260
|
+
for step in plan.steps:
|
|
261
|
+
self.registry.validate_step(step)
|
|
262
|
+
except ConnectorError as exc:
|
|
263
|
+
return str(exc)
|
|
264
|
+
return ""
|
|
265
|
+
|
|
266
|
+
def _fallback(
|
|
267
|
+
self, session: Session, decision: RoutingDecision, reason: str
|
|
268
|
+
) -> OrchestrationOutcome:
|
|
269
|
+
provider = decision.provider or self.router.large_provider or session.provider
|
|
270
|
+
model = decision.model or self.router.large_model
|
|
271
|
+
if not model:
|
|
272
|
+
try:
|
|
273
|
+
model = self.config.provider(provider).model
|
|
274
|
+
except (KeyError, ValueError):
|
|
275
|
+
model = session.model
|
|
276
|
+
context = decision.route_context()
|
|
277
|
+
context["fallback_reason"] = reason
|
|
278
|
+
return OrchestrationOutcome(
|
|
279
|
+
decision=decision,
|
|
280
|
+
provider=provider,
|
|
281
|
+
model=model,
|
|
282
|
+
route_context=context,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def format_plan(plan: ExecutionPlan) -> str:
|
|
287
|
+
lines = [
|
|
288
|
+
f"Plan `{plan.id}`",
|
|
289
|
+
f"intent: `{plan.intent_id}`",
|
|
290
|
+
f"effect: `{plan.effect}` · status: `{plan.status}`",
|
|
291
|
+
f"hash: `{plan.plan_hash}`",
|
|
292
|
+
"kroki:",
|
|
293
|
+
]
|
|
294
|
+
for index, step in enumerate(plan.steps, start=1):
|
|
295
|
+
args = json.dumps(step.args, ensure_ascii=False, separators=(",", ":"))
|
|
296
|
+
if len(args) > 500:
|
|
297
|
+
args = args[:500] + "…"
|
|
298
|
+
lines.append(f"{index}. `{step.connector}:{step.operation}` ({step.effect}) args={args}")
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def format_receipt(receipt: ExecutionReceipt) -> str:
|
|
303
|
+
# Builtin help is more useful as plain text than as nested JSON.
|
|
304
|
+
if len(receipt.steps) == 1 and receipt.steps[0].get("ok"):
|
|
305
|
+
result = receipt.steps[0].get("result")
|
|
306
|
+
if isinstance(result, dict) and isinstance(result.get("message"), str):
|
|
307
|
+
return result["message"] + f"\n\nReceipt `{receipt.id}`"
|
|
308
|
+
lines = [
|
|
309
|
+
f"Receipt `{receipt.id}` · plan `{receipt.plan_id}`",
|
|
310
|
+
f"wynik: `{'ok' if receipt.ok else 'failed'}`",
|
|
311
|
+
receipt.summary,
|
|
312
|
+
]
|
|
313
|
+
for step in receipt.steps:
|
|
314
|
+
state = "ok" if step.get("ok") else "failed"
|
|
315
|
+
lines.append(f"- `{step.get('connector')}:{step.get('operation')}`: {state}")
|
|
316
|
+
result = step.get("result")
|
|
317
|
+
if isinstance(result, dict) and result:
|
|
318
|
+
encoded = json.dumps(result, ensure_ascii=False, default=str)
|
|
319
|
+
if len(encoded) > 1500:
|
|
320
|
+
encoded = encoded[:1500] + "…"
|
|
321
|
+
lines.append(f" {encoded}")
|
|
322
|
+
if step.get("error"):
|
|
323
|
+
lines.append(f" {str(step['error'])[:1000]}")
|
|
324
|
+
return "\n".join(lines)
|
subactor_shell/policy.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .compiler import ExecutionPlan
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class PolicyDecision:
|
|
11
|
+
allowed: bool
|
|
12
|
+
reason: str
|
|
13
|
+
requires_approval: bool = False
|
|
14
|
+
auto_execute: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PolicyEngine:
|
|
18
|
+
def __init__(self, *, allow_destructive: bool = False):
|
|
19
|
+
self.allow_destructive = allow_destructive
|
|
20
|
+
|
|
21
|
+
def evaluate(self, plan: ExecutionPlan) -> PolicyDecision:
|
|
22
|
+
encoded = json.dumps(plan.to_dict(), ensure_ascii=False).casefold()
|
|
23
|
+
if "{{secret:" in encoded or "vault://" in encoded or "env://" in encoded or "file://" in encoded:
|
|
24
|
+
return PolicyDecision(
|
|
25
|
+
False,
|
|
26
|
+
"Plan nie może przenosić wartości ani referencji sekretów; connector może użyć wyłącznie lokalnego env_ref z konfiguracji",
|
|
27
|
+
)
|
|
28
|
+
if plan.effect == "destructive" and not self.allow_destructive:
|
|
29
|
+
return PolicyDecision(False, "Operacje destructive są wyłączone przez politykę")
|
|
30
|
+
if plan.effect == "read":
|
|
31
|
+
if plan.mode == "execute":
|
|
32
|
+
return PolicyDecision(True, "Odczyt może zostać wykonany lokalnie", auto_execute=True)
|
|
33
|
+
return PolicyDecision(True, "Użytkownik zażądał tylko planu odczytu")
|
|
34
|
+
return PolicyDecision(
|
|
35
|
+
True,
|
|
36
|
+
"Operacja zmienia stan i wymaga jawnego grantu apply",
|
|
37
|
+
requires_approval=True,
|
|
38
|
+
)
|