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,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