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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- 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)
|