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 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()