contextpress 0.6.3__tar.gz → 0.6.4__tar.gz
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.
- {contextpress-0.6.3 → contextpress-0.6.4}/CHANGELOG.md +10 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/PKG-INFO +6 -3
- {contextpress-0.6.3 → contextpress-0.6.4}/README.md +5 -2
- {contextpress-0.6.3 → contextpress-0.6.4}/ROADMAP.md +1 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/__init__.py +1 -1
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/models.py +1 -1
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/normalizer.py +16 -1
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/stats.py +4 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/budget.py +12 -3
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/filler.py +2 -11
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/structure.py +4 -2
- contextpress-0.6.4/contextpress/tools.py +181 -0
- contextpress-0.6.4/examples/openai_tools_compress.py +57 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/pyproject.toml +1 -1
- contextpress-0.6.4/tests/fixtures/chats/12_openai_tools.json +34 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/README.md +1 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_fixture_chats.py +1 -1
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v062.py +5 -1
- contextpress-0.6.4/tests/test_v064.py +175 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/.gitignore +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/AGENTS.md +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/AUDIT.md +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/CITATION.cff +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/CONTRIBUTING.md +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/LICENSE +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/NOTICE +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/_bootstrap.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/compression.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/core.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/costs.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/__init__.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/_helpers.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/adapters.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/base.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/pipeline.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/profiles.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/py.typed +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/registry.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/__init__.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/base.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/recency.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/repetition.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/resolution.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/text_sim.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/warnings_capture.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/agent_json_compress.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/agent_pipeline.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/benchmark_presets.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/dry_run_preview.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/estimate_and_stats.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/langchain_roundtrip.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_claude.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_gemini.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_ollama.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_openai.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/pick_preset.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/examples/structure_and_cost.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/__init__.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/01_filler_heavy.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/02_resolution_thread.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/03_repetition.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/04_long_history.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/05_agent_tools.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/06_rag_chunks.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/07_short_stable.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/08_mixed_ack_resolution.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/09_agent_tool_json.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/10_agent_repeated_logs.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/11_agent_mixed.json +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_budget.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_filler.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_llm_helpers.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_models.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_normalizer.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_pipeline.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_recency.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_repetition.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_resolution.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_stats.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v03.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v04.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v05.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v051.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v052.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v053.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v054.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v056.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v058.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v060.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v061.py +0 -0
- {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v063.py +0 -0
|
@@ -4,6 +4,16 @@ All notable changes to `contextpress` are recorded here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.4] - 2026-08-18
|
|
8
|
+
|
|
9
|
+
- **OpenAI tool messages** — ``role: tool`` / ``function`` are valid; ``tool_calls``,
|
|
10
|
+
``tool_call_id``, and ``name`` round-trip on dict messages.
|
|
11
|
+
- Structure minifies JSON in ``tool_calls[].function.arguments`` and tool-result content.
|
|
12
|
+
- Filler never drops empty assistant turns that only carry ``tool_calls``.
|
|
13
|
+
- Budget removes an assistant ``tool_calls`` turn together with matching ``role=tool``
|
|
14
|
+
results (no orphan tool messages).
|
|
15
|
+
- Example: `examples/openai_tools_compress.py`.
|
|
16
|
+
|
|
7
17
|
## [0.6.3] - 2026-08-12
|
|
8
18
|
|
|
9
19
|
- **LangChain round-trip** — ``compress()`` maps remaining turns back onto their original
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: contextpress
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
4
4
|
Summary: Deterministic context compression for LLM chat, RAG, and agent pipelines
|
|
5
5
|
Project-URL: Homepage, https://github.com/Taha-azizi/contextpress
|
|
6
6
|
Project-URL: Documentation, https://github.com/Taha-azizi/contextpress#readme
|
|
@@ -244,6 +244,8 @@ Description-Content-Type: text/markdown
|
|
|
244
244
|
Deterministic context compression for LLM chat, RAG, and agent pipelines.
|
|
245
245
|
Created and maintained by **[Taha Azizi](https://github.com/Taha-azizi)**.
|
|
246
246
|
|
|
247
|
+
**Write-up:** [Introducing contextpress](https://pub.towardsai.net/introducing-contextpress-the-python-library-that-refactors-your-llm-context-c57965617edb) — Towards AI (Medium)
|
|
248
|
+
|
|
247
249
|
---
|
|
248
250
|
|
|
249
251
|
## Project Status
|
|
@@ -389,7 +391,7 @@ That script builds a long history and a tight `token_budget` so you can see turn
|
|
|
389
391
|
|
|
390
392
|
- **chat** — Typical back-and-forth dialogue. Filler removal, repetition deduplication, resolution collapsing, recency weighting, and token budgets are tuned for conversational flow.
|
|
391
393
|
- **rag_doc** — Document chunks or RAG context. Resolution is off; repetition compares all chunks; recency uses relevance to the latest user query instead of chat recency.
|
|
392
|
-
- **agent** — Tool-using or task-oriented threads. Resolution can trigger on a single high-confidence completion signal; filler rules preserve tool-related turns when markers are present.
|
|
394
|
+
- **agent** — Tool-using or task-oriented threads. Resolution can trigger on a single high-confidence completion signal; filler rules preserve tool-related turns when markers are present. OpenAI Chat Completions ``tool_calls`` / ``role: tool`` messages are first-class (0.6.4+).
|
|
393
395
|
|
|
394
396
|
```python
|
|
395
397
|
ContextManager(type="chat")
|
|
@@ -398,6 +400,7 @@ ContextManager(type="agent")
|
|
|
398
400
|
```
|
|
399
401
|
|
|
400
402
|
Runnable agent example: [`examples/agent_pipeline.py`](examples/agent_pipeline.py).
|
|
403
|
+
OpenAI tools example: [`examples/openai_tools_compress.py`](examples/openai_tools_compress.py).
|
|
401
404
|
|
|
402
405
|
## Pipeline stages
|
|
403
406
|
|
|
@@ -406,7 +409,7 @@ Runnable agent example: [`examples/agent_pipeline.py`](examples/agent_pipeline.p
|
|
|
406
409
|
3. **Repetition** — TF-IDF cosine similarity; keeps the more recent of similar turns.
|
|
407
410
|
4. **Resolution** — Collapses agreed threads into a single `RESOLVED:` synthetic system turn (chat/agent only).
|
|
408
411
|
5. **Recency** — Extractively compresses older turns (or low-relevance chunks in `rag_doc`) while preserving the latest context.
|
|
409
|
-
6. **Budget** — Enforces a hard token limit with `tiktoken`, removing oldest turns first while protecting system prompts and recent turns.
|
|
412
|
+
6. **Budget** — Enforces a hard token limit with `tiktoken`, removing oldest turns first while protecting system prompts and recent turns. Assistant ``tool_calls`` and matching ``role: tool`` results are dropped together (0.6.4+).
|
|
410
413
|
|
|
411
414
|
**Cost estimate** (0.6+, approximate list prices for planning):
|
|
412
415
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Deterministic context compression for LLM chat, RAG, and agent pipelines.
|
|
4
4
|
Created and maintained by **[Taha Azizi](https://github.com/Taha-azizi)**.
|
|
5
5
|
|
|
6
|
+
**Write-up:** [Introducing contextpress](https://pub.towardsai.net/introducing-contextpress-the-python-library-that-refactors-your-llm-context-c57965617edb) — Towards AI (Medium)
|
|
7
|
+
|
|
6
8
|
---
|
|
7
9
|
|
|
8
10
|
## Project Status
|
|
@@ -148,7 +150,7 @@ That script builds a long history and a tight `token_budget` so you can see turn
|
|
|
148
150
|
|
|
149
151
|
- **chat** — Typical back-and-forth dialogue. Filler removal, repetition deduplication, resolution collapsing, recency weighting, and token budgets are tuned for conversational flow.
|
|
150
152
|
- **rag_doc** — Document chunks or RAG context. Resolution is off; repetition compares all chunks; recency uses relevance to the latest user query instead of chat recency.
|
|
151
|
-
- **agent** — Tool-using or task-oriented threads. Resolution can trigger on a single high-confidence completion signal; filler rules preserve tool-related turns when markers are present.
|
|
153
|
+
- **agent** — Tool-using or task-oriented threads. Resolution can trigger on a single high-confidence completion signal; filler rules preserve tool-related turns when markers are present. OpenAI Chat Completions ``tool_calls`` / ``role: tool`` messages are first-class (0.6.4+).
|
|
152
154
|
|
|
153
155
|
```python
|
|
154
156
|
ContextManager(type="chat")
|
|
@@ -157,6 +159,7 @@ ContextManager(type="agent")
|
|
|
157
159
|
```
|
|
158
160
|
|
|
159
161
|
Runnable agent example: [`examples/agent_pipeline.py`](examples/agent_pipeline.py).
|
|
162
|
+
OpenAI tools example: [`examples/openai_tools_compress.py`](examples/openai_tools_compress.py).
|
|
160
163
|
|
|
161
164
|
## Pipeline stages
|
|
162
165
|
|
|
@@ -165,7 +168,7 @@ Runnable agent example: [`examples/agent_pipeline.py`](examples/agent_pipeline.p
|
|
|
165
168
|
3. **Repetition** — TF-IDF cosine similarity; keeps the more recent of similar turns.
|
|
166
169
|
4. **Resolution** — Collapses agreed threads into a single `RESOLVED:` synthetic system turn (chat/agent only).
|
|
167
170
|
5. **Recency** — Extractively compresses older turns (or low-relevance chunks in `rag_doc`) while preserving the latest context.
|
|
168
|
-
6. **Budget** — Enforces a hard token limit with `tiktoken`, removing oldest turns first while protecting system prompts and recent turns.
|
|
171
|
+
6. **Budget** — Enforces a hard token limit with `tiktoken`, removing oldest turns first while protecting system prompts and recent turns. Assistant ``tool_calls`` and matching ``role: tool`` results are dropped together (0.6.4+).
|
|
169
172
|
|
|
170
173
|
**Cost estimate** (0.6+, approximate list prices for planning):
|
|
171
174
|
|
|
@@ -41,5 +41,6 @@ deterministic Tier‑1 NLP for chat / RAG / agent **message histories**, with op
|
|
|
41
41
|
| **0.6.1** | Wire estimated USD into `CompressionStats` / reports — shipped |
|
|
42
42
|
| **0.6.2** | Agent-oriented fixtures for JSON/tool payloads; `summary()` report — shipped |
|
|
43
43
|
| **0.6.3** | LangChain compress round-trip; `output_tokens` on cost stats / `summary()` — shipped |
|
|
44
|
+
| **0.6.4** | OpenAI `tool_calls` / `role=tool` round-trip, JSON minify, budget pair integrity — shipped |
|
|
44
45
|
|
|
45
46
|
Stay classical-NLP-first; keep optional LLM extras optional.
|
|
@@ -25,7 +25,7 @@ class Turn:
|
|
|
25
25
|
This is the canonical unit the entire pipeline operates on.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
|
-
role: str # "user" | "assistant" | "system"
|
|
28
|
+
role: str # "user" | "assistant" | "system" | "tool" | "function"
|
|
29
29
|
content: str | list[ContentBlock] # string for simple, list for multimodal
|
|
30
30
|
timestamp: datetime | None = None
|
|
31
31
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
@@ -12,11 +12,14 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from contextpress.models import ContentBlock, Conversation, Turn
|
|
14
14
|
|
|
15
|
-
_VALID_ROLES = frozenset({"user", "assistant", "system"})
|
|
15
|
+
_VALID_ROLES = frozenset({"user", "assistant", "system", "tool", "function"})
|
|
16
|
+
_TOOL_COPY_KEYS = ("tool_calls", "tool_call_id", "name")
|
|
16
17
|
_LC_TYPE_MAP = {
|
|
17
18
|
"human": "user",
|
|
18
19
|
"ai": "assistant",
|
|
19
20
|
"system": "system",
|
|
21
|
+
"tool": "tool",
|
|
22
|
+
"function": "function",
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
|
|
@@ -201,6 +204,9 @@ def normalize_messages(
|
|
|
201
204
|
ts = _parse_timestamp(d)
|
|
202
205
|
content = d.get("content", "")
|
|
203
206
|
meta: dict[str, Any] = {"_dict_index": i, "_original_dict": d}
|
|
207
|
+
for key in _TOOL_COPY_KEYS:
|
|
208
|
+
if key in d:
|
|
209
|
+
meta[key] = copy.deepcopy(d[key])
|
|
204
210
|
|
|
205
211
|
if isinstance(content, list):
|
|
206
212
|
blocks = _blocks_from_openai_style(content)
|
|
@@ -282,8 +288,17 @@ def denormalize_output(conversation: Conversation, ctx: dict[str, Any]) -> Any:
|
|
|
282
288
|
base["role"] = t.role
|
|
283
289
|
if isinstance(t.content, list):
|
|
284
290
|
base["content"] = _blocks_to_openai_style(t.content)
|
|
291
|
+
elif (
|
|
292
|
+
t.content == ""
|
|
293
|
+
and isinstance(t.metadata.get("_original_dict"), dict)
|
|
294
|
+
and t.metadata["_original_dict"].get("content") is None
|
|
295
|
+
):
|
|
296
|
+
base["content"] = None
|
|
285
297
|
else:
|
|
286
298
|
base["content"] = t.content
|
|
299
|
+
for key in _TOOL_COPY_KEYS:
|
|
300
|
+
if key in t.metadata:
|
|
301
|
+
base[key] = copy.deepcopy(t.metadata[key])
|
|
287
302
|
out_dicts.append(base)
|
|
288
303
|
return out_dicts
|
|
289
304
|
|
|
@@ -10,6 +10,7 @@ import tiktoken
|
|
|
10
10
|
from contextpress.costs import estimate_token_cost
|
|
11
11
|
from contextpress.models import Conversation, Turn
|
|
12
12
|
from contextpress.normalizer import extract_text_for_processing
|
|
13
|
+
from contextpress.tools import tool_payload_text
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def get_encoding(model: str | None) -> tiktoken.Encoding:
|
|
@@ -26,6 +27,9 @@ def count_turn_tokens(turn: Turn, encoding: tiktoken.Encoding) -> int:
|
|
|
26
27
|
body = turn.content
|
|
27
28
|
else:
|
|
28
29
|
body = extract_text_for_processing(turn)
|
|
30
|
+
extra = tool_payload_text(turn)
|
|
31
|
+
if extra:
|
|
32
|
+
body = f"{body}\n{extra}" if body else extra
|
|
29
33
|
return len(encoding.encode(f"{turn.role}\n{body}"))
|
|
30
34
|
|
|
31
35
|
|
|
@@ -8,6 +8,7 @@ import tiktoken
|
|
|
8
8
|
from contextpress.models import Conversation, Turn
|
|
9
9
|
from contextpress.stats import count_turn_tokens, get_encoding
|
|
10
10
|
from contextpress.strategies.base import BaseStrategy
|
|
11
|
+
from contextpress.tools import tool_group_indices
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _truncate_system_turn(turn: Turn, encoding: tiktoken.Encoding, max_tokens: int) -> Turn:
|
|
@@ -73,9 +74,17 @@ class BudgetStrategy(BaseStrategy):
|
|
|
73
74
|
keep = min(2, len(ns_positions))
|
|
74
75
|
protected = set(ns_positions[-keep:]) if keep else set()
|
|
75
76
|
removable = [i for i in ns_positions if i not in protected]
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
group: list[int] | None = None
|
|
78
|
+
for idx in removable:
|
|
79
|
+
candidate = tool_group_indices(turns, idx)
|
|
80
|
+
if any(g in protected for g in candidate):
|
|
81
|
+
continue
|
|
82
|
+
group = candidate
|
|
83
|
+
break
|
|
84
|
+
if group is not None:
|
|
85
|
+
for g in sorted(group, reverse=True):
|
|
86
|
+
turns.pop(g)
|
|
87
|
+
n_removed += 1
|
|
79
88
|
continue
|
|
80
89
|
|
|
81
90
|
# Last resort: truncate system (see behavior contract note on invariant 1)
|
|
@@ -6,6 +6,7 @@ import re
|
|
|
6
6
|
from contextpress.models import Conversation, Turn
|
|
7
7
|
from contextpress.normalizer import apply_text_to_turn, extract_text_for_processing
|
|
8
8
|
from contextpress.strategies.base import BaseStrategy
|
|
9
|
+
from contextpress.tools import has_tool_marker
|
|
9
10
|
|
|
10
11
|
# Curated lists — longer phrases first for safe replacement order
|
|
11
12
|
FILLER_PHRASES = [
|
|
@@ -70,16 +71,6 @@ ACKNOWLEDGEMENT_PHRASES = [
|
|
|
70
71
|
"thank you for that",
|
|
71
72
|
]
|
|
72
73
|
|
|
73
|
-
_TOOL_MARKERS = ("tool_calls", "tool_call", "tool_use", "tool_result", "<tool", "[tool")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _has_tool_marker(turn: Turn) -> bool:
|
|
77
|
-
meta = turn.metadata or {}
|
|
78
|
-
if any(k in meta for k in ("tool_calls", "tool_call", "tool_use", "tool_result")):
|
|
79
|
-
return True
|
|
80
|
-
text = extract_text_for_processing(turn).lower()
|
|
81
|
-
return any(m in text for m in _TOOL_MARKERS)
|
|
82
|
-
|
|
83
74
|
|
|
84
75
|
def _build_filler_pattern() -> re.Pattern[str]:
|
|
85
76
|
pattern_parts: list[str] = []
|
|
@@ -155,7 +146,7 @@ class FillerStrategy(BaseStrategy):
|
|
|
155
146
|
new_turns.append(nt)
|
|
156
147
|
continue
|
|
157
148
|
|
|
158
|
-
if
|
|
149
|
+
if has_tool_marker(turn):
|
|
159
150
|
new_turns.append(copy.deepcopy(turn))
|
|
160
151
|
continue
|
|
161
152
|
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
from contextpress.models import Conversation, Turn
|
|
10
10
|
from contextpress.normalizer import apply_text_to_turn, extract_text_for_processing
|
|
11
11
|
from contextpress.strategies.base import BaseStrategy
|
|
12
|
+
from contextpress.tools import minify_tool_fields
|
|
12
13
|
|
|
13
14
|
_CODE_FENCE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
|
|
14
15
|
_MULTI_BLANK = re.compile(r"\n{3,}")
|
|
@@ -85,9 +86,10 @@ class StructureStrategy(BaseStrategy):
|
|
|
85
86
|
text = extract_text_for_processing(turn)
|
|
86
87
|
compacted = compact_structure_text(text, aggressiveness=self.aggressiveness)
|
|
87
88
|
if compacted != text:
|
|
88
|
-
|
|
89
|
+
nt = apply_text_to_turn(turn, compacted)
|
|
89
90
|
else:
|
|
90
|
-
|
|
91
|
+
nt = copy.deepcopy(turn)
|
|
92
|
+
new_turns.append(minify_tool_fields(nt))
|
|
91
93
|
return Conversation(
|
|
92
94
|
turns=new_turns,
|
|
93
95
|
type=conversation.type,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Helpers for OpenAI-style tool_calls / role=tool messages (0.6.4+)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from contextpress.models import Turn
|
|
9
|
+
from contextpress.normalizer import extract_text_for_processing
|
|
10
|
+
|
|
11
|
+
_TOOL_META_KEYS = ("tool_calls", "tool_call", "tool_use", "tool_result", "tool_call_id")
|
|
12
|
+
_TEXT_MARKERS = ("tool_calls", "tool_call", "tool_use", "tool_result", "<tool", "[tool")
|
|
13
|
+
_RESULT_ROLES = frozenset({"tool", "function"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _layers(turn: Turn) -> list[dict[str, Any]]:
|
|
17
|
+
meta = turn.metadata or {}
|
|
18
|
+
layers: list[dict[str, Any]] = [meta]
|
|
19
|
+
orig = meta.get("_original_dict")
|
|
20
|
+
if isinstance(orig, dict) and orig is not meta:
|
|
21
|
+
layers.append(orig)
|
|
22
|
+
return layers
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def has_tool_marker(turn: Turn) -> bool:
|
|
26
|
+
"""True if this turn is a tool call, tool result, or has a textual tool marker."""
|
|
27
|
+
if turn.role in _RESULT_ROLES:
|
|
28
|
+
return True
|
|
29
|
+
for layer in _layers(turn):
|
|
30
|
+
if any(k in layer for k in _TOOL_META_KEYS):
|
|
31
|
+
return True
|
|
32
|
+
text = extract_text_for_processing(turn).lower()
|
|
33
|
+
return any(m in text for m in _TEXT_MARKERS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def assistant_tool_call_ids(turn: Turn) -> list[str]:
|
|
37
|
+
ids: list[str] = []
|
|
38
|
+
seen: set[str] = set()
|
|
39
|
+
for layer in _layers(turn):
|
|
40
|
+
calls = layer.get("tool_calls")
|
|
41
|
+
if not isinstance(calls, list):
|
|
42
|
+
continue
|
|
43
|
+
for call in calls:
|
|
44
|
+
if not isinstance(call, dict):
|
|
45
|
+
continue
|
|
46
|
+
cid = call.get("id")
|
|
47
|
+
if cid is None:
|
|
48
|
+
continue
|
|
49
|
+
sid = str(cid)
|
|
50
|
+
if sid not in seen:
|
|
51
|
+
seen.add(sid)
|
|
52
|
+
ids.append(sid)
|
|
53
|
+
break
|
|
54
|
+
return ids
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def result_tool_call_id(turn: Turn) -> str | None:
|
|
58
|
+
for layer in _layers(turn):
|
|
59
|
+
tid = layer.get("tool_call_id")
|
|
60
|
+
if tid is not None:
|
|
61
|
+
return str(tid)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def tool_payload_text(turn: Turn) -> str:
|
|
66
|
+
"""Extra text for token counting (tool_calls JSON, not already in content)."""
|
|
67
|
+
for layer in _layers(turn):
|
|
68
|
+
calls = layer.get("tool_calls")
|
|
69
|
+
if isinstance(calls, list) and calls:
|
|
70
|
+
return json.dumps(calls, ensure_ascii=False)
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _minify_json_string(text: str) -> str:
|
|
75
|
+
s = text.strip()
|
|
76
|
+
if not s or s[0] not in "{[":
|
|
77
|
+
return text
|
|
78
|
+
try:
|
|
79
|
+
obj = json.loads(s)
|
|
80
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
81
|
+
return text
|
|
82
|
+
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def minify_tool_fields(turn: Turn) -> Turn:
|
|
86
|
+
"""Minify JSON in tool_calls[].function.arguments; write back to metadata."""
|
|
87
|
+
meta = turn.metadata
|
|
88
|
+
if not meta:
|
|
89
|
+
return turn
|
|
90
|
+
changed = False
|
|
91
|
+
new_meta = dict(meta)
|
|
92
|
+
|
|
93
|
+
def _compact_calls(calls: Any) -> Any:
|
|
94
|
+
nonlocal changed
|
|
95
|
+
if not isinstance(calls, list):
|
|
96
|
+
return calls
|
|
97
|
+
out = []
|
|
98
|
+
for call in calls:
|
|
99
|
+
if not isinstance(call, dict):
|
|
100
|
+
out.append(call)
|
|
101
|
+
continue
|
|
102
|
+
c = dict(call)
|
|
103
|
+
fn = c.get("function")
|
|
104
|
+
if isinstance(fn, dict):
|
|
105
|
+
fn = dict(fn)
|
|
106
|
+
args = fn.get("arguments")
|
|
107
|
+
if isinstance(args, str):
|
|
108
|
+
mini = _minify_json_string(args)
|
|
109
|
+
if mini != args:
|
|
110
|
+
fn["arguments"] = mini
|
|
111
|
+
changed = True
|
|
112
|
+
c["function"] = fn
|
|
113
|
+
out.append(c)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
if "tool_calls" in new_meta:
|
|
117
|
+
new_meta["tool_calls"] = _compact_calls(new_meta["tool_calls"])
|
|
118
|
+
orig = new_meta.get("_original_dict")
|
|
119
|
+
if isinstance(orig, dict):
|
|
120
|
+
orig = dict(orig)
|
|
121
|
+
if "tool_calls" in orig:
|
|
122
|
+
orig["tool_calls"] = _compact_calls(orig["tool_calls"])
|
|
123
|
+
if turn.role in _RESULT_ROLES and isinstance(orig.get("content"), str):
|
|
124
|
+
target = turn.content if isinstance(turn.content, str) else orig["content"]
|
|
125
|
+
mini = _minify_json_string(target)
|
|
126
|
+
if mini != orig["content"]:
|
|
127
|
+
orig["content"] = mini
|
|
128
|
+
changed = True
|
|
129
|
+
new_meta["_original_dict"] = orig
|
|
130
|
+
|
|
131
|
+
if not changed:
|
|
132
|
+
return turn
|
|
133
|
+
return Turn(
|
|
134
|
+
role=turn.role,
|
|
135
|
+
content=turn.content,
|
|
136
|
+
timestamp=turn.timestamp,
|
|
137
|
+
metadata=new_meta,
|
|
138
|
+
importance=turn.importance,
|
|
139
|
+
resolved=turn.resolved,
|
|
140
|
+
compressed=True,
|
|
141
|
+
original_content=(
|
|
142
|
+
turn.original_content if turn.original_content is not None else turn.content
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def tool_group_indices(turns: list[Turn], index: int) -> list[int]:
|
|
148
|
+
"""Indices of an assistant tool_calls turn plus following matching tool results.
|
|
149
|
+
|
|
150
|
+
If ``index`` is a tool result, walk back to the parent assistant first.
|
|
151
|
+
"""
|
|
152
|
+
if index < 0 or index >= len(turns):
|
|
153
|
+
return [index]
|
|
154
|
+
t = turns[index]
|
|
155
|
+
if t.role in _RESULT_ROLES:
|
|
156
|
+
k = index - 1
|
|
157
|
+
while k >= 0:
|
|
158
|
+
if turns[k].role == "assistant" and assistant_tool_call_ids(turns[k]):
|
|
159
|
+
rid = result_tool_call_id(t)
|
|
160
|
+
ids = set(assistant_tool_call_ids(turns[k]))
|
|
161
|
+
if rid is None or rid in ids:
|
|
162
|
+
return tool_group_indices(turns, k)
|
|
163
|
+
break
|
|
164
|
+
if turns[k].role not in _RESULT_ROLES:
|
|
165
|
+
break
|
|
166
|
+
k -= 1
|
|
167
|
+
return [index]
|
|
168
|
+
|
|
169
|
+
ids = set(assistant_tool_call_ids(t))
|
|
170
|
+
if t.role != "assistant" or not ids:
|
|
171
|
+
return [index]
|
|
172
|
+
group = [index]
|
|
173
|
+
j = index + 1
|
|
174
|
+
while j < len(turns) and turns[j].role in _RESULT_ROLES:
|
|
175
|
+
rid = result_tool_call_id(turns[j])
|
|
176
|
+
if rid is None or rid in ids:
|
|
177
|
+
group.append(j)
|
|
178
|
+
else:
|
|
179
|
+
break
|
|
180
|
+
j += 1
|
|
181
|
+
return group
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""OpenAI Chat Completions tool_calls round-trip (0.6.4+)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from contextpress import ContextManager
|
|
8
|
+
|
|
9
|
+
arguments = json.dumps(
|
|
10
|
+
{"service": "api-v2", "environment": "staging", "limit": 20},
|
|
11
|
+
indent=2,
|
|
12
|
+
)
|
|
13
|
+
tool_result = json.dumps(
|
|
14
|
+
{
|
|
15
|
+
"service": "api-v2",
|
|
16
|
+
"events": [{"id": "evt-001", "version": "2.4.1", "status": "healthy"}],
|
|
17
|
+
},
|
|
18
|
+
indent=2,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
messages = [
|
|
22
|
+
{"role": "system", "content": "You are a deploy agent with function tools."},
|
|
23
|
+
{"role": "user", "content": "Find recent staging deploys for api-v2."},
|
|
24
|
+
{
|
|
25
|
+
"role": "assistant",
|
|
26
|
+
"content": None,
|
|
27
|
+
"tool_calls": [
|
|
28
|
+
{
|
|
29
|
+
"id": "call_search_1",
|
|
30
|
+
"type": "function",
|
|
31
|
+
"function": {"name": "search_deploys", "arguments": arguments},
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"role": "tool",
|
|
37
|
+
"tool_call_id": "call_search_1",
|
|
38
|
+
"name": "search_deploys",
|
|
39
|
+
"content": tool_result,
|
|
40
|
+
},
|
|
41
|
+
{"role": "assistant", "content": "Staging is healthy on 2.4.1."},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
cm = ContextManager(type="agent", compression="medium", cost_provider="openai")
|
|
45
|
+
result = cm.compress(messages, token_budget=None, return_stats=True)
|
|
46
|
+
|
|
47
|
+
print(result.summary())
|
|
48
|
+
print()
|
|
49
|
+
for m in result.messages:
|
|
50
|
+
role = m["role"]
|
|
51
|
+
if m.get("tool_calls"):
|
|
52
|
+
args = m["tool_calls"][0]["function"]["arguments"]
|
|
53
|
+
print(f"{role} tool_calls arguments:", args[:80])
|
|
54
|
+
elif role == "tool":
|
|
55
|
+
print(f"{role} ({m.get('tool_call_id')}):", str(m.get("content", ""))[:80])
|
|
56
|
+
else:
|
|
57
|
+
print(f"{role}:", str(m.get("content", ""))[:80])
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "12_openai_tools",
|
|
3
|
+
"type": "agent",
|
|
4
|
+
"source": "synthetic (OpenAI Chat Completions tool_calls + role=tool)",
|
|
5
|
+
"messages": [
|
|
6
|
+
{"role": "system", "content": "You are a deploy agent with function tools."},
|
|
7
|
+
{"role": "user", "content": "Find recent staging deploys for api-v2."},
|
|
8
|
+
{
|
|
9
|
+
"role": "assistant",
|
|
10
|
+
"content": null,
|
|
11
|
+
"tool_calls": [
|
|
12
|
+
{
|
|
13
|
+
"id": "call_search_1",
|
|
14
|
+
"type": "function",
|
|
15
|
+
"function": {
|
|
16
|
+
"name": "search_deploys",
|
|
17
|
+
"arguments": "{\n \"service\": \"api-v2\",\n \"environment\": \"staging\",\n \"limit\": 20\n}"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"role": "tool",
|
|
24
|
+
"tool_call_id": "call_search_1",
|
|
25
|
+
"name": "search_deploys",
|
|
26
|
+
"content": "{\n \"service\": \"api-v2\",\n \"environment\": \"staging\",\n \"events\": [\n {\n \"id\": \"evt-001\",\n \"version\": \"2.4.1\",\n \"status\": \"healthy\"\n }\n ]\n}"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"role": "assistant",
|
|
30
|
+
"content": "Staging is healthy on 2.4.1."
|
|
31
|
+
},
|
|
32
|
+
{"role": "user", "content": "Schedule a follow-up check."}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -16,3 +16,4 @@ No network required — used by `tests/test_fixture_chats.py`.
|
|
|
16
16
|
| `09_agent_tool_json.json` | agent | Large pretty-printed tool JSON |
|
|
17
17
|
| `10_agent_repeated_logs.json` | agent | Repeated log lines in tool output |
|
|
18
18
|
| `11_agent_mixed.json` | agent | Tool call + result + follow-up thread |
|
|
19
|
+
| `12_openai_tools.json` | agent | OpenAI `tool_calls` + `role=tool` |
|
|
@@ -95,7 +95,11 @@ def test_agent_repeated_logs_dedupes_lines():
|
|
|
95
95
|
cm = ContextManager(type="agent", compression="medium")
|
|
96
96
|
result = cm.compress(data["messages"], token_budget=None, return_stats=True)
|
|
97
97
|
assert "structure" in result.stats.stages_run
|
|
98
|
-
tool_turn = next(
|
|
98
|
+
tool_turn = next(
|
|
99
|
+
m
|
|
100
|
+
for m in result.messages
|
|
101
|
+
if m.get("role") == "user" and "Tool result" in str(m.get("content", ""))
|
|
102
|
+
)
|
|
99
103
|
content = str(tool_turn["content"])
|
|
100
104
|
assert content.count("payment gateway timeout") == 1
|
|
101
105
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""0.6.4 — OpenAI tool_calls / role=tool round-trip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from contextpress import ContextManager
|
|
10
|
+
from contextpress.models import Conversation, Turn
|
|
11
|
+
from contextpress.normalizer import normalize_messages
|
|
12
|
+
from contextpress.strategies.budget import BudgetStrategy
|
|
13
|
+
from contextpress.tools import has_tool_marker
|
|
14
|
+
|
|
15
|
+
FIXTURE = Path(__file__).parent / "fixtures" / "chats" / "12_openai_tools.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pretty_args() -> str:
|
|
19
|
+
return json.dumps({"service": "api-v2", "environment": "staging", "limit": 20}, indent=2)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _openai_tool_thread() -> list[dict]:
|
|
23
|
+
return [
|
|
24
|
+
{"role": "system", "content": "You are a deploy agent."},
|
|
25
|
+
{"role": "user", "content": "Find staging deploys."},
|
|
26
|
+
{
|
|
27
|
+
"role": "assistant",
|
|
28
|
+
"content": None,
|
|
29
|
+
"tool_calls": [
|
|
30
|
+
{
|
|
31
|
+
"id": "call_search_1",
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {"name": "search_deploys", "arguments": _pretty_args()},
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"role": "tool",
|
|
39
|
+
"tool_call_id": "call_search_1",
|
|
40
|
+
"name": "search_deploys",
|
|
41
|
+
"content": json.dumps({"ok": True, "version": "2.4.1"}, indent=2),
|
|
42
|
+
},
|
|
43
|
+
{"role": "assistant", "content": "Staging is healthy on 2.4.1."},
|
|
44
|
+
{"role": "user", "content": "Thanks, that is all."},
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _call_and_result_ids(messages: list[dict]) -> tuple[set[str], set[str]]:
|
|
49
|
+
calls: set[str] = set()
|
|
50
|
+
results: set[str] = set()
|
|
51
|
+
for m in messages:
|
|
52
|
+
if m.get("role") == "assistant":
|
|
53
|
+
for c in m.get("tool_calls") or []:
|
|
54
|
+
if isinstance(c, dict) and c.get("id"):
|
|
55
|
+
calls.add(str(c["id"]))
|
|
56
|
+
if m.get("role") == "tool" and m.get("tool_call_id"):
|
|
57
|
+
results.add(str(m["tool_call_id"]))
|
|
58
|
+
return calls, results
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_tool_role_no_unknown_warning():
|
|
62
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
63
|
+
warnings.simplefilter("always")
|
|
64
|
+
conv, _ = normalize_messages(_openai_tool_thread())
|
|
65
|
+
assert conv.turns[3].role == "tool"
|
|
66
|
+
assert not any("unknown role" in str(w.message).lower() for w in caught)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_tool_keys_copied_to_metadata():
|
|
70
|
+
conv, _ = normalize_messages(_openai_tool_thread())
|
|
71
|
+
asst = conv.turns[2]
|
|
72
|
+
tool = conv.turns[3]
|
|
73
|
+
assert "tool_calls" in asst.metadata
|
|
74
|
+
assert asst.metadata["tool_calls"][0]["id"] == "call_search_1"
|
|
75
|
+
assert tool.metadata.get("tool_call_id") == "call_search_1"
|
|
76
|
+
assert tool.metadata.get("name") == "search_deploys"
|
|
77
|
+
assert has_tool_marker(asst)
|
|
78
|
+
assert has_tool_marker(tool)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_has_tool_marker_via_original_dict_only():
|
|
82
|
+
t = Turn(
|
|
83
|
+
role="assistant",
|
|
84
|
+
content="",
|
|
85
|
+
metadata={
|
|
86
|
+
"_original_dict": {
|
|
87
|
+
"role": "assistant",
|
|
88
|
+
"content": None,
|
|
89
|
+
"tool_calls": [{"id": "x"}],
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
assert has_tool_marker(t)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_empty_tool_calls_assistant_not_dropped():
|
|
97
|
+
messages = [
|
|
98
|
+
{"role": "user", "content": "search now"},
|
|
99
|
+
{
|
|
100
|
+
"role": "assistant",
|
|
101
|
+
"content": None,
|
|
102
|
+
"tool_calls": [
|
|
103
|
+
{
|
|
104
|
+
"id": "call_1",
|
|
105
|
+
"type": "function",
|
|
106
|
+
"function": {"name": "search", "arguments": "{}"},
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
cm = ContextManager(type="agent", compression="high")
|
|
112
|
+
out = cm.compress(messages, token_budget=None)
|
|
113
|
+
assert any(m.get("tool_calls") for m in out)
|
|
114
|
+
assert out[1]["content"] is None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_roundtrip_keeps_tool_keys_and_minifies_json():
|
|
118
|
+
messages = _openai_tool_thread()
|
|
119
|
+
original = json.loads(json.dumps(messages))
|
|
120
|
+
cm = ContextManager(type="agent", compression="medium")
|
|
121
|
+
out = cm.compress(messages, token_budget=None)
|
|
122
|
+
assert messages == original
|
|
123
|
+
asst = next(m for m in out if m.get("tool_calls"))
|
|
124
|
+
tool = next(m for m in out if m.get("role") == "tool")
|
|
125
|
+
args = asst["tool_calls"][0]["function"]["arguments"]
|
|
126
|
+
assert "\n" not in args
|
|
127
|
+
assert json.loads(args)["service"] == "api-v2"
|
|
128
|
+
assert "\n" not in tool["content"]
|
|
129
|
+
assert json.loads(tool["content"])["ok"] is True
|
|
130
|
+
assert tool["tool_call_id"] == "call_search_1"
|
|
131
|
+
assert tool["name"] == "search_deploys"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_budget_does_not_orphan_tool_result():
|
|
135
|
+
padding = "deploy status note " * 40
|
|
136
|
+
turns = [
|
|
137
|
+
Turn(
|
|
138
|
+
role="assistant",
|
|
139
|
+
content="",
|
|
140
|
+
metadata={
|
|
141
|
+
"tool_calls": [
|
|
142
|
+
{
|
|
143
|
+
"id": "call_old",
|
|
144
|
+
"type": "function",
|
|
145
|
+
"function": {"name": "search", "arguments": "{}"},
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
Turn(role="tool", content=padding, metadata={"tool_call_id": "call_old"}),
|
|
151
|
+
Turn(role="user", content="what is the latest status?"),
|
|
152
|
+
Turn(role="assistant", content="Latest is healthy."),
|
|
153
|
+
]
|
|
154
|
+
c = Conversation(turns=turns, type="agent")
|
|
155
|
+
out = BudgetStrategy(token_budget=40, model=None).process(c)
|
|
156
|
+
remaining = [(t.role, (t.metadata or {}).get("tool_call_id")) for t in out.turns]
|
|
157
|
+
tool_ids = {t.metadata.get("tool_call_id") for t in out.turns if t.role == "tool"}
|
|
158
|
+
call_ids = set()
|
|
159
|
+
for t in out.turns:
|
|
160
|
+
for call in (t.metadata or {}).get("tool_calls") or []:
|
|
161
|
+
if isinstance(call, dict) and call.get("id"):
|
|
162
|
+
call_ids.add(call["id"])
|
|
163
|
+
assert tool_ids <= call_ids
|
|
164
|
+
assert remaining[-1][0] == "assistant"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_fixture_openai_tools_compresses():
|
|
168
|
+
data = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
169
|
+
cm = ContextManager(type="agent", compression="medium")
|
|
170
|
+
result = cm.compress(data["messages"], token_budget=None, return_stats=True)
|
|
171
|
+
calls, results = _call_and_result_ids(result.messages)
|
|
172
|
+
assert results <= calls
|
|
173
|
+
assert result.stats.tokens_after <= result.stats.tokens_before
|
|
174
|
+
asst = next(m for m in result.messages if m.get("tool_calls"))
|
|
175
|
+
assert "\n" not in asst["tool_calls"][0]["function"]["arguments"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|