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,503 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urljoin, urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .compiler import ExecutionPlan, ExecutionStep
|
|
18
|
+
from .config import AppConfig
|
|
19
|
+
from .control import SubactorControlClient
|
|
20
|
+
from .models import utc_now
|
|
21
|
+
from .redaction import ExactRedactor
|
|
22
|
+
from .secret_refs import SecretResolver
|
|
23
|
+
from .store import Store
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnectorError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_EFFECT_ORDER = {"read": 0, "local_write": 1, "external_write": 2, "destructive": 3}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class ConnectorDefinition:
|
|
35
|
+
name: str
|
|
36
|
+
kind: str
|
|
37
|
+
allowed_operations: list[str]
|
|
38
|
+
effect: str = "read"
|
|
39
|
+
command: list[str] = field(default_factory=list)
|
|
40
|
+
env_refs: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
inherit_env: bool = False
|
|
42
|
+
pass_env: list[str] = field(default_factory=list)
|
|
43
|
+
timeout_seconds: float = 30.0
|
|
44
|
+
output_limit_bytes: int = 65_536
|
|
45
|
+
base_url: str = ""
|
|
46
|
+
path: str = ""
|
|
47
|
+
method: str = "POST"
|
|
48
|
+
bearer_ref: str = ""
|
|
49
|
+
|
|
50
|
+
def public_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"kind": self.kind,
|
|
54
|
+
"allowed_operations": sorted(self.allowed_operations),
|
|
55
|
+
"effect": self.effect,
|
|
56
|
+
"command": self.command,
|
|
57
|
+
"env_ref_names": sorted(self.env_refs),
|
|
58
|
+
"inherit_env": self.inherit_env,
|
|
59
|
+
"pass_env": sorted(self.pass_env),
|
|
60
|
+
"base_url": self.base_url,
|
|
61
|
+
"path": self.path,
|
|
62
|
+
"method": self.method,
|
|
63
|
+
"has_bearer_ref": bool(self.bearer_ref),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class ExecutionReceipt:
|
|
69
|
+
id: str
|
|
70
|
+
plan_id: str
|
|
71
|
+
session_id: str
|
|
72
|
+
ok: bool
|
|
73
|
+
steps: list[dict[str, Any]]
|
|
74
|
+
summary: str
|
|
75
|
+
state_after: str
|
|
76
|
+
created_at: str = field(default_factory=utc_now)
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"id": self.id,
|
|
81
|
+
"plan_id": self.plan_id,
|
|
82
|
+
"session_id": self.session_id,
|
|
83
|
+
"ok": self.ok,
|
|
84
|
+
"steps": self.steps,
|
|
85
|
+
"summary": self.summary,
|
|
86
|
+
"state_after": self.state_after,
|
|
87
|
+
"created_at": self.created_at,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ConnectorRegistry:
|
|
92
|
+
def __init__(self, config: AppConfig):
|
|
93
|
+
self._items: dict[str, ConnectorDefinition] = {
|
|
94
|
+
"builtin": ConnectorDefinition(
|
|
95
|
+
name="builtin",
|
|
96
|
+
kind="builtin",
|
|
97
|
+
allowed_operations=[
|
|
98
|
+
"bridge.help",
|
|
99
|
+
"session.list",
|
|
100
|
+
"data.list",
|
|
101
|
+
"secret.list",
|
|
102
|
+
"usage.summary",
|
|
103
|
+
],
|
|
104
|
+
effect="read",
|
|
105
|
+
),
|
|
106
|
+
"subactor_control": ConnectorDefinition(
|
|
107
|
+
name="subactor_control",
|
|
108
|
+
kind="control",
|
|
109
|
+
allowed_operations=[str(item) for item in config.control.get("allowed_tools", [])],
|
|
110
|
+
effect="external_write",
|
|
111
|
+
),
|
|
112
|
+
"subactor_cli": ConnectorDefinition(
|
|
113
|
+
name="subactor_cli",
|
|
114
|
+
kind="subactor_cli",
|
|
115
|
+
allowed_operations=["cli.status"],
|
|
116
|
+
effect="read",
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
for name, raw in config.connectors.items():
|
|
120
|
+
if not isinstance(raw, dict):
|
|
121
|
+
raise ValueError(f"connectors.{name} musi być tabelą TOML")
|
|
122
|
+
definition = self._from_config(str(name), raw)
|
|
123
|
+
self._items[definition.name] = definition
|
|
124
|
+
public = [self._items[name].public_dict() for name in sorted(self._items)]
|
|
125
|
+
encoded = json.dumps(public, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
126
|
+
self.fingerprint = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
127
|
+
|
|
128
|
+
def list(self) -> list[ConnectorDefinition]:
|
|
129
|
+
return [self._items[name] for name in sorted(self._items)]
|
|
130
|
+
|
|
131
|
+
def get(self, name: str) -> ConnectorDefinition | None:
|
|
132
|
+
return self._items.get(name)
|
|
133
|
+
|
|
134
|
+
def validate_step(self, step: ExecutionStep) -> ConnectorDefinition:
|
|
135
|
+
definition = self.get(step.connector)
|
|
136
|
+
if not definition:
|
|
137
|
+
raise ConnectorError(f"Connector '{step.connector}' nie jest skonfigurowany")
|
|
138
|
+
if step.operation not in definition.allowed_operations:
|
|
139
|
+
raise ConnectorError(
|
|
140
|
+
f"Operation '{step.operation}' nie jest dozwolona dla connectora '{step.connector}'"
|
|
141
|
+
)
|
|
142
|
+
if step.effect not in _EFFECT_ORDER or definition.effect not in _EFFECT_ORDER:
|
|
143
|
+
raise ConnectorError("Connector lub krok ma nieprawidłowy effect")
|
|
144
|
+
if _EFFECT_ORDER[step.effect] > _EFFECT_ORDER[definition.effect]:
|
|
145
|
+
raise ConnectorError(
|
|
146
|
+
f"Krok ma effect {step.effect}, większy niż limit connectora {definition.effect}"
|
|
147
|
+
)
|
|
148
|
+
return definition
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _from_config(name: str, raw: dict[str, Any]) -> ConnectorDefinition:
|
|
152
|
+
kind = str(raw.get("kind", "")).strip().lower()
|
|
153
|
+
allowed = raw.get("allowed_operations", [])
|
|
154
|
+
if not isinstance(allowed, list) or not allowed:
|
|
155
|
+
raise ValueError(f"connectors.{name}.allowed_operations musi być niepustą tablicą")
|
|
156
|
+
effect = str(raw.get("effect", "external_write"))
|
|
157
|
+
if effect not in _EFFECT_ORDER:
|
|
158
|
+
raise ValueError(f"connectors.{name}.effect jest nieprawidłowy")
|
|
159
|
+
env_refs = raw.get("env_refs", {})
|
|
160
|
+
if not isinstance(env_refs, dict):
|
|
161
|
+
raise ValueError(f"connectors.{name}.env_refs musi być tabelą")
|
|
162
|
+
definition = ConnectorDefinition(
|
|
163
|
+
name=name,
|
|
164
|
+
kind=kind,
|
|
165
|
+
allowed_operations=[str(item) for item in allowed],
|
|
166
|
+
effect=effect,
|
|
167
|
+
env_refs={str(k): str(v) for k, v in env_refs.items()},
|
|
168
|
+
inherit_env=bool(raw.get("inherit_env", False)),
|
|
169
|
+
timeout_seconds=float(raw.get("timeout_seconds", 30.0)),
|
|
170
|
+
output_limit_bytes=max(1024, int(raw.get("output_limit_bytes", 65_536))),
|
|
171
|
+
)
|
|
172
|
+
if kind == "process":
|
|
173
|
+
command = raw.get("command", [])
|
|
174
|
+
if not isinstance(command, list) or not command:
|
|
175
|
+
raise ValueError(f"connectors.{name}.command musi być niepustą tablicą argv")
|
|
176
|
+
definition.command = [str(item) for item in command]
|
|
177
|
+
executable = Path(definition.command[0]).expanduser()
|
|
178
|
+
if not executable.is_absolute():
|
|
179
|
+
raise ValueError(f"connectors.{name}.command[0] musi być ścieżką absolutną")
|
|
180
|
+
definition.command[0] = str(executable)
|
|
181
|
+
pass_env = raw.get(
|
|
182
|
+
"pass_env",
|
|
183
|
+
["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "SYSTEMROOT", "WINDIR"],
|
|
184
|
+
)
|
|
185
|
+
if not isinstance(pass_env, list) or not all(
|
|
186
|
+
isinstance(item, str) and item and "=" not in item and "\0" not in item
|
|
187
|
+
for item in pass_env
|
|
188
|
+
):
|
|
189
|
+
raise ValueError(f"connectors.{name}.pass_env musi być tablicą nazw zmiennych")
|
|
190
|
+
definition.pass_env = list(dict.fromkeys(pass_env))
|
|
191
|
+
elif kind == "http":
|
|
192
|
+
definition.base_url = str(raw.get("base_url", "")).rstrip("/")
|
|
193
|
+
definition.path = str(raw.get("path", "/"))
|
|
194
|
+
definition.method = str(raw.get("method", "POST")).upper()
|
|
195
|
+
definition.bearer_ref = str(raw.get("bearer_ref", ""))
|
|
196
|
+
parsed = urlsplit(definition.base_url)
|
|
197
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
198
|
+
raise ValueError(f"connectors.{name}.base_url musi być adresem HTTP(S)")
|
|
199
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
200
|
+
raise ValueError(f"connectors.{name}.base_url nie może zawierać credentiali ani query")
|
|
201
|
+
if definition.method not in {"POST", "PUT", "PATCH", "DELETE", "GET"}:
|
|
202
|
+
raise ValueError(f"connectors.{name}.method nie jest dozwolona")
|
|
203
|
+
else:
|
|
204
|
+
raise ValueError(f"connectors.{name}.kind musi być process albo http")
|
|
205
|
+
return definition
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ConnectorExecutor:
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
config: AppConfig,
|
|
212
|
+
store: Store,
|
|
213
|
+
resolver: SecretResolver,
|
|
214
|
+
registry: ConnectorRegistry,
|
|
215
|
+
):
|
|
216
|
+
self.config = config
|
|
217
|
+
self.store = store
|
|
218
|
+
self.resolver = resolver
|
|
219
|
+
self.registry = registry
|
|
220
|
+
|
|
221
|
+
async def execute(self, plan: ExecutionPlan, *, approved: bool) -> ExecutionReceipt:
|
|
222
|
+
step_receipts: list[dict[str, Any]] = []
|
|
223
|
+
all_ok = True
|
|
224
|
+
for step in plan.steps:
|
|
225
|
+
started = utc_now()
|
|
226
|
+
try:
|
|
227
|
+
definition = self.registry.validate_step(step)
|
|
228
|
+
result = await self._execute_step(plan, step, definition, approved=approved)
|
|
229
|
+
step_receipts.append(
|
|
230
|
+
{
|
|
231
|
+
"step_id": step.id,
|
|
232
|
+
"connector": step.connector,
|
|
233
|
+
"operation": step.operation,
|
|
234
|
+
"ok": True,
|
|
235
|
+
"started_at": started,
|
|
236
|
+
"finished_at": utc_now(),
|
|
237
|
+
"result": result,
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
all_ok = False
|
|
242
|
+
step_receipts.append(
|
|
243
|
+
{
|
|
244
|
+
"step_id": step.id,
|
|
245
|
+
"connector": step.connector,
|
|
246
|
+
"operation": step.operation,
|
|
247
|
+
"ok": False,
|
|
248
|
+
"started_at": started,
|
|
249
|
+
"finished_at": utc_now(),
|
|
250
|
+
"error": f"{type(exc).__name__}: {str(exc)[:1000]}",
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
break
|
|
254
|
+
summary = (
|
|
255
|
+
f"Wykonano {len(step_receipts)}/{len(plan.steps)} kroków."
|
|
256
|
+
if all_ok
|
|
257
|
+
else f"Wykonanie zatrzymane na kroku {len(step_receipts)}."
|
|
258
|
+
)
|
|
259
|
+
return ExecutionReceipt(
|
|
260
|
+
id="receipt_" + uuid.uuid4().hex,
|
|
261
|
+
plan_id=plan.id,
|
|
262
|
+
session_id=plan.session_id,
|
|
263
|
+
ok=all_ok,
|
|
264
|
+
steps=step_receipts,
|
|
265
|
+
summary=summary,
|
|
266
|
+
state_after=self.store.state_fingerprint(),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async def _execute_step(
|
|
270
|
+
self,
|
|
271
|
+
plan: ExecutionPlan,
|
|
272
|
+
step: ExecutionStep,
|
|
273
|
+
definition: ConnectorDefinition,
|
|
274
|
+
*,
|
|
275
|
+
approved: bool,
|
|
276
|
+
) -> Any:
|
|
277
|
+
if definition.kind == "builtin":
|
|
278
|
+
return self._builtin(step.operation, step.args, plan.session_id)
|
|
279
|
+
if definition.kind == "control":
|
|
280
|
+
client = SubactorControlClient(self.config.control, self.resolver)
|
|
281
|
+
return await asyncio.to_thread(
|
|
282
|
+
client.call_tool,
|
|
283
|
+
step.operation,
|
|
284
|
+
step.args,
|
|
285
|
+
allow_execute=approved and step.operation == "cli.execute",
|
|
286
|
+
)
|
|
287
|
+
if definition.kind == "subactor_cli":
|
|
288
|
+
return await self._subactor_cli(step.operation)
|
|
289
|
+
if definition.kind == "process":
|
|
290
|
+
return await self._process(plan, step, definition)
|
|
291
|
+
if definition.kind == "http":
|
|
292
|
+
return await self._http(plan, step, definition)
|
|
293
|
+
raise ConnectorError(f"Nieobsługiwany connector kind: {definition.kind}")
|
|
294
|
+
|
|
295
|
+
async def _subactor_cli(self, operation: str) -> dict[str, Any]:
|
|
296
|
+
if operation != "cli.status":
|
|
297
|
+
raise ConnectorError(f"Nieznana operacja Subactor CLI: {operation}")
|
|
298
|
+
configured = str(self.config.control.get("cli_path", "")).strip()
|
|
299
|
+
discovered = configured or shutil.which("subactor") or ""
|
|
300
|
+
executable = Path(discovered).expanduser()
|
|
301
|
+
if not executable.is_absolute() or not executable.is_file():
|
|
302
|
+
raise ConnectorError(
|
|
303
|
+
"Nie znaleziono Subactor CLI; ustaw control.cli_path na bezwzględną ścieżkę"
|
|
304
|
+
)
|
|
305
|
+
executable = executable.resolve()
|
|
306
|
+
allowed_env = {
|
|
307
|
+
"HOME",
|
|
308
|
+
"LANG",
|
|
309
|
+
"LC_ALL",
|
|
310
|
+
"LC_CTYPE",
|
|
311
|
+
"PATH",
|
|
312
|
+
"SUBACTOR_ADMIN_TOKEN",
|
|
313
|
+
"SUBACTOR_CONTROL_URL",
|
|
314
|
+
"SUBACTOR_PLANFILE_URL",
|
|
315
|
+
"TZ",
|
|
316
|
+
"XDG_CONFIG_HOME",
|
|
317
|
+
"XDG_DATA_HOME",
|
|
318
|
+
}
|
|
319
|
+
env = {name: value for name, value in os.environ.items() if name in allowed_env}
|
|
320
|
+
process = await asyncio.create_subprocess_exec(
|
|
321
|
+
str(executable),
|
|
322
|
+
"status",
|
|
323
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
324
|
+
stdout=asyncio.subprocess.PIPE,
|
|
325
|
+
stderr=asyncio.subprocess.PIPE,
|
|
326
|
+
env=env,
|
|
327
|
+
)
|
|
328
|
+
try:
|
|
329
|
+
stdout, stderr = await asyncio.wait_for(
|
|
330
|
+
process.communicate(),
|
|
331
|
+
timeout=float(self.config.control.get("timeout_seconds", 10.0)),
|
|
332
|
+
)
|
|
333
|
+
except asyncio.TimeoutError as exc:
|
|
334
|
+
process.kill()
|
|
335
|
+
await process.wait()
|
|
336
|
+
raise ConnectorError("Subactor CLI przekroczył timeout") from exc
|
|
337
|
+
output = stdout[:65_536].decode("utf-8", errors="replace").strip()
|
|
338
|
+
error = stderr[:4_096].decode("utf-8", errors="replace").strip()
|
|
339
|
+
if process.returncode != 0:
|
|
340
|
+
raise ConnectorError(
|
|
341
|
+
f"Subactor CLI zakończył się kodem {process.returncode}: {error[:500]}"
|
|
342
|
+
)
|
|
343
|
+
occurred_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
344
|
+
return {
|
|
345
|
+
"message": (
|
|
346
|
+
f"[{occurred_at}] source=subactor-cli operation=cli.status exit=0\n{output}"
|
|
347
|
+
),
|
|
348
|
+
"occurred_at": occurred_at,
|
|
349
|
+
"source": "subactor-cli",
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
def _builtin(self, operation: str, args: dict[str, Any], session_id: str) -> Any:
|
|
353
|
+
if operation == "bridge.help":
|
|
354
|
+
return {
|
|
355
|
+
"message": (
|
|
356
|
+
"Subactor Shell Bridge: trwałe rozmowy, WorkingState, IntentIR, "
|
|
357
|
+
"lokalne plany, named connectors, Vault refs, ACP i telemetria tokenów."
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
if operation == "session.list":
|
|
361
|
+
return {
|
|
362
|
+
"sessions": [
|
|
363
|
+
{
|
|
364
|
+
"id": item.id,
|
|
365
|
+
"name": item.name,
|
|
366
|
+
"provider": item.provider,
|
|
367
|
+
"model": item.model,
|
|
368
|
+
"updated_at": item.updated_at,
|
|
369
|
+
}
|
|
370
|
+
for item in self.store.list_sessions(limit=int(args.get("limit", 20)))
|
|
371
|
+
]
|
|
372
|
+
}
|
|
373
|
+
if operation == "data.list":
|
|
374
|
+
return {
|
|
375
|
+
"data": [
|
|
376
|
+
{
|
|
377
|
+
"name": name,
|
|
378
|
+
"kind": kind,
|
|
379
|
+
"value": value if kind == "artifact" else f"{len(value)} chars",
|
|
380
|
+
}
|
|
381
|
+
for name, kind, value in self.store.list_data()
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
if operation == "secret.list":
|
|
385
|
+
return {
|
|
386
|
+
"bindings": [
|
|
387
|
+
{"alias": alias, "reference": reference}
|
|
388
|
+
for alias, reference in self.store.list_secret_bindings()
|
|
389
|
+
],
|
|
390
|
+
"values_read": False,
|
|
391
|
+
}
|
|
392
|
+
if operation == "usage.summary":
|
|
393
|
+
return self.store.usage_summary(session_id)
|
|
394
|
+
raise ConnectorError(f"Nieznana operacja builtin: {operation}")
|
|
395
|
+
|
|
396
|
+
async def _process(
|
|
397
|
+
self,
|
|
398
|
+
plan: ExecutionPlan,
|
|
399
|
+
step: ExecutionStep,
|
|
400
|
+
definition: ConnectorDefinition,
|
|
401
|
+
) -> Any:
|
|
402
|
+
env = (
|
|
403
|
+
os.environ.copy()
|
|
404
|
+
if definition.inherit_env
|
|
405
|
+
else {name: os.environ[name] for name in definition.pass_env if name in os.environ}
|
|
406
|
+
)
|
|
407
|
+
sensitive: list[str] = []
|
|
408
|
+
for name, reference in definition.env_refs.items():
|
|
409
|
+
value = self.resolver.resolve(reference)
|
|
410
|
+
env[name] = value
|
|
411
|
+
sensitive.append(value)
|
|
412
|
+
payload = json.dumps(
|
|
413
|
+
{
|
|
414
|
+
"plan_id": plan.id,
|
|
415
|
+
"plan_hash": plan.plan_hash,
|
|
416
|
+
"session_id": plan.session_id,
|
|
417
|
+
"intent_id": plan.intent_id,
|
|
418
|
+
"operation": step.operation,
|
|
419
|
+
"args": step.args,
|
|
420
|
+
},
|
|
421
|
+
ensure_ascii=False,
|
|
422
|
+
separators=(",", ":"),
|
|
423
|
+
).encode("utf-8")
|
|
424
|
+
process = None
|
|
425
|
+
try:
|
|
426
|
+
process = await asyncio.create_subprocess_exec(
|
|
427
|
+
*definition.command,
|
|
428
|
+
stdin=asyncio.subprocess.PIPE,
|
|
429
|
+
stdout=asyncio.subprocess.PIPE,
|
|
430
|
+
stderr=asyncio.subprocess.PIPE,
|
|
431
|
+
env=env,
|
|
432
|
+
)
|
|
433
|
+
stdout, stderr = await asyncio.wait_for(
|
|
434
|
+
process.communicate(payload), timeout=definition.timeout_seconds
|
|
435
|
+
)
|
|
436
|
+
except asyncio.TimeoutError as exc:
|
|
437
|
+
if process is not None:
|
|
438
|
+
process.kill()
|
|
439
|
+
await process.wait()
|
|
440
|
+
raise ConnectorError("Connector process przekroczył timeout") from exc
|
|
441
|
+
limit = definition.output_limit_bytes
|
|
442
|
+
redactor = ExactRedactor(sensitive)
|
|
443
|
+
out_text = redactor.redact(stdout[:limit].decode("utf-8", errors="replace"))
|
|
444
|
+
err_text = redactor.redact(stderr[:limit].decode("utf-8", errors="replace"))
|
|
445
|
+
if process.returncode != 0:
|
|
446
|
+
raise ConnectorError(
|
|
447
|
+
f"Connector process zakończył się kodem {process.returncode}: {err_text[:500]}"
|
|
448
|
+
)
|
|
449
|
+
result: dict[str, Any] = {
|
|
450
|
+
"exit_code": int(process.returncode or 0),
|
|
451
|
+
"truncated": len(stdout) > limit or len(stderr) > limit,
|
|
452
|
+
}
|
|
453
|
+
try:
|
|
454
|
+
parsed = json.loads(out_text)
|
|
455
|
+
if isinstance(parsed, (dict, list)):
|
|
456
|
+
result["json"] = parsed
|
|
457
|
+
else:
|
|
458
|
+
result["stdout"] = out_text[:4000]
|
|
459
|
+
except json.JSONDecodeError:
|
|
460
|
+
result["stdout"] = out_text[:4000]
|
|
461
|
+
if err_text:
|
|
462
|
+
result["stderr"] = err_text[:2000]
|
|
463
|
+
return result
|
|
464
|
+
|
|
465
|
+
async def _http(
|
|
466
|
+
self,
|
|
467
|
+
plan: ExecutionPlan,
|
|
468
|
+
step: ExecutionStep,
|
|
469
|
+
definition: ConnectorDefinition,
|
|
470
|
+
) -> Any:
|
|
471
|
+
headers = {"Content-Type": "application/json"}
|
|
472
|
+
sensitive: list[str] = []
|
|
473
|
+
if definition.bearer_ref:
|
|
474
|
+
token = self.resolver.resolve(definition.bearer_ref)
|
|
475
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
476
|
+
sensitive.append(token)
|
|
477
|
+
body = {
|
|
478
|
+
"plan_id": plan.id,
|
|
479
|
+
"plan_hash": plan.plan_hash,
|
|
480
|
+
"session_id": plan.session_id,
|
|
481
|
+
"intent_id": plan.intent_id,
|
|
482
|
+
"operation": step.operation,
|
|
483
|
+
"args": step.args,
|
|
484
|
+
}
|
|
485
|
+
url = urljoin(definition.base_url.rstrip("/") + "/", definition.path.lstrip("/"))
|
|
486
|
+
try:
|
|
487
|
+
async with httpx.AsyncClient(timeout=definition.timeout_seconds) as client:
|
|
488
|
+
response = await client.request(definition.method, url, headers=headers, json=body)
|
|
489
|
+
except httpx.HTTPError as exc:
|
|
490
|
+
raise ConnectorError(f"Błąd HTTP connectora ({type(exc).__name__})") from exc
|
|
491
|
+
raw = response.content[: definition.output_limit_bytes]
|
|
492
|
+
text = ExactRedactor(sensitive).redact(raw.decode("utf-8", errors="replace"))
|
|
493
|
+
if response.status_code >= 400:
|
|
494
|
+
raise ConnectorError(f"HTTP connector zwrócił {response.status_code}: {text[:500]}")
|
|
495
|
+
try:
|
|
496
|
+
parsed: Any = json.loads(text)
|
|
497
|
+
except json.JSONDecodeError:
|
|
498
|
+
parsed = text[:4000]
|
|
499
|
+
return {
|
|
500
|
+
"status": response.status_code,
|
|
501
|
+
"body": parsed,
|
|
502
|
+
"truncated": len(response.content) > definition.output_limit_bytes,
|
|
503
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .store import Store
|
|
9
|
+
from .token_budget import estimate_messages_tokens
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_REF_RE = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s<>\"']+", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class WorkingState:
|
|
17
|
+
goal: str = ""
|
|
18
|
+
active_intent: str = ""
|
|
19
|
+
active_refs: list[str] = field(default_factory=list)
|
|
20
|
+
constraints: list[str] = field(default_factory=list)
|
|
21
|
+
open_questions: list[str] = field(default_factory=list)
|
|
22
|
+
last_receipt_ref: str = ""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, payload: dict[str, Any] | None) -> "WorkingState":
|
|
26
|
+
payload = payload or {}
|
|
27
|
+
return cls(
|
|
28
|
+
goal=str(payload.get("goal", "")),
|
|
29
|
+
active_intent=str(payload.get("active_intent", "")),
|
|
30
|
+
active_refs=[str(item) for item in payload.get("active_refs", []) if isinstance(item, str)],
|
|
31
|
+
constraints=[str(item) for item in payload.get("constraints", []) if isinstance(item, str)],
|
|
32
|
+
open_questions=[str(item) for item in payload.get("open_questions", []) if isinstance(item, str)],
|
|
33
|
+
last_receipt_ref=str(payload.get("last_receipt_ref", "")),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"goal": self.goal,
|
|
39
|
+
"active_intent": self.active_intent,
|
|
40
|
+
"active_refs": self.active_refs,
|
|
41
|
+
"constraints": self.constraints,
|
|
42
|
+
"open_questions": self.open_questions,
|
|
43
|
+
"last_receipt_ref": self.last_receipt_ref,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class ContextBuildResult:
|
|
49
|
+
messages: list[dict[str, Any]]
|
|
50
|
+
included_history_messages: int
|
|
51
|
+
history_chars: int
|
|
52
|
+
estimated_input_tokens: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ContextBuilder:
|
|
56
|
+
def __init__(self, store: Store, settings: dict[str, Any]):
|
|
57
|
+
self.store = store
|
|
58
|
+
self.recent_messages = max(0, int(settings.get("recent_messages", 6)))
|
|
59
|
+
self.max_history_chars = max(0, int(settings.get("max_history_chars", 12_000)))
|
|
60
|
+
self.max_message_chars = max(256, int(settings.get("max_message_chars", 4_000)))
|
|
61
|
+
self.max_route_context_chars = max(512, int(settings.get("max_route_context_chars", 4_000)))
|
|
62
|
+
|
|
63
|
+
def build(
|
|
64
|
+
self,
|
|
65
|
+
session_id: str,
|
|
66
|
+
current_user_content: str,
|
|
67
|
+
*,
|
|
68
|
+
route_context: dict[str, Any] | None = None,
|
|
69
|
+
) -> ContextBuildResult:
|
|
70
|
+
messages: list[dict[str, Any]] = []
|
|
71
|
+
state = WorkingState.from_dict(self.store.get_session_state(session_id))
|
|
72
|
+
system_parts: list[str] = []
|
|
73
|
+
if any(state.to_dict().values()):
|
|
74
|
+
system_parts.append(
|
|
75
|
+
"Subactor WorkingState (compact conversation state; never bypass local policy):\n"
|
|
76
|
+
+ json.dumps(state.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
|
77
|
+
)
|
|
78
|
+
if route_context:
|
|
79
|
+
encoded = json.dumps(route_context, ensure_ascii=False, separators=(",", ":"))
|
|
80
|
+
if len(encoded) > self.max_route_context_chars:
|
|
81
|
+
encoded = encoded[: self.max_route_context_chars] + "…"
|
|
82
|
+
system_parts.append(
|
|
83
|
+
"Routing context. Treat as a hint. Do not invent connector calls or secret values:\n"
|
|
84
|
+
+ encoded
|
|
85
|
+
)
|
|
86
|
+
if system_parts:
|
|
87
|
+
messages.append({"role": "system", "content": "\n\n".join(system_parts)})
|
|
88
|
+
|
|
89
|
+
recent = self.store.list_messages_recent(session_id, limit=self.recent_messages)
|
|
90
|
+
selected_reversed: list[dict[str, str]] = []
|
|
91
|
+
used = 0
|
|
92
|
+
for message in reversed(recent):
|
|
93
|
+
content = message.context_content
|
|
94
|
+
if len(content) > self.max_message_chars:
|
|
95
|
+
half = max(64, self.max_message_chars // 2 - 24)
|
|
96
|
+
content = content[:half] + "\n[…history compacted…]\n" + content[-half:]
|
|
97
|
+
remaining = self.max_history_chars - used
|
|
98
|
+
if remaining <= 0:
|
|
99
|
+
break
|
|
100
|
+
if len(content) > remaining:
|
|
101
|
+
content = content[-remaining:]
|
|
102
|
+
selected_reversed.append({"role": message.role, "content": content})
|
|
103
|
+
used += len(content)
|
|
104
|
+
selected = list(reversed(selected_reversed))
|
|
105
|
+
messages.extend(selected)
|
|
106
|
+
messages.append({"role": "user", "content": current_user_content})
|
|
107
|
+
return ContextBuildResult(
|
|
108
|
+
messages=messages,
|
|
109
|
+
included_history_messages=len(selected),
|
|
110
|
+
history_chars=used,
|
|
111
|
+
estimated_input_tokens=estimate_messages_tokens(messages),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def compact_blocks(blocks: list[str], *, total_limit: int) -> list[str]:
|
|
116
|
+
result: list[str] = []
|
|
117
|
+
used = 0
|
|
118
|
+
for block in blocks:
|
|
119
|
+
remaining = total_limit - used
|
|
120
|
+
if remaining <= 0:
|
|
121
|
+
break
|
|
122
|
+
if len(block) > remaining:
|
|
123
|
+
block = block[: max(0, remaining - 29)] + "\n[EMBEDDED_CONTEXT_TRUNCATED]"
|
|
124
|
+
result.append(block)
|
|
125
|
+
used += len(block)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
def update_state(
|
|
129
|
+
self,
|
|
130
|
+
session_id: str,
|
|
131
|
+
*,
|
|
132
|
+
user_text: str,
|
|
133
|
+
intent_id: str = "",
|
|
134
|
+
constraints: list[str] | None = None,
|
|
135
|
+
unresolved: list[str] | None = None,
|
|
136
|
+
receipt_id: str = "",
|
|
137
|
+
) -> WorkingState:
|
|
138
|
+
state = WorkingState.from_dict(self.store.get_session_state(session_id))
|
|
139
|
+
compact_goal = " ".join(user_text.split())[:600]
|
|
140
|
+
if compact_goal:
|
|
141
|
+
state.goal = compact_goal
|
|
142
|
+
if intent_id:
|
|
143
|
+
state.active_intent = intent_id
|
|
144
|
+
refs = list(dict.fromkeys([*state.active_refs, *_REF_RE.findall(user_text)]))
|
|
145
|
+
state.active_refs = refs[-16:]
|
|
146
|
+
if constraints is not None:
|
|
147
|
+
state.constraints = list(dict.fromkeys([*state.constraints, *constraints]))[-16:]
|
|
148
|
+
if unresolved is not None:
|
|
149
|
+
state.open_questions = list(dict.fromkeys(unresolved))[:12]
|
|
150
|
+
if receipt_id:
|
|
151
|
+
state.last_receipt_ref = f"receipt://{receipt_id}"
|
|
152
|
+
self.store.set_session_state(session_id, state.to_dict())
|
|
153
|
+
return state
|