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.
Files changed (91) hide show
  1. {contextpress-0.6.3 → contextpress-0.6.4}/CHANGELOG.md +10 -0
  2. {contextpress-0.6.3 → contextpress-0.6.4}/PKG-INFO +6 -3
  3. {contextpress-0.6.3 → contextpress-0.6.4}/README.md +5 -2
  4. {contextpress-0.6.3 → contextpress-0.6.4}/ROADMAP.md +1 -0
  5. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/__init__.py +1 -1
  6. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/models.py +1 -1
  7. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/normalizer.py +16 -1
  8. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/stats.py +4 -0
  9. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/budget.py +12 -3
  10. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/filler.py +2 -11
  11. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/structure.py +4 -2
  12. contextpress-0.6.4/contextpress/tools.py +181 -0
  13. contextpress-0.6.4/examples/openai_tools_compress.py +57 -0
  14. {contextpress-0.6.3 → contextpress-0.6.4}/pyproject.toml +1 -1
  15. contextpress-0.6.4/tests/fixtures/chats/12_openai_tools.json +34 -0
  16. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/README.md +1 -0
  17. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_fixture_chats.py +1 -1
  18. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v062.py +5 -1
  19. contextpress-0.6.4/tests/test_v064.py +175 -0
  20. {contextpress-0.6.3 → contextpress-0.6.4}/.gitignore +0 -0
  21. {contextpress-0.6.3 → contextpress-0.6.4}/AGENTS.md +0 -0
  22. {contextpress-0.6.3 → contextpress-0.6.4}/AUDIT.md +0 -0
  23. {contextpress-0.6.3 → contextpress-0.6.4}/CITATION.cff +0 -0
  24. {contextpress-0.6.3 → contextpress-0.6.4}/CONTRIBUTING.md +0 -0
  25. {contextpress-0.6.3 → contextpress-0.6.4}/LICENSE +0 -0
  26. {contextpress-0.6.3 → contextpress-0.6.4}/NOTICE +0 -0
  27. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/_bootstrap.py +0 -0
  28. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/compression.py +0 -0
  29. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/core.py +0 -0
  30. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/costs.py +0 -0
  31. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/__init__.py +0 -0
  32. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/_helpers.py +0 -0
  33. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/adapters.py +0 -0
  34. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/llm/base.py +0 -0
  35. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/pipeline.py +0 -0
  36. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/profiles.py +0 -0
  37. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/py.typed +0 -0
  38. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/registry.py +0 -0
  39. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/__init__.py +0 -0
  40. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/base.py +0 -0
  41. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/recency.py +0 -0
  42. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/repetition.py +0 -0
  43. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/strategies/resolution.py +0 -0
  44. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/text_sim.py +0 -0
  45. {contextpress-0.6.3 → contextpress-0.6.4}/contextpress/warnings_capture.py +0 -0
  46. {contextpress-0.6.3 → contextpress-0.6.4}/examples/agent_json_compress.py +0 -0
  47. {contextpress-0.6.3 → contextpress-0.6.4}/examples/agent_pipeline.py +0 -0
  48. {contextpress-0.6.3 → contextpress-0.6.4}/examples/benchmark_presets.py +0 -0
  49. {contextpress-0.6.3 → contextpress-0.6.4}/examples/dry_run_preview.py +0 -0
  50. {contextpress-0.6.3 → contextpress-0.6.4}/examples/estimate_and_stats.py +0 -0
  51. {contextpress-0.6.3 → contextpress-0.6.4}/examples/langchain_roundtrip.py +0 -0
  52. {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_claude.py +0 -0
  53. {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_gemini.py +0 -0
  54. {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_ollama.py +0 -0
  55. {contextpress-0.6.3 → contextpress-0.6.4}/examples/llm_tier_openai.py +0 -0
  56. {contextpress-0.6.3 → contextpress-0.6.4}/examples/pick_preset.py +0 -0
  57. {contextpress-0.6.3 → contextpress-0.6.4}/examples/structure_and_cost.py +0 -0
  58. {contextpress-0.6.3 → contextpress-0.6.4}/tests/__init__.py +0 -0
  59. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/01_filler_heavy.json +0 -0
  60. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/02_resolution_thread.json +0 -0
  61. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/03_repetition.json +0 -0
  62. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/04_long_history.json +0 -0
  63. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/05_agent_tools.json +0 -0
  64. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/06_rag_chunks.json +0 -0
  65. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/07_short_stable.json +0 -0
  66. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/08_mixed_ack_resolution.json +0 -0
  67. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/09_agent_tool_json.json +0 -0
  68. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/10_agent_repeated_logs.json +0 -0
  69. {contextpress-0.6.3 → contextpress-0.6.4}/tests/fixtures/chats/11_agent_mixed.json +0 -0
  70. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_budget.py +0 -0
  71. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_filler.py +0 -0
  72. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_llm_helpers.py +0 -0
  73. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_models.py +0 -0
  74. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_normalizer.py +0 -0
  75. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_pipeline.py +0 -0
  76. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_recency.py +0 -0
  77. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_repetition.py +0 -0
  78. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_resolution.py +0 -0
  79. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_stats.py +0 -0
  80. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v03.py +0 -0
  81. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v04.py +0 -0
  82. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v05.py +0 -0
  83. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v051.py +0 -0
  84. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v052.py +0 -0
  85. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v053.py +0 -0
  86. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v054.py +0 -0
  87. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v056.py +0 -0
  88. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v058.py +0 -0
  89. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v060.py +0 -0
  90. {contextpress-0.6.3 → contextpress-0.6.4}/tests/test_v061.py +0 -0
  91. {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
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.
@@ -16,7 +16,7 @@ __all__ = [
16
16
  "CompressionResult",
17
17
  "CompressionStats",
18
18
  ]
19
- __version__ = "0.6.3"
19
+ __version__ = "0.6.4"
20
20
 
21
21
 
22
22
  def __getattr__(name: str) -> Any:
@@ -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
- if removable:
77
- turns.pop(removable[0])
78
- n_removed += 1
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 self.conv_type == "agent" and _has_tool_marker(turn):
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
- new_turns.append(apply_text_to_turn(turn, compacted))
89
+ nt = apply_text_to_turn(turn, compacted)
89
90
  else:
90
- new_turns.append(copy.deepcopy(turn))
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])
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "contextpress"
7
- version = "0.6.3"
7
+ version = "0.6.4"
8
8
  description = "Deterministic context compression for LLM chat, RAG, and agent pipelines"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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` |
@@ -58,7 +58,7 @@ def test_fixture_recommend_preset(path: Path):
58
58
 
59
59
 
60
60
  def test_fixture_catalog_not_empty():
61
- assert len(FIXTURES) >= 11
61
+ assert len(FIXTURES) >= 12
62
62
 
63
63
 
64
64
  def test_c3_ack_may_weaken_resolution_signal():
@@ -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(m for m in result.messages if m.get("role") == "user" and "Tool result" in str(m.get("content", "")))
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