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.
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from .agent import create_deep_research_agent
6
+
7
+ __all__ = ["create_deep_research_agent"]
@@ -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