agent-pack-haystack 0.0.1__py3-none-any.whl
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.
- agent_pack_haystack-0.0.1.dist-info/METADATA +39 -0
- agent_pack_haystack-0.0.1.dist-info/RECORD +16 -0
- agent_pack_haystack-0.0.1.dist-info/WHEEL +4 -0
- agent_pack_haystack-0.0.1.dist-info/licenses/LICENSE.txt +201 -0
- haystack_integrations/agent_pack/__init__.py +8 -0
- haystack_integrations/agent_pack/advanced_rag/__init__.py +21 -0
- haystack_integrations/agent_pack/advanced_rag/agent.py +170 -0
- haystack_integrations/agent_pack/advanced_rag/hooks.py +89 -0
- haystack_integrations/agent_pack/advanced_rag/prompts.py +91 -0
- haystack_integrations/agent_pack/advanced_rag/tools.py +721 -0
- haystack_integrations/agent_pack/deep_research/__init__.py +7 -0
- haystack_integrations/agent_pack/deep_research/agent.py +192 -0
- haystack_integrations/agent_pack/deep_research/hooks.py +96 -0
- haystack_integrations/agent_pack/deep_research/prompts.py +104 -0
- haystack_integrations/agent_pack/deep_research/tools.py +113 -0
- haystack_integrations/agent_pack/py.typed +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
# The two agents and the composition that wires them into one deep research Agent.
|
|
6
|
+
#
|
|
7
|
+
# - researcher: a reusable sub-agent that researches ONE sub-question in its own isolated context and returns a
|
|
8
|
+
# compressed, cited summary.
|
|
9
|
+
# - deep research agent:
|
|
10
|
+
# - before starting the Agent loop, turns the user query into a research brief using the Scope Hook.
|
|
11
|
+
# - delegates sub-questions to the researcher and accumulates the returned summaries in shared `State`.
|
|
12
|
+
# - after the Agent loop, turns the brief plus collected notes into the final report using the Write Hook.
|
|
13
|
+
|
|
14
|
+
from haystack import logging
|
|
15
|
+
from haystack.components.agents import Agent
|
|
16
|
+
from haystack.components.builders import ChatPromptBuilder
|
|
17
|
+
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
|
|
18
|
+
from haystack.components.generators.chat.types import ChatGenerator
|
|
19
|
+
from haystack.dataclasses import ChatMessage
|
|
20
|
+
from haystack.lazy_imports import LazyImport
|
|
21
|
+
from haystack.tools import ComponentTool
|
|
22
|
+
|
|
23
|
+
from haystack_integrations.agent_pack.deep_research import prompts
|
|
24
|
+
from haystack_integrations.agent_pack.deep_research.hooks import ScopeHook, WriteHook
|
|
25
|
+
from haystack_integrations.agent_pack.deep_research.tools import make_read_url_tool, think_tool
|
|
26
|
+
|
|
27
|
+
with LazyImport(message="Run 'pip install tavily-haystack'") as tavily_import:
|
|
28
|
+
from haystack_integrations.tools.tavily import TavilyWebSearchTool
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_DEFAULT_TIMEOUT = 180.0
|
|
33
|
+
_DEFAULT_MAX_RETRIES = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_llm(model: str) -> OpenAIResponsesChatGenerator:
|
|
37
|
+
"""A default OpenAI generator for a stage, with the pack's timeout/retry settings."""
|
|
38
|
+
return OpenAIResponsesChatGenerator(model=model, timeout=_DEFAULT_TIMEOUT, max_retries=_DEFAULT_MAX_RETRIES)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _collect_note(current: list[str] | None, message: ChatMessage) -> list[str]:
|
|
42
|
+
"""State handler: append a researcher's final summary (text) to `notes`."""
|
|
43
|
+
current = current or []
|
|
44
|
+
note = getattr(message, "text", None) # message is the researcher's last ChatMessage
|
|
45
|
+
if not note:
|
|
46
|
+
logger.info("sub-researcher returned an empty note (likely hit its step cap); skipping")
|
|
47
|
+
return current
|
|
48
|
+
logger.info(
|
|
49
|
+
"note collected (#{index}, {length} chars):\n{note}", index=len(current) + 1, length=len(note), note=note
|
|
50
|
+
)
|
|
51
|
+
return [*current, note]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _make_researcher_agent(
|
|
55
|
+
*,
|
|
56
|
+
researcher_llm: ChatGenerator,
|
|
57
|
+
summarizer_llm: ChatGenerator,
|
|
58
|
+
max_researcher_steps: int,
|
|
59
|
+
max_search_results: int,
|
|
60
|
+
max_content_length: int,
|
|
61
|
+
) -> Agent:
|
|
62
|
+
"""
|
|
63
|
+
The reusable sub-researcher.
|
|
64
|
+
|
|
65
|
+
:param researcher_llm: LLM that drives the search/read/think loop and writes the summary.
|
|
66
|
+
:param summarizer_llm: LLM used inside the `read_url` tool to summarize a page toward the question.
|
|
67
|
+
:param max_researcher_steps: Cap on the researcher's agent loop (search/read/think + final summary).
|
|
68
|
+
:param max_search_results: Results returned per `web_search` call.
|
|
69
|
+
:param max_content_length: Raw page chars fed to the summarizer (pre-summarization cap).
|
|
70
|
+
"""
|
|
71
|
+
tavily_import.check()
|
|
72
|
+
return Agent(
|
|
73
|
+
chat_generator=researcher_llm,
|
|
74
|
+
system_prompt=prompts.RESEARCHER_TEMPLATE,
|
|
75
|
+
tools=[
|
|
76
|
+
TavilyWebSearchTool(top_k=max_search_results),
|
|
77
|
+
make_read_url_tool(summarizer_llm=summarizer_llm, max_content_length=max_content_length),
|
|
78
|
+
think_tool,
|
|
79
|
+
],
|
|
80
|
+
exit_conditions=["text"], # exits when it writes its summary
|
|
81
|
+
max_agent_steps=max_researcher_steps,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Minimal input schema for the research_subtopic tool
|
|
86
|
+
_RESEARCH_SUBTOPIC_PARAMS = {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"messages": {
|
|
90
|
+
"type": "array",
|
|
91
|
+
"description": "Exactly one user message whose content is the sub-question to research.",
|
|
92
|
+
"items": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"role": {"type": "string", "enum": ["user"]},
|
|
96
|
+
"content": {"type": "string", "description": "The sub-question to research."},
|
|
97
|
+
},
|
|
98
|
+
"required": ["role", "content"],
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"required": ["messages"],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def create_deep_research_agent(
|
|
107
|
+
*,
|
|
108
|
+
scope_llm: ChatGenerator | None = None,
|
|
109
|
+
orchestrator_llm: ChatGenerator | None = None,
|
|
110
|
+
researcher_llm: ChatGenerator | None = None,
|
|
111
|
+
summarizer_llm: ChatGenerator | None = None,
|
|
112
|
+
writer_llm: ChatGenerator | None = None,
|
|
113
|
+
max_subtopics: int = 5,
|
|
114
|
+
max_concurrent_researchers: int = 5,
|
|
115
|
+
max_orchestrator_steps: int = 8,
|
|
116
|
+
max_researcher_steps: int = 20,
|
|
117
|
+
max_search_results: int = 10,
|
|
118
|
+
max_content_length: int = 50_000,
|
|
119
|
+
) -> Agent:
|
|
120
|
+
"""
|
|
121
|
+
Create the deep research agent.
|
|
122
|
+
|
|
123
|
+
:param scope_llm: LLM that rewrites the user query into a focused research brief.
|
|
124
|
+
Defaults to `OpenAIResponsesChatGenerator("gpt-5.4")`.
|
|
125
|
+
:param orchestrator_llm: LLM that plans the investigation and delegates the sub-questions.
|
|
126
|
+
Defaults to `OpenAIResponsesChatGenerator("gpt-5.4")`.
|
|
127
|
+
:param researcher_llm: LLM that drives each sub-researcher's search/read/think loop.
|
|
128
|
+
Defaults to `OpenAIResponsesChatGenerator("gpt-5.4-mini")`.
|
|
129
|
+
:param summarizer_llm: LLM used inside the `read_url` tool to summarize a fetched page toward
|
|
130
|
+
the question. Defaults to `OpenAIResponsesChatGenerator("gpt-5.4-mini")`.
|
|
131
|
+
:param writer_llm: LLM that turns the brief plus collected notes into the final report.
|
|
132
|
+
Defaults to `OpenAIResponsesChatGenerator("gpt-5.4")`.
|
|
133
|
+
:param max_subtopics: Maximum number of sub-questions the orchestrator may delegate (breadth).
|
|
134
|
+
:param max_concurrent_researchers: Maximum number of sub-researchers that run at the same time.
|
|
135
|
+
:param max_orchestrator_steps: Maximum steps for the orchestrator's agent loop (reflect -> delegate rounds).
|
|
136
|
+
:param max_researcher_steps: Maximum steps for each sub-researcher's agent loop.
|
|
137
|
+
:param max_search_results: Number of results returned per `web_search` call.
|
|
138
|
+
:param max_content_length: Maximum raw page characters fed to the summarizer, before summarization.
|
|
139
|
+
:returns: The deep research `Agent`. Call it with the question as a user message,
|
|
140
|
+
`agent.run(messages=[ChatMessage.from_user(question)])`; it returns a dict whose main output is
|
|
141
|
+
`report` (the final markdown report, a `str`). The dict also carries the intermediate `brief`
|
|
142
|
+
(`str`) and `notes` (`list[str]`), plus the standard Agent outputs `messages`, `last_message`,
|
|
143
|
+
`step_count`, `token_usage` and `tool_call_counts`.
|
|
144
|
+
"""
|
|
145
|
+
scope_llm = scope_llm or _default_llm("gpt-5.4")
|
|
146
|
+
orchestrator_llm = orchestrator_llm or _default_llm("gpt-5.4")
|
|
147
|
+
researcher_llm = researcher_llm or _default_llm("gpt-5.4-mini")
|
|
148
|
+
summarizer_llm = summarizer_llm or _default_llm("gpt-5.4-mini")
|
|
149
|
+
writer_llm = writer_llm or _default_llm("gpt-5.4")
|
|
150
|
+
|
|
151
|
+
researcher = _make_researcher_agent(
|
|
152
|
+
researcher_llm=researcher_llm,
|
|
153
|
+
summarizer_llm=summarizer_llm,
|
|
154
|
+
max_researcher_steps=max_researcher_steps,
|
|
155
|
+
max_search_results=max_search_results,
|
|
156
|
+
max_content_length=max_content_length,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
research_subtopic = ComponentTool(
|
|
160
|
+
component=researcher,
|
|
161
|
+
name="research_subtopic",
|
|
162
|
+
description=(
|
|
163
|
+
"Research ONE focused sub-question in an isolated context. "
|
|
164
|
+
"Pass the sub-question as a user message. "
|
|
165
|
+
"Returns a compressed, cited summary of the findings."
|
|
166
|
+
),
|
|
167
|
+
parameters=_RESEARCH_SUBTOPIC_PARAMS,
|
|
168
|
+
outputs_to_string={"source": "last_message"},
|
|
169
|
+
outputs_to_state={"notes": {"source": "last_message", "handler": _collect_note}},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
scope = ScopeHook(
|
|
173
|
+
generator=scope_llm,
|
|
174
|
+
prompt_builder=ChatPromptBuilder(template=prompts.SCOPE_TEMPLATE, required_variables=["query"]),
|
|
175
|
+
)
|
|
176
|
+
write = WriteHook(
|
|
177
|
+
generator=writer_llm,
|
|
178
|
+
prompt_builder=ChatPromptBuilder(template=prompts.WRITER_TEMPLATE, required_variables=["replies", "notes"]),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
system_prompt = prompts.ORCHESTRATOR_TEMPLATE.replace("{{ max_subtopics }}", str(max_subtopics))
|
|
182
|
+
|
|
183
|
+
return Agent(
|
|
184
|
+
chat_generator=orchestrator_llm,
|
|
185
|
+
system_prompt=system_prompt,
|
|
186
|
+
tools=[research_subtopic, think_tool],
|
|
187
|
+
exit_conditions=["text"],
|
|
188
|
+
max_agent_steps=max_orchestrator_steps,
|
|
189
|
+
tool_concurrency_limit=max_concurrent_researchers,
|
|
190
|
+
state_schema={"notes": {"type": list}, "brief": {"type": str}, "report": {"type": str}},
|
|
191
|
+
hooks={"before_run": [scope], "after_run": [write]},
|
|
192
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""The SCOPE and WRITE stages, as serializable Agent hooks."""
|
|
6
|
+
|
|
7
|
+
from haystack import logging
|
|
8
|
+
from haystack.components.agents.state import State
|
|
9
|
+
from haystack.components.builders import ChatPromptBuilder
|
|
10
|
+
from haystack.components.generators.chat.types import ChatGenerator
|
|
11
|
+
from haystack.core.serialization import default_from_dict, default_to_dict
|
|
12
|
+
from haystack.dataclasses import ChatMessage
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScopeHook:
|
|
18
|
+
"""Turn the user query into a research brief, once. Runs as a `before_run` hook."""
|
|
19
|
+
|
|
20
|
+
allowed_hook_points = ("before_run",)
|
|
21
|
+
|
|
22
|
+
def __init__(self, generator: ChatGenerator, prompt_builder: ChatPromptBuilder) -> None:
|
|
23
|
+
self.generator = generator
|
|
24
|
+
self.prompt_builder = prompt_builder
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
"""
|
|
28
|
+
Serialize the hook to a dictionary.
|
|
29
|
+
|
|
30
|
+
:returns: Dictionary with serialized data.
|
|
31
|
+
"""
|
|
32
|
+
return default_to_dict(self, generator=self.generator, prompt_builder=self.prompt_builder)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, data: dict) -> "ScopeHook":
|
|
36
|
+
"""
|
|
37
|
+
Deserialize the hook from a dictionary.
|
|
38
|
+
|
|
39
|
+
:param data: Dictionary to deserialize from.
|
|
40
|
+
:returns: Deserialized hook.
|
|
41
|
+
"""
|
|
42
|
+
return default_from_dict(cls, data)
|
|
43
|
+
|
|
44
|
+
def run(self, state: State) -> None:
|
|
45
|
+
"""Rewrite the query into a research brief and seed it as the sole user message."""
|
|
46
|
+
prompt = self.prompt_builder.run(query=state.data["messages"][-1].text)["prompt"]
|
|
47
|
+
brief = self.generator.run(messages=prompt)["replies"][0].text
|
|
48
|
+
logger.info("brief:\n{brief}", brief=brief)
|
|
49
|
+
state.set("brief", brief)
|
|
50
|
+
state.set("messages", [ChatMessage.from_user(brief)])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WriteHook:
|
|
54
|
+
"""Write the final report from the brief and notes, once. Runs as an `after_run` hook."""
|
|
55
|
+
|
|
56
|
+
allowed_hook_points = ("after_run",)
|
|
57
|
+
|
|
58
|
+
def __init__(self, generator: ChatGenerator, prompt_builder: ChatPromptBuilder) -> None:
|
|
59
|
+
self.generator = generator
|
|
60
|
+
self.prompt_builder = prompt_builder
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
"""
|
|
64
|
+
Serialize the hook to a dictionary.
|
|
65
|
+
|
|
66
|
+
:returns: Dictionary with serialized data.
|
|
67
|
+
"""
|
|
68
|
+
return default_to_dict(self, generator=self.generator, prompt_builder=self.prompt_builder)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: dict) -> "WriteHook":
|
|
72
|
+
"""
|
|
73
|
+
Deserialize the hook from a dictionary.
|
|
74
|
+
|
|
75
|
+
:param data: Dictionary to deserialize from.
|
|
76
|
+
:returns: Deserialized hook.
|
|
77
|
+
"""
|
|
78
|
+
return default_from_dict(cls, data)
|
|
79
|
+
|
|
80
|
+
def run(self, state: State) -> None:
|
|
81
|
+
"""Write the final report from the brief and collected notes."""
|
|
82
|
+
subtopics = [
|
|
83
|
+
tc.arguments["messages"][0]["content"]
|
|
84
|
+
for m in state.data.get("messages", [])
|
|
85
|
+
for tc in (m.tool_calls or [])
|
|
86
|
+
if tc.tool_name == "research_subtopic"
|
|
87
|
+
]
|
|
88
|
+
for i, subtopic in enumerate(subtopics, 1):
|
|
89
|
+
logger.info("subtopic #{index} delegated: {subtopic}", index=i, subtopic=subtopic)
|
|
90
|
+
notes = [n for n in state.get("notes", []) if n]
|
|
91
|
+
prompt = self.prompt_builder.run(replies=[ChatMessage.from_assistant(state.get("brief", ""))], notes=notes)[
|
|
92
|
+
"prompt"
|
|
93
|
+
]
|
|
94
|
+
report = self.generator.run(messages=prompt)["replies"][0].text
|
|
95
|
+
state.set("report", report)
|
|
96
|
+
state.set("messages", [ChatMessage.from_assistant(report)])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
SCOPE_TEMPLATE = """{% message role="system" %}
|
|
6
|
+
You turn a user's research request into a clear, focused research brief.
|
|
7
|
+
Today's date is {% now 'local', '%d %B %Y' %}.
|
|
8
|
+
|
|
9
|
+
In 3-5 sentences, restate:
|
|
10
|
+
- what exactly should be investigated,
|
|
11
|
+
- the key sub-dimensions a complete answer must cover,
|
|
12
|
+
- what a good answer looks like.
|
|
13
|
+
|
|
14
|
+
Be specific and neutral. Do NOT invent constraints or assume facts the user did not state; where
|
|
15
|
+
the request is open-ended, keep the dimension open rather than narrowing it. Write the brief in the
|
|
16
|
+
same language as the user's request. Output ONLY the brief.
|
|
17
|
+
{% endmessage %}
|
|
18
|
+
{% message role="user" %}
|
|
19
|
+
{{ query }}
|
|
20
|
+
{% endmessage %}"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
RESEARCHER_TEMPLATE = """{% message role="system" %}
|
|
24
|
+
You are a focused research assistant. You are given ONE specific sub-question.
|
|
25
|
+
Today's date is {% now 'local', '%d %B %Y' %}. Use it to interpret "current", "latest", and relative dates.
|
|
26
|
+
Your job is to gather evidence and return a compressed, self-contained summary.
|
|
27
|
+
|
|
28
|
+
Process:
|
|
29
|
+
1. Use `web_search` to find relevant sources (2-3 searches is usually enough).
|
|
30
|
+
2. Use `read_url` only when a snippet is promising but too shallow.
|
|
31
|
+
3. Use `think_tool` after searching to assess: what did I learn? what is missing? should I stop?
|
|
32
|
+
4. When you have enough evidence, STOP calling tools and write your final summary.
|
|
33
|
+
|
|
34
|
+
Your final message MUST be:
|
|
35
|
+
- A concise summary (a few short paragraphs) of the findings for this sub-question, with inline
|
|
36
|
+
citations using the EXACT full URLs from the search results (copied verbatim, in parentheses).
|
|
37
|
+
- Followed by a "Sources:" list of every exact URL you used.
|
|
38
|
+
|
|
39
|
+
Copy URLs verbatim from the search results. NEVER shorten a URL to its domain and never invent one.
|
|
40
|
+
Only state what the sources support.
|
|
41
|
+
{% endmessage %}"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ORCHESTRATOR_TEMPLATE = """{% message role="system" %}
|
|
45
|
+
You are a research orchestrator. You are given a research brief.
|
|
46
|
+
Today's date is {% now 'local', '%d %B %Y' %}.
|
|
47
|
+
|
|
48
|
+
Process:
|
|
49
|
+
1. Break the brief into at most {{ max_subtopics }} focused sub-questions, each covering a DISTINCT
|
|
50
|
+
facet of the brief. No two sub-questions may overlap; together they should partition the brief's
|
|
51
|
+
key dimensions.
|
|
52
|
+
2. Call `research_subtopic` ONCE PER sub-question, emitting all the calls together in a single
|
|
53
|
+
turn so they run in parallel.
|
|
54
|
+
3. When the results return, use `think_tool` to check coverage against the brief. Only if a real
|
|
55
|
+
dimension is still UNADDRESSED, do at most ONE more round — researching ONLY the genuine gap.
|
|
56
|
+
Never re-ask, rephrase, or "double-check" a sub-question that was already researched.
|
|
57
|
+
4. When coverage is sufficient, STOP and reply with a one-line note that research is complete.
|
|
58
|
+
(The detailed findings are already collected — do not repeat them.)
|
|
59
|
+
|
|
60
|
+
Stay tight: never exceed {{ max_subtopics }} sub-questions in the first round.
|
|
61
|
+
{% endmessage %}"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
SUMMARIZE_TEMPLATE = """{% message role="user" %}
|
|
65
|
+
You are extracting information from a web page for a research task.
|
|
66
|
+
|
|
67
|
+
Research question:
|
|
68
|
+
{{ question }}
|
|
69
|
+
|
|
70
|
+
Web page content:
|
|
71
|
+
{{ documents[0].content[:__MAXLEN__] }}
|
|
72
|
+
|
|
73
|
+
Write a concise, factual summary of ONLY the parts of the page relevant to the research question.
|
|
74
|
+
Preserve key facts, numbers, names, dates and short quotes verbatim. If the page contains nothing
|
|
75
|
+
relevant, say so in one line. Do not add anything not present in the page.
|
|
76
|
+
{% endmessage %}"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
WRITER_TEMPLATE = """{% message role="system" %}
|
|
80
|
+
Today's date is {% now 'local', '%d %B %Y' %}. Write the report in the same language as the brief.
|
|
81
|
+
{% endmessage %}
|
|
82
|
+
{% message role="user" %}
|
|
83
|
+
You are a research report writer. Using ONLY the collected research notes below,
|
|
84
|
+
write a well-structured markdown report that answers the brief.
|
|
85
|
+
|
|
86
|
+
Requirements:
|
|
87
|
+
- Open with a one-paragraph executive summary.
|
|
88
|
+
- Organise the body into clear sections with markdown headings.
|
|
89
|
+
- Be accurate and concise. Do not add facts that are not in the notes.
|
|
90
|
+
- Cite sources INLINE as Markdown links: `[short descriptive text](EXACT-URL-from-notes)`. Put a
|
|
91
|
+
citation on every sentence that states a fact from the notes.
|
|
92
|
+
- Use ONLY the EXACT full URLs that appear in the notes, copied verbatim. NEVER shorten a URL to its
|
|
93
|
+
domain and never invent one.
|
|
94
|
+
- End with a "## Sources" section listing the exact URLs you cited.
|
|
95
|
+
|
|
96
|
+
# Brief
|
|
97
|
+
{{ replies[0].text }}
|
|
98
|
+
|
|
99
|
+
# Research notes
|
|
100
|
+
{% for note in notes %}
|
|
101
|
+
---
|
|
102
|
+
{{ note }}
|
|
103
|
+
{% endfor %}
|
|
104
|
+
{% endmessage %}"""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
The tools defined here for the agents.
|
|
7
|
+
|
|
8
|
+
- `read_url`: a `PipelineTool` wrapping a small summarization pipeline. The fetched
|
|
9
|
+
page is routed by MIME type (`FileTypeRouter`) to `HTMLToDocument` or
|
|
10
|
+
`PyPDFToDocument` so PDFs are parsed too, then summarized toward the question.
|
|
11
|
+
- `think_tool`: the reflection no-op — a plain `@tool`.
|
|
12
|
+
|
|
13
|
+
`web_search` is the `TavilyWebSearchTool` provided by the Tavily integration; it is wired in `agent.py`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
from haystack import Document, Pipeline, component, logging
|
|
19
|
+
from haystack.components.builders import ChatPromptBuilder
|
|
20
|
+
from haystack.components.converters import HTMLToDocument, PyPDFToDocument
|
|
21
|
+
from haystack.components.fetchers import LinkContentFetcher
|
|
22
|
+
from haystack.components.generators.chat.types import ChatGenerator
|
|
23
|
+
from haystack.components.joiners import DocumentJoiner
|
|
24
|
+
from haystack.components.routers import FileTypeRouter
|
|
25
|
+
from haystack.tools import PipelineTool, tool
|
|
26
|
+
|
|
27
|
+
from haystack_integrations.agent_pack.deep_research import prompts
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@component
|
|
33
|
+
class ContentGate:
|
|
34
|
+
"""Content gate for `read_url`: pass documents to the summarizer only when the page actually has text."""
|
|
35
|
+
|
|
36
|
+
@component.output_types(documents=list[Document], empty=bool)
|
|
37
|
+
def run(self, documents: list[Document]) -> dict:
|
|
38
|
+
"""
|
|
39
|
+
Forward documents to the summarizer only when the fetched page has readable text.
|
|
40
|
+
|
|
41
|
+
:param documents: Documents produced from the fetched page.
|
|
42
|
+
:returns: `{"documents": [...]}` when the page has text, otherwise `{"empty": True}`.
|
|
43
|
+
"""
|
|
44
|
+
if documents and documents[0].content:
|
|
45
|
+
return {"documents": documents}
|
|
46
|
+
return {"empty": True}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_url_result(result: dict) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Turn the `read_url` pipeline result into one line for the agent (an `outputs_to_string` handler).
|
|
52
|
+
|
|
53
|
+
We don't treat HTTP/network failures here: the `LinkContentFetcher` has `raise_on_failure=True`, so these
|
|
54
|
+
errors are sent to the agent wrapped in an error tool-result message.
|
|
55
|
+
"""
|
|
56
|
+
replies = result.get("replies")
|
|
57
|
+
if replies and replies[0].text:
|
|
58
|
+
return replies[0].text
|
|
59
|
+
|
|
60
|
+
unsupported = result.get("unclassified")
|
|
61
|
+
if unsupported:
|
|
62
|
+
content_type = unsupported[0].meta.get("content_type", "unknown")
|
|
63
|
+
return f"Page not read: unsupported content type {content_type}; only HTML and PDF are read."
|
|
64
|
+
|
|
65
|
+
return "Page not read: the page returned no readable text."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_read_url_tool(*, summarizer_llm: ChatGenerator, max_content_length: int) -> PipelineTool:
|
|
69
|
+
"""
|
|
70
|
+
`read_url` = PipelineTool over a fetch -> convert to text -> summarize-based-on-question pipeline.
|
|
71
|
+
|
|
72
|
+
:param summarizer_llm: LLM that summarizes the fetched page toward the agent's question.
|
|
73
|
+
:param max_content_length: Raw page chars fed to the summarizer (pre-summarization cap).
|
|
74
|
+
"""
|
|
75
|
+
template = prompts.SUMMARIZE_TEMPLATE.replace("__MAXLEN__", str(max_content_length))
|
|
76
|
+
|
|
77
|
+
pipe = Pipeline()
|
|
78
|
+
pipe.add_component("fetcher", LinkContentFetcher(raise_on_failure=True, retry_attempts=2, timeout=10))
|
|
79
|
+
pipe.add_component("router", FileTypeRouter(mime_types=["text/html", "application/pdf"]))
|
|
80
|
+
pipe.add_component("html", HTMLToDocument())
|
|
81
|
+
pipe.add_component("pdf", PyPDFToDocument())
|
|
82
|
+
pipe.add_component("joiner", DocumentJoiner())
|
|
83
|
+
pipe.add_component("content_gate", ContentGate())
|
|
84
|
+
pipe.add_component("builder", ChatPromptBuilder(template=template, required_variables=["question", "documents"]))
|
|
85
|
+
pipe.add_component("summarizer", summarizer_llm)
|
|
86
|
+
pipe.connect("fetcher.streams", "router.sources")
|
|
87
|
+
pipe.connect("router.text/html", "html.sources")
|
|
88
|
+
pipe.connect("router.application/pdf", "pdf.sources")
|
|
89
|
+
pipe.connect("html.documents", "joiner.documents")
|
|
90
|
+
pipe.connect("pdf.documents", "joiner.documents")
|
|
91
|
+
pipe.connect("joiner.documents", "content_gate.documents")
|
|
92
|
+
pipe.connect("content_gate.documents", "builder.documents")
|
|
93
|
+
pipe.connect("builder.prompt", "summarizer.messages")
|
|
94
|
+
|
|
95
|
+
return PipelineTool(
|
|
96
|
+
pipeline=pipe,
|
|
97
|
+
name="read_url",
|
|
98
|
+
description=(
|
|
99
|
+
"Fetch a web page or PDF and get a summary focused on your question. Provide the URL to "
|
|
100
|
+
"read and the specific question you want answered from it. Use when a snippet is too shallow."
|
|
101
|
+
),
|
|
102
|
+
input_mapping={"urls": ["fetcher.urls"], "question": ["builder.question"]},
|
|
103
|
+
outputs_to_string={"handler": _read_url_result},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@tool
|
|
108
|
+
def think_tool(
|
|
109
|
+
reflection: Annotated[str, "What you learned, what is still missing, and whether to keep searching or stop."],
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Record a brief reflection to plan your research. Call this between searches."""
|
|
112
|
+
logger.info("think: {reflection}", reflection=reflection)
|
|
113
|
+
return "Reflection noted"
|
|
File without changes
|