leancontext 2.0.2__tar.gz → 2.0.5__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 (75) hide show
  1. leancontext-2.0.5/.github/workflows/codeql.yml +23 -0
  2. {leancontext-2.0.2 → leancontext-2.0.5}/.github/workflows/publish.yml +2 -0
  3. {leancontext-2.0.2 → leancontext-2.0.5}/CHANGELOG.md +22 -1
  4. {leancontext-2.0.2 → leancontext-2.0.5}/PKG-INFO +7 -6
  5. {leancontext-2.0.2 → leancontext-2.0.5}/README.md +6 -5
  6. leancontext-2.0.5/SUPPORT.md +7 -0
  7. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/core.py +16 -8
  8. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/clients.py +6 -1
  9. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/messages.py +86 -33
  10. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/paging.py +12 -3
  11. {leancontext-2.0.2 → leancontext-2.0.5}/pyproject.toml +1 -1
  12. leancontext-2.0.5/tests/test_concurrency.py +27 -0
  13. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_gateway.py +40 -0
  14. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_messages.py +11 -0
  15. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_paging.py +11 -0
  16. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_reducers.py +0 -1
  17. {leancontext-2.0.2 → leancontext-2.0.5}/.editorconfig +0 -0
  18. {leancontext-2.0.2 → leancontext-2.0.5}/.github/CODEOWNERS +0 -0
  19. {leancontext-2.0.2 → leancontext-2.0.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {leancontext-2.0.2 → leancontext-2.0.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  21. {leancontext-2.0.2 → leancontext-2.0.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  22. {leancontext-2.0.2 → leancontext-2.0.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {leancontext-2.0.2 → leancontext-2.0.5}/.github/dependabot.yml +0 -0
  24. {leancontext-2.0.2 → leancontext-2.0.5}/.github/workflows/ci.yml +0 -0
  25. {leancontext-2.0.2 → leancontext-2.0.5}/.gitignore +0 -0
  26. {leancontext-2.0.2 → leancontext-2.0.5}/.pre-commit-config.yaml +0 -0
  27. {leancontext-2.0.2 → leancontext-2.0.5}/AGENTS.md +0 -0
  28. {leancontext-2.0.2 → leancontext-2.0.5}/CITATION.cff +0 -0
  29. {leancontext-2.0.2 → leancontext-2.0.5}/CODE_OF_CONDUCT.md +0 -0
  30. {leancontext-2.0.2 → leancontext-2.0.5}/CONTRIBUTING.md +0 -0
  31. {leancontext-2.0.2 → leancontext-2.0.5}/LICENSE +0 -0
  32. {leancontext-2.0.2 → leancontext-2.0.5}/RELEASING.md +0 -0
  33. {leancontext-2.0.2 → leancontext-2.0.5}/SECURITY.md +0 -0
  34. {leancontext-2.0.2 → leancontext-2.0.5}/assets/logo.png +0 -0
  35. {leancontext-2.0.2 → leancontext-2.0.5}/assets/logo.svg +0 -0
  36. {leancontext-2.0.2 → leancontext-2.0.5}/bench.py +0 -0
  37. {leancontext-2.0.2 → leancontext-2.0.5}/demo.py +0 -0
  38. {leancontext-2.0.2 → leancontext-2.0.5}/docs/ARCHITECTURE.md +0 -0
  39. {leancontext-2.0.2 → leancontext-2.0.5}/examples/basic_usage.py +0 -0
  40. {leancontext-2.0.2 → leancontext-2.0.5}/examples/validate_caching.py +0 -0
  41. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/__init__.py +0 -0
  42. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/cli.py +0 -0
  43. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/cost.py +0 -0
  44. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/fidelity.py +0 -0
  45. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/__init__.py +0 -0
  46. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/_common.py +0 -0
  47. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/anthropic_native.py +0 -0
  48. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/decorator.py +0 -0
  49. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/frameworks.py +0 -0
  50. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/litellm.py +0 -0
  51. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/mcp_server.py +0 -0
  52. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/otel.py +0 -0
  53. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/integrations/proxy.py +0 -0
  54. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/py.typed +0 -0
  55. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/__init__.py +0 -0
  56. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/base.py +0 -0
  57. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/diff.py +0 -0
  58. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/html.py +0 -0
  59. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/json_data.py +0 -0
  60. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/logs.py +0 -0
  61. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/stacktrace.py +0 -0
  62. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/reducers/table.py +0 -0
  63. {leancontext-2.0.2 → leancontext-2.0.5}/leancontext/tokens.py +0 -0
  64. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_cache.py +0 -0
  65. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_core.py +0 -0
  66. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_differentiators.py +0 -0
  67. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_fidelity.py +0 -0
  68. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_frameworks.py +0 -0
  69. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_gemini.py +0 -0
  70. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_limits.py +0 -0
  71. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_mcp.py +0 -0
  72. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_otel.py +0 -0
  73. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_proxy.py +0 -0
  74. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_table.py +0 -0
  75. {leancontext-2.0.2 → leancontext-2.0.5}/tests/test_tokens.py +0 -0
@@ -0,0 +1,23 @@
1
+ name: CodeQL
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ schedule:
9
+ - cron: "0 6 * * 1" # weekly
10
+
11
+ permissions:
12
+ contents: read
13
+ security-events: write
14
+
15
+ jobs:
16
+ analyze:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: github/codeql-action/init@v3
21
+ with:
22
+ languages: python
23
+ - uses: github/codeql-action/analyze@v3
@@ -26,3 +26,5 @@ jobs:
26
26
  python -m build
27
27
  - name: Publish to PyPI
28
28
  uses: pypa/gh-action-pypi-publish@release/v1
29
+ with:
30
+ skip-existing: true # don't fail if a version was already uploaded
@@ -5,6 +5,25 @@ All notable changes to this project are documented here. The format is based on
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.0.5] - 2026-06-21
9
+
10
+ ### Security
11
+ - Fix a path traversal in the disk-backed paging store: `expand()` and `ContentStore.get()`
12
+ now accept only content-hash ids, so a crafted reference can no longer read files outside
13
+ the store (reachable via the MCP `expand` tool). The default in-memory store was unaffected.
14
+
15
+ ## [2.0.4] - 2026-06-21
16
+
17
+ ### Fixed
18
+ - README uses absolute image and link URLs so the logo and links render on the PyPI
19
+ project page (relative paths only resolve on GitHub).
20
+ - The reduction cache is now thread-safe (guarded by a lock) for multi-threaded agents.
21
+
22
+ ### Added
23
+ - OpenAI Responses API support: `reduce_messages` and `wrap_openai` handle `input`
24
+ with `function_call_output` items.
25
+ - PyPI downloads badge, `SUPPORT.md`, and a CodeQL security-scanning workflow.
26
+
8
27
  ## [2.0.2] - 2026-06-21
9
28
 
10
29
  ### Changed
@@ -34,6 +53,8 @@ All notable changes to this project are documented here. The format is based on
34
53
  - Targets Python 3.14; ruff, mypy, and coverage run in CI; examples, contributor, and
35
54
  security docs included.
36
55
 
37
- [Unreleased]: https://github.com/pankajniet/LeanContext/compare/v2.0.2...HEAD
56
+ [Unreleased]: https://github.com/pankajniet/LeanContext/compare/v2.0.5...HEAD
57
+ [2.0.5]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.5
58
+ [2.0.4]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.4
38
59
  [2.0.2]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.2
39
60
  [2.0.0]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leancontext
3
- Version: 2.0.2
3
+ Version: 2.0.5
4
4
  Summary: Deterministic, type-aware reduction of agent tool outputs at the source. Cut LLM token cost without making the agent do less.
5
5
  Project-URL: Homepage, https://github.com/pankajniet/LeanContext
6
6
  Project-URL: Repository, https://github.com/pankajniet/LeanContext
@@ -45,7 +45,7 @@ Requires-Dist: tiktoken>=0.12; extra == 'tiktoken'
45
45
  Description-Content-Type: text/markdown
46
46
 
47
47
  <p align="center">
48
- <img src="assets/logo.png" alt="LeanContext" width="460">
48
+ <img src="https://raw.githubusercontent.com/pankajniet/LeanContext/main/assets/logo.png" alt="LeanContext" width="460">
49
49
  </p>
50
50
 
51
51
  <p align="center">
@@ -54,7 +54,8 @@ Description-Content-Type: text/markdown
54
54
 
55
55
  <p align="center">
56
56
  <a href="https://pypi.org/project/leancontext/"><img alt="PyPI" src="https://img.shields.io/pypi/v/leancontext.svg"></a>
57
- <a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue.svg"></a>
57
+ <a href="https://pypi.org/project/leancontext/"><img alt="Downloads" src="https://img.shields.io/pypi/dm/leancontext.svg"></a>
58
+ <a href="https://github.com/pankajniet/LeanContext/blob/main/LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue.svg"></a>
58
59
  <img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-blue.svg">
59
60
  <a href="https://github.com/pankajniet/LeanContext/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/pankajniet/LeanContext/actions/workflows/ci.yml/badge.svg"></a>
60
61
  <a href="https://github.com/astral-sh/ruff"><img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json"></a>
@@ -192,7 +193,7 @@ Anything else, or any payload below the size, saving, or fidelity thresholds, pa
192
193
  Each tool output flows through fail-open gates (hash, size check, type detection, the typed
193
194
  reducer, then a saving and fidelity check) and returns either the reduced text or the original.
194
195
  Results are cached by content hash, so a payload re-sent across turns is reduced only once.
195
- See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for diagrams.
196
+ See [docs/ARCHITECTURE.md](https://github.com/pankajniet/LeanContext/blob/main/docs/ARCHITECTURE.md) for diagrams.
196
197
 
197
198
  ## Cost and telemetry
198
199
 
@@ -221,8 +222,8 @@ adapters, broader Anthropic native interop, and a PyPI release.
221
222
  ## Contributing
222
223
 
223
224
  Issues and PRs welcome. Run `pytest`. Reducers are pure functions, `str -> (reduced, notes)`,
224
- and must be deterministic and value-preserving. See [AGENTS.md](AGENTS.md) for the design rules.
225
+ and must be deterministic and value-preserving. See [AGENTS.md](https://github.com/pankajniet/LeanContext/blob/main/AGENTS.md) for the design rules.
225
226
 
226
227
  ## License
227
228
 
228
- [Apache-2.0](LICENSE)
229
+ [Apache-2.0](https://github.com/pankajniet/LeanContext/blob/main/LICENSE)
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="assets/logo.png" alt="LeanContext" width="460">
2
+ <img src="https://raw.githubusercontent.com/pankajniet/LeanContext/main/assets/logo.png" alt="LeanContext" width="460">
3
3
  </p>
4
4
 
5
5
  <p align="center">
@@ -8,7 +8,8 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://pypi.org/project/leancontext/"><img alt="PyPI" src="https://img.shields.io/pypi/v/leancontext.svg"></a>
11
- <a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue.svg"></a>
11
+ <a href="https://pypi.org/project/leancontext/"><img alt="Downloads" src="https://img.shields.io/pypi/dm/leancontext.svg"></a>
12
+ <a href="https://github.com/pankajniet/LeanContext/blob/main/LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue.svg"></a>
12
13
  <img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-blue.svg">
13
14
  <a href="https://github.com/pankajniet/LeanContext/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/pankajniet/LeanContext/actions/workflows/ci.yml/badge.svg"></a>
14
15
  <a href="https://github.com/astral-sh/ruff"><img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json"></a>
@@ -146,7 +147,7 @@ Anything else, or any payload below the size, saving, or fidelity thresholds, pa
146
147
  Each tool output flows through fail-open gates (hash, size check, type detection, the typed
147
148
  reducer, then a saving and fidelity check) and returns either the reduced text or the original.
148
149
  Results are cached by content hash, so a payload re-sent across turns is reduced only once.
149
- See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for diagrams.
150
+ See [docs/ARCHITECTURE.md](https://github.com/pankajniet/LeanContext/blob/main/docs/ARCHITECTURE.md) for diagrams.
150
151
 
151
152
  ## Cost and telemetry
152
153
 
@@ -175,8 +176,8 @@ adapters, broader Anthropic native interop, and a PyPI release.
175
176
  ## Contributing
176
177
 
177
178
  Issues and PRs welcome. Run `pytest`. Reducers are pure functions, `str -> (reduced, notes)`,
178
- and must be deterministic and value-preserving. See [AGENTS.md](AGENTS.md) for the design rules.
179
+ and must be deterministic and value-preserving. See [AGENTS.md](https://github.com/pankajniet/LeanContext/blob/main/AGENTS.md) for the design rules.
179
180
 
180
181
  ## License
181
182
 
182
- [Apache-2.0](LICENSE)
183
+ [Apache-2.0](https://github.com/pankajniet/LeanContext/blob/main/LICENSE)
@@ -0,0 +1,7 @@
1
+ # Support
2
+
3
+ - **Questions and ideas:** open a [Discussion](https://github.com/pankajniet/LeanContext/discussions),
4
+ or an issue using the *Feature request* template.
5
+ - **Bugs:** open an issue with the *Bug report* template.
6
+ - **Security:** see [SECURITY.md](SECURITY.md) — report privately, not in a public issue.
7
+ - **Docs:** start with the [README](README.md) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import os
12
+ import threading
12
13
  from collections import OrderedDict
13
14
  from collections.abc import Callable
14
15
  from dataclasses import dataclass, field
@@ -36,11 +37,13 @@ CONFIG = _Config()
36
37
  # A tool output is re-sent on every turn, so we reduce each unique payload once and
37
38
  # reuse the result. Keyed by content hash + options; deterministic, so this is safe.
38
39
  _CACHE: OrderedDict[tuple, Reduction] = OrderedDict()
40
+ _CACHE_LOCK = threading.Lock()
39
41
 
40
42
 
41
43
  def clear_cache() -> None:
42
44
  """Drop all cached reductions."""
43
- _CACHE.clear()
45
+ with _CACHE_LOCK:
46
+ _CACHE.clear()
44
47
 
45
48
 
46
49
  def disable() -> None:
@@ -167,15 +170,20 @@ def reduce_text(
167
170
  key = (ref, kind, min_saving, min_fidelity, CONFIG.min_tokens, CONFIG.max_input_chars)
168
171
  use_cache = CONFIG.cache_size > 0
169
172
 
170
- if use_cache and key in _CACHE:
171
- result = _CACHE[key]
172
- _CACHE.move_to_end(key)
173
- else:
173
+ result = None
174
+ if use_cache:
175
+ with _CACHE_LOCK:
176
+ result = _CACHE.get(key)
177
+ if result is not None:
178
+ _CACHE.move_to_end(key)
179
+
180
+ if result is None:
174
181
  result = _compute(original, before, ref, kind, min_saving, min_fidelity)
175
182
  if use_cache:
176
- _CACHE[key] = result
177
- if len(_CACHE) > CONFIG.cache_size:
178
- _CACHE.popitem(last=False) # evict least-recently-used
183
+ with _CACHE_LOCK:
184
+ _CACHE[key] = result
185
+ if len(_CACHE) > CONFIG.cache_size:
186
+ _CACHE.popitem(last=False) # evict least-recently-used
179
187
 
180
188
  if result.applied:
181
189
  _emit(result)
@@ -15,12 +15,17 @@ from ._common import wrap_messages_create
15
15
 
16
16
 
17
17
  def wrap_openai(client: Any, **opts) -> Any:
18
- """Reduce tool outputs on an OpenAI client's chat.completions.create."""
18
+ """Reduce tool outputs on an OpenAI client's chat.completions and responses APIs."""
19
19
  try:
20
20
  comp = client.chat.completions
21
21
  comp.create = wrap_messages_create(comp.create, fmt="openai", opts=opts)
22
22
  except Exception:
23
23
  pass # fail open
24
+ try:
25
+ responses = client.responses
26
+ responses.create = wrap_messages_create(responses.create, fmt="responses", opts=opts, key="input")
27
+ except Exception:
28
+ pass # fail open
24
29
  return client
25
30
 
26
31
 
@@ -1,39 +1,27 @@
1
1
  """Protocol-aware message reduction — the gateway/wire surface.
2
2
 
3
3
  This is how LeanContext plugs into gateways (LiteLLM), SDK client wrappers, and proxies
4
- *without* the structure-blindness that hurts wire-level compressors: the chat
5
- protocols already tag tool outputs (OpenAI ``role="tool"``; Anthropic
6
- ``tool_result`` blocks), so we can find and reduce exactly those — and nothing
7
- else. We never touch system/user/assistant instruction text. Fail-open throughout.
4
+ *without* the structure-blindness that hurts wire-level compressors: the chat protocols
5
+ already tag tool outputs, so we find and reduce exactly those and nothing else. We never
6
+ touch system/user/assistant instruction text. Fail-open throughout.
8
7
 
9
- Cache-safety: reductions are deterministic and content-addressed, so the same tool
10
- output always serialises to the same bytes the provider prompt-cache keeps hitting.
8
+ Each provider format registers a detector and a per-item reducer in ``_FORMATS`` (like the
9
+ typed-reducer registry), so adding a format means adding one entry. Supported: OpenAI
10
+ chat-completions, Anthropic messages, Gemini contents, and the OpenAI Responses API.
11
+
12
+ Cache-safety: reductions are deterministic and content-addressed, so the same tool output
13
+ always serialises to the same bytes → the provider prompt-cache keeps hitting.
11
14
  """
12
15
 
13
16
  from __future__ import annotations
14
17
 
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
15
20
  from typing import Any
16
21
 
17
22
  from .core import reduce_text
18
23
 
19
24
 
20
- def detect_format(messages: list) -> str:
21
- """Best-effort detection of the message protocol."""
22
- for m in messages:
23
- if not isinstance(m, dict):
24
- continue
25
- if isinstance(m.get("parts"), list):
26
- return "gemini"
27
- if m.get("role") in ("tool", "function"):
28
- return "openai"
29
- content = m.get("content")
30
- if isinstance(content, list):
31
- for block in content:
32
- if isinstance(block, dict) and block.get("type") == "tool_result":
33
- return "anthropic"
34
- return "openai"
35
-
36
-
37
25
  def _reduce_str(text: Any, opts: dict) -> Any:
38
26
  if not isinstance(text, str):
39
27
  return text
@@ -112,8 +100,7 @@ def _reduce_anthropic_textblock(x: Any, opts: dict) -> Any:
112
100
  # --- Gemini format -----------------------------------------------------------
113
101
  # Gemini uses `contents` -> `parts`, where a tool result is a `functionResponse`
114
102
  # part whose `response` is a dict. We reduce the large string values inside that
115
- # dict, keeping the dict shape Gemini requires. Typed SDK objects (non-dict)
116
- # pass through untouched.
103
+ # dict, keeping the dict shape Gemini requires. Typed SDK objects pass through.
117
104
 
118
105
  def _reduce_gemini_message(content: Any, opts: dict) -> Any:
119
106
  if not isinstance(content, dict) or not isinstance(content.get("parts"), list):
@@ -133,20 +120,86 @@ def _reduce_gemini_message(content: Any, opts: dict) -> Any:
133
120
  return {**content, "parts": new_parts}
134
121
 
135
122
 
123
+ # --- OpenAI Responses API format ---------------------------------------------
124
+ # The Responses API uses `input` (not `messages`); a tool result is an item with
125
+ # type "function_call_output" whose `output` is a string.
126
+
127
+ def _reduce_responses_message(item: Any, opts: dict) -> Any:
128
+ if (
129
+ isinstance(item, dict)
130
+ and item.get("type") == "function_call_output"
131
+ and isinstance(item.get("output"), str)
132
+ ):
133
+ new_item = dict(item)
134
+ new_item["output"] = _reduce_str(item["output"], opts)
135
+ return new_item
136
+ return item
137
+
138
+
139
+ # --- format registry ---------------------------------------------------------
140
+
141
+ def _is_responses(m: dict) -> bool:
142
+ return m.get("type") == "function_call_output"
143
+
144
+
145
+ def _is_gemini(m: dict) -> bool:
146
+ return isinstance(m.get("parts"), list)
147
+
148
+
149
+ def _is_openai(m: dict) -> bool:
150
+ return m.get("role") in ("tool", "function")
151
+
152
+
153
+ def _is_anthropic(m: dict) -> bool:
154
+ content = m.get("content")
155
+ return isinstance(content, list) and any(
156
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
157
+ )
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class _Format:
162
+ name: str
163
+ detect: Callable[[dict], bool]
164
+ reduce: Callable[[Any, dict], Any]
165
+ priority: int
166
+
167
+
168
+ # Detection runs in priority order; the first format any single message matches wins.
169
+ _FORMATS: list[_Format] = sorted(
170
+ [
171
+ _Format("responses", _is_responses, _reduce_responses_message, 10),
172
+ _Format("gemini", _is_gemini, _reduce_gemini_message, 20),
173
+ _Format("openai", _is_openai, _reduce_openai_message, 30),
174
+ _Format("anthropic", _is_anthropic, _reduce_anthropic_message, 40),
175
+ ],
176
+ key=lambda f: f.priority,
177
+ )
178
+ _REDUCE_BY_NAME = {f.name: f.reduce for f in _FORMATS}
179
+
180
+
136
181
  # --- public ------------------------------------------------------------------
137
182
 
183
+ def detect_format(messages: list) -> str:
184
+ """Best-effort detection of the message protocol; defaults to ``openai``."""
185
+ for m in messages:
186
+ if not isinstance(m, dict):
187
+ continue
188
+ for fmt in _FORMATS:
189
+ if fmt.detect(m):
190
+ return fmt.name
191
+ return "openai"
192
+
193
+
138
194
  def reduce_messages(messages: Any, *, fmt: str = "auto", **opts) -> Any:
139
195
  """Return a new message list with tool outputs reduced. Input is not mutated.
140
196
 
141
- Handles OpenAI (`role:"tool"`), Anthropic (`tool_result` blocks), and Gemini
142
- (`functionResponse` parts). Only tool-result content is touched; instructions
143
- are never altered. Anything unrecognised passes through unchanged (fail open).
197
+ Handles OpenAI (chat + Responses), Anthropic, and Gemini formats. Only tool-result
198
+ content is touched; instructions are never altered. Anything unrecognised passes
199
+ through unchanged (fail open).
144
200
  """
145
201
  if not isinstance(messages, list):
146
202
  return messages
147
203
  resolved = detect_format(messages) if fmt == "auto" else fmt
148
- if resolved == "anthropic":
149
- return [_reduce_anthropic_message(m, opts) for m in messages]
150
- if resolved == "gemini":
151
- return [_reduce_gemini_message(m, opts) for m in messages]
152
- return [_reduce_openai_message(m, opts) for m in messages]
204
+ reducer = _REDUCE_BY_NAME.get(resolved, _reduce_openai_message)
205
+ return [reducer(m, opts) for m in messages]
@@ -18,6 +18,7 @@ from .tokens import content_ref, count_tokens
18
18
 
19
19
  REF_SCHEME = "lc"
20
20
  _REF_RE = re.compile(r"lc://([0-9a-f]{6,40})")
21
+ _HEX_REF = re.compile(r"[0-9a-f]{6,40}") # a valid content-hash id (no path chars)
21
22
 
22
23
 
23
24
  class ContentStore:
@@ -42,6 +43,8 @@ class ContentStore:
42
43
  return ref
43
44
 
44
45
  def get(self, ref: str) -> str | None:
46
+ if not _HEX_REF.fullmatch(ref): # only content-hash ids; blocks path traversal
47
+ return None
45
48
  if self.root:
46
49
  try:
47
50
  with open(self._path(ref), encoding="utf-8") as fh:
@@ -54,9 +57,12 @@ class ContentStore:
54
57
  _DEFAULT_STORE = ContentStore()
55
58
 
56
59
 
57
- def _normalize(ref: str) -> str:
60
+ def _normalize(ref: str) -> str | None:
58
61
  m = _REF_RE.search(ref)
59
- return m.group(1) if m else ref.strip()
62
+ if m:
63
+ return m.group(1)
64
+ ref = ref.strip()
65
+ return ref if _HEX_REF.fullmatch(ref) else None
60
66
 
61
67
 
62
68
  def store(content: str, using: ContentStore | None = None) -> str:
@@ -66,7 +72,10 @@ def store(content: str, using: ContentStore | None = None) -> str:
66
72
 
67
73
  def expand(ref: str, using: ContentStore | None = None) -> str | None:
68
74
  """Return the original content for a ref (accepts 'lc://<id>' or a bare id)."""
69
- return (using or _DEFAULT_STORE).get(_normalize(ref))
75
+ norm = _normalize(ref)
76
+ if norm is None:
77
+ return None
78
+ return (using or _DEFAULT_STORE).get(norm)
70
79
 
71
80
 
72
81
  def reference_line(content: str, summary: str | None = None,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "leancontext"
3
- version = "2.0.2"
3
+ version = "2.0.5"
4
4
  description = "Deterministic, type-aware reduction of agent tool outputs at the source. Cut LLM token cost without making the agent do less."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,27 @@
1
+ import concurrent.futures
2
+
3
+ import leancontext
4
+ from leancontext.core import CONFIG, clear_cache
5
+
6
+
7
+ def _log(n=400):
8
+ lines = [f"2026-06-21T09:00:{i % 60:02d}Z INFO [worker] job={i} status=ok ms={i % 50}" for i in range(n)]
9
+ lines.insert(200, "2026-06-21T09:05:00Z FATAL [render] OOM killed worker=7 — root cause")
10
+ return "\n".join(lines)
11
+
12
+
13
+ def test_concurrent_reduce_with_eviction_is_safe():
14
+ # Small cache + many distinct payloads across threads exercises the cache's
15
+ # insert / move_to_end / evict paths concurrently. With the lock this is safe.
16
+ clear_cache()
17
+ old = CONFIG.cache_size
18
+ CONFIG.cache_size = 5
19
+ try:
20
+ payloads = [_log() + f"\nunique-{i} marker line " * 2 for i in range(60)] * 2
21
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
22
+ results = list(pool.map(lambda p: leancontext.reduce(p).text, payloads))
23
+ assert len(results) == len(payloads)
24
+ assert all("root cause" in r for r in results)
25
+ finally:
26
+ CONFIG.cache_size = old
27
+ clear_cache()
@@ -36,6 +36,46 @@ def test_wrap_openai_client_reduces_messages():
36
36
  assert len(sent) < len(_big_log()) and "root cause" in sent
37
37
 
38
38
 
39
+ def test_wrap_openai_responses_api():
40
+ openai = pytest.importorskip("openai")
41
+ client = openai.OpenAI(api_key="test-key")
42
+ if not hasattr(client, "responses"):
43
+ pytest.skip("Responses API not in this openai version")
44
+
45
+ captured = {}
46
+ client.responses.create = lambda **kw: captured.update(kw) or "OK"
47
+
48
+ from leancontext import wrap_openai
49
+ wrap_openai(client)
50
+
51
+ client.responses.create(
52
+ model="gpt-4o",
53
+ input=[{"type": "function_call_output", "call_id": "c", "output": _big_log()}],
54
+ )
55
+ sent = captured["input"][0]["output"]
56
+ assert len(sent) < len(_big_log()) and "root cause" in sent
57
+
58
+
59
+ def test_wrap_async_openai_client_reduces_messages():
60
+ openai = pytest.importorskip("openai")
61
+ client = openai.AsyncOpenAI(api_key="test-key")
62
+
63
+ captured = {}
64
+
65
+ async def fake(**kw):
66
+ captured.update(kw)
67
+ return "OK"
68
+
69
+ client.chat.completions.create = fake
70
+
71
+ from leancontext import wrap_openai
72
+ wrap_openai(client)
73
+
74
+ asyncio.run(client.chat.completions.create(model="gpt-4o", messages=[_openai_tool_msg()]))
75
+ sent = captured["messages"][0]["content"]
76
+ assert len(sent) < len(_big_log()) and "root cause" in sent
77
+
78
+
39
79
  def test_wrap_anthropic_client_reduces_tool_results():
40
80
  anthropic = pytest.importorskip("anthropic")
41
81
  client = anthropic.Anthropic(api_key="test-key")
@@ -52,6 +52,17 @@ def test_input_not_mutated():
52
52
  assert tool["content"] == before # original list/dicts untouched
53
53
 
54
54
 
55
+ def test_responses_format_reduced():
56
+ items = [
57
+ {"role": "user", "content": "why did it crash?"},
58
+ {"type": "function_call_output", "call_id": "c1", "output": _log()},
59
+ ]
60
+ out = reduce_messages(items) # auto-detect -> responses
61
+ assert out[0] == items[0] # the user message is untouched
62
+ reduced = out[1]["output"]
63
+ assert len(reduced) < len(_log()) and "root cause" in reduced
64
+
65
+
55
66
  def test_non_list_passthrough():
56
67
  assert reduce_messages("not a list") == "not a list"
57
68
 
@@ -44,3 +44,14 @@ def test_expand_tool_spec_shape():
44
44
  spec = paging.EXPAND_TOOL_SPEC
45
45
  assert spec["name"] == "leancontext_expand"
46
46
  assert spec["input_schema"]["required"] == ["ref"]
47
+
48
+
49
+ def test_expand_rejects_path_traversal(tmp_path):
50
+ store = paging.ContentStore(root=str(tmp_path))
51
+ secret = tmp_path.parent / "leak.txt"
52
+ secret.write_text("TOPSECRET")
53
+ # refs that aren't content hashes must never resolve to a filesystem path
54
+ for evil in ("../leak", "../../etc/hosts", "/etc/hosts", "..%2Fleak"):
55
+ assert paging.expand(evil, using=store) is None
56
+ assert store.get(evil) is None
57
+
@@ -1,6 +1,5 @@
1
1
  import leancontext
2
2
 
3
-
4
3
  # --- diff --------------------------------------------------------------------
5
4
 
6
5
  def _diff():
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