fusion-harness 0.2.1__tar.gz

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,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: fusion-harness
3
+ Version: 0.2.1
4
+ Summary: Canonical agent harness kernel: funnels, hooks, permissions, compaction
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: test
8
+ Requires-Dist: pytest>=8; extra == "test"
9
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
10
+
11
+ # fusion-harness
12
+
13
+ Canonical agent harness kernel for the Fusion ecosystem: budget funnels, lifecycle
14
+ hooks, permission engine, and context compaction. Pure stdlib, zero dependencies.
15
+
16
+ ## Consumers
17
+
18
+ - **fusion-agent-studio** and **fusion-cowork** import this package directly
19
+ (hard dependency — `pip install -e fusion-harness`; no in-tree fallback copies).
20
+ - **fusion-code** (TypeScript) keeps its own implementation held to the same
21
+ [kernel contract](docs/contract.md) by parity tests — cross-language runtime
22
+ sharing is not the goal; contract conformance is.
23
+
24
+ The behavior contract for all runtimes lives in [docs/contract.md](docs/contract.md).
25
+
26
+ ## Modules
27
+
28
+ - `fusion_harness.funnels` — `LLMFunnel`, `ToolFunnel`, `LifecycleBus` (budget enforcement + lifecycle events)
29
+ - `fusion_harness.permissions` — `PermissionEngine` (deny-first permission checks)
30
+ - `fusion_harness.hooks` — `HookEngine` (lifecycle hook registry + dispatch)
31
+ - `fusion_harness.compactor` — `Compactor` (context window compaction with retention)
32
+
33
+ ## Security defaults (0.2.0, breaking)
34
+
35
+ Permission enforcement is **fail-closed by default**: a `ToolFunnel` without a
36
+ wired `PermissionEngine` denies every tool call. Headless/CI deployments opt out
37
+ explicitly:
38
+
39
+ ```bash
40
+ export FUSION_FUNNEL_PERMISSIONS=off # or FUSION_FUNNEL_REQUIRE_PERMISSIONS=0
41
+ ```
42
+
43
+ Every call skipped this way logs `permission enforcement DISABLED via ...` — no
44
+ deployment is silently unarmed. See [docs/contract.md](docs/contract.md) §5 for
45
+ the full environment variable table.
46
+
47
+ ## Install
48
+
49
+ From PyPI:
50
+
51
+ ```bash
52
+ pip install fusion-harness
53
+ ```
54
+
55
+ From source (development):
56
+
57
+ ```bash
58
+ pip install -e ".[test]"
59
+ ```
60
+
61
+ Releases are published by CI on tag push (`.github/workflows/release.yml`) via
62
+ PyPI trusted publishing.
63
+
64
+ ## Usage
65
+
66
+ ```python
67
+ from fusion_harness import Compactor, LLMFunnel, LifecycleBus, PermissionEngine, ToolFunnel
68
+ ```
69
+
70
+ ## Test
71
+
72
+ ```bash
73
+ pytest tests/ # unit coverage per module: funnels, permissions, hooks, compactor
74
+ ```
75
+
76
+ The suite is the executable form of the contract: dangerous-pattern denies,
77
+ layered rule precedence, fail-closed hook semantics, compaction levels, and the
78
+ enforcement default flip. Consumer repos' CI (agent-studio, cowork) is the
79
+ downstream detector for hidden passthrough assumptions.
80
+
81
+ ## License
82
+
83
+ Same license as the fusion monorepo.
@@ -0,0 +1,73 @@
1
+ # fusion-harness
2
+
3
+ Canonical agent harness kernel for the Fusion ecosystem: budget funnels, lifecycle
4
+ hooks, permission engine, and context compaction. Pure stdlib, zero dependencies.
5
+
6
+ ## Consumers
7
+
8
+ - **fusion-agent-studio** and **fusion-cowork** import this package directly
9
+ (hard dependency — `pip install -e fusion-harness`; no in-tree fallback copies).
10
+ - **fusion-code** (TypeScript) keeps its own implementation held to the same
11
+ [kernel contract](docs/contract.md) by parity tests — cross-language runtime
12
+ sharing is not the goal; contract conformance is.
13
+
14
+ The behavior contract for all runtimes lives in [docs/contract.md](docs/contract.md).
15
+
16
+ ## Modules
17
+
18
+ - `fusion_harness.funnels` — `LLMFunnel`, `ToolFunnel`, `LifecycleBus` (budget enforcement + lifecycle events)
19
+ - `fusion_harness.permissions` — `PermissionEngine` (deny-first permission checks)
20
+ - `fusion_harness.hooks` — `HookEngine` (lifecycle hook registry + dispatch)
21
+ - `fusion_harness.compactor` — `Compactor` (context window compaction with retention)
22
+
23
+ ## Security defaults (0.2.0, breaking)
24
+
25
+ Permission enforcement is **fail-closed by default**: a `ToolFunnel` without a
26
+ wired `PermissionEngine` denies every tool call. Headless/CI deployments opt out
27
+ explicitly:
28
+
29
+ ```bash
30
+ export FUSION_FUNNEL_PERMISSIONS=off # or FUSION_FUNNEL_REQUIRE_PERMISSIONS=0
31
+ ```
32
+
33
+ Every call skipped this way logs `permission enforcement DISABLED via ...` — no
34
+ deployment is silently unarmed. See [docs/contract.md](docs/contract.md) §5 for
35
+ the full environment variable table.
36
+
37
+ ## Install
38
+
39
+ From PyPI:
40
+
41
+ ```bash
42
+ pip install fusion-harness
43
+ ```
44
+
45
+ From source (development):
46
+
47
+ ```bash
48
+ pip install -e ".[test]"
49
+ ```
50
+
51
+ Releases are published by CI on tag push (`.github/workflows/release.yml`) via
52
+ PyPI trusted publishing.
53
+
54
+ ## Usage
55
+
56
+ ```python
57
+ from fusion_harness import Compactor, LLMFunnel, LifecycleBus, PermissionEngine, ToolFunnel
58
+ ```
59
+
60
+ ## Test
61
+
62
+ ```bash
63
+ pytest tests/ # unit coverage per module: funnels, permissions, hooks, compactor
64
+ ```
65
+
66
+ The suite is the executable form of the contract: dangerous-pattern denies,
67
+ layered rule precedence, fail-closed hook semantics, compaction levels, and the
68
+ enforcement default flip. Consumer repos' CI (agent-studio, cowork) is the
69
+ downstream detector for hidden passthrough assumptions.
70
+
71
+ ## License
72
+
73
+ Same license as the fusion monorepo.
@@ -0,0 +1,33 @@
1
+ """fusion-harness — canonical agent harness kernel (funnels/hooks/permissions/compactor).
2
+
3
+ Extracted from fusion-agent-studio N0 kernel (#357). Pure stdlib, no deps.
4
+ Consumers: fusion-agent-studio (shim import), fusion-cowork, fusion-code.
5
+ """
6
+
7
+ from fusion_harness.compactor import CompactionConfig, Compactor
8
+ from fusion_harness.funnels import LifecycleBus, LLMFunnel, ToolFunnel
9
+ from fusion_harness.hooks import HookConfig, HookEngine, HookResult
10
+ from fusion_harness.permissions import (
11
+ PermissionDecision,
12
+ PermissionEngine,
13
+ PermissionMode,
14
+ PermissionRule,
15
+ )
16
+
17
+ KERNEL_VERSION = "0.2.1"
18
+
19
+ __all__ = [
20
+ "KERNEL_VERSION",
21
+ "CompactionConfig",
22
+ "Compactor",
23
+ "HookConfig",
24
+ "HookEngine",
25
+ "HookResult",
26
+ "LLMFunnel",
27
+ "LifecycleBus",
28
+ "PermissionDecision",
29
+ "PermissionEngine",
30
+ "PermissionMode",
31
+ "PermissionRule",
32
+ "ToolFunnel",
33
+ ]
@@ -0,0 +1,444 @@
1
+ # Canonical kernel module lives here; agent_runtime imports via shim (#357).
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, field
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _RETENTION_HEADER = "[Retention List]"
13
+
14
+
15
+ @dataclass
16
+ class RetentionList:
17
+ todos: list[str] = field(default_factory=list)
18
+ interfaces: list[str] = field(default_factory=list)
19
+ constraints: list[str] = field(default_factory=list)
20
+ active_tasks: list[str] = field(default_factory=list)
21
+
22
+ @classmethod
23
+ def from_messages(cls, messages: list[dict], max_per_section: int = 20) -> RetentionList:
24
+ rl = cls()
25
+ for m in messages:
26
+ content = m.get("content") or ""
27
+ if not isinstance(content, str):
28
+ content = str(content)
29
+ for raw in content.splitlines():
30
+ line = raw.strip()
31
+ if not line or len(line) > 200:
32
+ continue
33
+ if "TODO" in line or "待办" in line:
34
+ rl._add(rl.todos, line, max_per_section)
35
+ if re.match(r"^\s*(async def |def |class )\w+", line) or "接口" in line:
36
+ rl._add(rl.interfaces, line, max_per_section)
37
+ if "constraint" in line.lower() or "约束" in line:
38
+ rl._add(rl.constraints, line, max_per_section)
39
+ if "pending" in line.lower() or "in_progress" in line or "进行中" in line:
40
+ rl._add(rl.active_tasks, line, max_per_section)
41
+ logger.info(
42
+ "retention extracted todos=%d interfaces=%d constraints=%d tasks=%d",
43
+ len(rl.todos),
44
+ len(rl.interfaces),
45
+ len(rl.constraints),
46
+ len(rl.active_tasks),
47
+ )
48
+ return rl
49
+
50
+ def _add(self, bucket: list[str], item: str, cap: int) -> None:
51
+ if len(bucket) < cap and item not in bucket:
52
+ bucket.append(item)
53
+
54
+ def non_empty(self) -> bool:
55
+ return bool(self.todos or self.interfaces or self.constraints or self.active_tasks)
56
+
57
+ def render(self) -> str:
58
+ lines = [f"{_RETENTION_HEADER} (kept verbatim across compaction)"]
59
+ if self.todos:
60
+ lines.append("TODOs: " + "; ".join(self.todos))
61
+ if self.interfaces:
62
+ lines.append("Interfaces: " + "; ".join(self.interfaces))
63
+ if self.constraints:
64
+ lines.append("Constraints: " + "; ".join(self.constraints))
65
+ if self.active_tasks:
66
+ lines.append("Active tasks: " + "; ".join(self.active_tasks))
67
+ return "\n".join(lines)
68
+
69
+
70
+ @dataclass
71
+ class CompactionConfig:
72
+ context_window: int = 32768
73
+ warning_buffer: int = 3000
74
+ error_buffer: int = 2000
75
+ manual_buffer: int = 500
76
+ keep_recent_rounds: int = 3
77
+ tool_result_head: int = 200
78
+ tool_result_tail: int = 100
79
+ tool_result_max_lines: int = 50
80
+ retention_max_per_section: int = 20
81
+
82
+ def warning_threshold(self) -> int:
83
+ return self.context_window - self.warning_buffer
84
+
85
+ def error_threshold(self) -> int:
86
+ return self.context_window - self.error_buffer
87
+
88
+ def manual_threshold(self) -> int:
89
+ return self.context_window - self.manual_buffer
90
+
91
+
92
+ class Compactor:
93
+ def __init__(
94
+ self,
95
+ config: CompactionConfig | None = None,
96
+ llm_gateway=None,
97
+ memory_engine=None,
98
+ lifecycle_bus=None,
99
+ hooks=None,
100
+ ):
101
+ self.config = config or CompactionConfig()
102
+ self.llm_gateway = llm_gateway
103
+ self.memory_engine = memory_engine
104
+ self.lifecycle_bus = lifecycle_bus
105
+ self.hooks = hooks
106
+
107
+ def _persist_summary(self, summary: str, original_count: int) -> None:
108
+ if self.memory_engine is None or not summary:
109
+ return
110
+ try:
111
+ self.memory_engine.store_summary(
112
+ summary, scope="compaction", original_count=original_count
113
+ )
114
+ logger.info("persisted compaction summary orig_count=%d", original_count)
115
+ except Exception as exc:
116
+ logger.warning("failed to persist compaction summary: %s", exc)
117
+
118
+ def estimate_tokens(self, messages: list[dict]) -> int:
119
+ total = 0
120
+ for m in messages:
121
+ content = m.get("content") or ""
122
+ if not isinstance(content, str):
123
+ content = str(content)
124
+ total += len(content)
125
+ for tc in m.get("tool_calls") or []:
126
+ fn = tc.get("function", {})
127
+ total += len(fn.get("name", "")) + len(fn.get("arguments", ""))
128
+ return max(1, total // 4)
129
+
130
+ def summarize_messages(self, messages: list[dict]) -> str:
131
+ # N12: public summarizer for fork summary-resume (deterministic
132
+ # extraction, same path as hard compaction).
133
+ return self._summarize(messages)
134
+
135
+ def should_compact(self, messages: list[dict]) -> str:
136
+ tokens = self.estimate_tokens(messages)
137
+ if tokens >= self.config.error_threshold():
138
+ return "error"
139
+ if tokens >= self.config.warning_threshold():
140
+ return "warning"
141
+ if tokens >= self.config.manual_threshold():
142
+ return "manual"
143
+ return "none"
144
+
145
+ async def compact_async(self, messages: list[dict], level: str = "warning") -> list[dict]:
146
+ # A5: summarization runs on the local MLX server when a gateway is
147
+ # wired. The sync pipeline is offloaded to a thread so the event loop
148
+ # stays free; any failure degrades to the deterministic extractor.
149
+ if self.llm_gateway is not None and level != "none" and self._mlx_enabled():
150
+ return await asyncio.to_thread(self._compact_blocking, messages, level)
151
+ return self.compact(messages, level)
152
+
153
+ @staticmethod
154
+ def _mlx_enabled() -> bool:
155
+ return os.environ.get("FUSION_COMPACT_MLX", "1").strip().lower() not in (
156
+ "0",
157
+ "false",
158
+ "no",
159
+ "off",
160
+ )
161
+
162
+ def _compact_blocking(self, messages: list[dict], level: str) -> list[dict]:
163
+ return self.compact(messages, level, summarize=self._summarize_via_mlx)
164
+
165
+ def _summarize_via_mlx(self, messages: list[dict], hard: bool = False) -> str:
166
+ try:
167
+ prompt = self._summary_prompt(messages, hard)
168
+ resp = asyncio.run(
169
+ self.llm_gateway.chat(
170
+ messages=[{"role": "user", "content": prompt}],
171
+ model="default",
172
+ max_tokens=400,
173
+ temperature=0.2,
174
+ )
175
+ )
176
+ content = getattr(resp, "content", "") or ""
177
+ if not content and isinstance(resp, dict):
178
+ content = resp.get("content", "") or ""
179
+ content = str(content).strip()
180
+ if content:
181
+ logger.info("compaction summary via local mlx chars=%d", len(content))
182
+ return "[Compacted prior context]\n" + content
183
+ logger.warning("mlx compaction summary empty, falling back to deterministic")
184
+ except Exception as exc:
185
+ logger.warning("mlx compaction summary failed, deterministic fallback: %s", exc)
186
+ return self._summarize(messages, hard)
187
+
188
+ @staticmethod
189
+ def _summary_prompt(messages: list[dict], hard: bool) -> str:
190
+ lines = []
191
+ for m in messages[:80]:
192
+ role = m.get("role", "")
193
+ content = m.get("content") or ""
194
+ if not isinstance(content, str):
195
+ content = str(content)
196
+ if content:
197
+ lines.append(f"{role}: {content[:400]}")
198
+ body = "\n".join(lines)[:12000]
199
+ return (
200
+ "Summarize this agent conversation for continuation. Keep concrete "
201
+ "decisions, file paths, interfaces, constraints and open TODOs. "
202
+ "Be terse, use bullet lines.\n\n" + body
203
+ )
204
+
205
+ def compact(
206
+ self,
207
+ messages: list[dict],
208
+ level: str = "warning",
209
+ summarize=None,
210
+ ) -> list[dict]:
211
+ logger.info("compaction phase=1 trigger level=%s", level)
212
+ payload = {"level": level, "before_msgs": len(messages)}
213
+ if level != "none":
214
+ self._fire_compact_before(payload)
215
+ self._emit_lifecycle("compact_before", payload)
216
+ rl = RetentionList()
217
+ if level != "none":
218
+ logger.info("compaction phase=2 summarize")
219
+ try:
220
+ rl = RetentionList.from_messages(
221
+ messages, self.config.retention_max_per_section
222
+ )
223
+ except Exception as exc:
224
+ logger.warning("retention extraction failed: %s", exc)
225
+ logger.info(
226
+ "compaction phase=3 retention extracted todos=%d interfaces=%d constraints=%d tasks=%d",
227
+ len(rl.todos),
228
+ len(rl.interfaces),
229
+ len(rl.constraints),
230
+ len(rl.active_tasks),
231
+ )
232
+ before_tokens = self.estimate_tokens(messages)
233
+ msgs = self._microcompact(list(messages))
234
+ if self.estimate_tokens(msgs) >= self.config.warning_threshold():
235
+ msgs = self._smart_truncate(msgs, summarize)
236
+ if (
237
+ level == "error"
238
+ and self.estimate_tokens(msgs) >= self.config.error_threshold()
239
+ ):
240
+ msgs = self._hard_compact(msgs, summarize)
241
+ after_tokens = self.estimate_tokens(msgs)
242
+ logger.info(
243
+ "compaction phase=4 drop before_msgs=%d after_msgs=%d before_tok=%d after_tok=%d",
244
+ len(messages),
245
+ len(msgs),
246
+ before_tokens,
247
+ after_tokens,
248
+ )
249
+ if level != "none" and rl.non_empty():
250
+ msgs = self._inject_retention(msgs, rl)
251
+ logger.info("compaction phase=5 retention injected")
252
+ logger.info(
253
+ "compact level=%s before_msgs=%d after_msgs=%d before_tok=%d after_tok=%d",
254
+ level,
255
+ len(messages),
256
+ len(msgs),
257
+ before_tokens,
258
+ after_tokens,
259
+ )
260
+ if level != "none":
261
+ self._emit_lifecycle("compact_after", {**payload, "after_msgs": len(msgs)})
262
+ return msgs
263
+
264
+ def _fire_compact_before(self, payload: dict) -> None:
265
+ if self.hooks is None:
266
+ return
267
+ try:
268
+ try:
269
+ asyncio.get_running_loop()
270
+ except RuntimeError:
271
+ logger.debug("COMPACT_BEFORE skipped: no running event loop")
272
+ return
273
+ asyncio.ensure_future(self.hooks.fire("COMPACT_BEFORE", payload))
274
+ except Exception as exc:
275
+ logger.warning("COMPACT_BEFORE hook dispatch failed: %s", exc)
276
+
277
+ def _emit_lifecycle(self, event: str, payload: dict) -> None:
278
+ if self.lifecycle_bus is None:
279
+ return
280
+ try:
281
+ self.lifecycle_bus.emit("agent", event, payload, session_id="")
282
+ except Exception as exc:
283
+ logger.warning("lifecycle %s emit failed: %s", event, exc)
284
+
285
+ def _inject_retention(self, messages: list[dict], rl: RetentionList) -> list[dict]:
286
+ out = [
287
+ m
288
+ for m in messages
289
+ if not (
290
+ m.get("role") == "system"
291
+ and str(m.get("content") or "").startswith(_RETENTION_HEADER)
292
+ )
293
+ ]
294
+ retention_msg = {"role": "system", "content": rl.render()}
295
+ idx = 0
296
+ for i, m in enumerate(out):
297
+ if m.get("role") == "system":
298
+ idx = i + 1
299
+ else:
300
+ break
301
+ logger.info("retention injected at index=%d", idx)
302
+ return out[:idx] + [retention_msg] + out[idx:]
303
+
304
+ def reactive_strip(self, messages: list[dict]) -> list[dict]:
305
+ system_msgs, rest = self._partition(messages)
306
+ if len(rest) <= 2:
307
+ stripped = [self._truncate_tool(m) for m in rest]
308
+ else:
309
+ keep_tail = rest[-2:]
310
+ dropped = rest[:-2]
311
+ summary = self._summarize(dropped)
312
+ self._persist_summary(summary, len(dropped))
313
+ stripped = [{"role": "system", "content": summary}] + [
314
+ self._truncate_tool(m) for m in keep_tail
315
+ ]
316
+ logger.info(
317
+ "reactive_strip before_msgs=%d after_msgs=%d before_tok=%d after_tok=%d",
318
+ len(messages),
319
+ len(system_msgs) + len(stripped),
320
+ self.estimate_tokens(messages),
321
+ self.estimate_tokens(system_msgs + stripped),
322
+ )
323
+ return system_msgs + stripped
324
+
325
+ def _microcompact(self, messages: list[dict]) -> list[dict]:
326
+ out = []
327
+ for m in messages:
328
+ if m.get("role") == "tool":
329
+ m = self._truncate_tool(m)
330
+ out.append(m)
331
+ return out
332
+
333
+ def _truncate_tool(self, m: dict) -> dict:
334
+ content = m.get("content") or ""
335
+ if not isinstance(content, str):
336
+ content = str(content)
337
+ limit = self.config.tool_result_head + self.config.tool_result_tail
338
+ if len(content) <= limit:
339
+ return m
340
+ head = content[: self.config.tool_result_head]
341
+ tail = content[-self.config.tool_result_tail :]
342
+ dropped = len(content) - len(head) - len(tail)
343
+ logger.debug("truncate_tool dropped=%d", dropped)
344
+ return {**m, "content": f"{head}\n...[truncated {dropped} chars]...\n{tail}"}
345
+
346
+ def _smart_truncate(self, messages: list[dict], summarize=None) -> list[dict]:
347
+ system_msgs, rest = self._partition(messages)
348
+ rounds = self._split_rounds(rest)
349
+ keep = self.config.keep_recent_rounds
350
+ if len(rounds) <= keep:
351
+ return messages
352
+ dropped = rounds[: len(rounds) - keep]
353
+ kept = rounds[len(rounds) - keep :]
354
+ dropped_msgs = [m for r in dropped for m in r]
355
+ summary = (summarize or self._summarize)(dropped_msgs)
356
+ self._persist_summary(summary, len(dropped_msgs))
357
+ logger.info(
358
+ "smart_truncate dropped_rounds=%d kept_rounds=%d", len(dropped), len(kept)
359
+ )
360
+ return (
361
+ system_msgs
362
+ + [{"role": "system", "content": summary}]
363
+ + [m for r in kept for m in r]
364
+ )
365
+
366
+ def _hard_compact(self, messages: list[dict], summarize=None) -> list[dict]:
367
+ system_msgs, rest = self._partition(messages)
368
+ rounds = self._split_rounds(rest)
369
+ if len(rounds) <= 1:
370
+ return messages
371
+ dropped = rounds[: len(rounds) - 1]
372
+ kept = rounds[-1:]
373
+ dropped_msgs = [m for r in dropped for m in r]
374
+ summary = (summarize or self._summarize)(dropped_msgs, hard=True)
375
+ self._persist_summary(summary, len(dropped_msgs))
376
+ logger.info(
377
+ "hard_compact dropped_rounds=%d kept_rounds=%d", len(dropped), len(kept)
378
+ )
379
+ return (
380
+ system_msgs
381
+ + [{"role": "system", "content": summary}]
382
+ + [m for r in kept for m in r]
383
+ )
384
+
385
+ def _summarize(self, messages: list[dict], hard: bool = False) -> str:
386
+ user_intents = []
387
+ tool_names = []
388
+ conclusions = []
389
+ for m in messages:
390
+ role = m.get("role")
391
+ content = m.get("content") or ""
392
+ if not isinstance(content, str):
393
+ content = str(content)
394
+ if role == "user" and content:
395
+ user_intents.append(content[:120])
396
+ elif role == "assistant":
397
+ if content:
398
+ conclusions.append(content[:120])
399
+ for tc in m.get("tool_calls") or []:
400
+ fn = tc.get("function", {})
401
+ name = fn.get("name", "")
402
+ if name and name not in tool_names:
403
+ tool_names.append(name)
404
+ parts = ["[Compacted prior context]"]
405
+ if user_intents:
406
+ parts.append("User asked: " + " | ".join(user_intents[:5]))
407
+ if tool_names:
408
+ parts.append("Tools used: " + ", ".join(tool_names[:10]))
409
+ if conclusions:
410
+ parts.append("Conclusions: " + " | ".join(conclusions[:5]))
411
+ if hard:
412
+ parts.append("(hard-compact: only most recent round retained)")
413
+ return "\n".join(parts)
414
+
415
+ def _partition(self, messages: list[dict]) -> tuple[list[dict], list[dict]]:
416
+ system_msgs = []
417
+ rest = []
418
+ for m in messages:
419
+ if m.get("role") == "system" and not rest:
420
+ system_msgs.append(m)
421
+ else:
422
+ rest.append(m)
423
+ return system_msgs, rest
424
+
425
+ def _split_rounds(self, messages: list[dict]) -> list[list[dict]]:
426
+ rounds: list[list[dict]] = []
427
+ current: list[dict] = []
428
+ for m in messages:
429
+ if m.get("role") == "user":
430
+ if current:
431
+ rounds.append(current)
432
+ current = [m]
433
+ else:
434
+ current.append(m)
435
+ if current:
436
+ rounds.append(current)
437
+ return rounds
438
+
439
+
440
+ __all__ = [
441
+ "CompactionConfig",
442
+ "Compactor",
443
+ "RetentionList",
444
+ ]