mindagent 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.
- mindagent/__init__.py +1 -0
- mindagent/context/__init__.py +25 -0
- mindagent/context/manager.py +376 -0
- mindagent/context/models.py +63 -0
- mindagent/context/packer.py +217 -0
- mindagent/context/store.py +53 -0
- mindagent/core/__init__.py +54 -0
- mindagent/core/context.py +86 -0
- mindagent/core/contracts.py +53 -0
- mindagent/core/event.py +46 -0
- mindagent/core/heartbeat.py +39 -0
- mindagent/core/react_loop.py +321 -0
- mindagent/core/runtime.py +284 -0
- mindagent/core/state_machine.py +84 -0
- mindagent/core/trace.py +72 -0
- mindagent/providers/__init__.py +18 -0
- mindagent/providers/base.py +81 -0
- mindagent/providers/openai/__init__.py +4 -0
- mindagent/providers/openai/param.py +65 -0
- mindagent/providers/openai/provider.py +262 -0
- mindagent/providers/param.py +14 -0
- mindagent/providers/reasoner.py +89 -0
- mindagent/providers/router.py +77 -0
- mindagent/tools/__init__.py +38 -0
- mindagent/tools/base.py +86 -0
- mindagent/tools/builtin/__init__.py +13 -0
- mindagent/tools/builtin/calculator.py +87 -0
- mindagent/tools/builtin/context_query.py +41 -0
- mindagent/tools/builtin/image_understanding.py +69 -0
- mindagent/tools/builtin/memory.py +51 -0
- mindagent/tools/builtin/time_now.py +44 -0
- mindagent/tools/executor.py +86 -0
- mindagent/tools/registry.py +125 -0
- mindagent-0.1.0.dist-info/METADATA +309 -0
- mindagent-0.1.0.dist-info/RECORD +36 -0
- mindagent-0.1.0.dist-info/WHEEL +4 -0
mindagent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""mindagent public package."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .manager import ContextConfig, ContextManager
|
|
2
|
+
from .models import (
|
|
3
|
+
ContextBundle,
|
|
4
|
+
ContextPackResult,
|
|
5
|
+
ContextPressure,
|
|
6
|
+
ContextPressureLevel,
|
|
7
|
+
ContextRecord,
|
|
8
|
+
ContextScope,
|
|
9
|
+
)
|
|
10
|
+
from .packer import CacheAwarePacker, ContextOverflowError
|
|
11
|
+
from .store import ContextStore
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CacheAwarePacker",
|
|
15
|
+
"ContextBundle",
|
|
16
|
+
"ContextConfig",
|
|
17
|
+
"ContextManager",
|
|
18
|
+
"ContextOverflowError",
|
|
19
|
+
"ContextPackResult",
|
|
20
|
+
"ContextPressure",
|
|
21
|
+
"ContextPressureLevel",
|
|
22
|
+
"ContextRecord",
|
|
23
|
+
"ContextScope",
|
|
24
|
+
"ContextStore",
|
|
25
|
+
]
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mindagent.core.context import (
|
|
8
|
+
AgentContext,
|
|
9
|
+
AgentDecision,
|
|
10
|
+
DecisionType,
|
|
11
|
+
Observation,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .models import ContextPackResult, ContextRecord, ContextScope
|
|
15
|
+
from .packer import CacheAwarePacker
|
|
16
|
+
from .store import ContextStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ContextConfig:
|
|
21
|
+
system_prompt: str | None = None
|
|
22
|
+
max_tokens: int = 120000
|
|
23
|
+
response_reserve_tokens: int = 4096
|
|
24
|
+
safety_margin_tokens: int = 256
|
|
25
|
+
warning_ratio: float = 0.70
|
|
26
|
+
compact_trigger_ratio: float = 0.85
|
|
27
|
+
compact_target_ratio: float = 0.60
|
|
28
|
+
model: str = "gpt-4o"
|
|
29
|
+
image_token_cost: int = 1024
|
|
30
|
+
use_tiktoken: bool = False
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.max_tokens < 1:
|
|
34
|
+
raise ValueError("max_tokens 必须大于 0")
|
|
35
|
+
if self.response_reserve_tokens < 0:
|
|
36
|
+
raise ValueError("response_reserve_tokens 不能小于 0")
|
|
37
|
+
if self.safety_margin_tokens < 0:
|
|
38
|
+
raise ValueError("safety_margin_tokens 不能小于 0")
|
|
39
|
+
if self.image_token_cost < 0:
|
|
40
|
+
raise ValueError("image_token_cost 不能小于 0")
|
|
41
|
+
if not (
|
|
42
|
+
0 < self.warning_ratio
|
|
43
|
+
< self.compact_trigger_ratio
|
|
44
|
+
<= 1
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("warning/compact trigger 比例配置无效")
|
|
47
|
+
if not 0 < self.compact_target_ratio < self.compact_trigger_ratio:
|
|
48
|
+
raise ValueError("compact_target_ratio 必须小于触发比例")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class _RunContextState:
|
|
53
|
+
store: ContextStore
|
|
54
|
+
epoch: int = 0
|
|
55
|
+
packed_once: bool = False
|
|
56
|
+
dropped_signature: tuple[str, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ContextManager:
|
|
60
|
+
def __init__(self, config: ContextConfig | None = None):
|
|
61
|
+
self.config = config or ContextConfig()
|
|
62
|
+
self._encoder = (
|
|
63
|
+
self._load_encoder(self.config.model)
|
|
64
|
+
if self.config.use_tiktoken
|
|
65
|
+
else None
|
|
66
|
+
)
|
|
67
|
+
self._runs: dict[str, _RunContextState] = {}
|
|
68
|
+
|
|
69
|
+
async def build_context(self, context: AgentContext) -> None:
|
|
70
|
+
if context.metadata.get("_context_built"):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
state = self._state(context)
|
|
74
|
+
self._ingest_existing_messages(state.store, context.messages)
|
|
75
|
+
if self.config.system_prompt and not any(
|
|
76
|
+
record.kind == "system"
|
|
77
|
+
for record in state.store.records
|
|
78
|
+
):
|
|
79
|
+
state.store.append(
|
|
80
|
+
ContextRecord(
|
|
81
|
+
kind="system",
|
|
82
|
+
message={
|
|
83
|
+
"role": "system",
|
|
84
|
+
"content": self.config.system_prompt,
|
|
85
|
+
},
|
|
86
|
+
scope=ContextScope.GLOBAL,
|
|
87
|
+
required=True,
|
|
88
|
+
priority=100,
|
|
89
|
+
salience=1.0,
|
|
90
|
+
stable_prefix=True,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
state.store.append(
|
|
95
|
+
ContextRecord(
|
|
96
|
+
kind="user",
|
|
97
|
+
message={
|
|
98
|
+
"role": "user",
|
|
99
|
+
"content": self._build_user_content(context),
|
|
100
|
+
},
|
|
101
|
+
scope=ContextScope.TURN,
|
|
102
|
+
required=True,
|
|
103
|
+
priority=100,
|
|
104
|
+
salience=1.0,
|
|
105
|
+
group_id=f"turn:{context.run_id}",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
context.metadata["_context_built"] = True
|
|
109
|
+
await self.prepare_context(context)
|
|
110
|
+
|
|
111
|
+
async def prepare_context(
|
|
112
|
+
self,
|
|
113
|
+
context: AgentContext,
|
|
114
|
+
*,
|
|
115
|
+
tools: list[dict[str, Any]] | None = None,
|
|
116
|
+
) -> ContextPackResult:
|
|
117
|
+
state = self._state(context)
|
|
118
|
+
result = self._packer().pack(
|
|
119
|
+
state.store.bundles(),
|
|
120
|
+
tools=tools or (),
|
|
121
|
+
epoch=state.epoch,
|
|
122
|
+
allow_compaction=not state.packed_once,
|
|
123
|
+
)
|
|
124
|
+
dropped_signature = tuple(sorted(result.dropped_record_ids))
|
|
125
|
+
if (
|
|
126
|
+
dropped_signature
|
|
127
|
+
and dropped_signature != state.dropped_signature
|
|
128
|
+
and not state.packed_once
|
|
129
|
+
):
|
|
130
|
+
state.epoch += 1
|
|
131
|
+
result.epoch = state.epoch
|
|
132
|
+
state.dropped_signature = dropped_signature
|
|
133
|
+
state.packed_once = True
|
|
134
|
+
context.messages[:] = result.messages
|
|
135
|
+
context.metadata["_context_pack"] = {
|
|
136
|
+
"epoch": result.epoch,
|
|
137
|
+
"prefix_hash": result.prefix_hash,
|
|
138
|
+
"input_tokens": result.input_tokens,
|
|
139
|
+
"available_tokens": result.available_tokens,
|
|
140
|
+
"pressure": result.pressure.level.value,
|
|
141
|
+
"pressure_ratio": result.pressure.ratio,
|
|
142
|
+
"dropped_record_ids": result.dropped_record_ids,
|
|
143
|
+
}
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
async def record_decision(
|
|
147
|
+
self,
|
|
148
|
+
context: AgentContext,
|
|
149
|
+
decision: AgentDecision,
|
|
150
|
+
) -> None:
|
|
151
|
+
state = self._state(context)
|
|
152
|
+
if decision.decision_type == DecisionType.FINAL:
|
|
153
|
+
message = {
|
|
154
|
+
"role": "assistant",
|
|
155
|
+
"content": decision.final_answer or "",
|
|
156
|
+
}
|
|
157
|
+
group_id = f"turn:{context.run_id}"
|
|
158
|
+
else:
|
|
159
|
+
action = decision.action
|
|
160
|
+
if action is None:
|
|
161
|
+
return
|
|
162
|
+
call_id = decision.metadata.get("provider_call_id")
|
|
163
|
+
group_id = f"tool:{call_id or context.step_index}"
|
|
164
|
+
message = {
|
|
165
|
+
"role": "assistant",
|
|
166
|
+
"content": None,
|
|
167
|
+
"tool_calls": [
|
|
168
|
+
{
|
|
169
|
+
"id": call_id or f"call-{context.step_index}",
|
|
170
|
+
"type": "function",
|
|
171
|
+
"function": {
|
|
172
|
+
"name": action.name,
|
|
173
|
+
"arguments": action.arguments,
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
state.store.append(
|
|
180
|
+
ContextRecord(
|
|
181
|
+
kind="assistant",
|
|
182
|
+
message=message,
|
|
183
|
+
scope=ContextScope.ITERATION,
|
|
184
|
+
required=True,
|
|
185
|
+
priority=95,
|
|
186
|
+
salience=1.0,
|
|
187
|
+
group_id=group_id,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
context.messages.append(message)
|
|
191
|
+
|
|
192
|
+
async def update_after_observation(
|
|
193
|
+
self,
|
|
194
|
+
context: AgentContext,
|
|
195
|
+
observation: Observation,
|
|
196
|
+
) -> None:
|
|
197
|
+
call_id = None
|
|
198
|
+
if context.current_decision:
|
|
199
|
+
call_id = context.current_decision.metadata.get(
|
|
200
|
+
"provider_call_id"
|
|
201
|
+
)
|
|
202
|
+
message: dict[str, Any] = {
|
|
203
|
+
"role": "tool",
|
|
204
|
+
"name": observation.action.name,
|
|
205
|
+
"content": json.dumps(
|
|
206
|
+
{
|
|
207
|
+
"ok": observation.ok,
|
|
208
|
+
"result": observation.result,
|
|
209
|
+
"error": observation.error,
|
|
210
|
+
},
|
|
211
|
+
ensure_ascii=False,
|
|
212
|
+
default=str,
|
|
213
|
+
),
|
|
214
|
+
}
|
|
215
|
+
if call_id:
|
|
216
|
+
message["tool_call_id"] = call_id
|
|
217
|
+
|
|
218
|
+
self._state(context).store.append(
|
|
219
|
+
ContextRecord(
|
|
220
|
+
kind="tool",
|
|
221
|
+
message=message,
|
|
222
|
+
scope=ContextScope.ITERATION,
|
|
223
|
+
required=True,
|
|
224
|
+
priority=95,
|
|
225
|
+
salience=1.0,
|
|
226
|
+
group_id=f"tool:{call_id or context.step_index}",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
context.messages.append(message)
|
|
230
|
+
|
|
231
|
+
def estimate_tokens(self, messages: list[dict[str, Any]]) -> int:
|
|
232
|
+
total = 0
|
|
233
|
+
for message in messages:
|
|
234
|
+
total += 4
|
|
235
|
+
content = message.get("content")
|
|
236
|
+
if isinstance(content, list):
|
|
237
|
+
for item in content:
|
|
238
|
+
if item.get("type") in {"image_url", "input_image"}:
|
|
239
|
+
total += self.config.image_token_cost
|
|
240
|
+
else:
|
|
241
|
+
total += self._count_text(
|
|
242
|
+
json.dumps(item, ensure_ascii=False)
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
total += self._count_text(
|
|
246
|
+
json.dumps(message, ensure_ascii=False, default=str)
|
|
247
|
+
)
|
|
248
|
+
return total
|
|
249
|
+
|
|
250
|
+
def trim(self, context: AgentContext) -> None:
|
|
251
|
+
store = ContextStore()
|
|
252
|
+
self._ingest_existing_messages(store, context.messages)
|
|
253
|
+
bundles = store.bundles()
|
|
254
|
+
stable = [bundle for bundle in bundles if bundle.stable_prefix]
|
|
255
|
+
optional = [bundle for bundle in bundles if not bundle.stable_prefix]
|
|
256
|
+
selected = stable + (optional[-1:] if optional else [])
|
|
257
|
+
context.messages[:] = [
|
|
258
|
+
record.message
|
|
259
|
+
for bundle in selected
|
|
260
|
+
for record in bundle.records
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
def get_store(self, run_id: str) -> ContextStore | None:
|
|
264
|
+
state = self._runs.get(run_id)
|
|
265
|
+
return state.store if state else None
|
|
266
|
+
|
|
267
|
+
def release_context(self, run_id: str) -> None:
|
|
268
|
+
self._runs.pop(run_id, None)
|
|
269
|
+
|
|
270
|
+
def _state(self, context: AgentContext) -> _RunContextState:
|
|
271
|
+
return self._runs.setdefault(
|
|
272
|
+
context.run_id,
|
|
273
|
+
_RunContextState(store=ContextStore()),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _packer(self) -> CacheAwarePacker:
|
|
277
|
+
reserve = min(
|
|
278
|
+
self.config.response_reserve_tokens,
|
|
279
|
+
max(0, self.config.max_tokens // 4),
|
|
280
|
+
)
|
|
281
|
+
margin = min(
|
|
282
|
+
self.config.safety_margin_tokens,
|
|
283
|
+
max(0, self.config.max_tokens // 10),
|
|
284
|
+
)
|
|
285
|
+
return CacheAwarePacker(
|
|
286
|
+
self.estimate_tokens,
|
|
287
|
+
max_tokens=self.config.max_tokens,
|
|
288
|
+
response_reserve=reserve,
|
|
289
|
+
safety_margin=margin,
|
|
290
|
+
warning_ratio=self.config.warning_ratio,
|
|
291
|
+
compact_trigger_ratio=self.config.compact_trigger_ratio,
|
|
292
|
+
compact_target_ratio=self.config.compact_target_ratio,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _ingest_existing_messages(
|
|
296
|
+
self,
|
|
297
|
+
store: ContextStore,
|
|
298
|
+
messages: list[dict[str, Any]],
|
|
299
|
+
) -> None:
|
|
300
|
+
turn_index = 0
|
|
301
|
+
current_turn = "history:0"
|
|
302
|
+
for message in messages:
|
|
303
|
+
role = message.get("role", "unknown")
|
|
304
|
+
if role == "system":
|
|
305
|
+
group_id = None
|
|
306
|
+
elif role == "user":
|
|
307
|
+
turn_index += 1
|
|
308
|
+
current_turn = f"history:{turn_index}"
|
|
309
|
+
group_id = current_turn
|
|
310
|
+
elif role == "assistant" and message.get("tool_calls"):
|
|
311
|
+
calls = message["tool_calls"]
|
|
312
|
+
call_id = calls[0].get("id") if calls else None
|
|
313
|
+
group_id = f"tool:{call_id or current_turn}"
|
|
314
|
+
elif role == "tool":
|
|
315
|
+
call_id = message.get("tool_call_id")
|
|
316
|
+
group_id = f"tool:{call_id or current_turn}"
|
|
317
|
+
else:
|
|
318
|
+
group_id = current_turn
|
|
319
|
+
|
|
320
|
+
store.append(
|
|
321
|
+
ContextRecord(
|
|
322
|
+
kind=role,
|
|
323
|
+
message=message,
|
|
324
|
+
scope=(
|
|
325
|
+
ContextScope.GLOBAL
|
|
326
|
+
if role == "system"
|
|
327
|
+
else ContextScope.SESSION
|
|
328
|
+
),
|
|
329
|
+
required=role == "system",
|
|
330
|
+
priority=100 if role == "system" else 50,
|
|
331
|
+
salience=1.0 if role == "system" else 0.5,
|
|
332
|
+
group_id=group_id,
|
|
333
|
+
stable_prefix=role == "system",
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _build_user_content(self, context: AgentContext) -> Any:
|
|
338
|
+
images = context.artifacts.get("images") or []
|
|
339
|
+
if not images:
|
|
340
|
+
return context.user_input
|
|
341
|
+
|
|
342
|
+
content: list[dict[str, Any]] = [
|
|
343
|
+
{"type": "text", "text": context.user_input}
|
|
344
|
+
]
|
|
345
|
+
for image in images:
|
|
346
|
+
if isinstance(image, str):
|
|
347
|
+
image_url = {"url": image}
|
|
348
|
+
elif isinstance(image, dict) and "url" in image:
|
|
349
|
+
image_url = {
|
|
350
|
+
key: value
|
|
351
|
+
for key, value in image.items()
|
|
352
|
+
if key in {"url", "detail"}
|
|
353
|
+
}
|
|
354
|
+
else:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"images 仅支持 URL/data URL 字符串或包含 url 的字典"
|
|
357
|
+
)
|
|
358
|
+
content.append({"type": "image_url", "image_url": image_url})
|
|
359
|
+
return content
|
|
360
|
+
|
|
361
|
+
def _count_text(self, text: str) -> int:
|
|
362
|
+
if self._encoder is not None:
|
|
363
|
+
return len(self._encoder.encode(text))
|
|
364
|
+
return max(1, len(text) // 4)
|
|
365
|
+
|
|
366
|
+
@staticmethod
|
|
367
|
+
def _load_encoder(model: str):
|
|
368
|
+
try:
|
|
369
|
+
import tiktoken
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
return tiktoken.encoding_for_model(model)
|
|
373
|
+
except KeyError:
|
|
374
|
+
return tiktoken.get_encoding("cl100k_base")
|
|
375
|
+
except Exception:
|
|
376
|
+
return None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContextScope(str, Enum):
|
|
10
|
+
GLOBAL = "global"
|
|
11
|
+
SESSION = "session"
|
|
12
|
+
TURN = "turn"
|
|
13
|
+
ITERATION = "iteration"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContextPressureLevel(str, Enum):
|
|
17
|
+
NORMAL = "normal"
|
|
18
|
+
WARNING = "warning"
|
|
19
|
+
CRITICAL = "critical"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ContextRecord:
|
|
24
|
+
kind: str
|
|
25
|
+
message: dict[str, Any]
|
|
26
|
+
scope: ContextScope
|
|
27
|
+
required: bool = False
|
|
28
|
+
priority: int = 50
|
|
29
|
+
salience: float = 0.5
|
|
30
|
+
group_id: str | None = None
|
|
31
|
+
stable_prefix: bool = False
|
|
32
|
+
record_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ContextBundle:
|
|
37
|
+
bundle_id: str
|
|
38
|
+
records: tuple[ContextRecord, ...]
|
|
39
|
+
required: bool
|
|
40
|
+
priority: int
|
|
41
|
+
salience: float
|
|
42
|
+
stable_prefix: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ContextPressure:
|
|
47
|
+
level: ContextPressureLevel
|
|
48
|
+
total_tokens: int
|
|
49
|
+
available_tokens: int
|
|
50
|
+
ratio: float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ContextPackResult:
|
|
55
|
+
messages: list[dict[str, Any]]
|
|
56
|
+
input_tokens: int
|
|
57
|
+
available_tokens: int
|
|
58
|
+
pressure: ContextPressure
|
|
59
|
+
prefix_hash: str
|
|
60
|
+
epoch: int
|
|
61
|
+
selected_record_ids: list[str] = field(default_factory=list)
|
|
62
|
+
dropped_record_ids: list[str] = field(default_factory=list)
|
|
63
|
+
decisions: list[dict[str, Any]] = field(default_factory=list)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
ContextBundle,
|
|
10
|
+
ContextPackResult,
|
|
11
|
+
ContextPressure,
|
|
12
|
+
ContextPressureLevel,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TokenCounter = Callable[[list[dict[str, Any]]], int]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContextOverflowError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CacheAwarePacker:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
token_counter: TokenCounter,
|
|
27
|
+
*,
|
|
28
|
+
max_tokens: int,
|
|
29
|
+
response_reserve: int,
|
|
30
|
+
safety_margin: int,
|
|
31
|
+
warning_ratio: float,
|
|
32
|
+
compact_trigger_ratio: float,
|
|
33
|
+
compact_target_ratio: float,
|
|
34
|
+
):
|
|
35
|
+
self.token_counter = token_counter
|
|
36
|
+
self.max_tokens = max_tokens
|
|
37
|
+
self.response_reserve = response_reserve
|
|
38
|
+
self.safety_margin = safety_margin
|
|
39
|
+
self.warning_ratio = warning_ratio
|
|
40
|
+
self.compact_trigger_ratio = compact_trigger_ratio
|
|
41
|
+
self.compact_target_ratio = compact_target_ratio
|
|
42
|
+
|
|
43
|
+
def pack(
|
|
44
|
+
self,
|
|
45
|
+
bundles: Sequence[ContextBundle],
|
|
46
|
+
*,
|
|
47
|
+
tools: Sequence[dict[str, Any]] = (),
|
|
48
|
+
epoch: int = 0,
|
|
49
|
+
allow_compaction: bool = True,
|
|
50
|
+
) -> ContextPackResult:
|
|
51
|
+
available = (
|
|
52
|
+
self.max_tokens
|
|
53
|
+
- self.response_reserve
|
|
54
|
+
- self.safety_margin
|
|
55
|
+
)
|
|
56
|
+
if available < 1:
|
|
57
|
+
raise ValueError("Context 可用输入预算必须大于 0")
|
|
58
|
+
|
|
59
|
+
total = sum(self._bundle_tokens(bundle) for bundle in bundles)
|
|
60
|
+
ratio = total / available
|
|
61
|
+
level = self._pressure_level(ratio)
|
|
62
|
+
pressure = ContextPressure(level, total, available, ratio)
|
|
63
|
+
prefix_hash = self._prefix_hash(bundles, tools)
|
|
64
|
+
|
|
65
|
+
if ratio < self.compact_trigger_ratio or not allow_compaction:
|
|
66
|
+
selected = list(bundles)
|
|
67
|
+
if total > available:
|
|
68
|
+
selected = self._select(bundles, available)
|
|
69
|
+
else:
|
|
70
|
+
target = max(
|
|
71
|
+
self._required_tokens(bundles),
|
|
72
|
+
int(available * self.compact_target_ratio),
|
|
73
|
+
)
|
|
74
|
+
selected = self._select(bundles, min(target, available))
|
|
75
|
+
|
|
76
|
+
messages = [
|
|
77
|
+
record.message
|
|
78
|
+
for bundle in selected
|
|
79
|
+
for record in bundle.records
|
|
80
|
+
]
|
|
81
|
+
input_tokens = self.token_counter(messages)
|
|
82
|
+
if input_tokens > available:
|
|
83
|
+
raise ContextOverflowError(
|
|
84
|
+
"required context 超过模型可用输入预算"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
selected_ids = {
|
|
88
|
+
record.record_id
|
|
89
|
+
for bundle in selected
|
|
90
|
+
for record in bundle.records
|
|
91
|
+
}
|
|
92
|
+
selected_record_ids = [
|
|
93
|
+
record.record_id
|
|
94
|
+
for bundle in bundles
|
|
95
|
+
for record in bundle.records
|
|
96
|
+
if record.record_id in selected_ids
|
|
97
|
+
]
|
|
98
|
+
dropped_ids = [
|
|
99
|
+
record.record_id
|
|
100
|
+
for bundle in bundles
|
|
101
|
+
for record in bundle.records
|
|
102
|
+
if record.record_id not in selected_ids
|
|
103
|
+
]
|
|
104
|
+
decisions = [
|
|
105
|
+
{
|
|
106
|
+
"bundle_id": bundle.bundle_id,
|
|
107
|
+
"action": (
|
|
108
|
+
"kept"
|
|
109
|
+
if all(
|
|
110
|
+
record.record_id in selected_ids
|
|
111
|
+
for record in bundle.records
|
|
112
|
+
)
|
|
113
|
+
else "dropped"
|
|
114
|
+
),
|
|
115
|
+
"reason": (
|
|
116
|
+
"required_or_selected"
|
|
117
|
+
if any(
|
|
118
|
+
record.record_id in selected_ids
|
|
119
|
+
for record in bundle.records
|
|
120
|
+
)
|
|
121
|
+
else "context_pressure"
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
for bundle in bundles
|
|
125
|
+
]
|
|
126
|
+
return ContextPackResult(
|
|
127
|
+
messages=messages,
|
|
128
|
+
input_tokens=input_tokens,
|
|
129
|
+
available_tokens=available,
|
|
130
|
+
pressure=pressure,
|
|
131
|
+
prefix_hash=prefix_hash,
|
|
132
|
+
epoch=epoch,
|
|
133
|
+
selected_record_ids=selected_record_ids,
|
|
134
|
+
dropped_record_ids=dropped_ids,
|
|
135
|
+
decisions=decisions,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _select(
|
|
139
|
+
self,
|
|
140
|
+
bundles: Sequence[ContextBundle],
|
|
141
|
+
budget: int,
|
|
142
|
+
) -> list[ContextBundle]:
|
|
143
|
+
selected_ids: set[str] = set()
|
|
144
|
+
used = 0
|
|
145
|
+
|
|
146
|
+
for bundle in bundles:
|
|
147
|
+
if not (bundle.stable_prefix or bundle.required):
|
|
148
|
+
continue
|
|
149
|
+
cost = self._bundle_tokens(bundle)
|
|
150
|
+
selected_ids.add(bundle.bundle_id)
|
|
151
|
+
used += cost
|
|
152
|
+
|
|
153
|
+
optional = [
|
|
154
|
+
(index, bundle)
|
|
155
|
+
for index, bundle in enumerate(bundles)
|
|
156
|
+
if bundle.bundle_id not in selected_ids
|
|
157
|
+
]
|
|
158
|
+
optional.sort(
|
|
159
|
+
key=lambda item: (
|
|
160
|
+
-item[1].priority,
|
|
161
|
+
-item[1].salience,
|
|
162
|
+
-item[0],
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
for _, bundle in optional:
|
|
166
|
+
cost = self._bundle_tokens(bundle)
|
|
167
|
+
if used + cost <= budget:
|
|
168
|
+
selected_ids.add(bundle.bundle_id)
|
|
169
|
+
used += cost
|
|
170
|
+
|
|
171
|
+
return [
|
|
172
|
+
bundle
|
|
173
|
+
for bundle in bundles
|
|
174
|
+
if bundle.bundle_id in selected_ids
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
def _bundle_tokens(self, bundle: ContextBundle) -> int:
|
|
178
|
+
return self.token_counter(
|
|
179
|
+
[record.message for record in bundle.records]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _required_tokens(
|
|
183
|
+
self,
|
|
184
|
+
bundles: Sequence[ContextBundle],
|
|
185
|
+
) -> int:
|
|
186
|
+
return sum(
|
|
187
|
+
self._bundle_tokens(bundle)
|
|
188
|
+
for bundle in bundles
|
|
189
|
+
if bundle.stable_prefix or bundle.required
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _pressure_level(self, ratio: float) -> ContextPressureLevel:
|
|
193
|
+
if ratio >= self.compact_trigger_ratio:
|
|
194
|
+
return ContextPressureLevel.CRITICAL
|
|
195
|
+
if ratio >= self.warning_ratio:
|
|
196
|
+
return ContextPressureLevel.WARNING
|
|
197
|
+
return ContextPressureLevel.NORMAL
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _prefix_hash(
|
|
201
|
+
bundles: Sequence[ContextBundle],
|
|
202
|
+
tools: Sequence[dict[str, Any]],
|
|
203
|
+
) -> str:
|
|
204
|
+
prefix = [
|
|
205
|
+
record.message
|
|
206
|
+
for bundle in bundles
|
|
207
|
+
if bundle.stable_prefix
|
|
208
|
+
for record in bundle.records
|
|
209
|
+
]
|
|
210
|
+
payload = json.dumps(
|
|
211
|
+
{"messages": prefix, "tools": list(tools)},
|
|
212
|
+
ensure_ascii=False,
|
|
213
|
+
sort_keys=True,
|
|
214
|
+
separators=(",", ":"),
|
|
215
|
+
default=str,
|
|
216
|
+
)
|
|
217
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|