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.
Files changed (54) hide show
  1. {react_agent_harness-0.0.2/react_agent_harness.egg-info → react_agent_harness-0.1.0}/PKG-INFO +1 -1
  2. react_agent_harness-0.1.0/memory/working.py +418 -0
  3. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/pyproject.toml +1 -1
  4. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0/react_agent_harness.egg-info}/PKG-INFO +1 -1
  5. react_agent_harness-0.1.0/tests/test_working_memory.py +394 -0
  6. react_agent_harness-0.0.2/memory/working.py +0 -277
  7. react_agent_harness-0.0.2/tests/test_working_memory.py +0 -190
  8. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/LICENSE +0 -0
  9. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/README.md +0 -0
  10. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/agents/__init__.py +0 -0
  11. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/agents/base.py +0 -0
  12. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/__init__.py +0 -0
  13. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/annotation.py +0 -0
  14. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/checkpoint.py +0 -0
  15. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/events.py +0 -0
  16. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/executor_bridge.py +0 -0
  17. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/hitl.py +0 -0
  18. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/llm/__init__.py +0 -0
  19. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/llm/openai.py +0 -0
  20. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/otel.py +0 -0
  21. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/runtime.py +0 -0
  22. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/harness/utils.py +0 -0
  23. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/__init__.py +0 -0
  24. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/episodic_lance.py +0 -0
  25. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/manager.py +0 -0
  26. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/redis_store.py +0 -0
  27. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/memory/stores.py +0 -0
  28. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/orchestrator/__init__.py +0 -0
  29. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/orchestrator/planner.py +0 -0
  30. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/SOURCES.txt +0 -0
  31. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/dependency_links.txt +0 -0
  32. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/requires.txt +0 -0
  33. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/react_agent_harness.egg-info/top_level.txt +0 -0
  34. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/setup.cfg +0 -0
  35. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_agents_base.py +0 -0
  36. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_annotation.py +0 -0
  37. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_checkpoint_resume.py +0 -0
  38. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_executor_bridge.py +0 -0
  39. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_http_fetch.py +0 -0
  40. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_mcp_adapter.py +0 -0
  41. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_memory.py +0 -0
  42. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_openai_llm.py +0 -0
  43. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_orchestrator.py +0 -0
  44. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_otel.py +0 -0
  45. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_parse_action_json.py +0 -0
  46. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_redis_store.py +0 -0
  47. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_streaming.py +0 -0
  48. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tests/test_vision.py +0 -0
  49. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/__init__.py +0 -0
  50. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/builtin/__init__.py +0 -0
  51. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/builtin/fetch_image.py +0 -0
  52. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/builtin/http_fetch.py +0 -0
  53. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/mcp/__init__.py +0 -0
  54. {react_agent_harness-0.0.2 → react_agent_harness-0.1.0}/tools/mcp/adapter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: react-agent-harness
3
- Version: 0.0.2
3
+ Version: 0.1.0
4
4
  Summary: Multi-agent LLM orchestration: hybrid DAG planning, two-tier memory, streaming
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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.2"
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 = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: react-agent-harness
3
- Version: 0.0.2
3
+ Version: 0.1.0
4
4
  Summary: Multi-agent LLM orchestration: hybrid DAG planning, two-tier memory, streaming
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE