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.
- fusion_harness-0.2.1/PKG-INFO +83 -0
- fusion_harness-0.2.1/README.md +73 -0
- fusion_harness-0.2.1/fusion_harness/__init__.py +33 -0
- fusion_harness-0.2.1/fusion_harness/compactor.py +444 -0
- fusion_harness-0.2.1/fusion_harness/funnels.py +542 -0
- fusion_harness-0.2.1/fusion_harness/hooks.py +341 -0
- fusion_harness-0.2.1/fusion_harness/permissions.py +467 -0
- fusion_harness-0.2.1/fusion_harness.egg-info/PKG-INFO +83 -0
- fusion_harness-0.2.1/fusion_harness.egg-info/SOURCES.txt +17 -0
- fusion_harness-0.2.1/fusion_harness.egg-info/dependency_links.txt +1 -0
- fusion_harness-0.2.1/fusion_harness.egg-info/requires.txt +4 -0
- fusion_harness-0.2.1/fusion_harness.egg-info/top_level.txt +1 -0
- fusion_harness-0.2.1/pyproject.toml +27 -0
- fusion_harness-0.2.1/setup.cfg +4 -0
- fusion_harness-0.2.1/tests/test_compactor.py +131 -0
- fusion_harness-0.2.1/tests/test_funnels.py +160 -0
- fusion_harness-0.2.1/tests/test_hooks.py +123 -0
- fusion_harness-0.2.1/tests/test_kernel.py +50 -0
- fusion_harness-0.2.1/tests/test_permissions.py +128 -0
|
@@ -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
|
+
]
|