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.
- {compresr-2.7.2 → compresr-2.7.3}/PKG-INFO +1 -1
- compresr-2.7.3/compresr/agents/research/__init__.py +24 -0
- compresr-2.7.3/compresr/agents/research/agent.py +334 -0
- compresr-2.7.3/compresr/agents/research/facade.py +94 -0
- compresr-2.7.3/compresr/agents/research/parser.py +62 -0
- compresr-2.7.3/compresr/agents/research/prompts.py +35 -0
- compresr-2.7.3/compresr/agents/research/types.py +69 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/PKG-INFO +1 -1
- {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/SOURCES.txt +6 -0
- {compresr-2.7.2 → compresr-2.7.3}/pyproject.toml +9 -18
- {compresr-2.7.2 → compresr-2.7.3}/LICENSE +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/README.md +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/engine.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/anthropic.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/native.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/facades/openai.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/normalized.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/schemas/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/schemas/anthropic.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/schemas/openai.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/tools/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/agents/tools/web_search.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/clients.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/config.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/exceptions/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/exceptions/exceptions.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/client.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/compress.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/errors.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/filters.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/kernel.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/policy.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/query.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/_shared/tokens.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/middleware.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/retriever.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langchain/wrappers.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/checkpoint.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/handoff.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/nodes.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/langgraph/store.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/guardrail.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/initializer.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/litellm/types.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/memory.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/postprocessor.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/integrations/llamaindex/wrappers.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/base.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/inference.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/schemas/usage.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/services/__init__.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/services/base.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/services/compression.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr/services/proxy.py +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/dependency_links.txt +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/requires.txt +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/compresr.egg-info/top_level.txt +0 -0
- {compresr-2.7.2 → compresr-2.7.3}/setup.cfg +0 -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
|
|
@@ -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.
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|