leancontext 2.0.4__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.
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/workflows/publish.yml +2 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/CHANGELOG.md +12 -1
- {leancontext-2.0.4 → leancontext-2.0.5}/PKG-INFO +1 -1
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/core.py +16 -8
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/clients.py +6 -1
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/messages.py +86 -33
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/paging.py +12 -3
- {leancontext-2.0.4 → leancontext-2.0.5}/pyproject.toml +1 -1
- leancontext-2.0.5/tests/test_concurrency.py +27 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_gateway.py +40 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_messages.py +11 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_paging.py +11 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_reducers.py +0 -1
- {leancontext-2.0.4 → leancontext-2.0.5}/.editorconfig +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/CODEOWNERS +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/dependabot.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/workflows/ci.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.github/workflows/codeql.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.gitignore +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/.pre-commit-config.yaml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/AGENTS.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/CITATION.cff +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/CODE_OF_CONDUCT.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/CONTRIBUTING.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/LICENSE +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/README.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/RELEASING.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/SECURITY.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/SUPPORT.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/assets/logo.png +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/assets/logo.svg +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/bench.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/demo.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/docs/ARCHITECTURE.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/examples/basic_usage.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/examples/validate_caching.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/__init__.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/cli.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/cost.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/fidelity.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/__init__.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/_common.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/anthropic_native.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/decorator.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/frameworks.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/litellm.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/mcp_server.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/otel.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/integrations/proxy.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/py.typed +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/__init__.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/base.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/diff.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/html.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/json_data.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/logs.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/stacktrace.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/reducers/table.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/leancontext/tokens.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_cache.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_core.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_differentiators.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_fidelity.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_frameworks.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_gemini.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_limits.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_mcp.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_otel.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_proxy.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_table.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.5}/tests/test_tokens.py +0 -0
|
@@ -5,13 +5,23 @@ 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
|
+
|
|
8
15
|
## [2.0.4] - 2026-06-21
|
|
9
16
|
|
|
10
17
|
### Fixed
|
|
11
18
|
- README uses absolute image and link URLs so the logo and links render on the PyPI
|
|
12
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.
|
|
13
21
|
|
|
14
22
|
### Added
|
|
23
|
+
- OpenAI Responses API support: `reduce_messages` and `wrap_openai` handle `input`
|
|
24
|
+
with `function_call_output` items.
|
|
15
25
|
- PyPI downloads badge, `SUPPORT.md`, and a CodeQL security-scanning workflow.
|
|
16
26
|
|
|
17
27
|
## [2.0.2] - 2026-06-21
|
|
@@ -43,7 +53,8 @@ All notable changes to this project are documented here. The format is based on
|
|
|
43
53
|
- Targets Python 3.14; ruff, mypy, and coverage run in CI; examples, contributor, and
|
|
44
54
|
security docs included.
|
|
45
55
|
|
|
46
|
-
[Unreleased]: https://github.com/pankajniet/LeanContext/compare/v2.0.
|
|
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
|
|
47
58
|
[2.0.4]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.4
|
|
48
59
|
[2.0.2]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.2
|
|
49
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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
_CACHE
|
|
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.
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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 (
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
|
|
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
|