symphony-harness 0.0.1__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.
- core_harness/__init__.py +88 -0
- core_harness/addons/__init__.py +45 -0
- core_harness/addons/addon.py +67 -0
- core_harness/addons/compaction/__init__.py +76 -0
- core_harness/addons/compaction/policy.py +335 -0
- core_harness/addons/persistence/__init__.py +104 -0
- core_harness/addons/subagent/__init__.py +304 -0
- core_harness/addons/subagent/background.py +66 -0
- core_harness/config.py +123 -0
- core_harness/context/__init__.py +39 -0
- core_harness/context/compact.py +210 -0
- core_harness/context/state.py +316 -0
- core_harness/errors.py +15 -0
- core_harness/events.py +200 -0
- core_harness/extensions/__init__.py +39 -0
- core_harness/extensions/api.py +36 -0
- core_harness/extensions/dispatch.py +51 -0
- core_harness/extensions/events.py +20 -0
- core_harness/extensions/loader.py +107 -0
- core_harness/extensions/manifest.py +72 -0
- core_harness/extensions/recorder.py +47 -0
- core_harness/extensions/registry.py +66 -0
- core_harness/harness.py +443 -0
- core_harness/loop/__init__.py +9 -0
- core_harness/loop/session.py +412 -0
- core_harness/loop/stream.py +261 -0
- core_harness/loop/tools.py +286 -0
- core_harness/models.py +172 -0
- core_harness/ops.py +40 -0
- core_harness/py.typed +0 -0
- core_harness/timing.py +127 -0
- core_harness/tools.py +93 -0
- core_harness/turn_runner.py +280 -0
- symphony_harness-0.0.1.dist-info/METADATA +57 -0
- symphony_harness-0.0.1.dist-info/RECORD +37 -0
- symphony_harness-0.0.1.dist-info/WHEEL +4 -0
- symphony_harness-0.0.1.dist-info/licenses/LICENSE +21 -0
core_harness/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from core_harness.addons import (
|
|
2
|
+
Addon,
|
|
3
|
+
ChildConfig,
|
|
4
|
+
ChildIdentity,
|
|
5
|
+
CompactionAddon,
|
|
6
|
+
Compactor,
|
|
7
|
+
KeepDropPlan,
|
|
8
|
+
KeepSystemRecentCompactor,
|
|
9
|
+
PersistenceAddon,
|
|
10
|
+
SubagentAddon,
|
|
11
|
+
compaction_from_config,
|
|
12
|
+
plan_keep_drop,
|
|
13
|
+
)
|
|
14
|
+
from core_harness.addons.persistence import (
|
|
15
|
+
Checkpoint,
|
|
16
|
+
NullPersistence,
|
|
17
|
+
Persistence,
|
|
18
|
+
)
|
|
19
|
+
from core_harness.context import (
|
|
20
|
+
ContextReport,
|
|
21
|
+
bound_tool_result,
|
|
22
|
+
build_context_report,
|
|
23
|
+
)
|
|
24
|
+
from core_harness.config import (
|
|
25
|
+
HarnessConfig,
|
|
26
|
+
load_harness_config,
|
|
27
|
+
resolve_harness_config,
|
|
28
|
+
)
|
|
29
|
+
from core_harness.events import EventLog, EventSink, HumanInteraction, InteractionPort
|
|
30
|
+
from core_harness.ops import Answer, Interrupt, Op, UserInput
|
|
31
|
+
from core_harness.errors import HarnessCancelled, HarnessLimitExceeded
|
|
32
|
+
from core_harness.harness import CoreHarness
|
|
33
|
+
from core_harness.models import (
|
|
34
|
+
ControlPlaneEvent,
|
|
35
|
+
ControlPlaneEventType,
|
|
36
|
+
EVENT_SCHEMA_VERSION,
|
|
37
|
+
HarnessResult,
|
|
38
|
+
PendingToolCall,
|
|
39
|
+
RunLimits,
|
|
40
|
+
ToolCall,
|
|
41
|
+
ToolResult,
|
|
42
|
+
UsageTotals,
|
|
43
|
+
)
|
|
44
|
+
from core_harness.tools import Tool
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Answer",
|
|
48
|
+
"Addon",
|
|
49
|
+
"Checkpoint",
|
|
50
|
+
"ChildConfig",
|
|
51
|
+
"ChildIdentity",
|
|
52
|
+
"CompactionAddon",
|
|
53
|
+
"Compactor",
|
|
54
|
+
"ContextReport",
|
|
55
|
+
"ControlPlaneEvent",
|
|
56
|
+
"ControlPlaneEventType",
|
|
57
|
+
"CoreHarness",
|
|
58
|
+
"EVENT_SCHEMA_VERSION",
|
|
59
|
+
"EventLog",
|
|
60
|
+
"EventSink",
|
|
61
|
+
"HumanInteraction",
|
|
62
|
+
"InteractionPort",
|
|
63
|
+
"Interrupt",
|
|
64
|
+
"Op",
|
|
65
|
+
"UserInput",
|
|
66
|
+
"HarnessCancelled",
|
|
67
|
+
"HarnessLimitExceeded",
|
|
68
|
+
"HarnessResult",
|
|
69
|
+
"HarnessConfig",
|
|
70
|
+
"KeepDropPlan",
|
|
71
|
+
"KeepSystemRecentCompactor",
|
|
72
|
+
"NullPersistence",
|
|
73
|
+
"PendingToolCall",
|
|
74
|
+
"Persistence",
|
|
75
|
+
"PersistenceAddon",
|
|
76
|
+
"RunLimits",
|
|
77
|
+
"SubagentAddon",
|
|
78
|
+
"Tool",
|
|
79
|
+
"ToolCall",
|
|
80
|
+
"ToolResult",
|
|
81
|
+
"UsageTotals",
|
|
82
|
+
"bound_tool_result",
|
|
83
|
+
"build_context_report",
|
|
84
|
+
"compaction_from_config",
|
|
85
|
+
"load_harness_config",
|
|
86
|
+
"resolve_harness_config",
|
|
87
|
+
"plan_keep_drop",
|
|
88
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Opt-in harness add-ons.
|
|
2
|
+
|
|
3
|
+
``CoreHarness`` is an extension surface. Pass ``addons=[...]`` at construction
|
|
4
|
+
or call ``register_addon``. There is no directory discovery and no skill
|
|
5
|
+
loader; skills can hang off this same attach path later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from core_harness.addons.addon import Addon
|
|
11
|
+
from core_harness.addons.compaction import (
|
|
12
|
+
CompactionAddon,
|
|
13
|
+
Compactor,
|
|
14
|
+
KeepDropPlan,
|
|
15
|
+
KeepSystemRecentCompactor,
|
|
16
|
+
compaction_from_config,
|
|
17
|
+
plan_keep_drop,
|
|
18
|
+
)
|
|
19
|
+
from core_harness.addons.persistence import (
|
|
20
|
+
Checkpoint,
|
|
21
|
+
CheckpointStatus,
|
|
22
|
+
NullPersistence,
|
|
23
|
+
Persistence,
|
|
24
|
+
PersistenceAddon,
|
|
25
|
+
)
|
|
26
|
+
from core_harness.addons.subagent import ChildConfig, ChildIdentity, SubagentAddon
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Addon",
|
|
31
|
+
"Checkpoint",
|
|
32
|
+
"CheckpointStatus",
|
|
33
|
+
"ChildConfig",
|
|
34
|
+
"ChildIdentity",
|
|
35
|
+
"CompactionAddon",
|
|
36
|
+
"Compactor",
|
|
37
|
+
"KeepDropPlan",
|
|
38
|
+
"KeepSystemRecentCompactor",
|
|
39
|
+
"NullPersistence",
|
|
40
|
+
"Persistence",
|
|
41
|
+
"PersistenceAddon",
|
|
42
|
+
"SubagentAddon",
|
|
43
|
+
"compaction_from_config",
|
|
44
|
+
"plan_keep_drop",
|
|
45
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Attachable harness extensions.
|
|
2
|
+
|
|
3
|
+
Subclass :class:`Addon` and override hooks to opt in.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Addon:
|
|
12
|
+
"""Harness extension. Hooks are no-ops until overridden.
|
|
13
|
+
|
|
14
|
+
Public seams (override to opt in): ``before_run``, ``before_turn``,
|
|
15
|
+
``before_tool``, ``after_turn``, ``after_run``, ``on_tool``,
|
|
16
|
+
``on_compact``. ``before_tool`` may return a deny reason; the others
|
|
17
|
+
observe. Inherit on spawn by implementing :meth:`fork_for_child`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str = ""
|
|
21
|
+
|
|
22
|
+
def attach(self, harness: Any) -> None:
|
|
23
|
+
"""Mount this add-on onto a harness. Called once from ``register_addon``."""
|
|
24
|
+
del harness
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def fork_for_child(self, parent_harness: Any) -> Optional[Addon]:
|
|
28
|
+
"""Child inherit policy: return an instance to inherit, or ``None`` to skip.
|
|
29
|
+
|
|
30
|
+
The default skips, so children do not receive this instance.
|
|
31
|
+
``ChildConfig.addons`` / ``addon_factory`` replace this path when set.
|
|
32
|
+
"""
|
|
33
|
+
del parent_harness
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
async def before_run(self, **payload: Any) -> None:
|
|
37
|
+
del payload
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
async def before_turn(self, **payload: Any) -> None:
|
|
41
|
+
del payload
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
async def before_tool(self, **payload: Any) -> Optional[str]:
|
|
45
|
+
"""Return a deny reason to skip the tool, or ``None`` to allow it."""
|
|
46
|
+
del payload
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def after_turn(self, **payload: Any) -> None:
|
|
50
|
+
del payload
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
async def after_run(self, **payload: Any) -> None:
|
|
54
|
+
"""Called after a successful run, before ``CoreHarness.run`` returns."""
|
|
55
|
+
del payload
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
async def on_tool(self, **payload: Any) -> None:
|
|
59
|
+
del payload
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
async def on_compact(self, **payload: Any) -> None:
|
|
63
|
+
del payload
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["Addon"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Compaction add-on: mount a ``Compactor`` onto the harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from core_harness.addons.addon import Addon
|
|
8
|
+
from core_harness.addons.compaction.policy import (
|
|
9
|
+
COMPACTION_CONTINUATION,
|
|
10
|
+
Compactor,
|
|
11
|
+
KeepDropPlan,
|
|
12
|
+
KeepSystemRecentCompactor,
|
|
13
|
+
compaction_header,
|
|
14
|
+
compaction_target,
|
|
15
|
+
dropped_turn_facts,
|
|
16
|
+
plan_keep_drop,
|
|
17
|
+
summarize_dropped_turns,
|
|
18
|
+
)
|
|
19
|
+
from core_harness.config import HarnessConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CompactionAddon(Addon):
|
|
23
|
+
"""Mount a ``Compactor`` onto ``CoreHarness.state``."""
|
|
24
|
+
|
|
25
|
+
name = "compaction"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
compactor: Compactor | None = None,
|
|
30
|
+
*,
|
|
31
|
+
keep_recent_tools: int = 32,
|
|
32
|
+
target_tokens: Optional[int] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.compactor = compactor or KeepSystemRecentCompactor(
|
|
35
|
+
keep_recent_tools=keep_recent_tools,
|
|
36
|
+
target_tokens=target_tokens,
|
|
37
|
+
)
|
|
38
|
+
if isinstance(self.compactor, KeepSystemRecentCompactor):
|
|
39
|
+
keep_recent_tools = self.compactor.keep_recent_tools
|
|
40
|
+
target_tokens = self.compactor.target_tokens
|
|
41
|
+
self.keep_recent_tools = keep_recent_tools
|
|
42
|
+
self.target_tokens = target_tokens
|
|
43
|
+
|
|
44
|
+
def attach(self, harness: Any) -> None:
|
|
45
|
+
harness.state.compactor = self.compactor
|
|
46
|
+
|
|
47
|
+
def fork_for_child(self, parent_harness: Any) -> CompactionAddon:
|
|
48
|
+
"""New add-on with a fresh compactor using the same settings."""
|
|
49
|
+
del parent_harness
|
|
50
|
+
return CompactionAddon(
|
|
51
|
+
keep_recent_tools=self.keep_recent_tools,
|
|
52
|
+
target_tokens=self.target_tokens,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compaction_from_config(config: HarnessConfig) -> CompactionAddon:
|
|
57
|
+
"""Build the keep-system-recent add-on from harness settings."""
|
|
58
|
+
return CompactionAddon(
|
|
59
|
+
keep_recent_tools=config.compaction_keep_recent_tools,
|
|
60
|
+
target_tokens=config.context_target_tokens,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"COMPACTION_CONTINUATION",
|
|
66
|
+
"CompactionAddon",
|
|
67
|
+
"Compactor",
|
|
68
|
+
"KeepDropPlan",
|
|
69
|
+
"KeepSystemRecentCompactor",
|
|
70
|
+
"compaction_from_config",
|
|
71
|
+
"compaction_header",
|
|
72
|
+
"compaction_target",
|
|
73
|
+
"dropped_turn_facts",
|
|
74
|
+
"plan_keep_drop",
|
|
75
|
+
"summarize_dropped_turns",
|
|
76
|
+
]
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Keep-system-recent compaction policy.
|
|
2
|
+
|
|
3
|
+
``plan_keep_drop`` is the shared keep/drop rule: system prompt, pinned first
|
|
4
|
+
user task, atomic assistant/tool groups, recent window, token target.
|
|
5
|
+
``KeepSystemRecentCompactor`` applies it and writes a template summary with no
|
|
6
|
+
provider call. Products that summarise with a model call the same planner and
|
|
7
|
+
supply their own summary message.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Dict, List, Optional, Protocol, Sequence
|
|
15
|
+
|
|
16
|
+
from core_ai.content import text_from_content
|
|
17
|
+
from core_ai.types import Content, Message
|
|
18
|
+
|
|
19
|
+
from core_harness.context.compact import (
|
|
20
|
+
COMPACTED_CONTEXT_MARK,
|
|
21
|
+
estimate_prompt_tokens,
|
|
22
|
+
normalize_tool_protocol,
|
|
23
|
+
_tool_names,
|
|
24
|
+
_tool_refs,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _atomic_blocks(messages: List[Message]) -> List[List[Message]]:
|
|
29
|
+
"""Group an assistant tool declaration with all of its tool results."""
|
|
30
|
+
blocks: List[List[Message]] = []
|
|
31
|
+
index = 0
|
|
32
|
+
while index < len(messages):
|
|
33
|
+
message = messages[index]
|
|
34
|
+
if message.role == "assistant" and message.tool_calls:
|
|
35
|
+
block = [message]
|
|
36
|
+
index += 1
|
|
37
|
+
while index < len(messages) and messages[index].role == "tool":
|
|
38
|
+
block.append(messages[index])
|
|
39
|
+
index += 1
|
|
40
|
+
blocks.append(block)
|
|
41
|
+
else:
|
|
42
|
+
blocks.append([message])
|
|
43
|
+
index += 1
|
|
44
|
+
return blocks
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _first_user_message(messages: Sequence[Message]) -> Optional[Message]:
|
|
48
|
+
for message in messages:
|
|
49
|
+
if message.role != "user":
|
|
50
|
+
continue
|
|
51
|
+
if text_from_content(message.content).startswith(COMPACTED_CONTEXT_MARK):
|
|
52
|
+
continue
|
|
53
|
+
return message
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _preview_ask(content: Content) -> str:
|
|
58
|
+
text = " ".join(text_from_content(content).split())
|
|
59
|
+
if len(text) > 120:
|
|
60
|
+
return text[:117] + "…"
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
COMPACTION_CONTINUATION = (
|
|
65
|
+
"Continue from the messages below. Do not re-fetch already observed "
|
|
66
|
+
"paths unless they changed. Do not ask the user to repeat dropped work."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compaction_header(dropped_messages: int, *, tokens: int) -> List[str]:
|
|
71
|
+
"""Opening lines shared by every compacted-context message."""
|
|
72
|
+
return [
|
|
73
|
+
COMPACTED_CONTEXT_MARK,
|
|
74
|
+
f"Dropped {dropped_messages} earlier message(s) (~{tokens:,} tokens).",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dropped_turn_facts(turns: Sequence[Sequence[Message]]) -> List[str]:
|
|
79
|
+
"""Deterministic facts about dropped turns: asks, tools used, paths seen."""
|
|
80
|
+
asks: List[str] = []
|
|
81
|
+
tools: Counter[str] = Counter()
|
|
82
|
+
observed: List[str] = []
|
|
83
|
+
names: Dict[str, str] = {}
|
|
84
|
+
refs: Dict[str, str] = {}
|
|
85
|
+
for turn in turns:
|
|
86
|
+
names.update(_tool_names(turn))
|
|
87
|
+
refs.update(_tool_refs(turn))
|
|
88
|
+
for message in turn:
|
|
89
|
+
if message.role == "user":
|
|
90
|
+
preview = _preview_ask(message.content)
|
|
91
|
+
if preview and not preview.startswith(COMPACTED_CONTEXT_MARK):
|
|
92
|
+
asks.append(preview)
|
|
93
|
+
elif message.role == "tool":
|
|
94
|
+
name = names.get(str(message.tool_call_id or ""), "tool")
|
|
95
|
+
tools[name] += 1
|
|
96
|
+
ref = refs.get(str(message.tool_call_id or ""), "")
|
|
97
|
+
if ref and ref not in observed:
|
|
98
|
+
observed.append(ref)
|
|
99
|
+
|
|
100
|
+
lines: List[str] = []
|
|
101
|
+
if asks:
|
|
102
|
+
shown = asks[:6]
|
|
103
|
+
preview = "; ".join(f'"{ask}"' for ask in shown)
|
|
104
|
+
if len(asks) > 6:
|
|
105
|
+
preview += f"; … ({len(asks) - 6} more)"
|
|
106
|
+
lines.append(f"User asks: {preview}")
|
|
107
|
+
if tools:
|
|
108
|
+
used = ", ".join(
|
|
109
|
+
f"{name}×{count}" if count > 1 else name
|
|
110
|
+
for name, count in tools.items()
|
|
111
|
+
)
|
|
112
|
+
lines.append(f"Tools used: {used}")
|
|
113
|
+
if observed:
|
|
114
|
+
shown_obs = observed[:10]
|
|
115
|
+
extra = f"; … ({len(observed) - 10} more)" if len(observed) > 10 else ""
|
|
116
|
+
lines.append("Already observed: " + "; ".join(shown_obs) + extra)
|
|
117
|
+
return lines
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def summarize_dropped_turns(turns: Sequence[Sequence[Message]], *, tokens: int) -> Message:
|
|
121
|
+
"""Template summary of dropped turns; no model call."""
|
|
122
|
+
dropped_messages = sum(len(turn) for turn in turns)
|
|
123
|
+
lines = compaction_header(dropped_messages, tokens=tokens)
|
|
124
|
+
lines.extend(dropped_turn_facts(turns))
|
|
125
|
+
lines.append(COMPACTION_CONTINUATION)
|
|
126
|
+
return Message(role="user", content="\n".join(lines))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Compactor(Protocol):
|
|
130
|
+
async def compact(
|
|
131
|
+
self,
|
|
132
|
+
messages: List[Message],
|
|
133
|
+
*,
|
|
134
|
+
turn: int,
|
|
135
|
+
context_limit: Optional[int],
|
|
136
|
+
tokens_used: int,
|
|
137
|
+
context_left: Optional[int],
|
|
138
|
+
) -> List[Message]:
|
|
139
|
+
"""Return a reduced message list that fits better in the context window."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _tool_result_count(blocks: Sequence[Sequence[Message]]) -> int:
|
|
143
|
+
return sum(message.role == "tool" for block in blocks for message in block)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _split_recent_tool_blocks(
|
|
147
|
+
remainder: List[List[Message]],
|
|
148
|
+
*,
|
|
149
|
+
keep_recent_tools: int,
|
|
150
|
+
) -> tuple[List[List[Message]], List[List[Message]]]:
|
|
151
|
+
"""Keep the suffix containing the latest tool results and their call groups.
|
|
152
|
+
|
|
153
|
+
When a conversation has no tools, keep the latest ``keep_recent_tools``
|
|
154
|
+
message blocks so text-only conversations can still be compacted.
|
|
155
|
+
"""
|
|
156
|
+
total_tools = _tool_result_count(remainder)
|
|
157
|
+
if total_tools == 0:
|
|
158
|
+
split = max(len(remainder) - keep_recent_tools, 0)
|
|
159
|
+
return remainder[:split], remainder[split:]
|
|
160
|
+
|
|
161
|
+
wanted = min(keep_recent_tools, total_tools)
|
|
162
|
+
selected: List[List[Message]] = []
|
|
163
|
+
selected_tools = 0
|
|
164
|
+
dropped_end = 0
|
|
165
|
+
for index in range(len(remainder) - 1, -1, -1):
|
|
166
|
+
block = remainder[index]
|
|
167
|
+
selected.insert(0, block)
|
|
168
|
+
selected_tools += _tool_result_count([block])
|
|
169
|
+
dropped_end = index
|
|
170
|
+
if selected_tools >= wanted:
|
|
171
|
+
previous = index - 1
|
|
172
|
+
if previous >= 0 and any(
|
|
173
|
+
message.role == "user" for message in remainder[previous]
|
|
174
|
+
):
|
|
175
|
+
selected.insert(0, remainder[previous])
|
|
176
|
+
dropped_end = previous
|
|
177
|
+
break
|
|
178
|
+
else:
|
|
179
|
+
dropped_end = 0
|
|
180
|
+
selected = list(remainder)
|
|
181
|
+
return remainder[:dropped_end], selected
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _flatten(blocks: Sequence[Sequence[Message]]) -> List[Message]:
|
|
185
|
+
return [message for group in blocks for message in group]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def compaction_target(
|
|
189
|
+
target_tokens: Optional[int],
|
|
190
|
+
context_limit: Optional[int],
|
|
191
|
+
) -> Optional[int]:
|
|
192
|
+
"""Token budget a compact should land under; a third of the window by default."""
|
|
193
|
+
if target_tokens is not None:
|
|
194
|
+
return target_tokens
|
|
195
|
+
if context_limit is not None:
|
|
196
|
+
return max(context_limit // 3, 1)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class KeepDropPlan:
|
|
202
|
+
"""Outcome of ``plan_keep_drop``.
|
|
203
|
+
|
|
204
|
+
``leading`` is the system prompt plus the pinned original task; ``dropped``
|
|
205
|
+
and ``kept`` are atomic assistant/tool groups in conversation order.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
leading: List[Message] = field(default_factory=list)
|
|
209
|
+
dropped: List[List[Message]] = field(default_factory=list)
|
|
210
|
+
kept: List[List[Message]] = field(default_factory=list)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def dropped_messages(self) -> List[Message]:
|
|
214
|
+
return _flatten(self.dropped)
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def dropped_tokens(self) -> int:
|
|
218
|
+
return estimate_prompt_tokens(self.dropped_messages)
|
|
219
|
+
|
|
220
|
+
def assemble(self, summary: Optional[Message]) -> List[Message]:
|
|
221
|
+
"""``leading`` + ``summary`` (when there is dropped history) + ``kept``."""
|
|
222
|
+
middle = [summary] if summary is not None and self.dropped else []
|
|
223
|
+
return self.leading + middle + _flatten(self.kept)
|
|
224
|
+
|
|
225
|
+
def template_summary(self) -> Optional[Message]:
|
|
226
|
+
if not self.dropped:
|
|
227
|
+
return None
|
|
228
|
+
return summarize_dropped_turns(self.dropped, tokens=self.dropped_tokens)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def plan_keep_drop(
|
|
232
|
+
messages: List[Message],
|
|
233
|
+
*,
|
|
234
|
+
keep_recent_tools: int,
|
|
235
|
+
target_tokens: Optional[int] = None,
|
|
236
|
+
context_limit: Optional[int] = None,
|
|
237
|
+
) -> KeepDropPlan:
|
|
238
|
+
"""Decide what to keep and what to drop; no summary is written here.
|
|
239
|
+
|
|
240
|
+
Keeps the leading system prompt, the first real user message, and the suffix
|
|
241
|
+
containing the latest ``keep_recent_tools`` tool results. Assistant/tool
|
|
242
|
+
groups stay together so the provider protocol stays valid. The token target
|
|
243
|
+
is best-effort: protected recent tool results are never stubbed or dropped.
|
|
244
|
+
"""
|
|
245
|
+
if keep_recent_tools < 1:
|
|
246
|
+
raise ValueError("keep_recent_tools must be >= 1")
|
|
247
|
+
valid = normalize_tool_protocol(messages)
|
|
248
|
+
system = valid[0] if valid and valid[0].role == "system" else None
|
|
249
|
+
rest = valid[1:] if system is not None else valid
|
|
250
|
+
plan = KeepDropPlan(leading=[system] if system is not None else [])
|
|
251
|
+
if not rest:
|
|
252
|
+
return plan
|
|
253
|
+
|
|
254
|
+
first_user = _first_user_message(rest)
|
|
255
|
+
remainder: List[List[Message]] = []
|
|
256
|
+
for block in _atomic_blocks(rest):
|
|
257
|
+
if first_user is not None and first_user in block:
|
|
258
|
+
leftover = [message for message in block if message is not first_user]
|
|
259
|
+
if leftover:
|
|
260
|
+
remainder.append(leftover)
|
|
261
|
+
else:
|
|
262
|
+
remainder.append(block)
|
|
263
|
+
if first_user is not None:
|
|
264
|
+
plan.leading.append(first_user)
|
|
265
|
+
|
|
266
|
+
plan.dropped, plan.kept = _split_recent_tool_blocks(
|
|
267
|
+
remainder, keep_recent_tools=keep_recent_tools
|
|
268
|
+
)
|
|
269
|
+
target = compaction_target(target_tokens, context_limit)
|
|
270
|
+
protected_tools = min(keep_recent_tools, _tool_result_count(remainder))
|
|
271
|
+
while (
|
|
272
|
+
target is not None
|
|
273
|
+
and estimate_prompt_tokens(plan.assemble(plan.template_summary())) > target
|
|
274
|
+
and len(plan.kept) > 1
|
|
275
|
+
):
|
|
276
|
+
if protected_tools:
|
|
277
|
+
break
|
|
278
|
+
plan.dropped.append(plan.kept.pop(0))
|
|
279
|
+
return plan
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class KeepSystemRecentCompactor:
|
|
283
|
+
"""Keep the system prompt, the original task, and the most recent messages.
|
|
284
|
+
|
|
285
|
+
Assistant/tool groups stay together so the provider protocol stays valid.
|
|
286
|
+
Earlier tool groups are summarised while the latest tool results remain in
|
|
287
|
+
full. Dropped messages become one deterministic template summary; this
|
|
288
|
+
compactor never calls a provider.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
keep_recent_tools: int = 32,
|
|
294
|
+
target_tokens: Optional[int] = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
if keep_recent_tools < 1:
|
|
297
|
+
raise ValueError("keep_recent_tools must be >= 1")
|
|
298
|
+
if target_tokens is not None and target_tokens < 1:
|
|
299
|
+
raise ValueError("target_tokens must be positive or None")
|
|
300
|
+
self.keep_recent_tools = keep_recent_tools
|
|
301
|
+
self.target_tokens = target_tokens
|
|
302
|
+
|
|
303
|
+
def plan(self, messages: List[Message], *, context_limit: Optional[int]) -> KeepDropPlan:
|
|
304
|
+
return plan_keep_drop(
|
|
305
|
+
messages,
|
|
306
|
+
keep_recent_tools=self.keep_recent_tools,
|
|
307
|
+
target_tokens=self.target_tokens,
|
|
308
|
+
context_limit=context_limit,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def compact(
|
|
312
|
+
self,
|
|
313
|
+
messages: List[Message],
|
|
314
|
+
*,
|
|
315
|
+
turn: int,
|
|
316
|
+
context_limit: Optional[int],
|
|
317
|
+
tokens_used: int,
|
|
318
|
+
context_left: Optional[int],
|
|
319
|
+
) -> List[Message]:
|
|
320
|
+
del turn, tokens_used, context_left
|
|
321
|
+
plan = self.plan(messages, context_limit=context_limit)
|
|
322
|
+
return plan.assemble(plan.template_summary())
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
__all__ = [
|
|
326
|
+
"COMPACTION_CONTINUATION",
|
|
327
|
+
"Compactor",
|
|
328
|
+
"KeepDropPlan",
|
|
329
|
+
"KeepSystemRecentCompactor",
|
|
330
|
+
"compaction_header",
|
|
331
|
+
"compaction_target",
|
|
332
|
+
"dropped_turn_facts",
|
|
333
|
+
"plan_keep_drop",
|
|
334
|
+
"summarize_dropped_turns",
|
|
335
|
+
]
|