everalgo-agent-memory 0.1.0__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.
@@ -0,0 +1,16 @@
1
+ """Agent-side memory extractors — 2 Extractors + boundary facade."""
2
+
3
+ import logging
4
+
5
+ from everalgo.agent_memory.boundary import AgentBoundaryDetector
6
+ from everalgo.agent_memory.case import AgentCaseExtractor
7
+ from everalgo.agent_memory.skill import AgentSkillExtractor
8
+
9
+ __all__ = [
10
+ "AgentBoundaryDetector",
11
+ "AgentCaseExtractor",
12
+ "AgentSkillExtractor",
13
+ ]
14
+
15
+ # Library logging setup (ADR-013): NullHandler on each subpackage logger.
16
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -0,0 +1,52 @@
1
+ """Token-aware text utilities for agent_memory extractors (head/tail truncation, json_default)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from everalgo._tokenize import _get_tokenizer, count_tokens
9
+
10
+ __all__ = ["count_tokens", "json_default", "truncate_text"]
11
+
12
+
13
+ def truncate_text(
14
+ text: str,
15
+ max_tokens: int,
16
+ *,
17
+ head_ratio: float = 0.7,
18
+ suffix: str | None = None,
19
+ ) -> str:
20
+ r"""Truncate ``text`` to ``max_tokens`` tokens, preserving head + tail with a marker.
21
+
22
+ When ``suffix`` is ``None``: keep ``head_ratio`` of tokens from the start and the remainder from
23
+ the end, joined by ``"\\n[... trimmed N tokens ...]\\n"`` (or ``"..."`` when ``head_ratio == 1.0``).
24
+ When ``suffix`` is set: keep the head only and append ``suffix``.
25
+ Short / empty inputs are returned unchanged.
26
+ """
27
+ if not text:
28
+ return text
29
+ tokenizer = _get_tokenizer()
30
+ tokens = tokenizer.encode(text)
31
+ if len(tokens) <= max_tokens:
32
+ return text
33
+
34
+ if suffix is not None:
35
+ head = tokenizer.decode(tokens[:max_tokens])
36
+ return head.rstrip() + suffix
37
+
38
+ head_count = int(max_tokens * head_ratio)
39
+ tail_count = max_tokens - head_count
40
+ head_text = tokenizer.decode(tokens[:head_count])
41
+ if tail_count <= 0:
42
+ return head_text.rstrip() + "..."
43
+ tail_text = tokenizer.decode(tokens[-tail_count:])
44
+ trimmed = len(tokens) - max_tokens
45
+ return f"{head_text}\n[... trimmed {trimmed} tokens ...]\n{tail_text}"
46
+
47
+
48
+ def json_default(obj: Any) -> Any:
49
+ """``json.dumps(default=...)`` fallback: datetime → ISO string, anything else → ``str``."""
50
+ if isinstance(obj, datetime):
51
+ return obj.isoformat()
52
+ return str(obj)
@@ -0,0 +1,129 @@
1
+ """Agent trajectory boundary detection facade.
2
+
3
+ Wraps the low-level ``everalgo.boundary.detect_boundaries`` primitive with a filter → detect → remap
4
+ pipeline to handle mixed trajectories (``ChatMessage`` + ``ToolCallRequest`` + ``ToolCallResult``).
5
+
6
+ Tool items are filtered out before the LLM detection step so the LLM sees a clean conversation
7
+ view; boundary indices from the chat-only result are then remapped back to the original
8
+ ``ConversationItem`` index space so that the output ``MemCell.items`` preserves the full
9
+ trajectory (including tool calls).
10
+
11
+ Uses explicit composition rather than inheritance: filter → detect → remap layers are separate
12
+ functions rather than an extended class hierarchy.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import TYPE_CHECKING
19
+
20
+ from asgiref.sync import async_to_sync
21
+
22
+ from everalgo.boundary import DetectionResult, detect_boundaries
23
+ from everalgo.types import ChatMessage, MemCell
24
+
25
+ if TYPE_CHECKING:
26
+ from everalgo.llm.protocols import LLMClient
27
+ from everalgo.types import ConversationItem
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ __all__ = ["AgentBoundaryDetector"]
32
+
33
+
34
+ class AgentBoundaryDetector:
35
+ """Boundary detection on an agent trajectory (mixed ``ConversationItem`` list).
36
+
37
+ The filter → detect → remap pipeline:
38
+
39
+ 1. **Filter** — extract only ``ChatMessage`` items and remember their original indices.
40
+ 2. **Detect** — run ``everalgo.boundary.detect_boundaries`` on the chat-only view.
41
+ 3. **Remap** — translate chat-space cell boundaries back to the original ``ConversationItem``
42
+ index space; ``ToolCallRequest`` / ``ToolCallResult`` items between adjacent chat
43
+ messages are folded into the cell that contains the preceding chat message.
44
+
45
+ Invariant: ``sum(len(c.items) for c in result.cells) + len(result.tail) == len(items)``.
46
+
47
+ The LLM client is bound to the instance at construction time.
48
+ """
49
+
50
+ def __init__(self, *, llm: LLMClient) -> None:
51
+ self._llm = llm
52
+
53
+ async def adetect(
54
+ self,
55
+ items: list[ConversationItem],
56
+ *,
57
+ is_final: bool = False,
58
+ prompt: str | None = None,
59
+ ) -> DetectionResult:
60
+ """Detect boundaries on a mixed agent trajectory.
61
+
62
+ Args:
63
+ items: Ordered ``ConversationItem`` list for one trajectory slice — may contain
64
+ ``ChatMessage``, ``ToolCallRequest``, and ``ToolCallResult`` in any mix.
65
+ is_final: When ``True``, the caller guarantees no more items will arrive; trailing tool items
66
+ after the last cell's final chat message are folded into that cell rather than the tail.
67
+ When ``False`` (default), trailing tool items go to the tail.
68
+ prompt: Per-call boundary detection prompt override; ``None`` uses the built-in default.
69
+
70
+ Returns:
71
+ ``cells`` — remapped ``MemCell`` list preserving tool calls inside each cell.
72
+ ``tail`` — remainder items (not yet committed to a cell) for the non-final case.
73
+ """
74
+ chat_items: list[ChatMessage] = []
75
+ chat_to_orig: list[int] = []
76
+ for orig_idx, item in enumerate(items):
77
+ if isinstance(item, ChatMessage):
78
+ chat_items.append(item)
79
+ chat_to_orig.append(orig_idx)
80
+
81
+ if not chat_items:
82
+ # No chat messages → no LLM signal; everything goes to tail per should_wait semantics.
83
+ logger.debug("adetect: no ChatMessage items in trajectory (n_items=%d)", len(items))
84
+ return DetectionResult(cells=[], tail=list(items)) # type: ignore[arg-type]
85
+
86
+ chat_result = await detect_boundaries(chat_items, llm=self._llm, is_final=is_final, prompt=prompt)
87
+
88
+ remapped_cells: list[MemCell] = []
89
+ chat_cursor = 0
90
+ for cell in chat_result.cells:
91
+ cell_chat_count = len(cell.items)
92
+ start_chat_idx = chat_cursor
93
+ end_chat_idx = chat_cursor + cell_chat_count - 1
94
+ chat_cursor += cell_chat_count
95
+
96
+ start_orig = chat_to_orig[start_chat_idx]
97
+
98
+ if chat_cursor < len(chat_to_orig):
99
+ # More cells follow: include everything up to (exclusive) the next cell's first chat.
100
+ end_orig_exclusive = chat_to_orig[chat_cursor]
101
+ elif is_final:
102
+ # Last cell, caller says stream is done: absorb all remaining items.
103
+ end_orig_exclusive = len(items)
104
+ else:
105
+ # Last cell, stream is ongoing: include only up to (inclusive) the last chat in
106
+ # this cell; trailing tool items go to the tail so they can be re-processed.
107
+ end_orig_exclusive = chat_to_orig[end_chat_idx] + 1
108
+
109
+ remapped_cells.append(MemCell(items=list(items[start_orig:end_orig_exclusive]), timestamp=cell.timestamp))
110
+
111
+ # Tail: items after the last consumed original index.
112
+ if remapped_cells:
113
+ if is_final:
114
+ last_consumed_exclusive = len(items)
115
+ elif chat_cursor < len(chat_to_orig):
116
+ last_consumed_exclusive = chat_to_orig[chat_cursor]
117
+ else:
118
+ # chat_cursor == len(chat_to_orig): all chats consumed; last cell boundary already
119
+ # set to chat_to_orig[end_chat_idx] + 1, so tail starts right after.
120
+ last_consumed_exclusive = chat_to_orig[-1] + 1
121
+ tail = list(items[last_consumed_exclusive:])
122
+ else:
123
+ # detect_boundaries returned no cells — everything is tail.
124
+ tail = list(items)
125
+
126
+ return DetectionResult(cells=remapped_cells, tail=tail) # type: ignore[arg-type]
127
+
128
+ detect = async_to_sync(adetect)
129
+ """Sync bridge — only callable from non-event-loop contexts."""