langstate 0.2.2__tar.gz → 0.2.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.
@@ -16,10 +16,13 @@ Why they are excluded from current claims:
16
16
 
17
17
  - The JSON does not record a run time, source commit, full model identifier, or
18
18
  reproducible decoding/runtime configuration.
19
- - `bench_adapters.py` imports the legacy repository-root `adapters.py` and
20
- `compress.py`, not the shipping modules under `src/langstate/`; both pairs
21
- differ at current `main` commit
22
- `b9a8b036ab65d0a74d27a580602cd12afd438235`.
19
+ - When these rows were recorded, `bench_adapters.py` imported legacy
20
+ repository-root copies of `adapters.py` and `compress.py` (default models
21
+ qwen3:14b and claude-opus-4-7), not the shipping modules under
22
+ `src/langstate/`; both pairs differed at `main` commit
23
+ `b9a8b036ab65d0a74d27a580602cd12afd438235`. The root copies have since been
24
+ removed and the script now imports `langstate.*`, so a fresh run uses the
25
+ current shipping defaults rather than the configuration behind these bytes.
23
26
  - `bench_results.json` names missing-fact keys such as `corpus_size`,
24
27
  `arxiv_papers`, and `benchmarks` that are not in the current runner's
25
28
  `GROUND_TRUTH` list. The current script therefore cannot reproduce the stored
@@ -4,6 +4,5 @@
4
4
  include BENCHMARK-PROVENANCE.md
5
5
  prune tests
6
6
  prune bench
7
- prune _workspace
8
7
  global-exclude te_check.py
9
8
  global-exclude *.pyc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstate
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Inspectable context compression for LLM conversations, with visible scaffold state and lexical fact receipts
5
5
  Author-email: Rolando Bosch <roli@hermes-labs.ai>
6
6
  Maintainer-email: Hermes Labs <info@hermes-labs.ai>
@@ -28,6 +28,8 @@ Dynamic: license-file
28
28
 
29
29
  # langstate
30
30
 
31
+ [![Tests](https://github.com/hermes-labs-ai/langstate/actions/workflows/test.yml/badge.svg)](https://github.com/hermes-labs-ai/langstate/actions/workflows/test.yml)
32
+
31
33
  **Compress long LLM conversations into visible working state — then check that
32
34
  the literal facts you care about survived.**
33
35
 
@@ -49,12 +51,19 @@ semantic fidelity.
49
51
  ## Try it in two minutes
50
52
 
51
53
  ```bash
52
- python -m pip install langstate==0.2.2
54
+ python -m pip install langstate==0.2.4
55
+ langstate --version
53
56
  langstate demo
54
57
  ```
55
58
 
59
+ Or install the CLI from the [Hermes Labs Homebrew tap](https://github.com/hermes-labs-ai/homebrew-tap):
60
+
61
+ ```bash
62
+ brew install hermes-labs-ai/tap/langstate
63
+ ```
64
+
56
65
  It prints the compressed messages and a machine-readable receipt showing that
57
- both named facts survived. The proof needs no model, API key, network call, or
66
+ both named facts survived and that the history got smaller. The proof needs no model, API key, network call, or
58
67
  Ollama installation.
59
68
 
60
69
  Python 3.10+; no runtime dependencies beyond the standard library. To use the
@@ -147,7 +156,7 @@ contract; automatic fact extraction is a convenience heuristic.
147
156
 
148
157
  ## How it works
149
158
 
150
- For conversations of at least six user/assistant turns, `compress`:
159
+ For conversations of at least six user/assistant turn pairs, `compress`:
151
160
 
152
161
  1. retains all `system` messages and the requested recent suffix verbatim;
153
162
  2. sends the older non-system messages to the selected summarizer;
@@ -209,5 +218,10 @@ compress(
209
218
  validate(before, after, facts=None)
210
219
  ```
211
220
 
221
+ `preserve_recent` counts recent user-initiated turns. Each retained turn includes
222
+ its assistant messages, tool calls, and tool results verbatim, so the retained
223
+ tail does not begin in the middle of a tool exchange. Older history remains
224
+ a lossy summary; retain the original messages when exact replay is required.
225
+
212
226
  See `Receipt.as_dict()` for a JSON-friendly receipt. The package is
213
227
  Apache-2.0 licensed.
@@ -1,5 +1,7 @@
1
1
  # langstate
2
2
 
3
+ [![Tests](https://github.com/hermes-labs-ai/langstate/actions/workflows/test.yml/badge.svg)](https://github.com/hermes-labs-ai/langstate/actions/workflows/test.yml)
4
+
3
5
  **Compress long LLM conversations into visible working state — then check that
4
6
  the literal facts you care about survived.**
5
7
 
@@ -21,12 +23,19 @@ semantic fidelity.
21
23
  ## Try it in two minutes
22
24
 
23
25
  ```bash
24
- python -m pip install langstate==0.2.2
26
+ python -m pip install langstate==0.2.4
27
+ langstate --version
25
28
  langstate demo
26
29
  ```
27
30
 
31
+ Or install the CLI from the [Hermes Labs Homebrew tap](https://github.com/hermes-labs-ai/homebrew-tap):
32
+
33
+ ```bash
34
+ brew install hermes-labs-ai/tap/langstate
35
+ ```
36
+
28
37
  It prints the compressed messages and a machine-readable receipt showing that
29
- both named facts survived. The proof needs no model, API key, network call, or
38
+ both named facts survived and that the history got smaller. The proof needs no model, API key, network call, or
30
39
  Ollama installation.
31
40
 
32
41
  Python 3.10+; no runtime dependencies beyond the standard library. To use the
@@ -119,7 +128,7 @@ contract; automatic fact extraction is a convenience heuristic.
119
128
 
120
129
  ## How it works
121
130
 
122
- For conversations of at least six user/assistant turns, `compress`:
131
+ For conversations of at least six user/assistant turn pairs, `compress`:
123
132
 
124
133
  1. retains all `system` messages and the requested recent suffix verbatim;
125
134
  2. sends the older non-system messages to the selected summarizer;
@@ -181,5 +190,10 @@ compress(
181
190
  validate(before, after, facts=None)
182
191
  ```
183
192
 
193
+ `preserve_recent` counts recent user-initiated turns. Each retained turn includes
194
+ its assistant messages, tool calls, and tool results verbatim, so the retained
195
+ tail does not begin in the middle of a tool exchange. Older history remains
196
+ a lossy summary; retain the original messages when exact replay is required.
197
+
184
198
  See `Receipt.as_dict()` for a JSON-friendly receipt. The package is
185
199
  Apache-2.0 licensed.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstate"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Inspectable context compression for LLM conversations, with visible scaffold state and lexical fact receipts"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -3,5 +3,5 @@
3
3
  from langstate.compress import compress
4
4
  from langstate.validate import Receipt, extract_facts, validate
5
5
 
6
- __version__ = "0.2.2"
7
- __all__ = ["compress", "validate", "Receipt", "extract_facts", "__version__"]
6
+ __version__ = "0.2.4"
7
+ __all__ = ["Receipt", "__version__", "compress", "extract_facts", "validate"]
@@ -17,12 +17,13 @@ Production targets (one cheap default per provider, per the Hermes spec):
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import http.client
20
21
  import json
21
22
  import os
22
23
  import time
23
24
  import urllib.error
24
25
  import urllib.request
25
- from typing import Callable, Optional
26
+ from collections.abc import Callable
26
27
 
27
28
  Summarizer = Callable[[str], str]
28
29
 
@@ -82,7 +83,7 @@ DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
82
83
 
83
84
  def openai(
84
85
  model: str = DEFAULT_OPENAI_MODEL,
85
- api_key: Optional[str] = None,
86
+ api_key: str | None = None,
86
87
  url: str = OPENAI_URL,
87
88
  max_tokens: int = 4000,
88
89
  temperature: float = 0.2,
@@ -138,7 +139,7 @@ ANTHROPIC_VERSION = "2023-06-01"
138
139
 
139
140
  def anthropic(
140
141
  model: str = DEFAULT_ANTHROPIC_MODEL,
141
- api_key: Optional[str] = None,
142
+ api_key: str | None = None,
142
143
  url: str = ANTHROPIC_URL,
143
144
  max_tokens: int = 4000,
144
145
  temperature: float = 0.2,
@@ -210,7 +211,15 @@ def probe(name: str) -> dict:
210
211
  out = fn("Say OK.")
211
212
  except AdapterUnavailable as e:
212
213
  return {"name": name, "available": False, "reason": str(e), "latency_ms": None}
213
- except Exception as e: # noqa: BLE001
214
+ except (
215
+ AttributeError,
216
+ http.client.HTTPException,
217
+ IndexError,
218
+ KeyError,
219
+ RuntimeError,
220
+ TypeError,
221
+ ValueError,
222
+ ) as e:
214
223
  return {"name": name, "available": False, "reason": f"{type(e).__name__}: {e}", "latency_ms": None}
215
224
  return {
216
225
  "name": name,
@@ -10,16 +10,42 @@ from . import __version__, compress, validate
10
10
 
11
11
 
12
12
  def _demo_summary(_: str) -> str:
13
- return "- Project: Atlas\\n- Budget: $4,000"
13
+ return "- Project: Atlas (Q4 build-cluster migration)\n- Budget: $4,000 cap, no overrun"
14
14
 
15
15
 
16
16
  def _demo_payload() -> dict[str, object]:
17
17
  before = [
18
18
  {"role": "system", "content": "Preserve named project facts."},
19
- {"role": "user", "content": "The project is Atlas."},
20
- {"role": "assistant", "content": "Recorded Project: Atlas."},
21
- {"role": "user", "content": "The budget is $4,000."},
22
- {"role": "assistant", "content": "Recorded Budget: $4,000."},
19
+ {
20
+ "role": "user",
21
+ "content": (
22
+ "We are kicking off the Q4 infrastructure work. For tracking, "
23
+ "the project is called Atlas, and it covers moving every build "
24
+ "job from the old cluster to the new one before the freeze."
25
+ ),
26
+ },
27
+ {
28
+ "role": "assistant",
29
+ "content": (
30
+ "Recorded Project: Atlas, the Q4 migration of all build jobs "
31
+ "from the old cluster to the new one ahead of the freeze."
32
+ ),
33
+ },
34
+ {
35
+ "role": "user",
36
+ "content": (
37
+ "Finance signed off yesterday. The budget is $4,000 for the "
38
+ "whole migration, contractor hours included, and we cannot go "
39
+ "above that without a fresh approval."
40
+ ),
41
+ },
42
+ {
43
+ "role": "assistant",
44
+ "content": (
45
+ "Recorded Budget: $4,000, all-inclusive, with no overrun "
46
+ "allowed without a fresh approval."
47
+ ),
48
+ },
23
49
  {"role": "user", "content": "What remains?"},
24
50
  {"role": "assistant", "content": "Review the release packet."},
25
51
  ]
@@ -11,13 +11,13 @@ Usage:
11
11
  response = client.chat.completions.create(messages=compressed, model="gpt-4o")
12
12
  """
13
13
 
14
- from typing import Callable, Optional
14
+ from collections.abc import Callable
15
15
 
16
16
  from langstate import adapters as _adapters
17
17
 
18
18
  OLLAMA_URL = "http://localhost:11434/api/chat"
19
19
  DEFAULT_MODEL = "qwen3:4b"
20
- PRESERVE_RECENT = 4 # keep last N user/assistant turns verbatim
20
+ PRESERVE_RECENT = 4 # keep last N user/assistant turn pairs verbatim
21
21
 
22
22
  Summarizer = Callable[[str], str]
23
23
 
@@ -58,28 +58,43 @@ def compress(
58
58
  model: str = DEFAULT_MODEL,
59
59
  ollama_url: str = OLLAMA_URL,
60
60
  min_turns_to_compress: int = 6,
61
- summarizer: Optional[Summarizer] = None,
61
+ summarizer: Summarizer | None = None,
62
62
  ) -> list[dict]:
63
63
  """Summarize older messages while keeping system and recent turns verbatim.
64
64
 
65
65
  Args:
66
66
  messages: List of dicts with "role" and "content" keys.
67
- preserve_recent: Number of recent user/assistant turn pairs to keep verbatim.
67
+ preserve_recent: Number of recent user-initiated turns to keep verbatim,
68
+ including their assistant messages and tool exchanges.
68
69
  model: Ollama model to use when no summarizer is given (back-compat).
69
70
  ollama_url: Ollama API endpoint (back-compat).
70
- min_turns_to_compress: Don't compress if fewer than this many complete
71
- user/assistant turn pairs are present.
71
+ min_turns_to_compress: Don't compress if fewer than this many
72
+ user-initiated turns are present.
72
73
  summarizer: Optional ``summarize(prompt) -> str`` callable. If None, falls back
73
74
  to a local Ollama adapter (qwen3:4b) for back-compat with the MVP.
74
75
 
75
76
  Returns:
76
77
  Compressed messages list in the same OpenAI format.
78
+
79
+ Raises:
80
+ ValueError: If ``preserve_recent`` is not a non-negative integer.
81
+ TypeError: If the summarizer returns a non-string.
82
+ RuntimeError: If the summarizer returns empty or whitespace-only text.
77
83
  """
84
+ if (
85
+ isinstance(preserve_recent, bool)
86
+ or not isinstance(preserve_recent, int)
87
+ or preserve_recent < 0
88
+ ):
89
+ raise ValueError(
90
+ f"preserve_recent must be a non-negative integer, got {preserve_recent!r}"
91
+ )
92
+
78
93
  if not messages:
79
94
  return []
80
95
 
81
- # Count actual user/assistant turns, not total messages
82
- n_turns = sum(1 for m in messages if m.get("role") in ("user", "assistant")) // 2
96
+ # Tool-call assistant messages do not start additional user turns.
97
+ n_turns = sum(m.get("role") == "user" for m in messages)
83
98
  if n_turns < min_turns_to_compress:
84
99
  return list(messages)
85
100
 
@@ -90,14 +105,21 @@ def compress(
90
105
  if not conv_msgs:
91
106
  return list(messages)
92
107
 
93
- # Split: history to compress vs recent turns to preserve
94
- # preserve_recent refers to turn pairs, so preserve_recent*2 messages
95
- preserve_count = preserve_recent * 2
96
- if len(conv_msgs) <= preserve_count:
97
- return list(messages)
98
-
99
- history_msgs = conv_msgs[:-preserve_count]
100
- recent_msgs = conv_msgs[-preserve_count:]
108
+ # Keep entire user-initiated turns, including assistant tool calls and
109
+ # their results. Counting raw messages can split a tool exchange.
110
+ if preserve_recent:
111
+ user_indexes = [
112
+ index for index, message in enumerate(conv_msgs)
113
+ if message.get("role") == "user"
114
+ ]
115
+ if len(user_indexes) <= preserve_recent:
116
+ return list(messages)
117
+ recent_start = user_indexes[-preserve_recent]
118
+ history_msgs = conv_msgs[:recent_start]
119
+ recent_msgs = conv_msgs[recent_start:]
120
+ else:
121
+ history_msgs = conv_msgs
122
+ recent_msgs = []
101
123
 
102
124
  # Format history for summarization
103
125
  history_text = "\n".join(
@@ -109,6 +131,18 @@ def compress(
109
131
  summarizer = _adapters.ollama(model=model, url=ollama_url)
110
132
  summary = summarizer(_build_compression_prompt(history_text))
111
133
 
134
+ # Refuse to replace history with nothing: an empty scaffold would silently
135
+ # drop the compressed messages instead of summarizing them.
136
+ if not isinstance(summary, str):
137
+ raise TypeError(
138
+ f"summarizer must return str, got {type(summary).__name__}"
139
+ )
140
+ if not summary.strip():
141
+ raise RuntimeError(
142
+ f"summarizer returned empty output; refusing to drop "
143
+ f"{len(history_msgs)} messages without a summary"
144
+ )
145
+
112
146
  # Build compressed messages
113
147
  compressed = []
114
148
 
@@ -57,6 +57,17 @@ def _norm(s: str) -> str:
57
57
  return _WS.sub(" ", s).strip().lower()
58
58
 
59
59
 
60
+ def _contains_fact(haystack: str, key: str) -> bool:
61
+ """True iff normalized ``key`` occurs in ``haystack`` at lexical boundaries.
62
+
63
+ Plain substring membership would credit ``42`` against ``142`` or ``API``
64
+ against ``rapid``. Requiring a non-word (or string-edge) character on both
65
+ sides of the match blocks those, while leaving punctuation-led facts like
66
+ ``$4,000`` / ``90.1%`` and multiword facts untouched.
67
+ """
68
+ return re.search(r"(?<!\w)" + re.escape(key) + r"(?!\w)", haystack) is not None
69
+
70
+
60
71
  def _messages_text(messages: list[dict]) -> str:
61
72
  return "\n".join(m.get("content", "") or "" for m in messages)
62
73
 
@@ -164,9 +175,10 @@ def validate(
164
175
  (heuristic — pass an explicit list for a precise receipt).
165
176
 
166
177
  Returns:
167
- A :class:`Receipt`. A fact "survived" if its normalized text is a
168
- substring of the concatenated, normalized ``after`` content — a lexical,
169
- conservative check that under-counts rather than over-claims.
178
+ A :class:`Receipt`. A fact "survived" if its normalized text occurs
179
+ at word boundaries in the concatenated, normalized ``after`` content —
180
+ a lexical, conservative check that under-counts rather than over-claims
181
+ (``42`` does not survive inside ``142``, nor ``API`` inside ``rapid``).
170
182
  """
171
183
  if facts is None:
172
184
  facts = extract_facts(_messages_text(before))
@@ -176,7 +188,7 @@ def validate(
176
188
  dropped: list[str] = []
177
189
  for fact in facts:
178
190
  key = _norm(fact)
179
- if key and key in after_text:
191
+ if key and _contains_fact(after_text, key):
180
192
  survived.append(fact)
181
193
  else:
182
194
  dropped.append(fact)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstate
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Inspectable context compression for LLM conversations, with visible scaffold state and lexical fact receipts
5
5
  Author-email: Rolando Bosch <roli@hermes-labs.ai>
6
6
  Maintainer-email: Hermes Labs <info@hermes-labs.ai>
@@ -28,6 +28,8 @@ Dynamic: license-file
28
28
 
29
29
  # langstate
30
30
 
31
+ [![Tests](https://github.com/hermes-labs-ai/langstate/actions/workflows/test.yml/badge.svg)](https://github.com/hermes-labs-ai/langstate/actions/workflows/test.yml)
32
+
31
33
  **Compress long LLM conversations into visible working state — then check that
32
34
  the literal facts you care about survived.**
33
35
 
@@ -49,12 +51,19 @@ semantic fidelity.
49
51
  ## Try it in two minutes
50
52
 
51
53
  ```bash
52
- python -m pip install langstate==0.2.2
54
+ python -m pip install langstate==0.2.4
55
+ langstate --version
53
56
  langstate demo
54
57
  ```
55
58
 
59
+ Or install the CLI from the [Hermes Labs Homebrew tap](https://github.com/hermes-labs-ai/homebrew-tap):
60
+
61
+ ```bash
62
+ brew install hermes-labs-ai/tap/langstate
63
+ ```
64
+
56
65
  It prints the compressed messages and a machine-readable receipt showing that
57
- both named facts survived. The proof needs no model, API key, network call, or
66
+ both named facts survived and that the history got smaller. The proof needs no model, API key, network call, or
58
67
  Ollama installation.
59
68
 
60
69
  Python 3.10+; no runtime dependencies beyond the standard library. To use the
@@ -147,7 +156,7 @@ contract; automatic fact extraction is a convenience heuristic.
147
156
 
148
157
  ## How it works
149
158
 
150
- For conversations of at least six user/assistant turns, `compress`:
159
+ For conversations of at least six user/assistant turn pairs, `compress`:
151
160
 
152
161
  1. retains all `system` messages and the requested recent suffix verbatim;
153
162
  2. sends the older non-system messages to the selected summarizer;
@@ -209,5 +218,10 @@ compress(
209
218
  validate(before, after, facts=None)
210
219
  ```
211
220
 
221
+ `preserve_recent` counts recent user-initiated turns. Each retained turn includes
222
+ its assistant messages, tool calls, and tool results verbatim, so the retained
223
+ tail does not begin in the middle of a tool exchange. Older history remains
224
+ a lossy summary; retain the original messages when exact replay is required.
225
+
212
226
  See `Receipt.as_dict()` for a JSON-friendly receipt. The package is
213
227
  Apache-2.0 licensed.
File without changes
File without changes