compresr 2.6.5__tar.gz → 2.7.2__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 (62) hide show
  1. {compresr-2.6.5 → compresr-2.7.2}/PKG-INFO +38 -2
  2. {compresr-2.6.5 → compresr-2.7.2}/README.md +36 -0
  3. {compresr-2.6.5 → compresr-2.7.2}/compresr/__init__.py +7 -0
  4. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/engine.py +249 -9
  5. {compresr-2.6.5 → compresr-2.7.2}/compresr/services/compression.py +40 -2
  6. {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/PKG-INFO +38 -2
  7. {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/requires.txt +1 -1
  8. {compresr-2.6.5 → compresr-2.7.2}/pyproject.toml +2 -2
  9. {compresr-2.6.5 → compresr-2.7.2}/LICENSE +0 -0
  10. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/__init__.py +0 -0
  11. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/__init__.py +0 -0
  12. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/anthropic.py +0 -0
  13. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/native.py +0 -0
  14. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/openai.py +0 -0
  15. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/normalized.py +0 -0
  16. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/schemas/__init__.py +0 -0
  17. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/schemas/anthropic.py +0 -0
  18. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/schemas/openai.py +0 -0
  19. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/tools/__init__.py +0 -0
  20. {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/tools/web_search.py +0 -0
  21. {compresr-2.6.5 → compresr-2.7.2}/compresr/clients.py +0 -0
  22. {compresr-2.6.5 → compresr-2.7.2}/compresr/config.py +0 -0
  23. {compresr-2.6.5 → compresr-2.7.2}/compresr/exceptions/__init__.py +0 -0
  24. {compresr-2.6.5 → compresr-2.7.2}/compresr/exceptions/exceptions.py +0 -0
  25. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/__init__.py +0 -0
  26. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/__init__.py +0 -0
  27. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/client.py +0 -0
  28. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/compress.py +0 -0
  29. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/errors.py +0 -0
  30. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/filters.py +0 -0
  31. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/kernel.py +0 -0
  32. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/policy.py +0 -0
  33. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/query.py +0 -0
  34. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/tokens.py +0 -0
  35. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/__init__.py +0 -0
  36. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/middleware.py +0 -0
  37. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/retriever.py +0 -0
  38. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/wrappers.py +0 -0
  39. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/__init__.py +0 -0
  40. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/checkpoint.py +0 -0
  41. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/handoff.py +0 -0
  42. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/nodes.py +0 -0
  43. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/store.py +0 -0
  44. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/__init__.py +0 -0
  45. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/guardrail.py +0 -0
  46. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/initializer.py +0 -0
  47. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/types.py +0 -0
  48. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/__init__.py +0 -0
  49. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/memory.py +0 -0
  50. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/postprocessor.py +0 -0
  51. {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/wrappers.py +0 -0
  52. {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/__init__.py +0 -0
  53. {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/base.py +0 -0
  54. {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/inference.py +0 -0
  55. {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/usage.py +0 -0
  56. {compresr-2.6.5 → compresr-2.7.2}/compresr/services/__init__.py +0 -0
  57. {compresr-2.6.5 → compresr-2.7.2}/compresr/services/base.py +0 -0
  58. {compresr-2.6.5 → compresr-2.7.2}/compresr/services/proxy.py +0 -0
  59. {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/SOURCES.txt +0 -0
  60. {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/dependency_links.txt +0 -0
  61. {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/top_level.txt +0 -0
  62. {compresr-2.6.5 → compresr-2.7.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compresr
3
- Version: 2.6.5
3
+ Version: 2.7.2
4
4
  Summary: Python SDK for Compresr - Intelligent prompt compression service
5
5
  Author-email: Compresr Team <founders@compresr.ai>
6
6
  License-Expression: Apache-2.0
@@ -26,7 +26,7 @@ Requires-Dist: httpx>=0.27.0
26
26
  Requires-Dist: pydantic>=2.10.0
27
27
  Requires-Dist: langchain>=1.0
28
28
  Requires-Dist: langchain-core>=0.3
29
- Requires-Dist: langchain-anthropic>=0.3
29
+ Requires-Dist: langchain-anthropic>=1.0
30
30
  Requires-Dist: langchain-openai>=0.3
31
31
  Requires-Dist: langchain-google-genai>=4.0
32
32
  Requires-Dist: langchain-tavily>=0.1
@@ -216,6 +216,42 @@ OpenAI's `web_search_preview`, and Gemini's `google_search` run server-side
216
216
  and return encrypted/opaque content that Compresr cannot read or compress.
217
217
  Use Tavily or Brave so the result is plaintext we can compress.
218
218
 
219
+ ## Research agent
220
+
221
+ `client.research.run(question)` runs a multi-step web-research loop with
222
+ per-snippet `latte_v1` compression on tool results and multi-provider prompt
223
+ caching. Loop structure adapted from Perplexity `search_evals` (MIT).
224
+
225
+ ```python
226
+ from compresr import CompressionClient
227
+
228
+ client = CompressionClient(
229
+ api_key="cmp_...",
230
+ llm="anthropic:claude-sonnet-4-6",
231
+ llm_api_key="sk-ant-...",
232
+ )
233
+
234
+ result = client.research.run(
235
+ "What was the latest stable Python version released in 2025?",
236
+ search="tavily", # "tavily" | "brave" | a LangChain BaseTool
237
+ max_steps=10,
238
+ )
239
+
240
+ print(result.answer) # parsed Exact Answer field
241
+ print(result.explanation) # parsed Explanation field
242
+ print(result.confidence) # parsed 0-1 confidence
243
+ print(result.citations) # list[Citation(url=...)]
244
+ print(result.usage.cache_read_tokens, result.usage.calls)
245
+ ```
246
+
247
+ Single-shot mode: `client.research.search(question)` runs one search + a
248
+ forced final answer (equivalent to `run(..., max_steps=2)`).
249
+
250
+ The agent respects all the prompt-cache options on `CompressionClient`
251
+ (`enable_prompt_cache`, `prompt_cache_ttl`, `openai_prompt_cache_key`).
252
+ Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
253
+ (falls back to `BRAVE_API_KEY`).
254
+
219
255
  ## Compression options
220
256
 
221
257
  | Param | Purpose |
@@ -153,6 +153,42 @@ OpenAI's `web_search_preview`, and Gemini's `google_search` run server-side
153
153
  and return encrypted/opaque content that Compresr cannot read or compress.
154
154
  Use Tavily or Brave so the result is plaintext we can compress.
155
155
 
156
+ ## Research agent
157
+
158
+ `client.research.run(question)` runs a multi-step web-research loop with
159
+ per-snippet `latte_v1` compression on tool results and multi-provider prompt
160
+ caching. Loop structure adapted from Perplexity `search_evals` (MIT).
161
+
162
+ ```python
163
+ from compresr import CompressionClient
164
+
165
+ client = CompressionClient(
166
+ api_key="cmp_...",
167
+ llm="anthropic:claude-sonnet-4-6",
168
+ llm_api_key="sk-ant-...",
169
+ )
170
+
171
+ result = client.research.run(
172
+ "What was the latest stable Python version released in 2025?",
173
+ search="tavily", # "tavily" | "brave" | a LangChain BaseTool
174
+ max_steps=10,
175
+ )
176
+
177
+ print(result.answer) # parsed Exact Answer field
178
+ print(result.explanation) # parsed Explanation field
179
+ print(result.confidence) # parsed 0-1 confidence
180
+ print(result.citations) # list[Citation(url=...)]
181
+ print(result.usage.cache_read_tokens, result.usage.calls)
182
+ ```
183
+
184
+ Single-shot mode: `client.research.search(question)` runs one search + a
185
+ forced final answer (equivalent to `run(..., max_steps=2)`).
186
+
187
+ The agent respects all the prompt-cache options on `CompressionClient`
188
+ (`enable_prompt_cache`, `prompt_cache_ttl`, `openai_prompt_cache_key`).
189
+ Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
190
+ (falls back to `BRAVE_API_KEY`).
191
+
156
192
  ## Compression options
157
193
 
158
194
  | Param | Purpose |
@@ -56,6 +56,9 @@ __all__ = [
56
56
  "MODELS",
57
57
  "WebSearchTool",
58
58
  "CompressionPolicy",
59
+ "ResearchResult",
60
+ "ResearchAgent",
61
+ "Citation",
59
62
  ]
60
63
 
61
64
 
@@ -69,4 +72,8 @@ def __getattr__(name: str) -> Any:
69
72
  from .integrations._shared import CompressionPolicy as _P
70
73
 
71
74
  return _P
75
+ if name in {"ResearchResult", "ResearchAgent", "Citation"}:
76
+ from .agents import research as _r
77
+
78
+ return getattr(_r, name)
72
79
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -13,7 +13,7 @@ public surface. Only the normalized result type leaks out.
13
13
  from __future__ import annotations
14
14
 
15
15
  import logging
16
- from typing import Any, Optional, Sequence
16
+ from typing import Any, Literal, Optional, Sequence, cast
17
17
 
18
18
  from compresr.exceptions import CompresrError
19
19
  from compresr.integrations._shared import CompressionPolicy
@@ -64,6 +64,83 @@ def _CompresrToolMiddleware(*args: Any, **kwargs: Any) -> Any:
64
64
  _KNOWN_PROVIDERS = {"anthropic", "openai", "google_genai"}
65
65
 
66
66
 
67
+ # ---------------------------------------------------------------------------
68
+ # Solo-WebSearchTool auto-route
69
+ # ---------------------------------------------------------------------------
70
+ #
71
+ # When a generic-agent facade (``client.messages.create`` / ``client.chat
72
+ # .completions.create`` / ``client.run``) is invoked with exactly the
73
+ # Compresr ``WebSearchTool`` and nothing else, the engine silently routes the
74
+ # call through the deep-research loop instead of the standard ``create_agent``
75
+ # middleware path. The caller still gets back their facade's expected shape
76
+ # (Anthropic ``Message`` / OpenAI ``ChatCompletion`` / ``NormalizedResult``),
77
+ # but the loop semantics are research-agent: ``tool_choice="none"`` on the
78
+ # final step, per-snippet compression against the live query, 10-step cap,
79
+ # manual Anthropic ``cache_control`` stamping.
80
+ #
81
+ # Detection is by tool name because ``WebSearchTool.__new__`` returns a
82
+ # ``StructuredTool`` (not a ``WebSearchTool`` instance), so ``isinstance``
83
+ # would always be False.
84
+ _WEB_SEARCH_TOOL_NAMES = frozenset({"tavily_search", "brave_search"})
85
+
86
+
87
+ def _is_solo_web_search(tools: Sequence) -> bool:
88
+ """True when ``tools`` is exactly one Compresr-built web-search tool."""
89
+ if len(tools) != 1:
90
+ return False
91
+ name = getattr(tools[0], "name", None)
92
+ return isinstance(name, str) and name in _WEB_SEARCH_TOOL_NAMES
93
+
94
+
95
+ def _extract_last_user_text(messages: Sequence) -> str:
96
+ """Pull the last user/human message text out of a facade-shaped chain.
97
+
98
+ Supports the message shapes that the facades feed the engine — plain
99
+ dicts (Anthropic/OpenAI style) and LangChain ``BaseMessage`` instances.
100
+ Falls back to the first message's content when no explicit user message
101
+ is found, and to ``""`` on empty input.
102
+ """
103
+ for msg in reversed(list(messages)):
104
+ role = None
105
+ content: Any = None
106
+ if isinstance(msg, dict):
107
+ role = msg.get("role")
108
+ content = msg.get("content")
109
+ else:
110
+ role = getattr(msg, "role", None) or getattr(msg, "type", None)
111
+ content = getattr(msg, "content", None)
112
+ if role in ("user", "human") and content is not None:
113
+ return _content_text_any(content)
114
+ if messages:
115
+ first = messages[0]
116
+ content = (
117
+ first.get("content") if isinstance(first, dict) else getattr(first, "content", None)
118
+ )
119
+ return _content_text_any(content)
120
+ return ""
121
+
122
+
123
+ def _content_text_any(content: Any) -> str:
124
+ """Stringify the ``content`` field of a chat message.
125
+
126
+ Handles strings, lists of text blocks (Anthropic/OpenAI structured form),
127
+ and arbitrary fallbacks via ``str()``.
128
+ """
129
+ if isinstance(content, str):
130
+ return content
131
+ if isinstance(content, list):
132
+ parts: list[str] = []
133
+ for block in content:
134
+ if isinstance(block, dict):
135
+ text = block.get("text")
136
+ if isinstance(text, str):
137
+ parts.append(text)
138
+ elif isinstance(block, str):
139
+ parts.append(block)
140
+ return "".join(parts)
141
+ return str(content) if content is not None else ""
142
+
143
+
67
144
  # Kwargs that propagate to the underlying chat model via ``.bind(...)`` per
68
145
  # call. Covers Anthropic / OpenAI / Gemini. LangChain's chat models normalize
69
146
  # most of these across providers; unsupported keys are silently ignored
@@ -266,6 +343,15 @@ _USAGE_NUMERIC_KEYS = (
266
343
  "cache_creation_input_tokens",
267
344
  )
268
345
 
346
+ # Provider-specific cache-read aliases under ``input_token_details``.
347
+ _INPUT_DETAILS_CACHE_READ_ALIASES = (
348
+ "cached_tokens",
349
+ "cache_read",
350
+ "priority_cache_read",
351
+ "flex_cache_read",
352
+ "cached_content_token_count",
353
+ )
354
+
269
355
 
270
356
  def _aggregate_usage(messages: list) -> dict:
271
357
  """Sum ``usage_metadata`` across every AIMessage in the conversation.
@@ -305,8 +391,26 @@ def _aggregate_usage(messages: list) -> dict:
305
391
  if not has_top_cache:
306
392
  details = per.get("input_token_details")
307
393
  if isinstance(details, dict):
308
- total["cache_read_input_tokens"] += int(details.get("cache_read", 0) or 0)
309
- total["cache_creation_input_tokens"] += int(details.get("cache_creation", 0) or 0)
394
+ msg_cache_read = 0
395
+ msg_cache_create = 0
396
+ for alias in _INPUT_DETAILS_CACHE_READ_ALIASES:
397
+ v = details.get(alias)
398
+ if isinstance(v, (int, float)):
399
+ msg_cache_read += int(v)
400
+ # When cache_control sets a ttl, langchain-anthropic writes
401
+ # the count to the TTL-specific keys and zeroes ``cache_creation``.
402
+ ttl_writes = int(details.get("ephemeral_5m_input_tokens", 0) or 0) + int(
403
+ details.get("ephemeral_1h_input_tokens", 0) or 0
404
+ )
405
+ msg_cache_create = ttl_writes or int(details.get("cache_creation", 0) or 0)
406
+ total["cache_read_input_tokens"] += msg_cache_read
407
+ total["cache_creation_input_tokens"] += msg_cache_create
408
+ # langchain-anthropic 1.x inflates ``input_tokens`` to
409
+ # fresh + read + create; subtract so it means fresh-only.
410
+ this_input = per.get("input_tokens")
411
+ if isinstance(this_input, (int, float)) and (msg_cache_read or msg_cache_create):
412
+ overcount = msg_cache_read + msg_cache_create
413
+ total["input_tokens"] -= min(overcount, int(this_input))
310
414
  # Carry through any other numeric extras (reasoning_tokens, …).
311
415
  for k, v in per.items():
312
416
  if k in _USAGE_NUMERIC_KEYS or k == "input_token_details":
@@ -317,6 +421,22 @@ def _aggregate_usage(messages: list) -> dict:
317
421
  return out
318
422
 
319
423
 
424
+ def _tool_name(t: Any) -> str:
425
+ name = getattr(t, "name", None)
426
+ if isinstance(name, str):
427
+ return name
428
+ if isinstance(t, dict):
429
+ return str(t.get("name") or "")
430
+ return ""
431
+
432
+
433
+ def _sorted_tools_for_cache(tools: Sequence) -> list:
434
+ """Stable tool order so the Anthropic tool-block cache hash doesn't drift."""
435
+ indexed = list(enumerate(tools))
436
+ indexed.sort(key=lambda p: (_tool_name(p[1]), p[0]))
437
+ return [t for _, t in indexed]
438
+
439
+
320
440
  def _last_ai_message(messages: list) -> Any:
321
441
  """Return the final assistant message from an agent invoke result."""
322
442
  for msg in reversed(messages or []):
@@ -354,11 +474,19 @@ class _Engine:
354
474
  llm: str,
355
475
  llm_api_key: Optional[str] = None,
356
476
  policy: Optional[CompressionPolicy] = None,
477
+ enable_prompt_cache: bool = True,
478
+ prompt_cache_ttl: str = "5m",
479
+ prompt_cache_min_messages: int = 2,
480
+ openai_prompt_cache_key: Optional[str] = None,
357
481
  ) -> None:
358
482
  self._compresr_client = compresr_client
359
483
  self._provider, self._default_model_name = _parse_llm(llm)
360
484
  self._policy = policy or CompressionPolicy()
361
485
  self._llm_api_key = llm_api_key
486
+ self._enable_prompt_cache = bool(enable_prompt_cache)
487
+ self._prompt_cache_ttl = prompt_cache_ttl
488
+ self._prompt_cache_min_messages = int(prompt_cache_min_messages)
489
+ self._openai_prompt_cache_key = openai_prompt_cache_key
362
490
  # Chat-model cache keyed by effective model name. Construction is
363
491
  # deferred to the first ``run()`` so a provider-only client
364
492
  # (``llm="anthropic"``) doesn't try to resolve a model at init time.
@@ -416,6 +544,15 @@ class _Engine:
416
544
  kwargs: dict = dict(extra_kwargs)
417
545
  if self._llm_api_key is not None:
418
546
  kwargs.setdefault("api_key", self._llm_api_key)
547
+ # OpenAI caching is server-side; we only pass ``prompt_cache_key``
548
+ # and ``prompt_cache_retention`` via ``model_kwargs`` so they survive
549
+ # ``bind_tools``.
550
+ if self._provider == "openai" and self._enable_prompt_cache:
551
+ openai_extra = self._openai_cache_model_kwargs()
552
+ if openai_extra:
553
+ mk = dict(kwargs.get("model_kwargs") or {})
554
+ mk.update(openai_extra)
555
+ kwargs["model_kwargs"] = mk
419
556
  # OpenAI Responses output_version is a constructor kwarg in newer
420
557
  # langchain-openai; older versions reject unknown kwargs. Try both.
421
558
  if self._provider == "openai":
@@ -429,17 +566,57 @@ class _Engine:
429
566
  logger.debug("init_chat_model rejected output_version; falling back without it")
430
567
  return init_chat_model(f"{self._provider}:{model_name}", **kwargs)
431
568
 
569
+ def _openai_cache_model_kwargs(self) -> dict:
570
+ """Build the OpenAI-specific cache controls. ``prompt_cache_retention``
571
+ is only set for ``ttl="1h"`` so older langchain-openai doesn't reject
572
+ the unknown field on the default path."""
573
+ out: dict = {}
574
+ if self._openai_prompt_cache_key:
575
+ out["prompt_cache_key"] = self._openai_prompt_cache_key
576
+ if self._prompt_cache_ttl == "1h":
577
+ out["prompt_cache_retention"] = "24h"
578
+ return out
579
+
432
580
  def _build_middleware(self) -> list:
433
581
  """Build the middleware stack used for every run.
434
582
 
435
583
  The compression knobs come straight from
436
584
  :meth:`CompressionPolicy.tool_kwargs`.
437
585
  """
438
- mw = _CompresrToolMiddleware(
439
- client=self._compresr_client,
440
- **self._policy.tool_kwargs(),
586
+ mw: list = [
587
+ _CompresrToolMiddleware(
588
+ client=self._compresr_client,
589
+ **self._policy.tool_kwargs(),
590
+ )
591
+ ]
592
+ cache_mw = self._maybe_build_cache_middleware()
593
+ if cache_mw is not None:
594
+ # AFTER CompresrToolMiddleware so cache markers stamp the
595
+ # post-compression content.
596
+ mw.append(cache_mw)
597
+ return mw
598
+
599
+ def _maybe_build_cache_middleware(self) -> Optional[Any]:
600
+ """Anthropic prompt-cache middleware when enabled, else None. Silently
601
+ degrades on older ``langchain-anthropic`` without the middleware module."""
602
+ if not self._enable_prompt_cache or self._provider != "anthropic":
603
+ return None
604
+ try:
605
+ from langchain_anthropic.middleware import ( # type: ignore[import-not-found]
606
+ AnthropicPromptCachingMiddleware,
607
+ )
608
+ except ImportError:
609
+ logger.debug(
610
+ "langchain_anthropic.middleware.AnthropicPromptCachingMiddleware "
611
+ "unavailable; prompt caching disabled"
612
+ )
613
+ return None
614
+ ttl = cast(Literal["5m", "1h"], self._prompt_cache_ttl)
615
+ return AnthropicPromptCachingMiddleware(
616
+ ttl=ttl,
617
+ min_messages_to_cache=self._prompt_cache_min_messages,
618
+ unsupported_model_behavior="ignore",
441
619
  )
442
- return [mw]
443
620
 
444
621
  def _build_agent(
445
622
  self,
@@ -454,7 +631,7 @@ class _Engine:
454
631
  If a future/older version rejects it we fall back to prepending a
455
632
  system message at invoke time instead.
456
633
  """
457
- tools_list = list(tools)
634
+ tools_list = _sorted_tools_for_cache(tools)
458
635
  middleware = self._build_middleware()
459
636
  try:
460
637
  return create_agent(
@@ -566,9 +743,21 @@ class _Engine:
566
743
  ``stop_sequences``, etc.) are peeled off and forwarded to the
567
744
  underlying chat model via ``chat.bind(...)`` so they reach the
568
745
  provider without mutating the cached chat model.
746
+
747
+ When ``tools`` is exactly one Compresr ``WebSearchTool``, the call
748
+ is auto-routed through the deep-research loop — see
749
+ :data:`_WEB_SEARCH_TOOL_NAMES` and :meth:`_run_via_research_loop`.
569
750
  """
570
751
  effective_model = self._resolve_model(model)
571
752
  bind_kwargs = self._collect_bind_kwargs(max_tokens=max_tokens, kw=kw)
753
+ if _is_solo_web_search(tools):
754
+ return self._run_via_research_loop(
755
+ messages=messages,
756
+ search_tool=tools[0],
757
+ system=system,
758
+ model=effective_model,
759
+ bind_kwargs=bind_kwargs or None,
760
+ )
572
761
  chat = self._get_or_build_chat(effective_model, extra_kwargs=bind_kwargs or None)
573
762
  agent = self._build_agent(chat=chat, tools=tools, system=system)
574
763
  prepared = self._prepare_messages(agent=agent, messages=messages, system=system)
@@ -588,9 +777,26 @@ class _Engine:
588
777
  model: Optional[str] = None,
589
778
  **kw: Any,
590
779
  ) -> NormalizedResult:
591
- """Async version of :meth:`run`."""
780
+ """Async version of :meth:`run`.
781
+
782
+ Solo-WebSearchTool calls are auto-routed through the research loop;
783
+ because the loop itself is synchronous (LangChain ``bind_tools``
784
+ + ``invoke``), the async path runs it via :func:`asyncio.to_thread`
785
+ to avoid blocking the event loop.
786
+ """
592
787
  effective_model = self._resolve_model(model)
593
788
  bind_kwargs = self._collect_bind_kwargs(max_tokens=max_tokens, kw=kw)
789
+ if _is_solo_web_search(tools):
790
+ import asyncio
791
+
792
+ return await asyncio.to_thread(
793
+ self._run_via_research_loop,
794
+ messages=messages,
795
+ search_tool=tools[0],
796
+ system=system,
797
+ model=effective_model,
798
+ bind_kwargs=bind_kwargs or None,
799
+ )
594
800
  chat = self._get_or_build_chat(effective_model, extra_kwargs=bind_kwargs or None)
595
801
  agent = self._build_agent(chat=chat, tools=tools, system=system)
596
802
  prepared = self._prepare_messages(agent=agent, messages=messages, system=system)
@@ -600,6 +806,40 @@ class _Engine:
600
806
  raise CompresrError(f"Agent execution failed: {e}") from e
601
807
  return self._normalize(result)
602
808
 
809
+ def _run_via_research_loop(
810
+ self,
811
+ *,
812
+ messages: Sequence,
813
+ search_tool: Any,
814
+ system: Optional[Any],
815
+ model: str,
816
+ bind_kwargs: Optional[dict],
817
+ ) -> NormalizedResult:
818
+ """Auto-route handler for solo-WebSearchTool calls.
819
+
820
+ Builds a :class:`ResearchAgent`, runs its strict-output loop against
821
+ the last user message, then feeds the resulting message chain through
822
+ :meth:`_normalize` so each facade still gets its expected shape.
823
+
824
+ ``system`` from the caller wins when supplied (string); otherwise
825
+ the research-agent default prompt is used.
826
+ """
827
+ from compresr.agents.research.agent import ResearchAgent
828
+
829
+ question = _extract_last_user_text(messages)
830
+ system_prompt = system if isinstance(system, str) and system.strip() else None
831
+ agent = ResearchAgent(
832
+ engine=self,
833
+ search_tool=search_tool,
834
+ system_prompt=system_prompt,
835
+ )
836
+ state = agent._run_loop(
837
+ question,
838
+ model=model,
839
+ extra_chat_kwargs=bind_kwargs,
840
+ )
841
+ return self._normalize({"messages": state.messages})
842
+
603
843
  # ------------------------------------------------------------------
604
844
  # Normalization
605
845
  # ------------------------------------------------------------------
@@ -34,6 +34,18 @@ class CompressionClient(BaseCompressionClient):
34
34
  llm_api_key: Optional provider API key forwarded to the LLM.
35
35
  compression: Optional dict of :class:`CompressionPolicy` kwargs
36
36
  (e.g. ``{"target_compression_ratio": 0.7, "min_tokens": 1000}``).
37
+ enable_prompt_cache: Provider-aware prompt-cache control. Default ``True``.
38
+ Anthropic → wires ``AnthropicPromptCachingMiddleware``.
39
+ OpenAI → attaches ``prompt_cache_key`` (when set) and maps
40
+ ``prompt_cache_ttl="1h"`` to ``prompt_cache_retention="24h"``.
41
+ Gemini → no-op for now (implicit caching always on at the API).
42
+ prompt_cache_ttl: ``"5m"`` or ``"1h"``. Anthropic uses it as the
43
+ ephemeral cache TTL. OpenAI maps ``"1h"`` to the 24h retention tier.
44
+ prompt_cache_min_messages: Skip Anthropic cache stamping until the
45
+ conversation has at least this many messages.
46
+ openai_prompt_cache_key: Optional routing key for OpenAI prompt caching
47
+ (improves hit rate when multiple clients share a backend). Ignored
48
+ for non-OpenAI providers.
37
49
 
38
50
  Example::
39
51
 
@@ -66,12 +78,16 @@ class CompressionClient(BaseCompressionClient):
66
78
  llm: Optional[str] = None,
67
79
  llm_api_key: Optional[str] = None,
68
80
  compression: Optional[Dict[str, Any]] = None,
81
+ enable_prompt_cache: bool = True,
82
+ prompt_cache_ttl: str = "5m",
83
+ prompt_cache_min_messages: int = 2,
84
+ openai_prompt_cache_key: Optional[str] = None,
69
85
  ):
70
86
  super().__init__(api_key=api_key, base_url=base_url, timeout=timeout)
71
87
  self._engine = None
72
88
  if llm is not None:
73
- # Import lazily so that ``CompressionClient(api_key=...)`` without
74
- # ``llm=`` keeps working even when langchain isn't installed.
89
+ # Lazy import: ``CompressionClient(api_key=...)`` without ``llm=``
90
+ # must keep working when langchain isn't installed.
75
91
  from compresr.agents.engine import _Engine
76
92
  from compresr.integrations._shared import CompressionPolicy
77
93
 
@@ -81,7 +97,29 @@ class CompressionClient(BaseCompressionClient):
81
97
  llm=llm,
82
98
  llm_api_key=llm_api_key,
83
99
  policy=policy,
100
+ enable_prompt_cache=enable_prompt_cache,
101
+ prompt_cache_ttl=prompt_cache_ttl,
102
+ prompt_cache_min_messages=prompt_cache_min_messages,
103
+ openai_prompt_cache_key=openai_prompt_cache_key,
84
104
  )
105
+ self._research_facade: Optional[Any] = None
106
+
107
+ @property
108
+ def research(self) -> Any:
109
+ """Research facade: ``client.research.run("question")``. Requires ``llm=``."""
110
+ if self._research_facade is None:
111
+ if self._engine is None:
112
+ from ..exceptions import CompresrError
113
+
114
+ raise CompresrError(
115
+ "client.research requires an LLM provider. "
116
+ "Construct with CompressionClient(api_key=..., llm='anthropic:...', llm_api_key=...).",
117
+ code="missing_llm",
118
+ )
119
+ from ..agents.research.facade import ResearchFacade
120
+
121
+ self._research_facade = ResearchFacade(self._engine)
122
+ return self._research_facade
85
123
 
86
124
  def compress(
87
125
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compresr
3
- Version: 2.6.5
3
+ Version: 2.7.2
4
4
  Summary: Python SDK for Compresr - Intelligent prompt compression service
5
5
  Author-email: Compresr Team <founders@compresr.ai>
6
6
  License-Expression: Apache-2.0
@@ -26,7 +26,7 @@ Requires-Dist: httpx>=0.27.0
26
26
  Requires-Dist: pydantic>=2.10.0
27
27
  Requires-Dist: langchain>=1.0
28
28
  Requires-Dist: langchain-core>=0.3
29
- Requires-Dist: langchain-anthropic>=0.3
29
+ Requires-Dist: langchain-anthropic>=1.0
30
30
  Requires-Dist: langchain-openai>=0.3
31
31
  Requires-Dist: langchain-google-genai>=4.0
32
32
  Requires-Dist: langchain-tavily>=0.1
@@ -216,6 +216,42 @@ OpenAI's `web_search_preview`, and Gemini's `google_search` run server-side
216
216
  and return encrypted/opaque content that Compresr cannot read or compress.
217
217
  Use Tavily or Brave so the result is plaintext we can compress.
218
218
 
219
+ ## Research agent
220
+
221
+ `client.research.run(question)` runs a multi-step web-research loop with
222
+ per-snippet `latte_v1` compression on tool results and multi-provider prompt
223
+ caching. Loop structure adapted from Perplexity `search_evals` (MIT).
224
+
225
+ ```python
226
+ from compresr import CompressionClient
227
+
228
+ client = CompressionClient(
229
+ api_key="cmp_...",
230
+ llm="anthropic:claude-sonnet-4-6",
231
+ llm_api_key="sk-ant-...",
232
+ )
233
+
234
+ result = client.research.run(
235
+ "What was the latest stable Python version released in 2025?",
236
+ search="tavily", # "tavily" | "brave" | a LangChain BaseTool
237
+ max_steps=10,
238
+ )
239
+
240
+ print(result.answer) # parsed Exact Answer field
241
+ print(result.explanation) # parsed Explanation field
242
+ print(result.confidence) # parsed 0-1 confidence
243
+ print(result.citations) # list[Citation(url=...)]
244
+ print(result.usage.cache_read_tokens, result.usage.calls)
245
+ ```
246
+
247
+ Single-shot mode: `client.research.search(question)` runs one search + a
248
+ forced final answer (equivalent to `run(..., max_steps=2)`).
249
+
250
+ The agent respects all the prompt-cache options on `CompressionClient`
251
+ (`enable_prompt_cache`, `prompt_cache_ttl`, `openai_prompt_cache_key`).
252
+ Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
253
+ (falls back to `BRAVE_API_KEY`).
254
+
219
255
  ## Compression options
220
256
 
221
257
  | Param | Purpose |
@@ -2,7 +2,7 @@ httpx>=0.27.0
2
2
  pydantic>=2.10.0
3
3
  langchain>=1.0
4
4
  langchain-core>=0.3
5
- langchain-anthropic>=0.3
5
+ langchain-anthropic>=1.0
6
6
  langchain-openai>=0.3
7
7
  langchain-google-genai>=4.0
8
8
  langchain-tavily>=0.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compresr"
7
- version = "2.6.5"
7
+ version = "2.7.2"
8
8
  description = "Python SDK for Compresr - Intelligent prompt compression service"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -35,7 +35,7 @@ dependencies = [
35
35
  # Tavily + Brave search tools are all available out of the box.
36
36
  "langchain>=1.0",
37
37
  "langchain-core>=0.3",
38
- "langchain-anthropic>=0.3",
38
+ "langchain-anthropic>=1.0",
39
39
  "langchain-openai>=0.3",
40
40
  "langchain-google-genai>=4.0",
41
41
  "langchain-tavily>=0.1",
File without changes
File without changes
File without changes
File without changes