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
|
+
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
|
subactor_shell/config.py
ADDED
|
@@ -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)
|