compresr 2.7.2__tar.gz → 2.7.3__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 (68) hide show
  1. {compresr-2.7.2 → compresr-2.7.3}/PKG-INFO +1 -1
  2. compresr-2.7.3/compresr/agents/research/__init__.py +24 -0
  3. compresr-2.7.3/compresr/agents/research/agent.py +334 -0
  4. compresr-2.7.3/compresr/agents/research/facade.py +94 -0
  5. compresr-2.7.3/compresr/agents/research/parser.py +62 -0
  6. compresr-2.7.3/compresr/agents/research/prompts.py +35 -0
  7. compresr-2.7.3/compresr/agents/research/types.py +69 -0
  8. {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/PKG-INFO +1 -1
  9. {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/SOURCES.txt +6 -0
  10. {compresr-2.7.2 → compresr-2.7.3}/pyproject.toml +9 -18
  11. {compresr-2.7.2 → compresr-2.7.3}/LICENSE +0 -0
  12. {compresr-2.7.2 → compresr-2.7.3}/README.md +0 -0
  13. {compresr-2.7.2 → compresr-2.7.3}/compresr/__init__.py +0 -0
  14. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/__init__.py +0 -0
  15. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/engine.py +0 -0
  16. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/__init__.py +0 -0
  17. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/anthropic.py +0 -0
  18. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/native.py +0 -0
  19. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/openai.py +0 -0
  20. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/normalized.py +0 -0
  21. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/schemas/__init__.py +0 -0
  22. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/schemas/anthropic.py +0 -0
  23. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/schemas/openai.py +0 -0
  24. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/tools/__init__.py +0 -0
  25. {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/tools/web_search.py +0 -0
  26. {compresr-2.7.2 → compresr-2.7.3}/compresr/clients.py +0 -0
  27. {compresr-2.7.2 → compresr-2.7.3}/compresr/config.py +0 -0
  28. {compresr-2.7.2 → compresr-2.7.3}/compresr/exceptions/__init__.py +0 -0
  29. {compresr-2.7.2 → compresr-2.7.3}/compresr/exceptions/exceptions.py +0 -0
  30. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/__init__.py +0 -0
  31. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/__init__.py +0 -0
  32. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/client.py +0 -0
  33. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/compress.py +0 -0
  34. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/errors.py +0 -0
  35. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/filters.py +0 -0
  36. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/kernel.py +0 -0
  37. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/policy.py +0 -0
  38. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/query.py +0 -0
  39. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/tokens.py +0 -0
  40. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/__init__.py +0 -0
  41. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/middleware.py +0 -0
  42. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/retriever.py +0 -0
  43. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/wrappers.py +0 -0
  44. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/__init__.py +0 -0
  45. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/checkpoint.py +0 -0
  46. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/handoff.py +0 -0
  47. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/nodes.py +0 -0
  48. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/store.py +0 -0
  49. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/__init__.py +0 -0
  50. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/guardrail.py +0 -0
  51. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/initializer.py +0 -0
  52. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/types.py +0 -0
  53. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/__init__.py +0 -0
  54. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/memory.py +0 -0
  55. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/postprocessor.py +0 -0
  56. {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/wrappers.py +0 -0
  57. {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/__init__.py +0 -0
  58. {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/base.py +0 -0
  59. {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/inference.py +0 -0
  60. {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/usage.py +0 -0
  61. {compresr-2.7.2 → compresr-2.7.3}/compresr/services/__init__.py +0 -0
  62. {compresr-2.7.2 → compresr-2.7.3}/compresr/services/base.py +0 -0
  63. {compresr-2.7.2 → compresr-2.7.3}/compresr/services/compression.py +0 -0
  64. {compresr-2.7.2 → compresr-2.7.3}/compresr/services/proxy.py +0 -0
  65. {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/dependency_links.txt +0 -0
  66. {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/requires.txt +0 -0
  67. {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/top_level.txt +0 -0
  68. {compresr-2.7.2 → compresr-2.7.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compresr
3
- Version: 2.7.2
3
+ Version: 2.7.3
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
@@ -0,0 +1,24 @@
1
+ """Compresr research agent — multi-step web search with per-snippet compression.
2
+
3
+ Public entry: ``client.research.run(question)`` / ``client.research.search(question)``.
4
+ Loop structure adapted from Perplexity ``search_evals`` (MIT,
5
+ https://github.com/perplexityai/search_evals).
6
+ """
7
+
8
+ from .agent import ResearchAgent
9
+ from .facade import ResearchFacade
10
+ from .parser import parse_research_output
11
+ from .prompts import DEFAULT_RESEARCH_SYSTEM_PROMPT
12
+ from .types import Citation, ResearchResult, ResearchUsage, Step, StepKind
13
+
14
+ __all__ = [
15
+ "Citation",
16
+ "DEFAULT_RESEARCH_SYSTEM_PROMPT",
17
+ "ResearchAgent",
18
+ "ResearchFacade",
19
+ "ResearchResult",
20
+ "ResearchUsage",
21
+ "Step",
22
+ "StepKind",
23
+ "parse_research_output",
24
+ ]
@@ -0,0 +1,334 @@
1
+ """``ResearchAgent`` — strict-output ReAct loop with per-snippet compression.
2
+
3
+ Bypasses ``_Engine.run`` so we can control ``tool_choice`` per step (forces
4
+ the model to commit on the final step) and compress each tool result against
5
+ the live query before it enters the conversation. Cache control is stamped
6
+ manually for Anthropic since ``create_agent`` is not in the call path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Optional, Sequence
16
+
17
+ from .parser import parse_research_output
18
+ from .prompts import DEFAULT_RESEARCH_SYSTEM_PROMPT
19
+ from .types import Citation, ResearchResult, ResearchUsage, Step
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _DEFAULT_MAX_CONTEXT_TOKENS = 120_000
24
+ _DEFAULT_MAX_STEPS = 10
25
+ _DEFAULT_MIN_COMPRESS_TOKENS = 100
26
+
27
+
28
+ def _estimate_tokens(text: str) -> int:
29
+ return max(1, len(text) // 4)
30
+
31
+
32
+ @dataclass
33
+ class _LoopState:
34
+ """Internal result of :meth:`ResearchAgent._run_loop`.
35
+
36
+ Exposed to the engine's solo-WebSearchTool fast-path so it can feed the
37
+ raw message chain to :meth:`_Engine._normalize` while the public
38
+ :meth:`run` keeps producing a typed :class:`ResearchResult`.
39
+ """
40
+
41
+ messages: list
42
+ ai_history: list = field(default_factory=list)
43
+ trajectory: list = field(default_factory=list)
44
+ last_ai: Optional[Any] = None
45
+
46
+
47
+ class ResearchAgent:
48
+ """Multi-step web-research agent with on-the-fly compression.
49
+
50
+ Args:
51
+ engine: A live :class:`compresr.agents.engine._Engine`.
52
+ search_tool: LangChain ``BaseTool`` taking ``query: str``.
53
+ max_steps: Hard cap on LLM turns. On the final turn ``tool_choice``
54
+ is set to ``"none"`` so the model has to commit.
55
+ system_prompt: Override the default research prompt.
56
+ compress_snippets: Compress each tool result via
57
+ ``client.compress(query=live_query)`` before appending.
58
+ compression_model: Compresr model name (default ``"latte_v1"``).
59
+ min_compress_tokens: Skip compression for tool results below this size.
60
+ max_context_tokens: Drop the oldest tool-result pair when context
61
+ exceeds this cap.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ engine: Any,
68
+ search_tool: Any,
69
+ max_steps: int = _DEFAULT_MAX_STEPS,
70
+ system_prompt: Optional[str] = None,
71
+ compress_snippets: bool = True,
72
+ compression_model: str = "latte_v1",
73
+ min_compress_tokens: int = _DEFAULT_MIN_COMPRESS_TOKENS,
74
+ max_context_tokens: int = _DEFAULT_MAX_CONTEXT_TOKENS,
75
+ ) -> None:
76
+ self._engine = engine
77
+ self._search_tool = search_tool
78
+ self._max_steps = max(1, int(max_steps))
79
+ self._system_prompt = system_prompt or DEFAULT_RESEARCH_SYSTEM_PROMPT
80
+ self._compress_snippets = bool(compress_snippets)
81
+ self._compression_model = compression_model
82
+ self._min_compress_tokens = int(min_compress_tokens)
83
+ self._max_context_tokens = int(max_context_tokens)
84
+
85
+ def run(self, question: str, *, model: Optional[str] = None) -> ResearchResult:
86
+ state = self._run_loop(question, model=model)
87
+ usage = self._aggregate_usage(
88
+ state.ai_history,
89
+ search_calls=sum(1 for s in state.trajectory if s.type == "search"),
90
+ )
91
+ final_text = self._stringify(state.last_ai.content) if state.last_ai is not None else ""
92
+ parsed = parse_research_output(final_text)
93
+ citations = self._collect_citations(parsed.citation_urls, state.trajectory)
94
+
95
+ return ResearchResult(
96
+ answer=parsed.answer,
97
+ explanation=parsed.explanation,
98
+ confidence=parsed.confidence,
99
+ text=final_text,
100
+ citations=citations,
101
+ trajectory=state.trajectory,
102
+ usage=usage,
103
+ raw=state.last_ai,
104
+ )
105
+
106
+ def _run_loop(
107
+ self,
108
+ question: str,
109
+ *,
110
+ model: Optional[str] = None,
111
+ extra_chat_kwargs: Optional[dict] = None,
112
+ ) -> _LoopState:
113
+ """Execute the strict-output ReAct loop and return raw state.
114
+
115
+ Shared by :meth:`run` (which parses ``ResearchResult``) and the
116
+ engine's solo-WebSearchTool fast-path (which feeds ``state.messages``
117
+ into :meth:`_Engine._normalize`).
118
+
119
+ ``extra_chat_kwargs`` lets the engine path bake per-call LLM knobs
120
+ (``max_tokens``, ``temperature``…) into the cached chat model so
121
+ they survive ``bind_tools``.
122
+ """
123
+ from langchain_core.messages import ( # type: ignore[import-not-found]
124
+ HumanMessage,
125
+ SystemMessage,
126
+ ToolMessage,
127
+ )
128
+
129
+ effective_model = self._engine._resolve_model(model)
130
+ chat = self._engine._get_or_build_chat(effective_model, extra_kwargs=extra_chat_kwargs)
131
+
132
+ messages: list = [
133
+ SystemMessage(content=self._system_prompt),
134
+ HumanMessage(content=question),
135
+ ]
136
+ ai_history: list = []
137
+ trajectory: list[Step] = []
138
+ last_ai: Optional[Any] = None
139
+
140
+ for step_idx in range(self._max_steps):
141
+ self._maybe_truncate(messages)
142
+
143
+ tool_choice = "none" if step_idx == self._max_steps - 1 else "auto"
144
+ bound = chat.bind_tools([self._search_tool], tool_choice=tool_choice)
145
+ invoke_messages = self._apply_cache_control(messages)
146
+
147
+ t0 = time.time()
148
+ try:
149
+ response = bound.invoke(invoke_messages)
150
+ except Exception as exc: # noqa: BLE001
151
+ trajectory.append(
152
+ Step(type="error", text=str(exc), latency_s=round(time.time() - t0, 3))
153
+ )
154
+ break
155
+ step_latency = round(time.time() - t0, 3)
156
+
157
+ ai_history.append(response)
158
+ last_ai = response
159
+ messages.append(response)
160
+
161
+ tool_calls = getattr(response, "tool_calls", None) or []
162
+ if not tool_calls:
163
+ trajectory.append(
164
+ Step(
165
+ type="answer",
166
+ text=self._stringify(response.content),
167
+ latency_s=step_latency,
168
+ )
169
+ )
170
+ break
171
+
172
+ for tc in tool_calls:
173
+ args = tc.get("args") if isinstance(tc, dict) else {}
174
+ args = args if isinstance(args, dict) else {}
175
+ query = args.get("query")
176
+ trajectory.append(Step(type="search", query=query, latency_s=step_latency))
177
+
178
+ raw_result = self._invoke_search(args)
179
+ final_result = (
180
+ self._compress(raw_result, query or question)
181
+ if self._compress_snippets
182
+ else raw_result
183
+ )
184
+ trajectory.append(
185
+ Step(
186
+ type="tool_result",
187
+ chars=len(final_result),
188
+ text=final_result,
189
+ )
190
+ )
191
+
192
+ tool_call_id = tc.get("id") if isinstance(tc, dict) else None
193
+ messages.append(
194
+ ToolMessage(
195
+ content=final_result,
196
+ tool_call_id=tool_call_id or "",
197
+ name=getattr(self._search_tool, "name", "search_web"),
198
+ )
199
+ )
200
+
201
+ return _LoopState(
202
+ messages=messages,
203
+ ai_history=ai_history,
204
+ trajectory=trajectory,
205
+ last_ai=last_ai,
206
+ )
207
+
208
+ def _apply_cache_control(self, messages: list) -> list:
209
+ """Stamp Anthropic ephemeral cache_control on the last message.
210
+
211
+ Mirrors ``AnthropicPromptCachingMiddleware`` for the bypass path —
212
+ the research agent does not go through ``create_agent`` so the
213
+ engine's middleware never runs. No-op for non-Anthropic providers.
214
+ """
215
+ provider = getattr(self._engine, "_provider", None)
216
+ if provider != "anthropic":
217
+ return messages
218
+ if not getattr(self._engine, "_enable_prompt_cache", False):
219
+ return messages
220
+ min_msgs = getattr(self._engine, "_prompt_cache_min_messages", 2)
221
+ if len(messages) < min_msgs:
222
+ return messages
223
+
224
+ ttl = getattr(self._engine, "_prompt_cache_ttl", "5m")
225
+ marker = {"type": "ephemeral", "ttl": ttl}
226
+ last = messages[-1]
227
+ content = getattr(last, "content", None)
228
+ if isinstance(content, str):
229
+ new_content: list = [{"type": "text", "text": content, "cache_control": marker}]
230
+ elif isinstance(content, list) and content:
231
+ new_content = list(content)
232
+ tail = new_content[-1]
233
+ if isinstance(tail, dict):
234
+ tail = dict(tail)
235
+ tail["cache_control"] = marker
236
+ new_content[-1] = tail
237
+ else:
238
+ new_content.append({"type": "text", "text": str(tail), "cache_control": marker})
239
+ else:
240
+ return messages
241
+
242
+ try:
243
+ patched = last.model_copy(update={"content": new_content})
244
+ except Exception: # noqa: BLE001
245
+ patched = type(last)(content=new_content)
246
+ return [*messages[:-1], patched]
247
+
248
+ def _invoke_search(self, args: dict) -> str:
249
+ try:
250
+ out = self._search_tool.invoke(args)
251
+ except Exception as exc: # noqa: BLE001
252
+ logger.warning("research: search tool raised %s; agent will continue", exc)
253
+ return f"Search error: {exc}"
254
+ return self._stringify(out)
255
+
256
+ def _compress(self, text: str, query: str) -> str:
257
+ if not text or _estimate_tokens(text) < self._min_compress_tokens:
258
+ return text
259
+ client = self._engine._compresr_client
260
+ try:
261
+ resp = client.compress(
262
+ context=text,
263
+ query=query,
264
+ compression_model_name=self._compression_model,
265
+ )
266
+ data = getattr(resp, "data", None)
267
+ compressed = getattr(data, "compressed_context", None) if data is not None else None
268
+ return compressed if isinstance(compressed, str) and compressed else text
269
+ except Exception as exc: # noqa: BLE001
270
+ logger.debug("research: compression failed (%s); using raw snippet", exc)
271
+ return text
272
+
273
+ def _maybe_truncate(self, messages: list) -> None:
274
+ if not self._max_context_tokens:
275
+ return
276
+ total = sum(_estimate_tokens(self._stringify(getattr(m, "content", ""))) for m in messages)
277
+ if total <= self._max_context_tokens:
278
+ return
279
+ guard = 0
280
+ while total > self._max_context_tokens and len(messages) >= 4 and guard < 4:
281
+ del messages[2:4]
282
+ total = sum(
283
+ _estimate_tokens(self._stringify(getattr(m, "content", ""))) for m in messages
284
+ )
285
+ guard += 1
286
+
287
+ def _aggregate_usage(
288
+ self,
289
+ ai_messages: Sequence[Any],
290
+ *,
291
+ search_calls: int = 0,
292
+ ) -> ResearchUsage:
293
+ from compresr.agents.engine import _aggregate_usage
294
+
295
+ agg = _aggregate_usage(list(ai_messages))
296
+ return ResearchUsage(
297
+ input_tokens=int(agg.get("input_tokens", 0) or 0),
298
+ output_tokens=int(agg.get("output_tokens", 0) or 0),
299
+ cache_read_tokens=int(agg.get("cache_read_input_tokens", 0) or 0),
300
+ cache_creation_tokens=int(agg.get("cache_creation_input_tokens", 0) or 0),
301
+ calls=len(ai_messages),
302
+ search_calls=int(search_calls or 0),
303
+ )
304
+
305
+ def _collect_citations(
306
+ self, parsed_urls: Sequence[str], trajectory: Sequence[Step]
307
+ ) -> list[Citation]:
308
+ urls: list[str] = []
309
+ for u in parsed_urls:
310
+ if u not in urls:
311
+ urls.append(u)
312
+ for step in trajectory:
313
+ if step.type != "tool_result" or not step.text:
314
+ continue
315
+ for url in re.findall(r"https?://[^\s,;<>\"')]+", step.text):
316
+ if url not in urls:
317
+ urls.append(url)
318
+ return [Citation(url=u) for u in urls]
319
+
320
+ @staticmethod
321
+ def _stringify(content: Any) -> str:
322
+ if isinstance(content, str):
323
+ return content
324
+ if isinstance(content, list):
325
+ parts = []
326
+ for block in content:
327
+ if isinstance(block, dict):
328
+ text = block.get("text")
329
+ if isinstance(text, str):
330
+ parts.append(text)
331
+ elif isinstance(block, str):
332
+ parts.append(block)
333
+ return "".join(parts)
334
+ return str(content) if content is not None else ""
@@ -0,0 +1,94 @@
1
+ """``client.research`` facade — thin wrapper over :class:`ResearchAgent`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Optional
7
+
8
+ from .agent import ResearchAgent
9
+ from .types import ResearchResult
10
+
11
+ _SEARCH_PROVIDER_ENV = {
12
+ "tavily": ("TAVILY_API_KEY",),
13
+ "brave": ("BRAVE_SEARCH_API_KEY", "BRAVE_API_KEY"),
14
+ }
15
+
16
+
17
+ class ResearchFacade:
18
+ def __init__(self, engine: Any) -> None:
19
+ self._engine = engine
20
+
21
+ def run(
22
+ self,
23
+ question: str,
24
+ *,
25
+ search: Any = "tavily",
26
+ max_steps: int = 10,
27
+ model: Optional[str] = None,
28
+ compress_snippets: bool = True,
29
+ compression_model: str = "latte_v1",
30
+ min_compress_tokens: int = 100,
31
+ max_context_tokens: int = 120_000,
32
+ system_prompt: Optional[str] = None,
33
+ ) -> ResearchResult:
34
+ """Multi-step research loop.
35
+
36
+ ``search`` is either a provider name (``"tavily"`` | ``"brave"``)
37
+ or a ready-made LangChain ``BaseTool``.
38
+ """
39
+ tool = self._resolve_search_tool(search)
40
+ agent = ResearchAgent(
41
+ engine=self._engine,
42
+ search_tool=tool,
43
+ max_steps=max_steps,
44
+ system_prompt=system_prompt,
45
+ compress_snippets=compress_snippets,
46
+ compression_model=compression_model,
47
+ min_compress_tokens=min_compress_tokens,
48
+ max_context_tokens=max_context_tokens,
49
+ )
50
+ return agent.run(question, model=model)
51
+
52
+ def search(
53
+ self,
54
+ question: str,
55
+ *,
56
+ search: Any = "tavily",
57
+ model: Optional[str] = None,
58
+ compress_snippets: bool = True,
59
+ compression_model: str = "latte_v1",
60
+ min_compress_tokens: int = 100,
61
+ max_context_tokens: int = 120_000,
62
+ system_prompt: Optional[str] = None,
63
+ ) -> ResearchResult:
64
+ """Single-shot: one search + forced answer (``max_steps=2``)."""
65
+ return self.run(
66
+ question,
67
+ search=search,
68
+ max_steps=2,
69
+ model=model,
70
+ compress_snippets=compress_snippets,
71
+ compression_model=compression_model,
72
+ min_compress_tokens=min_compress_tokens,
73
+ max_context_tokens=max_context_tokens,
74
+ system_prompt=system_prompt,
75
+ )
76
+
77
+ def _resolve_search_tool(self, search: Any) -> Any:
78
+ if not isinstance(search, str):
79
+ return search
80
+ provider = search.lower()
81
+ if provider not in _SEARCH_PROVIDER_ENV:
82
+ raise ValueError(
83
+ f"unsupported search provider {search!r}; expected one of "
84
+ f"{sorted(_SEARCH_PROVIDER_ENV)} or a LangChain BaseTool"
85
+ )
86
+ from ..tools.web_search import WebSearchTool
87
+
88
+ api_key: Optional[str] = None
89
+ for env_var in _SEARCH_PROVIDER_ENV[provider]:
90
+ v = os.environ.get(env_var)
91
+ if v:
92
+ api_key = v
93
+ break
94
+ return WebSearchTool(provider=provider, api_key=api_key)
@@ -0,0 +1,62 @@
1
+ """Parse the strict-format final response into structured fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class _Parsed:
12
+ answer: str
13
+ explanation: str
14
+ confidence: Optional[float]
15
+ citation_urls: list[str]
16
+
17
+
18
+ _FIELD_PATTERNS = {
19
+ "explanation": r"(?im)^\s*explanation\s*:\s*(.+?)(?=^\s*(?:exact\s+answer|confidence|citations)\s*:|\Z)",
20
+ "answer": r"(?im)^\s*exact\s+answer\s*:\s*(.+?)(?=^\s*(?:explanation|confidence|citations)\s*:|\Z)",
21
+ "confidence": r"(?im)^\s*confidence\s*:\s*(.+?)(?=^\s*(?:explanation|exact\s+answer|citations)\s*:|\Z)",
22
+ "citations": r"(?im)^\s*citations\s*:\s*(.+?)\Z",
23
+ }
24
+
25
+
26
+ def parse_research_output(text: str) -> _Parsed:
27
+ """Extract Explanation / Exact Answer / Confidence / Citations from ``text``.
28
+
29
+ Missing fields default to empty / None. Confidence accepts ``"62"``,
30
+ ``"62%"``, or ``"0.62"`` and normalizes to a 0–1 float.
31
+ """
32
+ if not text:
33
+ return _Parsed(answer="", explanation="", confidence=None, citation_urls=[])
34
+
35
+ def _grab(name: str) -> str:
36
+ m = re.search(_FIELD_PATTERNS[name], text, re.DOTALL)
37
+ return m.group(1).strip() if m else ""
38
+
39
+ answer = _grab("answer")
40
+ explanation = _grab("explanation")
41
+ confidence_raw = _grab("confidence")
42
+ citations_raw = _grab("citations")
43
+
44
+ confidence: Optional[float] = None
45
+ if confidence_raw:
46
+ m = re.search(r"(\d+(?:\.\d+)?)", confidence_raw)
47
+ if m:
48
+ v = float(m.group(1))
49
+ confidence = v / 100 if v > 1 else v
50
+
51
+ citation_urls: list[str] = []
52
+ if citations_raw:
53
+ for url in re.findall(r"https?://[^\s,;<>\"']+", citations_raw):
54
+ if url not in citation_urls:
55
+ citation_urls.append(url)
56
+
57
+ return _Parsed(
58
+ answer=answer,
59
+ explanation=explanation,
60
+ confidence=confidence,
61
+ citation_urls=citation_urls,
62
+ )
@@ -0,0 +1,35 @@
1
+ """System prompts for the research agent.
2
+
3
+ Default prompt adapted from Perplexity ``search_evals`` (MIT,
4
+ https://github.com/perplexityai/search_evals/blob/main/search_evals/agents/deep_research.py).
5
+ """
6
+
7
+ DEFAULT_RESEARCH_SYSTEM_PROMPT = """\
8
+ You are a research agent. The user will ask you a specific question whose answer cannot be reliably produced from your internal weights (hallucination risk is high), and that takes several steps of web research to resolve.
9
+
10
+ ## Tool
11
+ search_web(query: str) — Search the web with a single query. Returns titles, URLs, and snippets.
12
+
13
+ ## Query format
14
+ Write queries for a modern keyword/semantic search engine, not for an LLM:
15
+ - Keep them focused — search is a limited budget.
16
+ - Do not rely on quotes, brackets, or special syntax.
17
+ - Natural language is fine; keyword strings are fine too.
18
+
19
+ ## Solution strategy
20
+ You have a hard search budget. At each step, decrement the counter and plan ahead how you will spend what is left. Ask yourself: did this SERP bring me closer to the answer? If not, and rephrasing didn't help, explicitly say you're switching hypothesis and pursue a different direction.
21
+
22
+ ## General web-search recommendations
23
+ - One query at a time.
24
+ - Cross-verify across sources before committing.
25
+ - If sources conflict, search more to resolve the conflict.
26
+ - Never assert a claim you don't have a source for.
27
+
28
+ ## Response format (mandatory)
29
+ When you are done researching, end your response with exactly these fields in this order:
30
+
31
+ Explanation: <your research process and the key findings from each source>
32
+ Exact Answer: <the precise final answer, or "I don't know" if you couldn't find it>
33
+ Confidence: <0-100%>
34
+ Citations: <comma-separated list of URLs you actually relied on>
35
+ """
@@ -0,0 +1,69 @@
1
+ """Dataclasses returned by :class:`ResearchAgent.run`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal, Optional
7
+
8
+ StepKind = Literal["search", "tool_result", "answer", "error"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Citation:
13
+ url: str
14
+ title: Optional[str] = None
15
+ snippet: Optional[str] = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Step:
20
+ """One step in the agent trajectory."""
21
+
22
+ type: StepKind
23
+ query: Optional[str] = None
24
+ text: Optional[str] = None
25
+ chars: Optional[int] = None
26
+ latency_s: Optional[float] = None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ResearchUsage:
31
+ """Raw usage counts aggregated across the research loop.
32
+
33
+ The SDK exposes counts only — no USD pricing. Neither Anthropic / OpenAI
34
+ inference responses nor Brave / Tavily search responses carry a per-call
35
+ dollar field, so any cost rollup must be done by the caller against their
36
+ own (mutable) price table. Compresr's benchmark suite owns the canonical
37
+ table for its experiments; downstream apps are expected to do the same.
38
+
39
+ Fields:
40
+ * ``input_tokens`` / ``output_tokens`` — fresh tokens, as reported by
41
+ the provider's ``usage`` block.
42
+ * ``cache_read_tokens`` / ``cache_creation_tokens`` — Anthropic
43
+ ephemeral cache classes (0 elsewhere).
44
+ * ``calls`` — LLM round-trips (one per loop step that hit the model).
45
+ * ``search_calls`` — number of times the search tool fired. Independent
46
+ of ``calls`` because one LLM turn can request multiple tool calls.
47
+ """
48
+
49
+ input_tokens: int = 0
50
+ output_tokens: int = 0
51
+ cache_read_tokens: int = 0
52
+ cache_creation_tokens: int = 0
53
+ calls: int = 0
54
+ search_calls: int = 0
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ResearchResult:
59
+ """Final research output. ``text`` is the model's raw final response;
60
+ ``answer`` / ``explanation`` / ``confidence`` are parsed from it."""
61
+
62
+ answer: str
63
+ explanation: str
64
+ confidence: Optional[float]
65
+ text: str
66
+ citations: list[Citation] = field(default_factory=list)
67
+ trajectory: list[Step] = field(default_factory=list)
68
+ usage: ResearchUsage = field(default_factory=ResearchUsage)
69
+ raw: Any = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compresr
3
- Version: 2.7.2
3
+ Version: 2.7.3
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
@@ -16,6 +16,12 @@ compresr/agents/facades/__init__.py
16
16
  compresr/agents/facades/anthropic.py
17
17
  compresr/agents/facades/native.py
18
18
  compresr/agents/facades/openai.py
19
+ compresr/agents/research/__init__.py
20
+ compresr/agents/research/agent.py
21
+ compresr/agents/research/facade.py
22
+ compresr/agents/research/parser.py
23
+ compresr/agents/research/prompts.py
24
+ compresr/agents/research/types.py
19
25
  compresr/agents/schemas/__init__.py
20
26
  compresr/agents/schemas/anthropic.py
21
27
  compresr/agents/schemas/openai.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compresr"
7
- version = "2.7.2"
7
+ version = "2.7.3"
8
8
  description = "Python SDK for Compresr - Intelligent prompt compression service"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -90,23 +90,14 @@ Documentation = "https://compresr.ai/docs"
90
90
  Repository = "https://github.com/Compresr-ai/Compresr-SDK"
91
91
  Issues = "https://github.com/Compresr-ai/Compresr-SDK/issues"
92
92
 
93
- [tool.setuptools]
94
- packages = [
95
- "compresr",
96
- "compresr.services",
97
- "compresr.schemas",
98
- "compresr.exceptions",
99
- "compresr.integrations",
100
- "compresr.integrations._shared",
101
- "compresr.integrations.langchain",
102
- "compresr.integrations.langgraph",
103
- "compresr.integrations.llamaindex",
104
- "compresr.integrations.litellm",
105
- "compresr.agents",
106
- "compresr.agents.tools",
107
- "compresr.agents.facades",
108
- "compresr.agents.schemas",
109
- ]
93
+ [tool.setuptools.packages.find]
94
+ # Autodiscover every `compresr` subpackage so newly added ones (e.g.
95
+ # `compresr.agents.research`) ship automatically. A previous hand-maintained
96
+ # `packages = [...]` allowlist silently dropped packages that weren't added to
97
+ # it by hand. The `compresr*` filter keeps sibling dirs (tests, tutorial,
98
+ # litellm_shim) out of the distribution.
99
+ where = ["."]
100
+ include = ["compresr*"]
110
101
 
111
102
  [tool.setuptools.package-data]
112
103
  compresr = ["py.typed"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes