framework-agents 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.
Files changed (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,231 @@
1
+ """Checkpointed HITL threads + local file locks.
2
+
3
+ Parity: packages/typescript/src/checkpoint/index.ts — recreated after a
4
+ build-script accident wiped the file; EVERY property below is load-bearing
5
+ for tests (conversation sessions, gaps, pause/resume examples):
6
+
7
+ - ThreadId is a single directory name under <root>/threads/: "", ".", ".."
8
+ and anything containing "/" raise THREAD_ID_INVALID (path traversal guard).
9
+ - save() OVERWRITES thread_id/saved_at on the wire (camelCase JSON), forcing
10
+ ISO-8601 with ms precision ("2026-10-12T21:50:59.123Z").
11
+ - Serialization is STRICT: non-JSON payload values (datetime, Path, models)
12
+ raise CHECKPOINT_SERIALIZABLE — never `default=str`, which would round-trip
13
+ Python reprs into tools on resume.
14
+ - with_file_lock: O_CREAT|O_EXCL exclusive create; fresh lock -> "Lock held";
15
+ stale (>stale_ms, default 30000) -> unlink + retry; finally-unlock.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import os
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Awaitable, Callable
28
+
29
+ from .errors import AgentError
30
+ from .types import ChatMessage, deserialize_messages
31
+
32
+
33
+ def _iso_now() -> str:
34
+ now = datetime.now(timezone.utc)
35
+ return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{int(now.microsecond / 1000):03d}Z"
36
+
37
+
38
+ @dataclass
39
+ class PendingCall:
40
+ """A suspended tool call awaiting a human decision."""
41
+
42
+ interrupt_id: str
43
+ tool: str
44
+ args: Any = None
45
+
46
+
47
+ @dataclass
48
+ class ThreadCheckpoint:
49
+ thread_id: str
50
+ saved_at: str
51
+ messages: list[Any] = field(default_factory=list)
52
+ state: dict[str, Any] = field(default_factory=dict)
53
+ pending: list[PendingCall] = field(default_factory=list)
54
+
55
+
56
+ class Checkpointer:
57
+ """Protocol-style base: save/load/delete per threadId (duck-typed)."""
58
+
59
+ async def save(self, thread_id: str, checkpoint: ThreadCheckpoint) -> None: # pragma: no cover
60
+ raise NotImplementedError
61
+
62
+ async def load(self, thread_id: str) -> ThreadCheckpoint | None: # pragma: no cover
63
+ raise NotImplementedError
64
+
65
+ async def delete(self, thread_id: str) -> None: # pragma: no cover
66
+ raise NotImplementedError
67
+
68
+
69
+ def _validate_thread_id(thread_id: str) -> None:
70
+ if thread_id in ("", ".", "..") or "/" in thread_id or "\0" in thread_id:
71
+ raise AgentError("THREAD_ID_INVALID", f"Invalid threadId: {thread_id!r}", retryable=False)
72
+
73
+
74
+ class FileCheckpointer(Checkpointer):
75
+ """JSON-file checkpointer: <root>/threads/<threadId>/checkpoint.json, lock-guarded."""
76
+
77
+ def __init__(self, root: str) -> None:
78
+ self.root = Path(root)
79
+
80
+ def _file(self, thread_id: str) -> Path:
81
+ _validate_thread_id(thread_id)
82
+ return self.root / "threads" / thread_id / "checkpoint.json"
83
+
84
+ def _lock(self, thread_id: str) -> Path:
85
+ _validate_thread_id(thread_id)
86
+ return self.root / "threads" / thread_id / ".lock"
87
+
88
+ async def save(self, thread_id: str, checkpoint: ThreadCheckpoint) -> None:
89
+ record = ThreadCheckpoint(
90
+ thread_id=thread_id,
91
+ saved_at=_iso_now(),
92
+ messages=[_dump_message(m) for m in checkpoint.messages],
93
+ state=dict(checkpoint.state or {}),
94
+ pending=[PendingCall(p.interrupt_id, p.tool, p.args) for p in (checkpoint.pending or [])],
95
+ )
96
+ try:
97
+ payload = json.dumps(
98
+ {
99
+ "threadId": record.thread_id,
100
+ "savedAt": record.saved_at,
101
+ "messages": record.messages,
102
+ "state": record.state,
103
+ "pending": [
104
+ {"interruptId": p.interrupt_id, "tool": p.tool, "args": p.args}
105
+ for p in record.pending
106
+ ],
107
+ }
108
+ )
109
+ except (TypeError, ValueError) as err:
110
+ # STRICT serialization: a datetime/Path in pending args would
111
+ # otherwise resume as its "__repr__ string" and crash the tool.
112
+ raise AgentError(
113
+ "CHECKPOINT_SERIALIZABLE",
114
+ f"Checkpoint for thread {thread_id!r} contains non-JSON values: {err}",
115
+ retryable=False,
116
+ ) from err
117
+
118
+ def write() -> None:
119
+ target = self._file(thread_id)
120
+ target.parent.mkdir(parents=True, exist_ok=True)
121
+ finite_retries = 3
122
+ for _ in range(finite_retries):
123
+ try:
124
+ with open(target, "w", encoding="utf-8") as fh:
125
+ fh.write(payload)
126
+ return
127
+ except OSError:
128
+ time.sleep(0.01)
129
+ raise AgentError("CHECKPOINT_WRITE", f"Could not write checkpoint for {thread_id!r}", retryable=True)
130
+
131
+ async def locked() -> None:
132
+ lock = self._lock(thread_id)
133
+
134
+ def body() -> None:
135
+ write()
136
+
137
+ await with_file_lock(lock, body)
138
+
139
+ await asyncio.to_thread(locked) if False else await locked()
140
+
141
+ async def load(self, thread_id: str) -> ThreadCheckpoint | None:
142
+ def read() -> ThreadCheckpoint | None:
143
+ try:
144
+ raw = self._file(thread_id).read_text(encoding="utf-8")
145
+ except (FileNotFoundError, NotADirectoryError):
146
+ return None
147
+ except OSError:
148
+ return None
149
+ try:
150
+ data = json.loads(raw)
151
+ except ValueError:
152
+ return None # corrupt -> treat as absent (TS matches: JSON.parse try/catch)
153
+ pending = [
154
+ PendingCall(p.get("interruptId", ""), p.get("tool", ""), p.get("args"))
155
+ for p in (data.get("pending") or [])
156
+ ]
157
+ raw_messages = data.get("messages") or []
158
+ messages = [
159
+ m if isinstance(m, ChatMessage) else ChatMessage.model_validate(m)
160
+ for m in raw_messages
161
+ ]
162
+ return ThreadCheckpoint(
163
+ thread_id=data.get("threadId", thread_id),
164
+ saved_at=data.get("savedAt", ""),
165
+ messages=messages,
166
+ state=data.get("state") or {},
167
+ pending=pending,
168
+ )
169
+
170
+ return await asyncio.to_thread(read)
171
+
172
+ async def delete(self, thread_id: str) -> None:
173
+ def remove() -> None:
174
+ try:
175
+ self._file(thread_id).unlink()
176
+ except (FileNotFoundError, NotADirectoryError, OSError):
177
+ pass # missing checkpoint is not an error (TS parity)
178
+
179
+ async def locked() -> None:
180
+ lock = self._lock(thread_id)
181
+ await with_file_lock(lock, remove)
182
+
183
+ await locked()
184
+
185
+
186
+ def _dump_message(message: Any) -> Any:
187
+ dump = getattr(message, "model_dump", None)
188
+ if dump is not None:
189
+ return dump(by_alias=True, exclude_none=True)
190
+ return message
191
+
192
+
193
+ async def with_file_lock(lock_path: Path | str, fn: Callable[[], Awaitable[Any] | Any], stale_ms: float = 30000) -> Any:
194
+ """Exclusive local lock via O_CREAT|O_EXCL. Stale locks (older than
195
+ stale_ms) are reclaimed. Single-machine correctness; distributed locking
196
+ is explicitly out of scope (TS parity, roadmap P3)."""
197
+ path = Path(lock_path)
198
+ remaining = 20 # bounded retry against reclaim races
199
+ while remaining > 0:
200
+ remaining -= 1
201
+ try:
202
+ path.parent.mkdir(parents=True, exist_ok=True)
203
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
204
+ os.close(fd)
205
+ break
206
+ except FileExistsError:
207
+ try:
208
+ age_ms = (time.time() - path.stat().st_mtime) * 1000
209
+ except FileNotFoundError:
210
+ continue # raced with a releaser; retry acquisition
211
+ if age_ms < stale_ms:
212
+ raise RuntimeError(f"Lock held: {path}") # plain error: callers may hold for a long time
213
+ try:
214
+ path.unlink()
215
+ except FileNotFoundError:
216
+ continue # raced with another releaser
217
+ except OSError as err:
218
+ raise AgentError("LOCK_ERROR", f"Lock error on {path}: {err}", retryable=False) from err
219
+ else:
220
+ raise AgentError("LOCK_ERROR", f"Could not acquire lock: {path}", retryable=False)
221
+
222
+ try:
223
+ result = fn()
224
+ if asyncio.iscoroutine(result) or asyncio.isfuture(result):
225
+ return await result
226
+ return result
227
+ finally:
228
+ try:
229
+ path.unlink()
230
+ except FileNotFoundError:
231
+ pass # already released or reclaimed; ignore (TS parity)
@@ -0,0 +1,200 @@
1
+ """Env config + provider factory. Parity: src/config + src/models/factory.
2
+
3
+ Walk-up .env loading (cwd, then 3 parents), TogetherAI preferred when its
4
+ key is set — same defaults as TS loadConfig/initChatModel.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .models import ModelProvider
13
+ from .models.openai_compatible import OpenAICompatibleOptions, OpenAICompatibleProvider
14
+ from .models.anthropic import AnthropicOptions, AnthropicProvider
15
+ from .models.ollama import OllamaOptions, OllamaProvider
16
+
17
+
18
+ def parse_env_file(text: str) -> dict[str, str]:
19
+ """Parse .env text into key/value pairs.
20
+
21
+ Divergences fixed against the classic dotenv loaders (TS parity checks):
22
+ - ``export KEY=v`` (sh-style export prefix) is supported
23
+ - unquoted trailing comments are stripped: ``KEY=v # note`` -> ``v``
24
+ - duplicate keys within ONE file: LAST wins (the real process env still
25
+ wins over every file value — callers apply these with setdefault)
26
+ """
27
+ values: dict[str, str] = {}
28
+ for raw_line in text.splitlines():
29
+ line = raw_line.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ if line.startswith("export "):
33
+ line = line[len("export "):].strip()
34
+ if "=" not in line:
35
+ continue
36
+ key, _, value = line.partition("=")
37
+ key = key.strip()
38
+ value = value.strip()
39
+ if len(value) >= 2 and value[0] in "'\"" and value[-1] == value[0]:
40
+ value = value[1:-1] # fully quoted: keep everything inside
41
+ elif " #" in value:
42
+ # Unquoted trailing comment: only " #" (hash after whitespace) so
43
+ # "a#b" values and quoted "# within value" stay intact.
44
+ value = value.split(" #", 1)[0].rstrip()
45
+ values[key] = value
46
+ return values
47
+
48
+
49
+ def _try_setuptools_free_dotenv() -> None: # minimal, dependency-free
50
+ import os
51
+
52
+ def load_env_file(path: Path) -> None:
53
+ try:
54
+ for key, value in parse_env_file(path.read_text(encoding="utf-8")).items():
55
+ # setdefault: real process env always wins over file values.
56
+ os.environ.setdefault(key, value)
57
+ except OSError:
58
+ pass
59
+
60
+ override = os.environ.get("FRAMEWORK_ENV_PATH")
61
+ if override:
62
+ load_env_file(Path(override))
63
+ return
64
+ load_env_file(Path.cwd() / ".env")
65
+ node = Path.cwd()
66
+ for _ in range(3):
67
+ node = node.parent
68
+ candidate = node / ".env"
69
+ if candidate.exists():
70
+ load_env_file(candidate)
71
+
72
+
73
+ _ENV_LOADED = False
74
+
75
+
76
+ def _ensure_env() -> None:
77
+ global _ENV_LOADED
78
+ if not _ENV_LOADED:
79
+ _try_setuptools_free_dotenv()
80
+ _ENV_LOADED = True
81
+
82
+
83
+ _DEFAULTS = {
84
+ "TOGETHER_BASE_URL": "https://api.together.xyz/v1",
85
+ "TOGETHER_MODEL": "Qwen/Qwen3.5-9B",
86
+ "OPENAI_BASE_URL": "https://api.openai.com/v1",
87
+ "OPENAI_MODEL": "gpt-4o-mini",
88
+ "OLLAMA_BASE_URL": "http://localhost:11434/v1",
89
+ "OLLAMA_MODEL": "qwen2.5",
90
+ }
91
+
92
+
93
+ @dataclass
94
+ class Config:
95
+ together_api_key: str
96
+ together_base_url: str
97
+ together_model: str
98
+ openai_api_key: str
99
+ openai_base_url: str
100
+ openai_model: str
101
+ anthropic_api_key: str
102
+ anthropic_base_url: str
103
+ anthropic_model: str
104
+ ollama_base_url: str
105
+ ollama_model: str
106
+
107
+ @property
108
+ def active(self) -> tuple[str, str, str, str]:
109
+ """(source, base_url, api_key, model) — Together wins when keyed."""
110
+ import os
111
+
112
+ if self.together_api_key:
113
+ return ("together", self.together_base_url, self.together_api_key, self.together_model)
114
+ return ("openai", self.openai_base_url, self.openai_api_key, self.openai_model)
115
+
116
+
117
+ _cache: Config | None = None
118
+
119
+
120
+ def load_config() -> Config:
121
+ global _cache
122
+ if _cache is None:
123
+ _ensure_env()
124
+ import os
125
+
126
+ _cache = Config(
127
+ together_api_key=os.environ.get("TOGETHER_API_KEY", ""),
128
+ together_base_url=os.environ.get("TOGETHER_BASE_URL", _DEFAULTS["TOGETHER_BASE_URL"]),
129
+ together_model=os.environ.get("TOGETHER_MODEL", _DEFAULTS["TOGETHER_MODEL"]),
130
+ openai_api_key=os.environ.get("OPENAI_API_KEY", ""),
131
+ openai_base_url=os.environ.get("OPENAI_BASE_URL", _DEFAULTS["OPENAI_BASE_URL"]),
132
+ openai_model=os.environ.get("OPENAI_MODEL", _DEFAULTS["OPENAI_MODEL"]),
133
+ anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY", ""),
134
+ anthropic_base_url=os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
135
+ anthropic_model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
136
+ ollama_base_url=os.environ.get("OLLAMA_BASE_URL", _DEFAULTS["OLLAMA_BASE_URL"]),
137
+ ollama_model=os.environ.get("OLLAMA_MODEL", _DEFAULTS["OLLAMA_MODEL"]),
138
+ )
139
+ return _cache
140
+
141
+
142
+ def init_chat_model(
143
+ spec: str,
144
+ *,
145
+ api_key: str | None = None,
146
+ base_url: str | None = None,
147
+ max_tokens: int | None = None,
148
+ stream: bool = False,
149
+ thinking_budget: int | None = None,
150
+ ) -> ModelProvider:
151
+ """Resolve `provider:model` strings (bare ids default to together)."""
152
+ cfg = load_config()
153
+ provider, _, model = (spec if ":" in spec else f"together:{spec}").partition(":")
154
+ extra = OpenAICompatibleOptions(
155
+ base_url=base_url or "",
156
+ api_key=api_key or "",
157
+ model=model,
158
+ max_tokens=max_tokens,
159
+ stream=stream,
160
+ )
161
+ if provider == "together":
162
+ key = api_key or cfg.together_api_key
163
+ if not key:
164
+ raise ValueError("init_chat_model: set TOGETHER_API_KEY in .env (or pass api_key)")
165
+ return OpenAICompatibleProvider(
166
+ OpenAICompatibleOptions(base_url=base_url or cfg.together_base_url, api_key=key, model=model,
167
+ max_tokens=max_tokens, stream=stream)
168
+ )
169
+ if provider == "openai":
170
+ key = api_key or cfg.openai_api_key
171
+ if not key:
172
+ raise ValueError("init_chat_model: set OPENAI_API_KEY in .env (or pass api_key)")
173
+ return OpenAICompatibleProvider(
174
+ OpenAICompatibleOptions(base_url=base_url or cfg.openai_base_url, api_key=key, model=model,
175
+ max_tokens=max_tokens, stream=stream)
176
+ )
177
+ if provider == "anthropic":
178
+ key = api_key or cfg.anthropic_api_key
179
+ if not key:
180
+ raise ValueError("init_chat_model: set ANTHROPIC_API_KEY in .env (or pass api_key)")
181
+ return AnthropicProvider(AnthropicOptions(
182
+ api_key=key,
183
+ model=model,
184
+ base_url=base_url or cfg.anthropic_base_url,
185
+ max_tokens=max_tokens if max_tokens is not None else 1024,
186
+ stream=stream,
187
+ thinking_budget=thinking_budget,
188
+ ))
189
+ if provider == "ollama":
190
+ return OllamaProvider(OllamaOptions(
191
+ model=model,
192
+ base_url=base_url or cfg.ollama_base_url,
193
+ max_tokens=max_tokens,
194
+ stream=stream,
195
+ ))
196
+ if provider == "custom":
197
+ if not base_url or not api_key:
198
+ raise ValueError("init_chat_model: custom: needs base_url + api_key")
199
+ return OpenAICompatibleProvider(extra)
200
+ raise ValueError(f'init_chat_model: unknown provider "{provider}" (use together:|openai:|anthropic:|ollama:|custom:)')
@@ -0,0 +1,249 @@
1
+ """Conversation managers keep history within context limits.
2
+
3
+ Parity: src/conversation/index.ts.
4
+
5
+ - apply(): after-run management (before memory.save).
6
+ - reduce(): explicit shrink (overflow recovery / proactive triggers).
7
+ - before_model(): per-turn management inside the loop (sliding window).
8
+ Pinned messages are never removed. Tool/assistant-tool_call pairs are
9
+ never orphaned: trimming drops leading dangling messages.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import inspect
15
+ import math
16
+ import re
17
+ from typing import Any, Callable, Protocol
18
+
19
+ from .models import CompleteOptions, ModelProvider
20
+ from .types import ChatMessage, TextBlock, ToolCallBlock, text_of
21
+
22
+ Summarizer = Callable[[list[ChatMessage]], Any] # str or awaitable str
23
+
24
+ # Word-ish runs, numbers, single CJK chars, punctuation clusters — close to
25
+ # subword behavior on prose (calibrated so estimate_tokens("abcd") == 1).
26
+ _TOKEN_RE = re.compile(
27
+ r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+(?:\.\d+)?|[一-鿿぀-ヿ가-힯]|[^A-Za-z\d\s]"
28
+ )
29
+ _WORD_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?$")
30
+ _CJK_RE = re.compile(r"[一-鿿぀-ヿ가-힯]")
31
+
32
+
33
+ def estimate_tokens(text: str) -> int:
34
+ """Local token estimate — dependency-free approximation, not a BPE."""
35
+ if not text:
36
+ return 0
37
+ tokens = 0
38
+ for w in _TOKEN_RE.findall(text):
39
+ if _CJK_RE.fullmatch(w):
40
+ tokens += 1
41
+ elif _WORD_RE.fullmatch(w):
42
+ tokens += max(1, math.ceil(len(w) / 5))
43
+ else:
44
+ tokens += 1
45
+ return tokens
46
+
47
+
48
+ class ConversationManager(Protocol):
49
+ """History-shrinking hook set. removed_count tracks load skipped so far."""
50
+
51
+ name: str
52
+ removed_count: int
53
+
54
+ async def apply(self, messages: list[ChatMessage]) -> list[ChatMessage]: ...
55
+ async def reduce(self, messages: list[ChatMessage]) -> list[ChatMessage]: ...
56
+
57
+
58
+ def _has_only_tool_calls(content: Any) -> bool:
59
+ if not isinstance(content, list):
60
+ return False
61
+ blocks = [
62
+ b
63
+ for b in content
64
+ if (b.get("type") if isinstance(b, dict) else b.type) == "tool_call"
65
+ or (
66
+ (b.get("type") if isinstance(b, dict) else b.type) == "text"
67
+ and ((b.get("text") if isinstance(b, dict) else b.text) or "").strip() == ""
68
+ )
69
+ ]
70
+ return len(blocks) > 0 and len(blocks) == len(content)
71
+
72
+
73
+ def trim_dangling(messages: list[ChatMessage]) -> list[ChatMessage]:
74
+ """Drop leading messages that lost their pair (tool head, call-only
75
+ assistant head). Pinned messages stop the scan: they are keepers, not
76
+ orphans."""
77
+ i = 0
78
+ while i < len(messages):
79
+ m = messages[i]
80
+ if m.pinned:
81
+ break
82
+ if m.role == "tool":
83
+ i += 1
84
+ continue
85
+ if m.role == "assistant" and _has_only_tool_calls(m.content):
86
+ i += 1
87
+ continue
88
+ break
89
+ return messages[i:]
90
+
91
+
92
+ def message_tokens(m: ChatMessage) -> int:
93
+ text = m.content if isinstance(m.content, str) else text_of(m.content)
94
+ return estimate_tokens(text)
95
+
96
+
97
+ def history_tokens(messages: list[ChatMessage]) -> int:
98
+ return sum(message_tokens(m) for m in messages)
99
+
100
+
101
+ class NullConversationManager:
102
+ """Keeps full history. Pass explicitly to disable the default window."""
103
+
104
+ name = "null"
105
+ removed_count = 0
106
+
107
+ async def apply(self, messages: list[ChatMessage]) -> list[ChatMessage]:
108
+ return list(messages)
109
+
110
+ async def reduce(self, messages: list[ChatMessage]) -> list[ChatMessage]:
111
+ return list(messages)
112
+
113
+ async def before_model(self, messages: list[ChatMessage], step: int) -> list[ChatMessage]:
114
+ return list(messages)
115
+
116
+
117
+ class SlidingWindowConversationManager:
118
+ """Keep the newest `window_size` unpinned messages; pinned survive."""
119
+
120
+ name = "sliding-window"
121
+
122
+ def __init__(
123
+ self,
124
+ window_size: int = 40,
125
+ pin_first: int = 0,
126
+ per_turn: bool | int = False,
127
+ ) -> None:
128
+ self.window_size = max(1, window_size)
129
+ self.pin_first = max(0, pin_first)
130
+ self.per_turn = per_turn
131
+ self.removed_count = 0
132
+
133
+ @staticmethod
134
+ def _pin(messages: list[ChatMessage], pin_first: int) -> None:
135
+ for m in messages[: min(pin_first, len(messages))]:
136
+ m.pinned = True
137
+
138
+ def _trim(self, messages: list[ChatMessage]) -> list[ChatMessage]:
139
+ self._pin(messages, self.pin_first)
140
+ keep: set[int] = set()
141
+ rest: list[int] = []
142
+ for idx, m in enumerate(messages):
143
+ if m.pinned:
144
+ keep.add(idx)
145
+ else:
146
+ rest.append(idx)
147
+ dropped = max(0, len(rest) - self.window_size)
148
+ self.removed_count += dropped
149
+ keep.update(rest[dropped:])
150
+ # Merge in ORIGINAL order: hoisting pinned messages to the front would
151
+ # reorder the conversation whenever a mid-history message is pinned.
152
+ return trim_dangling([m for i, m in enumerate(messages) if i in keep])
153
+
154
+ async def apply(self, messages: list[ChatMessage]) -> list[ChatMessage]:
155
+ self._pin(messages, self.pin_first)
156
+ return self._trim(messages)
157
+
158
+ async def reduce(self, messages: list[ChatMessage]) -> list[ChatMessage]:
159
+ return self._trim(list(messages))
160
+
161
+ async def before_model(self, messages: list[ChatMessage], step: int) -> list[ChatMessage]:
162
+ if self.per_turn is False:
163
+ return messages
164
+ if isinstance(self.per_turn, int) and (step % self.per_turn != 0 or self.per_turn <= 0):
165
+ return messages
166
+ return self._trim(list(messages))
167
+
168
+
169
+ class SummarizingConversationManager:
170
+ """Summarize the oldest fraction away into a single boundary message."""
171
+
172
+ name = "summarizing"
173
+
174
+ def __init__(
175
+ self,
176
+ summary_ratio: float = 0.3,
177
+ preserve_recent: int = 10,
178
+ pin_first: int = 0,
179
+ summarizer: Summarizer | None = None,
180
+ model: ModelProvider | None = None,
181
+ summary_prefix: str = "Summary of earlier conversation:",
182
+ ) -> None:
183
+ self.ratio = min(0.8, max(0.1, summary_ratio))
184
+ self.preserve_recent = max(1, preserve_recent)
185
+ self.pin_first = max(0, pin_first)
186
+ self.summarizer = summarizer
187
+ self.model = model
188
+ self.summary_prefix = summary_prefix
189
+ self.removed_count = 0
190
+ # Set when the last apply() summarized (tests/UI can assert on it).
191
+ self.last_summary: str | None = None
192
+
193
+ def _summarizable(self, messages: list[ChatMessage]) -> tuple[list[ChatMessage], list[ChatMessage]]:
194
+ self._pin(messages)
195
+ eligible_idx = [i for i, m in enumerate(messages) if not m.pinned]
196
+ count = min(
197
+ len(eligible_idx) - self.preserve_recent,
198
+ math.floor(len(eligible_idx) * self.ratio),
199
+ )
200
+ if count <= 0:
201
+ return [], list(messages)
202
+ cutoff = set(eligible_idx[:count])
203
+ head = [m for i, m in enumerate(messages) if i in cutoff]
204
+ tail = [m for i, m in enumerate(messages) if i not in cutoff]
205
+ return head, tail
206
+
207
+ def _pin(self, messages: list[ChatMessage]) -> None:
208
+ SlidingWindowConversationManager._pin(messages, self.pin_first)
209
+
210
+ def _transcript(self, messages: list[ChatMessage]) -> str:
211
+ return "\n".join(
212
+ f"[{m.role}] {m.content if isinstance(m.content, str) else text_of(m.content)}"
213
+ for m in messages
214
+ )
215
+
216
+ async def _summarize(self, head: list[ChatMessage]) -> str:
217
+ if self.summarizer is not None:
218
+ result = self.summarizer(head)
219
+ return await result if inspect.isawaitable(result) else result
220
+ if self.model is None:
221
+ # Extractive stub: first 200 chars of each message.
222
+ return "\n".join(
223
+ f"[{m.role}] {(m.content if isinstance(m.content, str) else text_of(m.content))[:200]}"
224
+ for m in head
225
+ )
226
+ completion = await self.model.complete(
227
+ CompleteOptions(
228
+ messages=[
229
+ ChatMessage(
230
+ role="user",
231
+ content=f"Summarize concisely, preserving decisions, names, tool outcomes:\n{self._transcript(head)}",
232
+ )
233
+ ],
234
+ tools=[],
235
+ )
236
+ )
237
+ return completion.text
238
+
239
+ async def apply(self, messages: list[ChatMessage]) -> list[ChatMessage]:
240
+ head, tail = self._summarizable(messages)
241
+ if not head:
242
+ return list(messages)
243
+ summary = await self._summarize(head)
244
+ self.last_summary = summary
245
+ self.removed_count += len(head) - 1
246
+ return [ChatMessage(role="assistant", content=f"{self.summary_prefix}\n{summary}"), *trim_dangling(tail)]
247
+
248
+ async def reduce(self, messages: list[ChatMessage]) -> list[ChatMessage]:
249
+ return list(messages)