agentkai 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.
- agentkai/__init__.py +54 -0
- agentkai/agent.py +331 -0
- agentkai/browser.py +631 -0
- agentkai/channels/README.md +117 -0
- agentkai/channels/__init__.py +88 -0
- agentkai/channels/core.py +507 -0
- agentkai/channels/discord.py +126 -0
- agentkai/channels/examples/whatsapp-sidecar/package.json +1 -0
- agentkai/channels/examples/whatsapp-sidecar/server.js +74 -0
- agentkai/channels/static/chat.html +106 -0
- agentkai/channels/telegram.py +121 -0
- agentkai/channels/webchat.py +182 -0
- agentkai/channels/whatsapp.py +130 -0
- agentkai/cli.py +505 -0
- agentkai/dashboard/API.md +84 -0
- agentkai/dashboard/__init__.py +35 -0
- agentkai/dashboard/server.py +535 -0
- agentkai/dashboard/static/app.js +478 -0
- agentkai/dashboard/static/img/logo.gif +0 -0
- agentkai/dashboard/static/index.html +66 -0
- agentkai/dashboard/static/style.css +246 -0
- agentkai/devices/README.md +66 -0
- agentkai/devices/__init__.py +455 -0
- agentkai/events.py +178 -0
- agentkai/goals.py +509 -0
- agentkai/mcp.py +515 -0
- agentkai/media.py +616 -0
- agentkai/memory/__init__.py +192 -0
- agentkai/permissions.py +175 -0
- agentkai/providers.py +495 -0
- agentkai/scheduler/__init__.py +485 -0
- agentkai/skills.py +374 -0
- agentkai/skills_bundle/__init__.py +9 -0
- agentkai/skills_bundle/_common.py +58 -0
- agentkai/skills_bundle/_http.py +116 -0
- agentkai/skills_bundle/github/SKILL.md +35 -0
- agentkai/skills_bundle/github/tools.py +230 -0
- agentkai/skills_bundle/gmail/SKILL.md +41 -0
- agentkai/skills_bundle/gmail/tools.py +195 -0
- agentkai/skills_bundle/google_calendar/SKILL.md +38 -0
- agentkai/skills_bundle/google_calendar/tools.py +211 -0
- agentkai/skills_bundle/health/SKILL.md +53 -0
- agentkai/skills_bundle/health/tools.py +279 -0
- agentkai/skills_bundle/image_search/SKILL.md +40 -0
- agentkai/skills_bundle/image_search/tools.py +62 -0
- agentkai/skills_bundle/media/SKILL.md +62 -0
- agentkai/skills_bundle/media/tools.py +9 -0
- agentkai/skills_bundle/payments/SKILL.md +55 -0
- agentkai/skills_bundle/payments/tools.py +129 -0
- agentkai/skills_bundle/places/SKILL.md +47 -0
- agentkai/skills_bundle/places/tools.py +153 -0
- agentkai/skills_bundle/shopping/SKILL.md +65 -0
- agentkai/skills_bundle/shopping/tools.py +131 -0
- agentkai/skills_bundle/spotify/SKILL.md +38 -0
- agentkai/skills_bundle/spotify/tools.py +194 -0
- agentkai/skills_bundle/travel/SKILL.md +68 -0
- agentkai/skills_bundle/travel/tools.py +191 -0
- agentkai/skills_bundle/voice/SKILL.md +62 -0
- agentkai/skills_bundle/voice/tools.py +9 -0
- agentkai/subagents.py +386 -0
- agentkai/tools/__init__.py +519 -0
- agentkai/voice.py +574 -0
- agentkai/widgets/PROTOCOL.md +114 -0
- agentkai/widgets/__init__.py +247 -0
- agentkai-0.1.0.dist-info/METADATA +139 -0
- agentkai-0.1.0.dist-info/RECORD +70 -0
- agentkai-0.1.0.dist-info/WHEEL +5 -0
- agentkai-0.1.0.dist-info/entry_points.txt +2 -0
- agentkai-0.1.0.dist-info/licenses/LICENSE +176 -0
- agentkai-0.1.0.dist-info/top_level.txt +1 -0
agentkai/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""agentkai: model-agnostic personal AI agent."""
|
|
2
|
+
from .agent import Agent, RunResult
|
|
3
|
+
from .events import Event, EventLog, list_runs, replay, summarize
|
|
4
|
+
from .permissions import Action, PermissionGate, cli_ask
|
|
5
|
+
from .providers import (
|
|
6
|
+
ALIASES,
|
|
7
|
+
LLMClient,
|
|
8
|
+
LLMMessage,
|
|
9
|
+
ProviderConfig,
|
|
10
|
+
ToolCall,
|
|
11
|
+
normalize_tool_calls,
|
|
12
|
+
probe_capabilities,
|
|
13
|
+
resolve_fallbacks,
|
|
14
|
+
resolve_model,
|
|
15
|
+
)
|
|
16
|
+
from .scheduler import Job, Scheduler
|
|
17
|
+
from .subagents import (
|
|
18
|
+
ChildRun,
|
|
19
|
+
SubagentManager,
|
|
20
|
+
default_manager,
|
|
21
|
+
readonly_subset,
|
|
22
|
+
scoped_registry,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Agent",
|
|
29
|
+
"RunResult",
|
|
30
|
+
"Event",
|
|
31
|
+
"EventLog",
|
|
32
|
+
"list_runs",
|
|
33
|
+
"replay",
|
|
34
|
+
"summarize",
|
|
35
|
+
"Action",
|
|
36
|
+
"PermissionGate",
|
|
37
|
+
"cli_ask",
|
|
38
|
+
"ALIASES",
|
|
39
|
+
"LLMClient",
|
|
40
|
+
"LLMMessage",
|
|
41
|
+
"ProviderConfig",
|
|
42
|
+
"ToolCall",
|
|
43
|
+
"normalize_tool_calls",
|
|
44
|
+
"probe_capabilities",
|
|
45
|
+
"resolve_fallbacks",
|
|
46
|
+
"resolve_model",
|
|
47
|
+
"Job",
|
|
48
|
+
"Scheduler",
|
|
49
|
+
"ChildRun",
|
|
50
|
+
"SubagentManager",
|
|
51
|
+
"default_manager",
|
|
52
|
+
"readonly_subset",
|
|
53
|
+
"scoped_registry",
|
|
54
|
+
]
|
agentkai/agent.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Hardened ReAct agent loop: think -> tool-call -> observe -> repeat.
|
|
2
|
+
|
|
3
|
+
Provider-agnostic: works with any model behind :class:`LLMClient`, as long
|
|
4
|
+
as it supports function calling (Claude, Gemini, GPT, most others; small
|
|
5
|
+
local models vary — see research/ARCHITECTURE.md).
|
|
6
|
+
|
|
7
|
+
Hardening over the v0 scaffold:
|
|
8
|
+
- streaming responses with incremental text deltas (``on_text`` callback)
|
|
9
|
+
- tool calls normalized to one :class:`ToolCall` dataclass across providers
|
|
10
|
+
- per-step timeout, total run timeout, cooperative cancellation
|
|
11
|
+
- context-window budget: oldest tool outputs are truncated first, then
|
|
12
|
+
compacted into a short summary
|
|
13
|
+
- max-iteration guard
|
|
14
|
+
- permission gate (allow/ask/deny) consulted before every tool call
|
|
15
|
+
- every step recorded to an append-only JSONL event log
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
import litellm
|
|
26
|
+
|
|
27
|
+
from .events import Event, EventLog
|
|
28
|
+
from .permissions import Action, PermissionGate
|
|
29
|
+
from .providers import LLMClient, ProviderConfig, ToolCall
|
|
30
|
+
from .tools import default_registry
|
|
31
|
+
|
|
32
|
+
SYSTEM_PROMPT = """You are agentkai, a personal AI assistant running on the \
|
|
33
|
+
user's own machine. You have tools: use them to get things done instead of \
|
|
34
|
+
guessing. Be concise, honest about uncertainty, and never invent file contents, \
|
|
35
|
+
URLs, or results — read them with your tools first."""
|
|
36
|
+
|
|
37
|
+
# Rough token estimate when the provider can't count (chars/4 is the
|
|
38
|
+
# standard heuristic; ARCHITECTURE.md notes provider counters are 10-30% off
|
|
39
|
+
# for non-OpenAI models, so this only drives compaction, never hard limits).
|
|
40
|
+
CHARS_PER_TOKEN = 4
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RunResult:
|
|
45
|
+
run_id: str
|
|
46
|
+
text: str
|
|
47
|
+
status: str # done | max_iterations | timeout | cancelled | error
|
|
48
|
+
iterations: int
|
|
49
|
+
events_path: Path
|
|
50
|
+
error: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def estimate_tokens(messages: list[dict], model: str) -> int:
|
|
54
|
+
"""Best-effort token count for a message list.
|
|
55
|
+
|
|
56
|
+
Tries LiteLLM's counter first; falls back to chars/4. Used only for
|
|
57
|
+
context-budget compaction — never for billing or hard provider limits.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
return int(litellm.token_counter(model=model, messages=messages))
|
|
61
|
+
except Exception:
|
|
62
|
+
total = 0
|
|
63
|
+
for m in messages:
|
|
64
|
+
total += len(str(m.get("content", "")))
|
|
65
|
+
for tc in m.get("tool_calls", []) or []:
|
|
66
|
+
fn = (tc.get("function", {}) if isinstance(tc, dict)
|
|
67
|
+
else getattr(tc, "function", None))
|
|
68
|
+
if isinstance(fn, dict):
|
|
69
|
+
total += len(str(fn.get("arguments", "")))
|
|
70
|
+
elif fn is not None:
|
|
71
|
+
total += len(str(getattr(fn, "arguments", "")))
|
|
72
|
+
return total // CHARS_PER_TOKEN
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Agent:
|
|
76
|
+
"""The hardened agent loop."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
model: str = "claude",
|
|
81
|
+
system_prompt: str = SYSTEM_PROMPT,
|
|
82
|
+
registry=None,
|
|
83
|
+
gate: PermissionGate | None = None,
|
|
84
|
+
client: LLMClient | None = None,
|
|
85
|
+
config: ProviderConfig | None = None,
|
|
86
|
+
max_iterations: int = 25,
|
|
87
|
+
step_timeout: float = 180,
|
|
88
|
+
total_timeout: float = 1200,
|
|
89
|
+
tool_timeout: float = 300,
|
|
90
|
+
max_context_tokens: int = 100_000,
|
|
91
|
+
events_root: str | Path | None = None,
|
|
92
|
+
on_text: Callable[[str], None] | None = None,
|
|
93
|
+
on_event: Callable[[Event], None] | None = None,
|
|
94
|
+
**client_kwargs,
|
|
95
|
+
):
|
|
96
|
+
self.system_prompt = system_prompt
|
|
97
|
+
self.registry = registry or default_registry()
|
|
98
|
+
self.gate = gate or PermissionGate()
|
|
99
|
+
self.config = config or ProviderConfig.load()
|
|
100
|
+
self.client = client or LLMClient(model, config=self.config,
|
|
101
|
+
**client_kwargs)
|
|
102
|
+
self.model_name = self.client.model
|
|
103
|
+
self.max_iterations = max_iterations
|
|
104
|
+
self.step_timeout = step_timeout
|
|
105
|
+
self.total_timeout = total_timeout
|
|
106
|
+
self.tool_timeout = tool_timeout
|
|
107
|
+
self.max_context_tokens = max_context_tokens
|
|
108
|
+
self.events_root = events_root
|
|
109
|
+
self.on_text = on_text
|
|
110
|
+
self.on_event = on_event
|
|
111
|
+
self.messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
|
112
|
+
|
|
113
|
+
# -- public API ------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def run(self, prompt: str,
|
|
116
|
+
cancel_event: threading.Event | None = None,
|
|
117
|
+
on_text: Callable[[str], None] | None = None) -> RunResult:
|
|
118
|
+
"""Run the ReAct loop to completion. Returns a RunResult."""
|
|
119
|
+
cancel = cancel_event or threading.Event()
|
|
120
|
+
emit = on_text or self.on_text
|
|
121
|
+
log = EventLog(root=self.events_root, on_event=self.on_event)
|
|
122
|
+
deadline = time.monotonic() + self.total_timeout
|
|
123
|
+
self.messages.append({"role": "user", "content": prompt})
|
|
124
|
+
log.record("run_start", model=self.model_name, prompt=prompt,
|
|
125
|
+
max_iterations=self.max_iterations)
|
|
126
|
+
|
|
127
|
+
def cancelled() -> bool:
|
|
128
|
+
return cancel.is_set() or time.monotonic() >= deadline
|
|
129
|
+
|
|
130
|
+
status, final_text, error = "done", "", ""
|
|
131
|
+
iterations = 0
|
|
132
|
+
try:
|
|
133
|
+
for i in range(self.max_iterations):
|
|
134
|
+
iterations = i + 1
|
|
135
|
+
if cancel.is_set():
|
|
136
|
+
status = "cancelled"
|
|
137
|
+
break
|
|
138
|
+
if time.monotonic() >= deadline:
|
|
139
|
+
status = "timeout"
|
|
140
|
+
error = (f"total run timeout ({self.total_timeout}s) "
|
|
141
|
+
"exceeded")
|
|
142
|
+
break
|
|
143
|
+
self._enforce_budget()
|
|
144
|
+
remaining = max(1.0, deadline - time.monotonic())
|
|
145
|
+
step_to = min(self.step_timeout, remaining)
|
|
146
|
+
try:
|
|
147
|
+
msg = self._llm_step(step_to, cancel, emit)
|
|
148
|
+
except TimeoutError as exc:
|
|
149
|
+
status = "timeout"
|
|
150
|
+
error = str(exc)
|
|
151
|
+
break
|
|
152
|
+
log.record("llm_message", text=msg.text,
|
|
153
|
+
tool_calls=[{"id": tc.id, "name": tc.name,
|
|
154
|
+
"arguments": tc.arguments}
|
|
155
|
+
for tc in msg.tool_calls])
|
|
156
|
+
# (text deltas were already streamed via on_text during the step)
|
|
157
|
+
if not msg.tool_calls:
|
|
158
|
+
final_text = msg.text
|
|
159
|
+
status = "done"
|
|
160
|
+
break
|
|
161
|
+
self.messages.append({
|
|
162
|
+
"role": "assistant",
|
|
163
|
+
"content": msg.text,
|
|
164
|
+
"tool_calls": [
|
|
165
|
+
{"id": tc.id, "type": "function",
|
|
166
|
+
"function": {"name": tc.name,
|
|
167
|
+
"arguments": tc.raw_arguments
|
|
168
|
+
or "{}"}}
|
|
169
|
+
for tc in msg.tool_calls],
|
|
170
|
+
})
|
|
171
|
+
for tc in msg.tool_calls:
|
|
172
|
+
if cancelled():
|
|
173
|
+
status = "cancelled" if cancel.is_set() else "timeout"
|
|
174
|
+
break
|
|
175
|
+
self._exec_tool(tc, log, cancel)
|
|
176
|
+
else:
|
|
177
|
+
continue
|
|
178
|
+
break
|
|
179
|
+
else:
|
|
180
|
+
status = "max_iterations"
|
|
181
|
+
error = (f"stopped after {self.max_iterations} iterations "
|
|
182
|
+
"without a final answer")
|
|
183
|
+
final_text = error
|
|
184
|
+
except Exception as exc: # noqa: BLE001 - run must always report
|
|
185
|
+
status = "error"
|
|
186
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
187
|
+
final_text = error
|
|
188
|
+
finally:
|
|
189
|
+
log.record("run_end", status=status, final_text=final_text[:2000],
|
|
190
|
+
error=error, iterations=iterations)
|
|
191
|
+
events_path = log.path
|
|
192
|
+
log.close()
|
|
193
|
+
return RunResult(run_id=log.run_id, text=final_text, status=status,
|
|
194
|
+
iterations=iterations, events_path=events_path,
|
|
195
|
+
error=error)
|
|
196
|
+
|
|
197
|
+
def chat(self, text: str) -> str:
|
|
198
|
+
"""Non-streaming convenience: run one prompt, return final text."""
|
|
199
|
+
return self.run(text).text
|
|
200
|
+
|
|
201
|
+
# -- one LLM step with timeout ----------------------------------------
|
|
202
|
+
|
|
203
|
+
def _llm_step(self, timeout: float, cancel: threading.Event,
|
|
204
|
+
emit: Callable[[str], None] | None):
|
|
205
|
+
"""Run client.generate in a worker thread; bound by ``timeout``.
|
|
206
|
+
|
|
207
|
+
The worker is a daemon: on timeout the agent moves on (the stuck
|
|
208
|
+
network read is abandoned, not leaked into the loop).
|
|
209
|
+
"""
|
|
210
|
+
box: dict = {}
|
|
211
|
+
|
|
212
|
+
def _target():
|
|
213
|
+
try:
|
|
214
|
+
box["msg"] = self.client.generate(
|
|
215
|
+
self.messages, tools=self.registry.schemas(),
|
|
216
|
+
on_delta=emit, cancel_event=cancel)
|
|
217
|
+
except Exception as exc: # noqa: BLE001
|
|
218
|
+
box["exc"] = exc
|
|
219
|
+
|
|
220
|
+
t = threading.Thread(target=_target, daemon=True)
|
|
221
|
+
t.start()
|
|
222
|
+
t.join(timeout)
|
|
223
|
+
if t.is_alive():
|
|
224
|
+
raise TimeoutError(f"LLM step timed out after {timeout:.0f}s")
|
|
225
|
+
if "exc" in box:
|
|
226
|
+
# Preserve cancellation/timeout semantics from the client.
|
|
227
|
+
raise box["exc"]
|
|
228
|
+
if "msg" not in box:
|
|
229
|
+
raise RuntimeError("LLM step produced no response")
|
|
230
|
+
return box["msg"]
|
|
231
|
+
|
|
232
|
+
# -- tool execution with permission gate --------------------------------
|
|
233
|
+
|
|
234
|
+
def _exec_tool(self, tc: ToolCall, log: EventLog,
|
|
235
|
+
cancel: threading.Event) -> None:
|
|
236
|
+
log.record("tool_call", id=tc.id, name=tc.name,
|
|
237
|
+
arguments=tc.arguments)
|
|
238
|
+
try:
|
|
239
|
+
tool = self.registry.get(tc.name)
|
|
240
|
+
except KeyError:
|
|
241
|
+
result = f"ERROR: unknown tool {tc.name!r}"
|
|
242
|
+
ok = False
|
|
243
|
+
else:
|
|
244
|
+
action = Action(tool=tc.name, args=tc.arguments, risk=tool.risk)
|
|
245
|
+
decision = self.gate.check(action)
|
|
246
|
+
log.record("approval", tool=tc.name, decision=decision,
|
|
247
|
+
risk=tool.risk)
|
|
248
|
+
if decision != "allow":
|
|
249
|
+
result = (f"ERROR: permission gate denied {tc.name}: "
|
|
250
|
+
f"decision={decision}")
|
|
251
|
+
ok = False
|
|
252
|
+
else:
|
|
253
|
+
result, ok = self._run_tool_guarded(tool, tc.arguments,
|
|
254
|
+
cancel)
|
|
255
|
+
if not isinstance(result, str):
|
|
256
|
+
import json as _json
|
|
257
|
+
result = _json.dumps(result, default=str)
|
|
258
|
+
log.record_tool_result(tc.id, tc.name, result, ok=ok)
|
|
259
|
+
self.messages.append({"role": "tool", "tool_call_id": tc.id,
|
|
260
|
+
"name": tc.name, "content": result})
|
|
261
|
+
|
|
262
|
+
def _run_tool_guarded(self, tool, args: dict,
|
|
263
|
+
cancel: threading.Event) -> tuple[str, bool]:
|
|
264
|
+
"""Run a tool in a worker thread with a timeout. Tools report
|
|
265
|
+
errors as strings; unexpected exceptions become ERROR strings."""
|
|
266
|
+
box: dict = {}
|
|
267
|
+
|
|
268
|
+
def _target():
|
|
269
|
+
try:
|
|
270
|
+
box["out"] = tool.run(**args)
|
|
271
|
+
except Exception as exc: # noqa: BLE001
|
|
272
|
+
box["out"] = f"ERROR: {type(exc).__name__}: {exc}"
|
|
273
|
+
|
|
274
|
+
t = threading.Thread(target=_target, daemon=True)
|
|
275
|
+
t.start()
|
|
276
|
+
# Cooperative cancellation: poll in small slices.
|
|
277
|
+
waited = 0.0
|
|
278
|
+
while t.is_alive() and waited < self.tool_timeout:
|
|
279
|
+
if cancel.is_set():
|
|
280
|
+
return "ERROR: cancelled by user", False
|
|
281
|
+
time.sleep(0.05)
|
|
282
|
+
waited += 0.05
|
|
283
|
+
if t.is_alive():
|
|
284
|
+
return (f"ERROR: tool {tool.name} timed out after "
|
|
285
|
+
f"{self.tool_timeout:.0f}s", False)
|
|
286
|
+
out = box.get("out", "ERROR: tool produced no output")
|
|
287
|
+
ok = not (isinstance(out, str) and out.startswith("ERROR:"))
|
|
288
|
+
return out, ok
|
|
289
|
+
|
|
290
|
+
# -- context budget -----------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _enforce_budget(self) -> None:
|
|
293
|
+
"""Keep estimated tokens under budget.
|
|
294
|
+
|
|
295
|
+
1. Truncate the oldest tool outputs first (keep the newest two
|
|
296
|
+
intact — the model usually needs those).
|
|
297
|
+
2. If still over budget, compact older tool exchanges into a short
|
|
298
|
+
deterministic summary.
|
|
299
|
+
"""
|
|
300
|
+
if estimate_tokens(self.messages, self.model_name) \
|
|
301
|
+
<= self.max_context_tokens:
|
|
302
|
+
return
|
|
303
|
+
tool_msgs = [m for m in self.messages if m.get("role") == "tool"]
|
|
304
|
+
# Phase 1: truncate oldest tool outputs, newest two untouched.
|
|
305
|
+
for m in tool_msgs[:-2]:
|
|
306
|
+
content = str(m.get("content", ""))
|
|
307
|
+
if len(content) > 800:
|
|
308
|
+
m["content"] = (content[:800]
|
|
309
|
+
+ f"\n... [truncated "
|
|
310
|
+
f"{len(content) - 800} chars for context]")
|
|
311
|
+
if estimate_tokens(self.messages, self.model_name) \
|
|
312
|
+
<= self.max_context_tokens:
|
|
313
|
+
return
|
|
314
|
+
# Phase 2: compact all but the last 4 tool exchanges into a summary.
|
|
315
|
+
keep_ids = {id(m) for m in tool_msgs[-4:]}
|
|
316
|
+
drop = [m for m in tool_msgs if id(m) not in keep_ids]
|
|
317
|
+
if drop:
|
|
318
|
+
lines = []
|
|
319
|
+
for m in drop:
|
|
320
|
+
first = str(m.get("content", "")).split("\n")[0][:160]
|
|
321
|
+
lines.append(f"- {m.get('name')}: {first}")
|
|
322
|
+
summary = ("[earlier tool activity compacted for context: "
|
|
323
|
+
f"{len(drop)} calls]\n" + "\n".join(lines))
|
|
324
|
+
# Replace the dropped messages' content in place (keeps ids).
|
|
325
|
+
for m in drop:
|
|
326
|
+
m["content"] = "[compacted]"
|
|
327
|
+
# Insert the summary right before the first kept tool message.
|
|
328
|
+
kept = [m for m in tool_msgs if id(m) in keep_ids]
|
|
329
|
+
idx = self.messages.index(kept[0]) if kept else len(self.messages)
|
|
330
|
+
self.messages.insert(idx, {"role": "user",
|
|
331
|
+
"content": summary})
|