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
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from .catalog import IntentDefinition
10
+ from .intent_ir import IntentIR
11
+ from .models import utc_now
12
+
13
+
14
+ class CompileError(ValueError):
15
+ pass
16
+
17
+
18
+ _EFFECT_ORDER = {"read": 0, "local_write": 1, "external_write": 2, "destructive": 3}
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class ExecutionStep:
23
+ id: str
24
+ kind: str
25
+ connector: str
26
+ operation: str
27
+ args: dict[str, Any]
28
+ effect: str
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return {
32
+ "id": self.id,
33
+ "kind": self.kind,
34
+ "connector": self.connector,
35
+ "operation": self.operation,
36
+ "args": self.args,
37
+ "effect": self.effect,
38
+ }
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class ExecutionPlan:
43
+ id: str
44
+ session_id: str
45
+ intent_id: str
46
+ mode: str
47
+ steps: list[ExecutionStep]
48
+ effect: str
49
+ approval: str
50
+ risk: str
51
+ constraints: list[str]
52
+ state_fingerprint: str
53
+ catalog_fingerprint: str
54
+ status: str = "planned"
55
+ plan_hash: str = ""
56
+ created_at: str = field(default_factory=utc_now)
57
+
58
+ def to_dict(self) -> dict[str, Any]:
59
+ return {
60
+ "id": self.id,
61
+ "session_id": self.session_id,
62
+ "intent_id": self.intent_id,
63
+ "mode": self.mode,
64
+ "steps": [step.to_dict() for step in self.steps],
65
+ "effect": self.effect,
66
+ "approval": self.approval,
67
+ "risk": self.risk,
68
+ "constraints": self.constraints,
69
+ "state_fingerprint": self.state_fingerprint,
70
+ "catalog_fingerprint": self.catalog_fingerprint,
71
+ "status": self.status,
72
+ "plan_hash": self.plan_hash,
73
+ "created_at": self.created_at,
74
+ }
75
+
76
+ @classmethod
77
+ def from_dict(cls, payload: dict[str, Any]) -> "ExecutionPlan":
78
+ steps = [
79
+ ExecutionStep(
80
+ id=str(item.get("id", "")),
81
+ kind=str(item.get("kind", "connector")),
82
+ connector=str(item.get("connector", "")),
83
+ operation=str(item.get("operation", "")),
84
+ args=dict(item.get("args", {})),
85
+ effect=str(item.get("effect", payload.get("effect", "read"))),
86
+ )
87
+ for item in payload.get("steps", [])
88
+ if isinstance(item, dict)
89
+ ]
90
+ return cls(
91
+ id=str(payload["id"]),
92
+ session_id=str(payload["session_id"]),
93
+ intent_id=str(payload.get("intent_id", "")),
94
+ mode=str(payload.get("mode", "execute")),
95
+ steps=steps,
96
+ effect=str(payload.get("effect", "read")),
97
+ approval=str(payload.get("approval", "none")),
98
+ risk=str(payload.get("risk", "low")),
99
+ constraints=[str(item) for item in payload.get("constraints", [])],
100
+ state_fingerprint=str(payload.get("state_fingerprint", "")),
101
+ catalog_fingerprint=str(payload.get("catalog_fingerprint", "")),
102
+ status=str(payload.get("status", "planned")),
103
+ plan_hash=str(payload.get("plan_hash", "")),
104
+ created_at=str(payload.get("created_at", utc_now())),
105
+ )
106
+
107
+
108
+ def compute_plan_hash(plan: ExecutionPlan | dict[str, Any]) -> str:
109
+ payload = plan.to_dict() if isinstance(plan, ExecutionPlan) else dict(plan)
110
+ payload.pop("plan_hash", None)
111
+ payload.pop("status", None)
112
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
113
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
114
+
115
+
116
+ class PlanCompiler:
117
+ def compile(
118
+ self,
119
+ *,
120
+ session_id: str,
121
+ intent: IntentIR,
122
+ definition: IntentDefinition,
123
+ state_fingerprint: str,
124
+ catalog_fingerprint: str,
125
+ ) -> ExecutionPlan:
126
+ execution = definition.execution
127
+ kind = str(execution.get("kind", "chat"))
128
+ if kind == "chat":
129
+ raise CompileError("Intent konwersacyjny nie ma deterministycznego planu")
130
+ raw_steps = execution.get("steps")
131
+ step_specs = raw_steps if isinstance(raw_steps, list) and raw_steps else [execution]
132
+ steps: list[ExecutionStep] = []
133
+ for index, spec in enumerate(step_specs, start=1):
134
+ if not isinstance(spec, dict):
135
+ raise CompileError("execution.steps musi zawierać obiekty")
136
+ step_kind = str(spec.get("kind", kind))
137
+ if step_kind == "builtin":
138
+ connector = "builtin"
139
+ else:
140
+ connector = str(spec.get("connector", execution.get("connector", "")))
141
+ operation = str(spec.get("operation", execution.get("operation", "")))
142
+ if not connector or not operation:
143
+ raise CompileError("Definicja wykonania wymaga nazwanego connectora i operation")
144
+ effect = str(spec.get("effect", execution.get("effect", "read")))
145
+ if effect not in _EFFECT_ORDER:
146
+ raise CompileError(f"Nieobsługiwany effect: {effect}")
147
+ args = self._map_args(spec.get("argument_map", execution.get("argument_map")), intent.args)
148
+ steps.append(
149
+ ExecutionStep(
150
+ id=f"step_{index}",
151
+ kind="connector",
152
+ connector=connector,
153
+ operation=operation,
154
+ args=args,
155
+ effect=effect,
156
+ )
157
+ )
158
+ top_effect = max((step.effect for step in steps), key=lambda item: _EFFECT_ORDER[item])
159
+ plan = ExecutionPlan(
160
+ id="plan_" + uuid.uuid4().hex,
161
+ session_id=session_id,
162
+ intent_id=intent.intent_id,
163
+ mode=intent.mode,
164
+ steps=steps,
165
+ effect=top_effect,
166
+ approval="none" if top_effect == "read" else "required",
167
+ risk=definition.risk,
168
+ constraints=list(dict.fromkeys([*definition.constraints, *intent.constraints])),
169
+ state_fingerprint=state_fingerprint,
170
+ catalog_fingerprint=catalog_fingerprint,
171
+ )
172
+ plan.plan_hash = compute_plan_hash(plan)
173
+ return plan
174
+
175
+ @staticmethod
176
+ def _map_args(mapping: Any, args: dict[str, Any]) -> dict[str, Any]:
177
+ if mapping is None:
178
+ return dict(args)
179
+ if not isinstance(mapping, dict):
180
+ raise CompileError("argument_map musi być obiektem")
181
+ result: dict[str, Any] = {}
182
+ for target, source in mapping.items():
183
+ if isinstance(source, str) and source.startswith("$args."):
184
+ key = source[6:]
185
+ if key in args:
186
+ result[str(target)] = args[key]
187
+ elif source == "$all_args":
188
+ result[str(target)] = dict(args)
189
+ else:
190
+ result[str(target)] = source
191
+ return result
@@ -0,0 +1,374 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import os
5
+ import stat
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .models import ProviderProfile
12
+
13
+
14
+ DEFAULT_CONFIG_TEXT = """# Konfiguracja Subactor Shell 0.2.
15
+
16
+ [defaults]
17
+ provider = "mock"
18
+ model = "mock"
19
+ max_attachment_bytes = 5242880
20
+ max_attachment_text_chars = 262144
21
+
22
+ # Pełna historia pozostaje w SQLite. Model otrzymuje WorkingState, kilka
23
+ # ostatnich wiadomości oraz lokalnie wybrane fragmenty danych/artefaktów.
24
+ [context]
25
+ recent_messages = 6
26
+ max_history_chars = 12000
27
+ max_message_chars = 4000
28
+ max_data_chars = 6000
29
+ max_attachment_prompt_chars = 8000
30
+ artifact_chunk_chars = 1800
31
+ max_artifact_chunks = 4
32
+ max_embedded_context_chars = 8000
33
+ max_route_context_chars = 4000
34
+
35
+ # Kaskada: phrase/template -> lokalny parser 4B -> tani parser zdalny ->
36
+ # provider rozmowy / duży model. Model generuje wyłącznie IntentIR v1.
37
+ [orchestration]
38
+ enabled = true
39
+ mode = "active" # active | shadow | off
40
+ local_parser_provider = ""
41
+ local_parser_model = ""
42
+ cheap_parser_provider = ""
43
+ cheap_parser_model = ""
44
+ large_provider = ""
45
+ large_model = ""
46
+ top_k = 5
47
+ min_candidate_score = 0.32
48
+ deterministic_threshold = 0.93
49
+ local_execute_threshold = 0.82
50
+ cheap_remote_threshold = 0.68
51
+ max_parser_output_tokens = 192
52
+ allow_destructive = false
53
+ show_route = false
54
+ intent_catalog_paths = []
55
+
56
+ [providers.mock]
57
+ kind = "mock"
58
+ model = "mock"
59
+
60
+ # Uziemiona rozmowa Founder przez kanoniczny Subactor Control.
61
+ [providers.control]
62
+ kind = "subactor_control"
63
+ base_url = "http://127.0.0.1:8091"
64
+ endpoint = "/api/llm/intent"
65
+ api_key_ref = "env://SUBACTOR_ADMIN_TOKEN"
66
+ model = "control"
67
+ auth_required = true
68
+ timeout_seconds = 90.0
69
+
70
+ # Przykład lokalnego endpointu OpenAI-compatible (vLLM/SGLang/llama.cpp).
71
+ # Po uruchomieniu ustaw orchestration.local_parser_provider = "local_4b".
72
+ [providers.local_4b]
73
+ kind = "openai_compat"
74
+ base_url = "http://127.0.0.1:8000/v1"
75
+ endpoint = "/chat/completions"
76
+ api_key_ref = ""
77
+ auth_required = false
78
+ model = "local-4b-instruct"
79
+ max_tokens = 512
80
+ max_output_tokens = 192
81
+ structured_mode = "json_schema"
82
+ timeout_seconds = 60.0
83
+ input_cost_per_million = 0.0
84
+ cached_input_cost_per_million = 0.0
85
+ output_cost_per_million = 0.0
86
+
87
+ [vault]
88
+ address = "http://127.0.0.1:8200"
89
+ token_ref = "env://VAULT_TOKEN"
90
+ namespace = ""
91
+ verify_tls = true
92
+ timeout_seconds = 10.0
93
+
94
+ [control]
95
+ base_url = "http://127.0.0.1:8197"
96
+ cli_path = ""
97
+ account_id = "softreck"
98
+ provider = "chatgpt"
99
+ tool_id = "codex"
100
+ bearer_ref = "file://~/.config/subactor-shell/control.token"
101
+ allowed_tools = ["cli.status", "cli.plan", "cli.execute"]
102
+ timeout_seconds = 10.0
103
+
104
+ # Nazwane connectory są allowlistą. Process connector nie używa shell=True;
105
+ # plan JSON jest przekazywany przez stdin. Przykład:
106
+ # [connectors.my_script]
107
+ # kind = "process"
108
+ # command = ["/opt/subactor/bin/my-connector", "--json-stdin"]
109
+ # allowed_operations = ["project.inspect", "project.apply"]
110
+ # inherit_env = false
111
+ # pass_env = ["PATH", "LANG", "LC_ALL", "TZ"]
112
+ # timeout_seconds = 30.0
113
+ # output_limit_bytes = 65536
114
+ # effect = "external_write"
115
+ # [connectors.my_script.env_refs]
116
+ # API_TOKEN = "vault://secret/subactor/connector#token"
117
+ """
118
+
119
+ DEFAULTS: dict[str, Any] = {
120
+ "defaults": {
121
+ "provider": "mock",
122
+ "model": "mock",
123
+ "max_attachment_bytes": 5 * 1024 * 1024,
124
+ "max_attachment_text_chars": 256 * 1024,
125
+ },
126
+ "context": {
127
+ "recent_messages": 6,
128
+ "max_history_chars": 12_000,
129
+ "max_message_chars": 4_000,
130
+ "max_data_chars": 6_000,
131
+ "max_attachment_prompt_chars": 8_000,
132
+ "artifact_chunk_chars": 1_800,
133
+ "max_artifact_chunks": 4,
134
+ "max_embedded_context_chars": 8_000,
135
+ "max_route_context_chars": 4_000,
136
+ },
137
+ "orchestration": {
138
+ "enabled": True,
139
+ "mode": "active",
140
+ "local_parser_provider": "",
141
+ "local_parser_model": "",
142
+ "cheap_parser_provider": "",
143
+ "cheap_parser_model": "",
144
+ "large_provider": "",
145
+ "large_model": "",
146
+ "top_k": 5,
147
+ "min_candidate_score": 0.32,
148
+ "deterministic_threshold": 0.93,
149
+ "local_execute_threshold": 0.82,
150
+ "cheap_remote_threshold": 0.68,
151
+ "max_parser_output_tokens": 192,
152
+ "allow_destructive": False,
153
+ "show_route": False,
154
+ "intent_catalog_paths": [],
155
+ },
156
+ "providers": {
157
+ "mock": {"kind": "mock", "model": "mock"},
158
+ "control": {
159
+ "kind": "subactor_control",
160
+ "base_url": "http://127.0.0.1:8091",
161
+ "endpoint": "/api/llm/intent",
162
+ "api_key_ref": "env://SUBACTOR_ADMIN_TOKEN",
163
+ "model": "control",
164
+ "auth_required": True,
165
+ "timeout_seconds": 90.0,
166
+ },
167
+ "local_4b": {
168
+ "kind": "openai_compat",
169
+ "base_url": "http://127.0.0.1:8000/v1",
170
+ "endpoint": "/chat/completions",
171
+ "api_key_ref": "",
172
+ "auth_required": False,
173
+ "model": "local-4b-instruct",
174
+ "max_tokens": 512,
175
+ "max_output_tokens": 192,
176
+ "structured_mode": "json_schema",
177
+ "timeout_seconds": 60.0,
178
+ "input_cost_per_million": 0.0,
179
+ "cached_input_cost_per_million": 0.0,
180
+ "output_cost_per_million": 0.0,
181
+ },
182
+ },
183
+ "vault": {
184
+ "address": "http://127.0.0.1:8200",
185
+ "token_ref": "env://VAULT_TOKEN",
186
+ "namespace": "",
187
+ "verify_tls": True,
188
+ "timeout_seconds": 10.0,
189
+ },
190
+ "control": {
191
+ "base_url": "http://127.0.0.1:8197",
192
+ "cli_path": "",
193
+ "account_id": "softreck",
194
+ "provider": "chatgpt",
195
+ "tool_id": "codex",
196
+ "bearer_ref": "file://~/.config/subactor-shell/control.token",
197
+ "allowed_tools": ["cli.status", "cli.plan", "cli.execute"],
198
+ "timeout_seconds": 10.0,
199
+ },
200
+ "connectors": {},
201
+ }
202
+
203
+
204
+ def _xdg_path(env_name: str, fallback: str) -> Path:
205
+ value = os.environ.get(env_name)
206
+ return Path(value).expanduser() if value else Path(fallback).expanduser()
207
+
208
+
209
+ def default_config_dir() -> Path:
210
+ return _xdg_path("XDG_CONFIG_HOME", "~/.config") / "subactor-shell"
211
+
212
+
213
+ def default_data_dir() -> Path:
214
+ return _xdg_path("XDG_DATA_HOME", "~/.local/share") / "subactor-shell"
215
+
216
+
217
+ def default_config_path() -> Path:
218
+ overridden = os.environ.get("SUBACTOR_SHELL_CONFIG")
219
+ return Path(overridden).expanduser() if overridden else default_config_dir() / "config.toml"
220
+
221
+
222
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
223
+ result = copy.deepcopy(base)
224
+ for key, value in override.items():
225
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
226
+ result[key] = _deep_merge(result[key], value)
227
+ else:
228
+ result[key] = value
229
+ return result
230
+
231
+
232
+ def ensure_private_dir(path: Path) -> Path:
233
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
234
+ try:
235
+ path.chmod(0o700)
236
+ except OSError:
237
+ pass
238
+ return path
239
+
240
+
241
+ def ensure_private_file(path: Path) -> None:
242
+ try:
243
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
244
+ except OSError:
245
+ pass
246
+
247
+
248
+ @dataclass(slots=True)
249
+ class AppConfig:
250
+ raw: dict[str, Any]
251
+ config_path: Path
252
+ data_dir: Path
253
+
254
+ @property
255
+ def default_provider(self) -> str:
256
+ return str(self.raw["defaults"].get("provider", "mock"))
257
+
258
+ @property
259
+ def default_model(self) -> str:
260
+ return str(self.raw["defaults"].get("model", "mock"))
261
+
262
+ @property
263
+ def max_attachment_bytes(self) -> int:
264
+ return int(self.raw["defaults"].get("max_attachment_bytes", 5 * 1024 * 1024))
265
+
266
+ @property
267
+ def max_attachment_text_chars(self) -> int:
268
+ return int(self.raw["defaults"].get("max_attachment_text_chars", 256 * 1024))
269
+
270
+ @property
271
+ def context(self) -> dict[str, Any]:
272
+ return dict(self.raw.get("context", {}))
273
+
274
+ @property
275
+ def orchestration(self) -> dict[str, Any]:
276
+ return dict(self.raw.get("orchestration", {}))
277
+
278
+ @property
279
+ def vault(self) -> dict[str, Any]:
280
+ return dict(self.raw.get("vault", {}))
281
+
282
+ @property
283
+ def control(self) -> dict[str, Any]:
284
+ return dict(self.raw.get("control", {}))
285
+
286
+ @property
287
+ def connectors(self) -> dict[str, Any]:
288
+ value = self.raw.get("connectors", {})
289
+ if not isinstance(value, dict):
290
+ raise ValueError("connectors musi być tabelą TOML")
291
+ return copy.deepcopy(value)
292
+
293
+ def intent_catalog_paths(self) -> list[Path]:
294
+ values = self.orchestration.get("intent_catalog_paths", [])
295
+ if not isinstance(values, list):
296
+ raise ValueError("orchestration.intent_catalog_paths musi być tablicą")
297
+ result: list[Path] = []
298
+ for value in values:
299
+ path = Path(str(value)).expanduser()
300
+ if not path.is_absolute():
301
+ path = self.config_path.parent / path
302
+ result.append(path)
303
+ return result
304
+
305
+ def provider_names(self) -> list[str]:
306
+ return sorted(str(name) for name in self.raw.get("providers", {}))
307
+
308
+ def provider(self, name: str) -> ProviderProfile:
309
+ providers = self.raw.get("providers", {})
310
+ if name not in providers:
311
+ available = ", ".join(sorted(providers)) or "brak"
312
+ raise KeyError(f"Nieznany provider '{name}'. Dostępne: {available}")
313
+ item = dict(providers[name])
314
+ model = str(item.get("model") or self.default_model)
315
+ headers = item.get("extra_headers", {})
316
+ if not isinstance(headers, dict):
317
+ raise ValueError(f"providers.{name}.extra_headers musi być tabelą TOML")
318
+ api_key_ref = str(item.get("api_key_ref", ""))
319
+ return ProviderProfile(
320
+ name=name,
321
+ kind=str(item.get("kind", "openai_compat")),
322
+ model=model,
323
+ base_url=str(os.environ.get("SUBACTOR_CONTROL_URL") or item.get("base_url", ""))
324
+ if name == "control"
325
+ else str(item.get("base_url", "")),
326
+ endpoint=str(item.get("endpoint", "")),
327
+ api_key_ref=api_key_ref,
328
+ auth_required=bool(item.get("auth_required", bool(api_key_ref))),
329
+ max_tokens=int(item.get("max_tokens", 4096)),
330
+ max_output_tokens=int(item.get("max_output_tokens", item.get("max_tokens", 256))),
331
+ structured_mode=str(item.get("structured_mode", "auto")),
332
+ reasoning_effort=str(item.get("reasoning_effort", "")),
333
+ anthropic_version=str(item.get("anthropic_version", "2023-06-01")),
334
+ timeout_seconds=float(item.get("timeout_seconds", 120.0)),
335
+ extra_headers={str(k): str(v) for k, v in headers.items()},
336
+ input_cost_per_million=float(item.get("input_cost_per_million", 0.0)),
337
+ cached_input_cost_per_million=float(
338
+ item.get("cached_input_cost_per_million", item.get("input_cost_per_million", 0.0))
339
+ ),
340
+ output_cost_per_million=float(item.get("output_cost_per_million", 0.0)),
341
+ )
342
+
343
+
344
+ def initialize_layout(
345
+ config_path: Path | None = None, data_dir: Path | None = None
346
+ ) -> tuple[Path, Path]:
347
+ config_path = (config_path or default_config_path()).expanduser()
348
+ data_dir = (data_dir or default_data_dir()).expanduser()
349
+ ensure_private_dir(config_path.parent)
350
+ ensure_private_dir(data_dir)
351
+ ensure_private_dir(data_dir / "artifacts")
352
+ if not config_path.exists():
353
+ config_path.write_text(DEFAULT_CONFIG_TEXT, encoding="utf-8")
354
+ ensure_private_file(config_path)
355
+ return config_path, data_dir
356
+
357
+
358
+ def load_config(
359
+ config_path: Path | None = None,
360
+ data_dir: Path | None = None,
361
+ *,
362
+ create: bool = True,
363
+ ) -> AppConfig:
364
+ config_path = (config_path or default_config_path()).expanduser()
365
+ data_dir = (data_dir or default_data_dir()).expanduser()
366
+ if create:
367
+ initialize_layout(config_path, data_dir)
368
+ if config_path.exists():
369
+ with config_path.open("rb") as handle:
370
+ loaded = tomllib.load(handle)
371
+ else:
372
+ loaded = {}
373
+ raw = _deep_merge(DEFAULTS, loaded)
374
+ return AppConfig(raw=raw, config_path=config_path, data_dir=data_dir)