react-agent-harness 0.0.2__tar.gz → 0.1.0__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.
- {react_agent_harness-0.0.2/react_agent_harness.egg-info → react_agent_harness-0.1.0}/PKG-INFO +1 -1
- react_agent_harness-0.1.0/memory/working.py +418 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/pyproject.toml +1 -1
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0/react_agent_harness.egg-info}/PKG-INFO +1 -1
- react_agent_harness-0.1.0/tests/test_working_memory.py +394 -0
- react_agent_harness-0.0.2/memory/working.py +0 -277
- react_agent_harness-0.0.2/tests/test_working_memory.py +0 -190
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/LICENSE +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/README.md +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/agents/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/agents/base.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/annotation.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/checkpoint.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/events.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/executor_bridge.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/hitl.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/llm/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/llm/openai.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/otel.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/runtime.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/utils.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/episodic_lance.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/manager.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/redis_store.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/stores.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/orchestrator/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/orchestrator/planner.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/SOURCES.txt +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/dependency_links.txt +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/requires.txt +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/top_level.txt +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/setup.cfg +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_agents_base.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_annotation.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_checkpoint_resume.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_executor_bridge.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_http_fetch.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_mcp_adapter.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_memory.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_openai_llm.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_orchestrator.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_otel.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_parse_action_json.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_redis_store.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_streaming.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_vision.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/builtin/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/builtin/fetch_image.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/builtin/http_fetch.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/mcp/__init__.py +0 -0
- {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/mcp/adapter.py +0 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WorkingMemory — per-agent, per-run in-context memory.
|
|
3
|
+
|
|
4
|
+
Eviction strategy: rolling structured summary with a recency window.
|
|
5
|
+
|
|
6
|
+
When total tokens exceed `max_tokens`, the oldest unpinned messages outside
|
|
7
|
+
the recency window are folded into a single structured summary block
|
|
8
|
+
(sections: Facts / Tools used / Errors / Open questions). At most one
|
|
9
|
+
summary block exists at any time — subsequent evictions EXTEND the existing
|
|
10
|
+
summary (the LLM sees the prior structured summary plus the new batch and
|
|
11
|
+
returns an updated structured summary) instead of re-summarizing its own
|
|
12
|
+
paragraph output, avoiding fidelity decay across passes.
|
|
13
|
+
|
|
14
|
+
The recency window (last N non-pinned, non-summary messages) is protected
|
|
15
|
+
from eviction in normal operation and is only relaxed when budget pressure
|
|
16
|
+
forces it. The summary's role is always set opposite the next non-pinned
|
|
17
|
+
non-summary message so the ReAct user/assistant alternation invariant holds.
|
|
18
|
+
|
|
19
|
+
Token counting: chars/4 heuristic by default — stable across content types
|
|
20
|
+
(code, JSON, English, non-English) within ~10–20% of real BPE counts, with
|
|
21
|
+
zero dependencies. For exact counts, pass a custom `token_counter`:
|
|
22
|
+
|
|
23
|
+
import tiktoken
|
|
24
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
25
|
+
wm = WorkingMemory(llm=..., token_counter=lambda s: len(enc.encode(s)))
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any, Protocol
|
|
33
|
+
|
|
34
|
+
# ── Token counting ────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def count_tokens(text: str) -> int:
|
|
38
|
+
"""
|
|
39
|
+
Chars-per-4 heuristic — the standard "~4 chars per token" rule. Stable
|
|
40
|
+
across JSON, code, English, and most non-English text within ~10–20% of
|
|
41
|
+
real BPE counts. Override via WorkingMemory(token_counter=…) for exact.
|
|
42
|
+
"""
|
|
43
|
+
return max(1, len(text) // 4) if text else 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Rough token cost for a single image block regardless of resolution.
|
|
47
|
+
# GPT-4o "auto" detail ≈ 85–1700 tokens depending on size; 500 is a conservative
|
|
48
|
+
# mid-point that avoids under-counting without being too aggressive.
|
|
49
|
+
_IMAGE_TOKEN_ESTIMATE = 500
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _count_content(content: str | list, counter: Callable[[str], int]) -> int:
|
|
53
|
+
"""Token count for a message whose content may be a string or a content-block list."""
|
|
54
|
+
if isinstance(content, str):
|
|
55
|
+
return counter(content)
|
|
56
|
+
total = sum(
|
|
57
|
+
counter(block.get("text", ""))
|
|
58
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
59
|
+
else _IMAGE_TOKEN_ESTIMATE
|
|
60
|
+
for block in content
|
|
61
|
+
)
|
|
62
|
+
return max(1, total) if total else 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── LLM Protocol — injected, not imported ─────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LLMClient(Protocol):
|
|
69
|
+
async def complete(
|
|
70
|
+
self,
|
|
71
|
+
system: str,
|
|
72
|
+
messages: list[dict],
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> dict: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Working Memory ────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _format_for_summary(m: Message) -> str:
|
|
81
|
+
"""Render a message as plain text for the summarization LLM.
|
|
82
|
+
|
|
83
|
+
Image content blocks become "[image]" so a text-only summarizer can still
|
|
84
|
+
produce a useful summary that acknowledges the image was present.
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(m.content, str):
|
|
87
|
+
return f"[{m.role.upper()}]: {m.content}"
|
|
88
|
+
parts = []
|
|
89
|
+
for block in m.content:
|
|
90
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
91
|
+
parts.append(block.get("text", ""))
|
|
92
|
+
else:
|
|
93
|
+
parts.append("[image]")
|
|
94
|
+
return f"[{m.role.upper()}]: {''.join(parts)}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Marker emitted by new summaries. Legacy checkpoints from the previous
|
|
98
|
+
# implementation used "[Memory compressed]:" — both are recognized on load.
|
|
99
|
+
SUMMARY_HEADER = "[Memory summary]"
|
|
100
|
+
_LEGACY_SUMMARY_PREFIXES = ("[Memory summary]", "[Memory compressed]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
SUMMARIZE_SYSTEM = """
|
|
104
|
+
You are a memory compressor for an AI agent.
|
|
105
|
+
Produce a structured summary of the conversation messages below.
|
|
106
|
+
|
|
107
|
+
Use exactly this format, omitting any section that has no entries:
|
|
108
|
+
|
|
109
|
+
[Memory summary]
|
|
110
|
+
Facts:
|
|
111
|
+
- <one fact per bullet>
|
|
112
|
+
Tools used:
|
|
113
|
+
- <tool>: <one-line outcome>
|
|
114
|
+
Errors:
|
|
115
|
+
- <error or failed approach>
|
|
116
|
+
Open questions:
|
|
117
|
+
- <unresolved item or next step>
|
|
118
|
+
|
|
119
|
+
Rules:
|
|
120
|
+
- One short line per bullet. No multi-sentence bullets.
|
|
121
|
+
- Preserve concrete details (file paths, names, numbers, error messages).
|
|
122
|
+
- Discard pleasantries, restated context, and verbose tool output.
|
|
123
|
+
- Output ONLY the [Memory summary] block — no preamble, no closing remarks.
|
|
124
|
+
""".strip()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
EXTEND_SUMMARY_SYSTEM = """
|
|
128
|
+
You are a memory compressor for an AI agent.
|
|
129
|
+
You are given an existing structured summary and a batch of new conversation
|
|
130
|
+
messages. Produce an UPDATED structured summary that merges the new
|
|
131
|
+
information into the existing one.
|
|
132
|
+
|
|
133
|
+
Rules:
|
|
134
|
+
- Use the same format and section headers as the existing summary.
|
|
135
|
+
- Merge or deduplicate bullets where the new messages elaborate on or
|
|
136
|
+
resolve existing items (e.g. an open question becoming a fact).
|
|
137
|
+
- Add new bullets for genuinely new information.
|
|
138
|
+
- Keep bullets short, one line each. Preserve concrete details.
|
|
139
|
+
- Omit sections with no entries.
|
|
140
|
+
- Output ONLY the [Memory summary] block — no preamble, no closing remarks.
|
|
141
|
+
""".strip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Message:
|
|
146
|
+
role: str # system | user | assistant
|
|
147
|
+
content: str | list # str for text; list of content blocks for multimodal
|
|
148
|
+
token_count: int = 0 # set by WorkingMemory.append using its configured counter
|
|
149
|
+
pinned: bool = False # pinned messages are never evicted (e.g. system prompt)
|
|
150
|
+
is_summary: bool = False # marks the rolling-summary message (at most one)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class WorkingMemory:
|
|
154
|
+
"""
|
|
155
|
+
Token-budget-aware in-context memory for a single agent run.
|
|
156
|
+
|
|
157
|
+
Parameters:
|
|
158
|
+
llm: client used for compression calls.
|
|
159
|
+
max_tokens: budget; eviction fires when total exceeds this.
|
|
160
|
+
summarize_ratio: fraction of *eligible* messages folded in per
|
|
161
|
+
eviction call. Eligible = non-pinned, non-summary, outside the
|
|
162
|
+
recency window.
|
|
163
|
+
recency_window: number of trailing non-pinned, non-summary messages
|
|
164
|
+
protected from eviction in normal operation. The window is
|
|
165
|
+
relaxed (oldest-protected-first) if budget forces it. Default 4
|
|
166
|
+
preserves the last two ReAct steps verbatim.
|
|
167
|
+
token_counter: optional exact counter; defaults to chars/4 heuristic.
|
|
168
|
+
|
|
169
|
+
Eviction:
|
|
170
|
+
- If a prior summary exists, the new batch is folded into it
|
|
171
|
+
(extend mode); otherwise a fresh summary is created.
|
|
172
|
+
- The new summary occupies the slot of the oldest message in the
|
|
173
|
+
replaced set, with its role set opposite the next non-pinned
|
|
174
|
+
non-summary message to preserve ReAct alternation.
|
|
175
|
+
- Up to two compaction passes fire per append() before falling
|
|
176
|
+
back to a hard FIFO drop (which still protects the recency
|
|
177
|
+
window until forced to relax it).
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
llm: LLMClient,
|
|
183
|
+
max_tokens: int = 8000,
|
|
184
|
+
summarize_ratio: float = 0.5, # summarize oldest 50% of eligible when evicting
|
|
185
|
+
recency_window: int = 4, # protect last N non-pinned non-summary messages
|
|
186
|
+
token_counter: Callable[[str], int] | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
self._llm = llm
|
|
189
|
+
self.max_tokens = max_tokens
|
|
190
|
+
self.summarize_ratio = summarize_ratio
|
|
191
|
+
self.recency_window = max(0, recency_window)
|
|
192
|
+
self._count = token_counter or count_tokens
|
|
193
|
+
self._messages: list[Message] = []
|
|
194
|
+
self._token_total: int = 0
|
|
195
|
+
self._summarization_count: int = 0
|
|
196
|
+
|
|
197
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
async def append(self, role: str, content: str | list, pinned: bool = False) -> None:
|
|
200
|
+
msg = Message(
|
|
201
|
+
role=role,
|
|
202
|
+
content=content,
|
|
203
|
+
pinned=pinned,
|
|
204
|
+
token_count=_count_content(content, self._count),
|
|
205
|
+
)
|
|
206
|
+
self._messages.append(msg)
|
|
207
|
+
self._token_total += msg.token_count
|
|
208
|
+
|
|
209
|
+
if self._token_total > self.max_tokens:
|
|
210
|
+
await self._evict()
|
|
211
|
+
|
|
212
|
+
def get_messages(self) -> list[dict]:
|
|
213
|
+
return [{"role": m.role, "content": m.content} for m in self._messages]
|
|
214
|
+
|
|
215
|
+
def token_count(self) -> int:
|
|
216
|
+
return self._token_total
|
|
217
|
+
|
|
218
|
+
def clear(self) -> None:
|
|
219
|
+
self._messages.clear()
|
|
220
|
+
self._token_total = 0
|
|
221
|
+
|
|
222
|
+
def to_dict(self) -> dict:
|
|
223
|
+
"""Serialize to a JSON-safe dict for checkpoint storage."""
|
|
224
|
+
return {
|
|
225
|
+
"messages": [
|
|
226
|
+
{
|
|
227
|
+
"role": m.role,
|
|
228
|
+
"content": m.content,
|
|
229
|
+
"pinned": m.pinned,
|
|
230
|
+
"token_count": m.token_count,
|
|
231
|
+
"is_summary": m.is_summary,
|
|
232
|
+
}
|
|
233
|
+
for m in self._messages
|
|
234
|
+
],
|
|
235
|
+
"summarization_count": self._summarization_count,
|
|
236
|
+
"max_tokens": self.max_tokens,
|
|
237
|
+
"summarize_ratio": self.summarize_ratio,
|
|
238
|
+
"recency_window": self.recency_window,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def from_dict(
|
|
243
|
+
cls,
|
|
244
|
+
data: dict,
|
|
245
|
+
llm: LLMClient,
|
|
246
|
+
token_counter: Callable[[str], int] | None = None,
|
|
247
|
+
) -> WorkingMemory:
|
|
248
|
+
"""Restore from a checkpoint dict. Stored token counts are reused as-is.
|
|
249
|
+
|
|
250
|
+
Legacy checkpoints (no `is_summary` field, no `recency_window`) are
|
|
251
|
+
backfilled: content prefixed with a known summary marker is treated
|
|
252
|
+
as `is_summary=True`, and `recency_window` defaults to 4.
|
|
253
|
+
"""
|
|
254
|
+
wm = cls(
|
|
255
|
+
llm=llm,
|
|
256
|
+
max_tokens=data["max_tokens"],
|
|
257
|
+
summarize_ratio=data["summarize_ratio"],
|
|
258
|
+
recency_window=data.get("recency_window", 4),
|
|
259
|
+
token_counter=token_counter,
|
|
260
|
+
)
|
|
261
|
+
for m in data["messages"]:
|
|
262
|
+
content = m["content"]
|
|
263
|
+
is_summary = m.get("is_summary")
|
|
264
|
+
if is_summary is None:
|
|
265
|
+
is_summary = isinstance(content, str) and any(
|
|
266
|
+
content.startswith(p) for p in _LEGACY_SUMMARY_PREFIXES
|
|
267
|
+
)
|
|
268
|
+
wm._messages.append(
|
|
269
|
+
Message(
|
|
270
|
+
role=m["role"],
|
|
271
|
+
content=content,
|
|
272
|
+
pinned=m["pinned"],
|
|
273
|
+
token_count=m["token_count"],
|
|
274
|
+
is_summary=bool(is_summary),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
wm._token_total = sum(msg.token_count for msg in wm._messages)
|
|
278
|
+
wm._summarization_count = data["summarization_count"]
|
|
279
|
+
return wm
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def summarization_count(self) -> int:
|
|
283
|
+
return self._summarization_count
|
|
284
|
+
|
|
285
|
+
# ── Eviction ──────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _eligible_indices(self, relax_recency: int = 0) -> list[int]:
|
|
288
|
+
"""Indices of summarizable messages (non-pinned, non-summary), oldest first.
|
|
289
|
+
|
|
290
|
+
The newest `recency_window - relax_recency` non-pinned non-summary
|
|
291
|
+
messages are protected; everything older is eligible. Walks from
|
|
292
|
+
newest backward so the protection count is robust to interleaved
|
|
293
|
+
pinned/summary messages.
|
|
294
|
+
"""
|
|
295
|
+
protect = max(0, self.recency_window - relax_recency)
|
|
296
|
+
candidates_back: list[int] = []
|
|
297
|
+
for i in range(len(self._messages) - 1, -1, -1):
|
|
298
|
+
m = self._messages[i]
|
|
299
|
+
if m.pinned or m.is_summary:
|
|
300
|
+
continue
|
|
301
|
+
candidates_back.append(i)
|
|
302
|
+
# candidates_back is newest-first; protect the first `protect` of them.
|
|
303
|
+
return sorted(candidates_back[protect:])
|
|
304
|
+
|
|
305
|
+
async def _evict(self, _depth: int = 0) -> None:
|
|
306
|
+
# Find eligible messages, relaxing recency_window only if necessary.
|
|
307
|
+
relax = 0
|
|
308
|
+
eligible_idx = self._eligible_indices(relax)
|
|
309
|
+
while not eligible_idx and relax <= self.recency_window:
|
|
310
|
+
relax += 1
|
|
311
|
+
eligible_idx = self._eligible_indices(relax)
|
|
312
|
+
|
|
313
|
+
if eligible_idx:
|
|
314
|
+
cutoff = max(1, int(len(eligible_idx) * self.summarize_ratio))
|
|
315
|
+
chosen_idx = eligible_idx[:cutoff]
|
|
316
|
+
to_summarize = [self._messages[i] for i in chosen_idx]
|
|
317
|
+
|
|
318
|
+
prior_summary = next((m for m in self._messages if m.is_summary), None)
|
|
319
|
+
if prior_summary is None:
|
|
320
|
+
summary_text = await self._summarize_initial(to_summarize)
|
|
321
|
+
else:
|
|
322
|
+
summary_text = await self._summarize_extend(prior_summary.content, to_summarize)
|
|
323
|
+
|
|
324
|
+
summary_content = (
|
|
325
|
+
summary_text
|
|
326
|
+
if summary_text.startswith(SUMMARY_HEADER)
|
|
327
|
+
else f"{SUMMARY_HEADER}\n{summary_text}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Remove the picked messages AND the prior summary (if any); the
|
|
331
|
+
# new summary replaces both.
|
|
332
|
+
removed_ids = {id(m) for m in to_summarize}
|
|
333
|
+
if prior_summary is not None:
|
|
334
|
+
removed_ids.add(id(prior_summary))
|
|
335
|
+
|
|
336
|
+
insert_idx = next(i for i, m in enumerate(self._messages) if id(m) in removed_ids)
|
|
337
|
+
remaining = [m for m in self._messages if id(m) not in removed_ids]
|
|
338
|
+
|
|
339
|
+
# Role = opposite of the next non-pinned non-summary message so
|
|
340
|
+
# the ReAct alternating invariant holds. No such message → "user".
|
|
341
|
+
first_after = next(
|
|
342
|
+
(m for m in remaining if not m.pinned and not m.is_summary),
|
|
343
|
+
None,
|
|
344
|
+
)
|
|
345
|
+
summary_role = "assistant" if (first_after and first_after.role == "user") else "user"
|
|
346
|
+
|
|
347
|
+
summary_msg = Message(
|
|
348
|
+
role=summary_role,
|
|
349
|
+
content=summary_content,
|
|
350
|
+
token_count=self._count(summary_content),
|
|
351
|
+
is_summary=True,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
self._messages = remaining
|
|
355
|
+
self._messages.insert(insert_idx, summary_msg)
|
|
356
|
+
self._token_total = sum(m.token_count for m in self._messages)
|
|
357
|
+
self._summarization_count += 1
|
|
358
|
+
|
|
359
|
+
# Second pass before resorting to hard drops (max 2 passes).
|
|
360
|
+
if self._token_total > self.max_tokens and _depth < 1:
|
|
361
|
+
await self._evict(_depth=_depth + 1)
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
# Safety valve: hard FIFO drop. Drops oldest non-pinned non-summary
|
|
365
|
+
# first; only touches the summary or recency-window messages if
|
|
366
|
+
# nothing else is left.
|
|
367
|
+
while self._token_total > self.max_tokens:
|
|
368
|
+
drop_idx = next(
|
|
369
|
+
(i for i, m in enumerate(self._messages) if not m.pinned and not m.is_summary),
|
|
370
|
+
None,
|
|
371
|
+
)
|
|
372
|
+
if drop_idx is None:
|
|
373
|
+
drop_idx = next(
|
|
374
|
+
(i for i, m in enumerate(self._messages) if not m.pinned),
|
|
375
|
+
None,
|
|
376
|
+
)
|
|
377
|
+
if drop_idx is None:
|
|
378
|
+
break # only pinned messages remain — accept overshoot
|
|
379
|
+
self._token_total -= self._messages[drop_idx].token_count
|
|
380
|
+
self._messages.pop(drop_idx)
|
|
381
|
+
|
|
382
|
+
# ── Summarization helpers ─────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
async def _summarize_initial(self, messages: list[Message]) -> str:
|
|
385
|
+
formatted = "\n".join(_format_for_summary(m) for m in messages)
|
|
386
|
+
return await self._call_llm(SUMMARIZE_SYSTEM, formatted)
|
|
387
|
+
|
|
388
|
+
async def _summarize_extend(
|
|
389
|
+
self,
|
|
390
|
+
prior_summary_content: str | list,
|
|
391
|
+
new_messages: list[Message],
|
|
392
|
+
) -> str:
|
|
393
|
+
# prior_summary_content is expected to be a string (summaries are
|
|
394
|
+
# always text), but defensively handle the list-of-blocks case too.
|
|
395
|
+
if isinstance(prior_summary_content, list):
|
|
396
|
+
prior_text = "".join(
|
|
397
|
+
b.get("text", "") if isinstance(b, dict) and b.get("type") == "text" else "[image]"
|
|
398
|
+
for b in prior_summary_content
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
prior_text = prior_summary_content
|
|
402
|
+
new_text = "\n".join(_format_for_summary(m) for m in new_messages)
|
|
403
|
+
user_content = f"Existing summary:\n{prior_text}\n\nNew messages to fold in:\n{new_text}"
|
|
404
|
+
return await self._call_llm(EXTEND_SUMMARY_SYSTEM, user_content)
|
|
405
|
+
|
|
406
|
+
async def _call_llm(self, system: str, user_content: str) -> str:
|
|
407
|
+
try:
|
|
408
|
+
result = await self._llm.complete(
|
|
409
|
+
system=system,
|
|
410
|
+
messages=[{"role": "user", "content": user_content}],
|
|
411
|
+
)
|
|
412
|
+
if isinstance(result, dict):
|
|
413
|
+
return result.get("text") or result.get("answer") or str(result)
|
|
414
|
+
return str(result)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
# Fallback: truncated raw context — never let summarization break the agent.
|
|
417
|
+
fallback = user_content[:500]
|
|
418
|
+
return f"{SUMMARY_HEADER}\n[Summarization failed: {e}] Truncated context: {fallback}"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "react-agent-harness"
|
|
7
|
-
version = "0.0
|
|
7
|
+
version = "0.1.0"
|
|
8
8
|
description = "Multi-agent LLM orchestration: hybrid DAG planning, two-tier memory, streaming"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = []
|