VibecoderMcSwaggins commited on
Commit
e0c585c
·
unverified ·
1 Parent(s): b074f88

feat(SPEC-08): Integrate shared memory layer + CodeRabbit fixes (#74)

Browse files

## Summary
- SPEC-08: Shared memory layer integration
- CodeRabbit critical fix: async add_evidence() with proper deduplication
- Security: Fixed CVEs in langgraph-checkpoint and urllib3
- CI: Hardened to fail on security vulnerabilities
- Docstrings: 80%+ coverage

All 178 tests pass. No known vulnerabilities.

.github/workflows/ci.yml CHANGED
@@ -40,11 +40,9 @@ jobs:
40
 
41
  - name: Security scan with bandit
42
  run: uv run bandit -r src -ll -q
43
- continue-on-error: true # Don't fail CI, just report
44
 
45
  - name: Dependency vulnerability audit
46
  run: uv run pip-audit
47
- continue-on-error: true # Informational - deps may have known issues
48
 
49
  - name: Run tests with coverage
50
  run: uv run pytest tests/unit/ -v --cov=src --cov-report=xml --cov-report=term-missing
 
40
 
41
  - name: Security scan with bandit
42
  run: uv run bandit -r src -ll -q
 
43
 
44
  - name: Dependency vulnerability audit
45
  run: uv run pip-audit
 
46
 
47
  - name: Run tests with coverage
48
  run: uv run pytest tests/unit/ -v --cov=src --cov-report=xml --cov-report=term-missing
docs/bugs/P3_ARCHITECTURAL_GAP_STRUCTURED_MEMORY.md CHANGED
@@ -69,11 +69,13 @@ Based on [comprehensive analysis](https://latenode.com/blog/langgraph-multi-agen
69
  ### Target Architecture
70
 
71
  ```python
72
- # src/agents/graph/state.py (PROPOSED)
73
  from typing import Annotated, TypedDict, Literal
74
  import operator
 
 
75
 
76
- class Hypothesis(TypedDict):
77
  id: str
78
  statement: str
79
  status: Literal["proposed", "validating", "confirmed", "refuted"]
@@ -81,7 +83,7 @@ class Hypothesis(TypedDict):
81
  supporting_evidence_ids: list[str]
82
  contradicting_evidence_ids: list[str]
83
 
84
- class Conflict(TypedDict):
85
  id: str
86
  description: str
87
  source_a_id: str
 
69
  ### Target Architecture
70
 
71
  ```python
72
+ # src/agents/graph/state.py (IMPLEMENTED)
73
  from typing import Annotated, TypedDict, Literal
74
  import operator
75
+ from pydantic import BaseModel, Field
76
+ from langchain_core.messages import BaseMessage
77
 
78
+ class Hypothesis(BaseModel):
79
  id: str
80
  statement: str
81
  status: Literal["proposed", "validating", "confirmed", "refuted"]
 
83
  supporting_evidence_ids: list[str]
84
  contradicting_evidence_ids: list[str]
85
 
86
+ class Conflict(BaseModel):
87
  id: str
88
  description: str
89
  source_a_id: str
docs/specs/SPEC_07_LANGGRAPH_MEMORY_ARCH.md CHANGED
@@ -120,26 +120,30 @@ Based on [comprehensive framework comparison](https://kanerika.com/blogs/langcha
120
  from typing import Annotated, TypedDict, Literal
121
  import operator
122
  from langchain_core.messages import BaseMessage
 
123
 
124
 
125
- class Hypothesis(TypedDict):
126
  """A research hypothesis with evidence tracking."""
127
- id: str
128
- statement: str
129
- status: Literal["proposed", "validating", "confirmed", "refuted"]
130
- confidence: float # 0.0 - 1.0
131
- supporting_evidence_ids: list[str]
132
- contradicting_evidence_ids: list[str]
 
 
 
133
 
134
 
135
- class Conflict(TypedDict):
136
  """A detected contradiction between sources."""
137
- id: str
138
- description: str
139
- source_a_id: str
140
- source_b_id: str
141
- status: Literal["open", "resolved"]
142
- resolution: str | None
143
 
144
 
145
  class ResearchState(TypedDict):
@@ -151,11 +155,12 @@ class ResearchState(TypedDict):
151
  # Immutable context
152
  query: str
153
 
154
- # Cognitive state (the "blackboard")
 
155
  hypotheses: Annotated[list[Hypothesis], operator.add]
156
  conflicts: Annotated[list[Conflict], operator.add]
157
 
158
- # Evidence links (actual content in ChromaDB)
159
  evidence_ids: Annotated[list[str], operator.add]
160
 
161
  # Chat history (for LLM context)
@@ -169,90 +174,78 @@ class ResearchState(TypedDict):
169
 
170
  ### 4.2 Graph Nodes
171
 
172
- Each node is a pure function: `(state: ResearchState) -> dict`
173
 
174
  **File:** `src/agents/graph/nodes.py`
175
 
176
  ```python
177
  """Graph node implementations."""
178
- from langchain_core.messages import HumanMessage, AIMessage
179
- from src.tools.pubmed import search_pubmed
180
- from src.tools.clinicaltrials import search_clinicaltrials
181
- from src.tools.europepmc import search_europepmc
182
 
183
 
184
- async def search_node(state: ResearchState) -> dict:
 
 
185
  """Execute search across all sources.
186
 
187
- Returns partial state update (additive via operator.add).
 
188
  """
189
- query = state["query"]
190
- # Reuse existing tools
191
- results = await asyncio.gather(
192
- search_pubmed(query),
193
- search_clinicaltrials(query),
194
- search_europepmc(query),
195
- )
196
- new_evidence_ids = [...] # Store in ChromaDB, return IDs
197
  return {
198
- "evidence_ids": new_evidence_ids,
199
- "messages": [AIMessage(content=f"Found {len(new_evidence_ids)} papers")],
200
  }
201
 
202
 
203
- async def judge_node(state: ResearchState) -> dict:
 
 
204
  """Evaluate evidence and update hypothesis confidence.
205
 
206
- Key responsibility: Detect conflicts and flag them.
207
  """
208
- # LLM call to evaluate hypotheses against evidence
209
- # If contradiction found: add to conflicts list
210
  return {
211
- "hypotheses": updated_hypotheses, # With new confidence scores
212
- "conflicts": new_conflicts, # Any detected contradictions
213
- "messages": [...],
214
  }
215
 
216
 
217
- async def resolve_node(state: ResearchState) -> dict:
218
- """Handle open conflicts via tie-breaker logic.
219
-
220
- Triggers targeted search or reasoning to resolve.
221
- """
222
- open_conflicts = [c for c in state["conflicts"] if c["status"] == "open"]
223
- # For each conflict: search for decisive evidence or make judgment call
224
- return {
225
- "conflicts": resolved_conflicts,
226
- "messages": [...],
227
- }
228
-
229
 
230
- async def synthesize_node(state: ResearchState) -> dict:
231
- """Generate final research report.
232
 
233
- Only uses confirmed hypotheses and resolved conflicts.
234
- """
235
- confirmed = [h for h in state["hypotheses"] if h["status"] == "confirmed"]
236
- # Generate structured report
237
- return {
238
- "messages": [AIMessage(content=report_markdown)],
239
- "next_step": "finish",
240
- }
241
 
242
 
243
- def supervisor_node(state: ResearchState) -> dict:
244
- """Route to next node based on state.
 
 
245
 
246
  This is the "brain" - uses LLM to decide next action
247
- based on STRUCTURED STATE (not just chat).
248
  """
249
- # Decision logic:
250
- # 1. If open conflicts exist -> "resolve"
251
- # 2. If hypotheses need more evidence -> "search"
252
- # 3. If evidence is sufficient -> "judge"
253
- # 4. If all hypotheses confirmed -> "synthesize"
254
- # 5. If max iterations -> "synthesize" (forced)
255
- return {"next_step": decided_step, "iteration_count": state["iteration_count"] + 1}
256
  ```
257
 
258
  ### 4.3 Graph Definition
@@ -261,57 +254,40 @@ def supervisor_node(state: ResearchState) -> dict:
261
 
262
  ```python
263
  """LangGraph workflow definition."""
 
264
  from langgraph.graph import StateGraph, END
265
- from langgraph.checkpoint.sqlite import SqliteSaver
266
 
267
  from src.agents.graph.state import ResearchState
268
- from src.agents.graph.nodes import (
269
- search_node,
270
- judge_node,
271
- resolve_node,
272
- synthesize_node,
273
- supervisor_node,
274
- )
275
 
276
 
277
- def create_research_graph(checkpointer=None):
 
 
 
 
278
  """Build the research state graph.
279
 
280
  Args:
281
- checkpointer: Optional SqliteSaver/MongoDBSaver for persistence
 
 
282
  """
283
  graph = StateGraph(ResearchState)
284
 
285
- # Add nodes
286
- graph.add_node("supervisor", supervisor_node)
287
- graph.add_node("search", search_node)
288
- graph.add_node("judge", judge_node)
289
- graph.add_node("resolve", resolve_node)
290
- graph.add_node("synthesize", synthesize_node)
291
-
292
- # Define edges (supervisor routes based on state.next_step)
293
- graph.add_edge("search", "supervisor")
294
- graph.add_edge("judge", "supervisor")
295
- graph.add_edge("resolve", "supervisor")
296
- graph.add_edge("synthesize", END)
297
-
298
- # Conditional routing from supervisor
299
- graph.add_conditional_edges(
300
- "supervisor",
301
- lambda state: state["next_step"],
302
- {
303
- "search": "search",
304
- "judge": "judge",
305
- "resolve": "resolve",
306
- "synthesize": "synthesize",
307
- "finish": END,
308
- },
309
- )
310
 
311
- # Entry point
312
- graph.set_entry_point("supervisor")
 
 
313
 
314
- return graph.compile(checkpointer=checkpointer)
315
  ```
316
 
317
  ### 4.4 Orchestrator Integration
 
120
  from typing import Annotated, TypedDict, Literal
121
  import operator
122
  from langchain_core.messages import BaseMessage
123
+ from pydantic import BaseModel, Field
124
 
125
 
126
+ class Hypothesis(BaseModel):
127
  """A research hypothesis with evidence tracking."""
128
+ id: str = Field(description="Unique identifier for the hypothesis")
129
+ statement: str = Field(description="The hypothesis statement")
130
+ status: Literal["proposed", "validating", "confirmed", "refuted"] = Field(
131
+ default="proposed", description="Current validation status"
132
+ )
133
+ confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="Confidence score (0.0-1.0)")
134
+ supporting_evidence_ids: list[str] = Field(default_factory=list)
135
+ contradicting_evidence_ids: list[str] = Field(default_factory=list)
136
+ reasoning: str | None = Field(default=None, description="Reasoning for current status")
137
 
138
 
139
+ class Conflict(BaseModel):
140
  """A detected contradiction between sources."""
141
+ id: str = Field(description="Unique identifier for the conflict")
142
+ description: str = Field(description="Description of the contradiction")
143
+ source_a_id: str = Field(description="ID of the first conflicting source")
144
+ source_b_id: str = Field(description="ID of the second conflicting source")
145
+ status: Literal["open", "resolved"] = Field(default="open")
146
+ resolution: str | None = Field(default=None, description="Resolution explanation if resolved")
147
 
148
 
149
  class ResearchState(TypedDict):
 
155
  # Immutable context
156
  query: str
157
 
158
+ # Cognitive state (The "Blackboard")
159
+ # Note: We store these as lists of Pydantic models.
160
  hypotheses: Annotated[list[Hypothesis], operator.add]
161
  conflicts: Annotated[list[Conflict], operator.add]
162
 
163
+ # Evidence links (actual content stored in ChromaDB)
164
  evidence_ids: Annotated[list[str], operator.add]
165
 
166
  # Chat history (for LLM context)
 
174
 
175
  ### 4.2 Graph Nodes
176
 
177
+ Each node is an async function that receives the state and injected dependencies.
178
 
179
  **File:** `src/agents/graph/nodes.py`
180
 
181
  ```python
182
  """Graph node implementations."""
183
+ from typing import Any
184
+ from langchain_core.messages import AIMessage
185
+ from src.services.embeddings import EmbeddingService
186
+ from src.tools.search_handler import SearchHandler
187
 
188
 
189
+ async def search_node(
190
+ state: ResearchState, embedding_service: EmbeddingService | None = None
191
+ ) -> dict[str, Any]:
192
  """Execute search across all sources.
193
 
194
+ Uses SearchHandler to query PubMed, ClinicalTrials, and EuropePMC.
195
+ Deduplicates evidence using EmbeddingService.
196
  """
197
+ # ... implementation ...
 
 
 
 
 
 
 
198
  return {
199
+ "evidence_ids": new_ids,
200
+ "messages": [AIMessage(content=message)],
201
  }
202
 
203
 
204
+ async def judge_node(
205
+ state: ResearchState, embedding_service: EmbeddingService | None = None
206
+ ) -> dict[str, Any]:
207
  """Evaluate evidence and update hypothesis confidence.
208
 
209
+ Uses pydantic_ai Agent to generate structured HypothesisAssessment.
210
  """
211
+ # ... implementation ...
 
212
  return {
213
+ "hypotheses": new_hypotheses,
214
+ "messages": [AIMessage(content=f"Judge: Generated {len(new_hypotheses)} hypotheses.")],
215
+ "next_step": "resolve",
216
  }
217
 
218
 
219
+ async def resolve_node(
220
+ state: ResearchState, embedding_service: EmbeddingService | None = None
221
+ ) -> dict[str, Any]:
222
+ """Handle open conflicts."""
223
+ # ... implementation ...
224
+ return {"messages": messages}
 
 
 
 
 
 
225
 
 
 
226
 
227
+ async def synthesize_node(
228
+ state: ResearchState, embedding_service: EmbeddingService | None = None
229
+ ) -> dict[str, Any]:
230
+ """Generate final research report."""
231
+ # ... implementation ...
232
+ return {"messages": [AIMessage(content=report_markdown)], "next_step": "finish"}
 
 
233
 
234
 
235
+ async def supervisor_node(
236
+ state: ResearchState, llm: BaseChatModel | None = None
237
+ ) -> dict[str, Any]:
238
+ """Route to next node based on state using robust Pydantic parsing.
239
 
240
  This is the "brain" - uses LLM to decide next action
241
+ based on STRUCTURED STATE.
242
  """
243
+ # ... implementation ...
244
+ return {
245
+ "next_step": decision.next_step,
246
+ "iteration_count": state["iteration_count"] + 1,
247
+ "messages": [AIMessage(content=f"Supervisor: {decision.reasoning}")],
248
+ }
 
249
  ```
250
 
251
  ### 4.3 Graph Definition
 
254
 
255
  ```python
256
  """LangGraph workflow definition."""
257
+ from functools import partial
258
  from langgraph.graph import StateGraph, END
259
+ from langgraph.graph.state import CompiledStateGraph
260
 
261
  from src.agents.graph.state import ResearchState
262
+ from src.services.embeddings import EmbeddingService
263
+ # ... imports ...
 
 
 
 
 
264
 
265
 
266
+ def create_research_graph(
267
+ llm=None,
268
+ checkpointer=None,
269
+ embedding_service: EmbeddingService | None = None,
270
+ ) -> CompiledStateGraph:
271
  """Build the research state graph.
272
 
273
  Args:
274
+ llm: Supervisor LLM
275
+ checkpointer: Optional persistence layer
276
+ embedding_service: Service for evidence storage
277
  """
278
  graph = StateGraph(ResearchState)
279
 
280
+ # Bind dependencies using partial
281
+ bound_supervisor = partial(supervisor_node, llm=llm) if llm else supervisor_node
282
+ bound_search = partial(search_node, embedding_service=embedding_service)
283
+ # ... binding other nodes ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ # Add nodes
286
+ graph.add_node("supervisor", bound_supervisor)
287
+ graph.add_node("search", bound_search)
288
+ # ...
289
 
290
+ # ... edges ...
291
  ```
292
 
293
  ### 4.4 Orchestrator Integration
docs/specs/SPEC_08_INTEGRATE_MEMORY_LAYER.md CHANGED
@@ -54,34 +54,29 @@ Extract the memory logic from LangGraph nodes into a standalone service.
54
  ```python
55
  """Shared research memory layer for all orchestration modes."""
56
 
57
- from dataclasses import dataclass, field
58
  from typing import Literal
59
 
60
  from src.agents.graph.state import Conflict, Hypothesis
61
  from src.services.embeddings import EmbeddingService
62
- from src.utils.models import Evidence
63
 
64
 
65
- @dataclass
66
  class ResearchMemory:
67
  """Shared cognitive state for research workflows.
68
 
69
  This is the memory layer that ALL modes use.
70
- Built from SPEC_07, now extracted for integration.
71
  """
72
 
73
- query: str
74
- hypotheses: list[Hypothesis] = field(default_factory=list)
75
- conflicts: list[Conflict] = field(default_factory=list)
76
- evidence_ids: list[str] = field(default_factory=list)
77
- iteration_count: int = 0
78
-
79
- # Injected services
80
- _embedding_service: EmbeddingService | None = None
81
-
82
- def __post_init__(self):
83
- if self._embedding_service is None:
84
- self._embedding_service = EmbeddingService()
85
 
86
  async def store_evidence(self, evidence: list[Evidence]) -> list[str]:
87
  """Store evidence and return new IDs (deduped)."""
@@ -113,7 +108,34 @@ class ResearchMemory:
113
  """Retrieve relevant evidence for current query."""
114
  if not self._embedding_service:
115
  return []
116
- return await self._embedding_service.search_similar(self.query, n_results=n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  def add_hypothesis(self, hypothesis: Hypothesis) -> None:
119
  """Add a hypothesis to tracking."""
 
54
  ```python
55
  """Shared research memory layer for all orchestration modes."""
56
 
 
57
  from typing import Literal
58
 
59
  from src.agents.graph.state import Conflict, Hypothesis
60
  from src.services.embeddings import EmbeddingService
61
+ from src.utils.models import Citation, Evidence
62
 
63
 
 
64
  class ResearchMemory:
65
  """Shared cognitive state for research workflows.
66
 
67
  This is the memory layer that ALL modes use.
68
+ It mimics the LangGraph state management but for manual orchestration.
69
  """
70
 
71
+ def __init__(self, query: str, embedding_service: EmbeddingService | None = None):
72
+ self.query = query
73
+ self.hypotheses: list[Hypothesis] = []
74
+ self.conflicts: list[Conflict] = []
75
+ self.evidence_ids: list[str] = []
76
+ self.iteration_count: int = 0
77
+
78
+ # Injected service
79
+ self._embedding_service = embedding_service or EmbeddingService()
 
 
 
80
 
81
  async def store_evidence(self, evidence: list[Evidence]) -> list[str]:
82
  """Store evidence and return new IDs (deduped)."""
 
108
  """Retrieve relevant evidence for current query."""
109
  if not self._embedding_service:
110
  return []
111
+
112
+ results = await self._embedding_service.search_similar(self.query, n_results=n)
113
+ evidence_list = []
114
+
115
+ for r in results:
116
+ meta = r.get("metadata", {})
117
+ authors_str = meta.get("authors", "")
118
+ authors = authors_str.split(",") if authors_str else []
119
+
120
+ # Reconstruct Evidence object
121
+ # Note: SourceName validation might be needed, defaulting to 'web' or similar if unknown
122
+ source_raw = meta.get("source", "web")
123
+
124
+ citation = Citation(
125
+ source=source_raw, # type: ignore
126
+ title=meta.get("title", "Unknown"),
127
+ url=meta.get("url", r["id"]),
128
+ date=meta.get("date", "Unknown"),
129
+ authors=authors
130
+ )
131
+
132
+ evidence_list.append(Evidence(
133
+ content=r["content"],
134
+ citation=citation,
135
+ relevance=1.0 - r.get("distance", 0.5) # Approx conversion
136
+ ))
137
+
138
+ return evidence_list
139
 
140
  def add_hypothesis(self, hypothesis: Hypothesis) -> None:
141
  """Add a hypothesis to tracking."""
pyproject.toml CHANGED
@@ -26,11 +26,14 @@ dependencies = [
26
  "requests>=2.32.5", # ClinicalTrials.gov (httpx blocked by WAF)
27
  "limits>=3.0", # Rate limiting
28
  "duckduckgo-search>=5.0", # Web search
29
- "langgraph>=0.2.50",
30
- "langchain>=0.3.9",
31
- "langchain-core>=0.3.21",
32
- "langchain-huggingface>=0.1.2",
33
- "langgraph-checkpoint-sqlite>=2.0.0",
 
 
 
34
  ]
35
 
36
  [project.optional-dependencies]
 
26
  "requests>=2.32.5", # ClinicalTrials.gov (httpx blocked by WAF)
27
  "limits>=3.0", # Rate limiting
28
  "duckduckgo-search>=5.0", # Web search
29
+ # LangGraph deps - upper bounds prevent breaking changes from major versions
30
+ "langgraph>=0.2.50,<1.0",
31
+ "langchain>=0.3.9,<1.0",
32
+ "langchain-core>=0.3.21,<1.0",
33
+ "langchain-huggingface>=0.1.2,<1.0",
34
+ "langgraph-checkpoint-sqlite>=3.0.0,<4.0", # 3.0.0 required for GHSA-wwqv-p2pp-99h5 fix
35
+ # Security: Pin urllib3 to fix GHSA-48p4-8xcf-vxj5 and GHSA-pq67-6m6q-mj2v
36
+ "urllib3>=2.5.0",
37
  ]
38
 
39
  [project.optional-dependencies]
src/agents/graph/nodes.py CHANGED
@@ -43,15 +43,35 @@ def _convert_hypothesis_to_mechanism(h: Hypothesis) -> MechanismHypothesis:
43
  We parse this back into structured MechanismHypothesis fields.
44
  """
45
  # Parse statement format: "drug -> target -> pathway -> effect"
46
- parts = h.statement.split(" -> ")
47
- if len(parts) >= 4:
48
- drug, target, pathway, effect = parts[0], parts[1], parts[2], parts[3]
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  else:
50
- # Fallback if format is unexpected
51
- drug = h.id
 
 
 
 
 
 
 
52
  target = "Unknown"
53
  pathway = "Unknown"
54
- effect = h.statement
55
 
56
  return MechanismHypothesis(
57
  drug=drug,
 
43
  We parse this back into structured MechanismHypothesis fields.
44
  """
45
  # Parse statement format: "drug -> target -> pathway -> effect"
46
+ # Handle both " -> " (standard) and "->" (compact) separators
47
+ separator = " -> " if " -> " in h.statement else "->"
48
+ parts = [p.strip() for p in h.statement.split(separator)]
49
+
50
+ # Validate: exactly 4 non-empty parts
51
+ if len(parts) == 4 and all(parts):
52
+ drug, target, pathway, effect = parts
53
+ elif len(parts) > 4 and all(parts[:4]):
54
+ # More than 4 parts: join extras into effect
55
+ drug, target, pathway = parts[0], parts[1], parts[2]
56
+ effect = f"{separator}".join(parts[3:])
57
+ logger.debug(
58
+ "Hypothesis has extra parts, joined into effect",
59
+ hypothesis_id=h.id,
60
+ parts_count=len(parts),
61
+ )
62
  else:
63
+ # Log parsing failure for debugging
64
+ logger.warning(
65
+ "Failed to parse hypothesis statement format",
66
+ hypothesis_id=h.id,
67
+ statement=h.statement[:100], # Truncate for log safety
68
+ parts_count=len(parts),
69
+ )
70
+ # Use meaningful fallback values
71
+ drug = "Unknown"
72
  target = "Unknown"
73
  pathway = "Unknown"
74
+ effect = h.statement.strip() if h.statement else "Unknown effect"
75
 
76
  return MechanismHypothesis(
77
  drug=drug,
src/agents/graph/workflow.py CHANGED
@@ -4,6 +4,7 @@ from functools import partial
4
  from typing import Any
5
 
6
  from langchain_core.language_models.chat_models import BaseChatModel
 
7
  from langgraph.graph import END, StateGraph
8
  from langgraph.graph.state import CompiledStateGraph
9
 
@@ -20,9 +21,9 @@ from src.services.embeddings import EmbeddingService
20
 
21
  def create_research_graph(
22
  llm: BaseChatModel | None = None,
23
- checkpointer: Any = None,
24
  embedding_service: EmbeddingService | None = None,
25
- ) -> CompiledStateGraph: # type: ignore
26
  """Build the research state graph.
27
 
28
  Args:
 
4
  from typing import Any
5
 
6
  from langchain_core.language_models.chat_models import BaseChatModel
7
+ from langgraph.checkpoint.base import BaseCheckpointSaver
8
  from langgraph.graph import END, StateGraph
9
  from langgraph.graph.state import CompiledStateGraph
10
 
 
21
 
22
  def create_research_graph(
23
  llm: BaseChatModel | None = None,
24
+ checkpointer: "BaseCheckpointSaver[Any]" | None = None, # Generic type from langgraph
25
  embedding_service: EmbeddingService | None = None,
26
+ ) -> "CompiledStateGraph[Any]": # type: ignore[type-arg]
27
  """Build the research state graph.
28
 
29
  Args:
src/agents/retrieval_agent.py CHANGED
@@ -32,9 +32,8 @@ async def search_web(query: str, max_results: int = 10) -> str:
32
  logger.info("Web search returned no results", query=query)
33
  return f"No web results found for: {query}"
34
 
35
- # Update state
36
- # We add *all* found results to state
37
- new_count = state.add_evidence(results.evidence)
38
  logger.info(
39
  "Web search complete",
40
  query=query,
@@ -42,11 +41,6 @@ async def search_web(query: str, max_results: int = 10) -> str:
42
  new_evidence=new_count,
43
  )
44
 
45
- # Use embedding service for deduplication/indexing if available
46
- if state.embedding_service:
47
- # This method also adds to vector DB as a side effect for unique items
48
- await state.embedding_service.deduplicate(results.evidence)
49
-
50
  output = [f"Found {len(results.evidence)} web results ({new_count} new stored):\n"]
51
  for i, r in enumerate(results.evidence[:max_results], 1):
52
  output.append(f"{i}. **{r.citation.title}**")
 
32
  logger.info("Web search returned no results", query=query)
33
  return f"No web results found for: {query}"
34
 
35
+ # Store evidence with deduplication and embedding (all handled by memory layer)
36
+ new_count = await state.add_evidence(results.evidence)
 
37
  logger.info(
38
  "Web search complete",
39
  query=query,
 
41
  new_evidence=new_count,
42
  )
43
 
 
 
 
 
 
44
  output = [f"Found {len(results.evidence)} web results ({new_count} new stored):\n"]
45
  for i, r in enumerate(results.evidence[:max_results], 1):
46
  output.append(f"{i}. **{r.citation.title}**")
src/agents/state.py CHANGED
@@ -5,78 +5,70 @@ searching simultaneously via Gradio).
5
  """
6
 
7
  from contextvars import ContextVar
8
- from typing import TYPE_CHECKING, Any
9
 
10
- from pydantic import BaseModel, Field
11
 
12
- from src.utils.models import Citation, Evidence
13
 
14
  if TYPE_CHECKING:
15
  from src.services.embeddings import EmbeddingService
 
16
 
17
 
18
  class MagenticState(BaseModel):
19
  """Mutable state for a Magentic workflow session."""
20
 
21
- evidence: list[Evidence] = Field(default_factory=list)
22
- # Type as Any to avoid circular imports/runtime resolution issues
23
- # The actual object injected will be an EmbeddingService instance
24
- embedding_service: Any = None
25
 
26
  model_config = {"arbitrary_types_allowed": True}
27
 
28
- def add_evidence(self, new_evidence: list[Evidence]) -> int:
29
- """Add new evidence, deduplicating by URL.
 
 
 
 
 
 
 
 
 
 
30
 
31
  Returns:
32
- Number of *new* items added.
33
  """
34
- existing_urls = {e.citation.url for e in self.evidence}
35
- count = 0
36
- for item in new_evidence:
37
- if item.citation.url not in existing_urls:
38
- self.evidence.append(item)
39
- existing_urls.add(item.citation.url)
40
- count += 1
41
- return count
42
-
43
- async def search_related(self, query: str, n_results: int = 5) -> list[Evidence]:
44
- """Search for semantically related evidence using the embedding service."""
45
- if not self.embedding_service:
46
- return []
47
-
48
- results = await self.embedding_service.search_similar(query, n_results=n_results)
49
-
50
- # Convert dict results back to Evidence objects
51
- evidence_list = []
52
- for item in results:
53
- meta = item.get("metadata", {})
54
- authors_str = meta.get("authors", "")
55
- authors = [a.strip() for a in authors_str.split(",") if a.strip()]
56
-
57
- ev = Evidence(
58
- content=item["content"],
59
- citation=Citation(
60
- title=meta.get("title", "Related Evidence"),
61
- url=item["id"],
62
- source="pubmed", # Defaulting to pubmed if unknown
63
- date=meta.get("date", "n.d."),
64
- authors=authors,
65
- ),
66
- relevance=max(0.0, 1.0 - item.get("distance", 0.5)),
67
- )
68
- evidence_list.append(ev)
69
-
70
- return evidence_list
71
 
72
 
73
  # The ContextVar holds the MagenticState for the current execution context
74
  _magentic_state_var: ContextVar[MagenticState | None] = ContextVar("magentic_state", default=None)
75
 
76
 
77
- def init_magentic_state(embedding_service: "EmbeddingService | None" = None) -> MagenticState:
 
 
78
  """Initialize a new state for the current context."""
79
- state = MagenticState(embedding_service=embedding_service)
 
80
  _magentic_state_var.set(state)
81
  return state
82
 
@@ -85,6 +77,5 @@ def get_magentic_state() -> MagenticState:
85
  """Get the current state. Raises RuntimeError if not initialized."""
86
  state = _magentic_state_var.get()
87
  if state is None:
88
- # Auto-initialize if missing (e.g. during tests or simple scripts)
89
- return init_magentic_state()
90
  return state
 
5
  """
6
 
7
  from contextvars import ContextVar
8
+ from typing import TYPE_CHECKING, Any, cast
9
 
10
+ from pydantic import BaseModel
11
 
12
+ from src.services.research_memory import ResearchMemory
13
 
14
  if TYPE_CHECKING:
15
  from src.services.embeddings import EmbeddingService
16
+ from src.utils.models import Evidence
17
 
18
 
19
  class MagenticState(BaseModel):
20
  """Mutable state for a Magentic workflow session."""
21
 
22
+ # We wrap ResearchMemory. Type as Any to avoid pydantic validation issues with complex objects
23
+ memory: Any = None # Instance of ResearchMemory
 
 
24
 
25
  model_config = {"arbitrary_types_allowed": True}
26
 
27
+ # --- Proxy methods for backwards compatibility with retrieval_agent.py ---
28
+
29
+ async def add_evidence(self, evidence: list["Evidence"]) -> int:
30
+ """Add evidence to memory with deduplication and embedding storage.
31
+
32
+ This method delegates to ResearchMemory.store_evidence() which:
33
+ 1. Performs semantic deduplication (threshold 0.9)
34
+ 2. Stores unique evidence in the vector store
35
+ 3. Caches evidence for retrieval
36
+
37
+ Args:
38
+ evidence: List of Evidence objects to store.
39
 
40
  Returns:
41
+ Number of new (non-duplicate) evidence items stored.
42
  """
43
+ if self.memory is None:
44
+ return 0
45
+
46
+ memory: ResearchMemory = self.memory
47
+ initial_count = len(memory.evidence_ids)
48
+ await memory.store_evidence(evidence)
49
+ return len(memory.evidence_ids) - initial_count
50
+
51
+ @property
52
+ def embedding_service(self) -> "EmbeddingService | None":
53
+ """Get the embedding service from memory."""
54
+ if self.memory is None:
55
+ return None
56
+ # Cast needed because memory is typed as Any to avoid Pydantic issues
57
+ from src.services.embeddings import EmbeddingService as EmbeddingSvc
58
+
59
+ return cast(EmbeddingSvc | None, self.memory._embedding_service)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
 
62
  # The ContextVar holds the MagenticState for the current execution context
63
  _magentic_state_var: ContextVar[MagenticState | None] = ContextVar("magentic_state", default=None)
64
 
65
 
66
+ def init_magentic_state(
67
+ query: str, embedding_service: "EmbeddingService | None" = None
68
+ ) -> MagenticState:
69
  """Initialize a new state for the current context."""
70
+ memory = ResearchMemory(query=query, embedding_service=embedding_service)
71
+ state = MagenticState(memory=memory)
72
  _magentic_state_var.set(state)
73
  return state
74
 
 
77
  """Get the current state. Raises RuntimeError if not initialized."""
78
  state = _magentic_state_var.get()
79
  if state is None:
80
+ raise RuntimeError("MagenticState not initialized. Call init_magentic_state() first.")
 
81
  return state
src/agents/tools.py CHANGED
@@ -38,27 +38,29 @@ async def search_pubmed(query: str, max_results: int = 10) -> str:
38
  if not results:
39
  return f"No PubMed results found for: {query}"
40
 
41
- # 2. Semantic Deduplication & Expansion (The "Digital Twin" Brain)
42
- display_results = results
43
- if state.embedding_service:
44
- # Deduplicate against what we just found vs what's in the DB
45
- unique_results = await state.embedding_service.deduplicate(results)
46
 
47
- # Search for related context in the vector DB (previous searches)
48
- related = await state.search_related(query, n_results=3)
 
49
 
50
- # Combine unique new results + relevant historical results
51
- display_results = unique_results + related
52
 
53
- # 3. Update State (Persist for ReportAgent)
54
- # We add *all* found results to state, not just the displayed ones
55
- new_count = state.add_evidence(results)
 
 
56
 
57
  # 4. Format Output for LLM
58
  output = [f"Found {len(results)} results ({new_count} new stored):\n"]
59
 
60
  # Limit display to avoid context window overflow, but state has everything
61
- limit = min(len(display_results), max_results)
62
 
63
  for i, r in enumerate(display_results[:limit], 1):
64
  title = r.citation.title
@@ -96,7 +98,8 @@ async def search_clinical_trials(query: str, max_results: int = 10) -> str:
96
  return f"No clinical trials found for: {query}"
97
 
98
  # Update state
99
- new_count = state.add_evidence(results)
 
100
 
101
  output = [f"Found {len(results)} clinical trials ({new_count} new stored):\n"]
102
  for i, r in enumerate(results[:max_results], 1):
@@ -135,7 +138,8 @@ async def search_preprints(query: str, max_results: int = 10) -> str:
135
  return f"No papers found for: {query}"
136
 
137
  # Update state
138
- new_count = state.add_evidence(results)
 
139
 
140
  output = [f"Found {len(results)} papers ({new_count} new stored):\n"]
141
  for i, r in enumerate(results[:max_results], 1):
@@ -164,11 +168,13 @@ async def get_bibliography() -> str:
164
  Formatted bibliography string.
165
  """
166
  state = get_magentic_state()
167
- if not state.evidence:
 
 
168
  return "No evidence collected."
169
 
170
  output = ["## References"]
171
- for i, ev in enumerate(state.evidence, 1):
172
  output.append(f"{i}. {ev.citation.formatted}")
173
  output.append(f" URL: {ev.citation.url}")
174
 
 
38
  if not results:
39
  return f"No PubMed results found for: {query}"
40
 
41
+ # 2. Store in Memory (handles dedup and persistence)
42
+ # ResearchMemory handles semantic deduplication and persistence
43
+ new_ids = await state.memory.store_evidence(results)
44
+ new_count = len(new_ids)
 
45
 
46
+ # 3. Context Expansion (The "Digital Twin" Brain)
47
+ # Combine what we just found with what we already know is relevant
48
+ display_results = list(results)
49
 
50
+ # Search for related context in the memory (previous searches)
51
+ related = await state.memory.get_relevant_evidence(n=3)
52
 
53
+ # Add related items if they aren't already in the results
54
+ current_urls = {r.citation.url for r in display_results}
55
+ for item in related:
56
+ if item.citation.url not in current_urls:
57
+ display_results.append(item)
58
 
59
  # 4. Format Output for LLM
60
  output = [f"Found {len(results)} results ({new_count} new stored):\n"]
61
 
62
  # Limit display to avoid context window overflow, but state has everything
63
+ limit = min(len(display_results), max_results + 3)
64
 
65
  for i, r in enumerate(display_results[:limit], 1):
66
  title = r.citation.title
 
98
  return f"No clinical trials found for: {query}"
99
 
100
  # Update state
101
+ new_ids = await state.memory.store_evidence(results)
102
+ new_count = len(new_ids)
103
 
104
  output = [f"Found {len(results)} clinical trials ({new_count} new stored):\n"]
105
  for i, r in enumerate(results[:max_results], 1):
 
138
  return f"No papers found for: {query}"
139
 
140
  # Update state
141
+ new_ids = await state.memory.store_evidence(results)
142
+ new_count = len(new_ids)
143
 
144
  output = [f"Found {len(results)} papers ({new_count} new stored):\n"]
145
  for i, r in enumerate(results[:max_results], 1):
 
168
  Formatted bibliography string.
169
  """
170
  state = get_magentic_state()
171
+ all_evidence = state.memory.get_all_evidence()
172
+
173
+ if not all_evidence:
174
  return "No evidence collected."
175
 
176
  output = ["## References"]
177
+ for i, ev in enumerate(all_evidence, 1):
178
  output.append(f"{i}. {ev.citation.formatted}")
179
  output.append(f" URL: {ev.citation.url}")
180
 
src/app.py CHANGED
@@ -252,7 +252,7 @@ def create_demo() -> tuple[gr.ChatInterface, gr.Accordion]:
252
  ],
253
  [
254
  "Clinical trials for erectile dysfunction alternatives to PDE5 inhibitors?",
255
- "god",
256
  None,
257
  None,
258
  ],
@@ -266,10 +266,10 @@ def create_demo() -> tuple[gr.ChatInterface, gr.Accordion]:
266
  additional_inputs_accordion=additional_inputs_accordion,
267
  additional_inputs=[
268
  gr.Radio(
269
- choices=["simple", "advanced", "god"],
270
  value="simple",
271
  label="Orchestrator Mode",
272
- info="⚡ Simple: Free/Any | 🔬 Advanced: OpenAI | 🧠 God: Graph + Llama 3.1 (Exp)",
273
  ),
274
  gr.Textbox(
275
  label="🔑 API Key (Optional)",
 
252
  ],
253
  [
254
  "Clinical trials for erectile dysfunction alternatives to PDE5 inhibitors?",
255
+ "advanced",
256
  None,
257
  None,
258
  ],
 
266
  additional_inputs_accordion=additional_inputs_accordion,
267
  additional_inputs=[
268
  gr.Radio(
269
+ choices=["simple", "advanced"],
270
  value="simple",
271
  label="Orchestrator Mode",
272
+ info="⚡ Simple: Free/Any | 🔬 Advanced: OpenAI (Deep Research)",
273
  ),
274
  gr.Textbox(
275
  label="🔑 API Key (Optional)",
src/orchestrators/advanced.py CHANGED
@@ -152,7 +152,7 @@ class AdvancedOrchestrator(OrchestratorProtocol):
152
 
153
  # Initialize context state
154
  embedding_service = self._init_embedding_service()
155
- init_magentic_state(embedding_service)
156
 
157
  workflow = self._build_workflow()
158
 
@@ -355,6 +355,7 @@ def _create_deprecated_alias() -> type["AdvancedOrchestrator"]:
355
  """
356
 
357
  def __init__(self, *args: Any, **kwargs: Any) -> None:
 
358
  warnings.warn(
359
  "MagenticOrchestrator is deprecated, use AdvancedOrchestrator instead. "
360
  "The name 'magentic' was confusing with the 'magentic' PyPI package.",
 
152
 
153
  # Initialize context state
154
  embedding_service = self._init_embedding_service()
155
+ init_magentic_state(query, embedding_service)
156
 
157
  workflow = self._build_workflow()
158
 
 
355
  """
356
 
357
  def __init__(self, *args: Any, **kwargs: Any) -> None:
358
+ """Initialize deprecated MagenticOrchestrator (use AdvancedOrchestrator)."""
359
  warnings.warn(
360
  "MagenticOrchestrator is deprecated, use AdvancedOrchestrator instead. "
361
  "The name 'magentic' was confusing with the 'magentic' PyPI package.",
src/orchestrators/factory.py CHANGED
@@ -52,33 +52,11 @@ def _get_advanced_orchestrator_class() -> type["AdvancedOrchestrator"]:
52
  ) from e
53
 
54
 
55
- def _get_langgraph_orchestrator_class() -> type["OrchestratorProtocol"]:
56
- """Import LangGraphOrchestrator lazily.
57
-
58
- Returns:
59
- The LangGraphOrchestrator class
60
-
61
- Raises:
62
- ValueError: If langgraph dependencies are missing
63
- """
64
- try:
65
- from src.orchestrators.langgraph_orchestrator import LangGraphOrchestrator
66
-
67
- return LangGraphOrchestrator # type: ignore
68
- except ImportError as e:
69
- logger.error("Failed to import LangGraphOrchestrator", error=str(e))
70
- raise ValueError(
71
- "LangGraph mode requires langgraph and langchain-huggingface. "
72
- "Install with: uv add langgraph langchain-huggingface"
73
- ) from e
74
-
75
-
76
  def create_orchestrator(
77
  search_handler: SearchHandlerProtocol | None = None,
78
  judge_handler: JudgeHandlerProtocol | None = None,
79
  config: OrchestratorConfig | None = None,
80
- mode: Literal["simple", "magentic", "advanced", "hierarchical", "langgraph", "god"]
81
- | None = None,
82
  api_key: str | None = None,
83
  ) -> OrchestratorProtocol:
84
  """
@@ -92,9 +70,8 @@ def create_orchestrator(
92
  search_handler: The search handler (required for simple mode)
93
  judge_handler: The judge handler (required for simple mode)
94
  config: Optional configuration (max_iterations, timeouts, etc.)
95
- mode: "simple", "magentic", "advanced", "hierarchical", "langgraph" or "god"
96
  Note: "magentic" is an alias for "advanced" (kept for backwards compatibility)
97
- Note: "god" is an alias for "langgraph"
98
  api_key: Optional API key for advanced mode (OpenAI)
99
 
100
  Returns:
@@ -108,15 +85,6 @@ def create_orchestrator(
108
  effective_mode = _determine_mode(mode, api_key)
109
  logger.info("Creating orchestrator", mode=effective_mode)
110
 
111
- if effective_mode == "langgraph":
112
- orchestrator_cls = _get_langgraph_orchestrator_class()
113
- # Checkpoint path for dev persistence
114
- checkpoint_path = "checkpoints.sqlite"
115
- return orchestrator_cls( # type: ignore
116
- max_iterations=effective_config.max_iterations,
117
- checkpoint_path=checkpoint_path,
118
- )
119
-
120
  if effective_mode == "advanced":
121
  orchestrator_cls = _get_advanced_orchestrator_class()
122
  return orchestrator_cls(
@@ -152,11 +120,9 @@ def _determine_mode(explicit_mode: str | None, api_key: str | None) -> str:
152
  api_key: API key provided by caller
153
 
154
  Returns:
155
- Effective mode string: "simple", "advanced", "hierarchical", or "langgraph"
156
  """
157
  if explicit_mode:
158
- if explicit_mode in ("langgraph", "god"):
159
- return "langgraph"
160
  if explicit_mode in ("magentic", "advanced"):
161
  return "advanced"
162
  if explicit_mode == "hierarchical":
 
52
  ) from e
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def create_orchestrator(
56
  search_handler: SearchHandlerProtocol | None = None,
57
  judge_handler: JudgeHandlerProtocol | None = None,
58
  config: OrchestratorConfig | None = None,
59
+ mode: Literal["simple", "magentic", "advanced", "hierarchical"] | None = None,
 
60
  api_key: str | None = None,
61
  ) -> OrchestratorProtocol:
62
  """
 
70
  search_handler: The search handler (required for simple mode)
71
  judge_handler: The judge handler (required for simple mode)
72
  config: Optional configuration (max_iterations, timeouts, etc.)
73
+ mode: "simple", "magentic", "advanced", or "hierarchical"
74
  Note: "magentic" is an alias for "advanced" (kept for backwards compatibility)
 
75
  api_key: Optional API key for advanced mode (OpenAI)
76
 
77
  Returns:
 
85
  effective_mode = _determine_mode(mode, api_key)
86
  logger.info("Creating orchestrator", mode=effective_mode)
87
 
 
 
 
 
 
 
 
 
 
88
  if effective_mode == "advanced":
89
  orchestrator_cls = _get_advanced_orchestrator_class()
90
  return orchestrator_cls(
 
120
  api_key: API key provided by caller
121
 
122
  Returns:
123
+ Effective mode string: "simple", "advanced", or "hierarchical"
124
  """
125
  if explicit_mode:
 
 
126
  if explicit_mode in ("magentic", "advanced"):
127
  return "advanced"
128
  if explicit_mode == "hierarchical":
src/orchestrators/hierarchical.py CHANGED
@@ -98,7 +98,7 @@ class HierarchicalOrchestrator(OrchestratorProtocol):
98
  logger.info("Starting hierarchical orchestrator", query=query)
99
 
100
  service = get_embedding_service_if_available()
101
- init_magentic_state(service)
102
 
103
  yield AgentEvent(type="started", message=f"Starting research: {query}")
104
 
 
98
  logger.info("Starting hierarchical orchestrator", query=query)
99
 
100
  service = get_embedding_service_if_available()
101
+ init_magentic_state(query, service)
102
 
103
  yield AgentEvent(type="started", message=f"Starting research: {query}")
104
 
src/orchestrators/langgraph_orchestrator.py CHANGED
@@ -1,6 +1,12 @@
1
- """LangGraph-based orchestrator implementation."""
 
 
 
 
 
2
 
3
  import os
 
4
  from collections.abc import AsyncGenerator, AsyncIterator
5
  from typing import Any, Literal
6
 
@@ -16,7 +22,11 @@ from src.utils.models import AgentEvent
16
 
17
 
18
  class LangGraphOrchestrator(OrchestratorProtocol):
19
- """State-driven research orchestrator using LangGraph."""
 
 
 
 
20
 
21
  def __init__(
22
  self,
@@ -34,7 +44,7 @@ class LangGraphOrchestrator(OrchestratorProtocol):
34
  api_key = settings.hf_token
35
  if not api_key:
36
  raise ValueError(
37
- "HF_TOKEN (Hugging Face API Token) is required for God Mode to use Llama 3.1."
38
  )
39
 
40
  self.llm_endpoint = HuggingFaceEndpoint( # type: ignore
@@ -53,8 +63,10 @@ class LangGraphOrchestrator(OrchestratorProtocol):
53
 
54
  # Setup checkpointer (SQLite for dev)
55
  if self._checkpoint_path:
56
- # Ensure directory exists
57
- os.makedirs(os.path.dirname(self._checkpoint_path), exist_ok=True)
 
 
58
  saver = AsyncSqliteSaver.from_conn_string(self._checkpoint_path)
59
  else:
60
  saver = None
@@ -91,10 +103,11 @@ class LangGraphOrchestrator(OrchestratorProtocol):
91
  "max_iterations": self._max_iterations,
92
  }
93
 
94
- yield AgentEvent(type="started", message=f"Starting 'God Mode' research: {query}")
95
 
96
- # Config for persistence (thread_id required if checkpointer used)
97
- config = {"configurable": {"thread_id": "1"}} if saver else {}
 
98
 
99
  # Stream events
100
  # We use astream to get updates from the graph
 
1
+ """LangGraph-based orchestrator implementation.
2
+
3
+ NOTE: This orchestrator is deprecated in favor of the shared memory layer
4
+ integrated into Simple and Advanced modes (SPEC-08). It remains as a reference
5
+ implementation for LangGraph patterns.
6
+ """
7
 
8
  import os
9
+ import uuid
10
  from collections.abc import AsyncGenerator, AsyncIterator
11
  from typing import Any, Literal
12
 
 
22
 
23
 
24
  class LangGraphOrchestrator(OrchestratorProtocol):
25
+ """State-driven research orchestrator using LangGraph.
26
+
27
+ DEPRECATED: Memory features are now integrated into Simple and Advanced modes.
28
+ This class is kept for reference and potential future use.
29
+ """
30
 
31
  def __init__(
32
  self,
 
44
  api_key = settings.hf_token
45
  if not api_key:
46
  raise ValueError(
47
+ "HF_TOKEN (Hugging Face API Token) is required for LangGraph orchestrator."
48
  )
49
 
50
  self.llm_endpoint = HuggingFaceEndpoint( # type: ignore
 
63
 
64
  # Setup checkpointer (SQLite for dev)
65
  if self._checkpoint_path:
66
+ # Ensure directory exists (handle paths without directory component)
67
+ dir_name = os.path.dirname(self._checkpoint_path)
68
+ if dir_name:
69
+ os.makedirs(dir_name, exist_ok=True)
70
  saver = AsyncSqliteSaver.from_conn_string(self._checkpoint_path)
71
  else:
72
  saver = None
 
103
  "max_iterations": self._max_iterations,
104
  }
105
 
106
+ yield AgentEvent(type="started", message=f"Starting LangGraph research: {query}")
107
 
108
+ # Config for persistence (unique thread_id per run to avoid state conflicts)
109
+ thread_id = str(uuid.uuid4())
110
+ config = {"configurable": {"thread_id": thread_id}} if saver else {}
111
 
112
  # Stream events
113
  # We use astream to get updates from the graph
src/orchestrators/simple.py CHANGED
@@ -93,36 +93,6 @@ class Orchestrator:
93
  self._enable_analysis = False
94
  return self._analyzer
95
 
96
- def _get_embeddings(self) -> EmbeddingService | None:
97
- """Lazy initialization of EmbeddingService."""
98
- if self._embeddings is None and self._enable_embeddings:
99
- from src.utils.service_loader import get_embedding_service_if_available
100
-
101
- self._embeddings = get_embedding_service_if_available()
102
- if self._embeddings is None:
103
- self._enable_embeddings = False
104
- return self._embeddings
105
-
106
- async def _deduplicate_and_rank(self, evidence: list[Evidence], query: str) -> list[Evidence]:
107
- """Use embeddings to deduplicate and rank evidence by relevance."""
108
- embeddings = self._get_embeddings()
109
- if not embeddings or not evidence:
110
- return evidence
111
-
112
- try:
113
- # Deduplicate using semantic similarity
114
- unique_evidence: list[Evidence] = await embeddings.deduplicate(evidence, threshold=0.85)
115
-
116
- logger.info(
117
- "Deduplicated evidence",
118
- before=len(evidence),
119
- after=len(unique_evidence),
120
- )
121
- return unique_evidence
122
- except Exception as e:
123
- logger.warning("Deduplication failed, using original", error=str(e))
124
- return evidence
125
-
126
  async def _run_analysis_phase(
127
  self, query: str, evidence: list[Evidence], iteration: int
128
  ) -> AsyncGenerator[AgentEvent, None]:
@@ -237,6 +207,10 @@ class Orchestrator:
237
  Yields:
238
  AgentEvent objects for each step of the process
239
  """
 
 
 
 
240
  logger.info("Starting orchestrator", query=query)
241
 
242
  yield AgentEvent(
@@ -245,6 +219,9 @@ class Orchestrator:
245
  iteration=0,
246
  )
247
 
 
 
 
248
  all_evidence: list[Evidence] = []
249
  current_queries = [query]
250
  iteration = 0
@@ -282,15 +259,14 @@ class Orchestrator:
282
  # Should not happen with return_exceptions=True but safe fallback
283
  errors.append(f"Unknown result type for '{q}': {type(result)}")
284
 
285
- # Deduplicate evidence by URL (fast, basic)
286
- seen_urls = {e.citation.url for e in all_evidence}
287
- unique_new = [e for e in new_evidence if e.citation.url not in seen_urls]
 
288
 
289
- # BUG FIX: Only dedup NEW evidence, not all_evidence
290
- # Old evidence is already in the vector store - re-checking it
291
- # would mark items as duplicates of themselves (distance 0)
292
- if unique_new:
293
- unique_new = await self._deduplicate_and_rank(unique_new, query)
294
 
295
  all_evidence.extend(unique_new)
296
 
@@ -319,15 +295,35 @@ class Orchestrator:
319
  # === JUDGE PHASE ===
320
  yield AgentEvent(
321
  type="judging",
322
- message=f"Evaluating {len(all_evidence)} sources...",
323
  iteration=iteration,
324
  )
325
 
326
  try:
 
 
 
 
 
 
 
 
327
  assessment = await self.judge.assess(
328
- query, all_evidence, iteration, self.config.max_iterations
329
  )
330
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  yield AgentEvent(
332
  type="judge_complete",
333
  message=(
@@ -388,6 +384,7 @@ class Orchestrator:
388
  )
389
 
390
  # Generate final response
 
391
  final_response = self._generate_synthesis(query, all_evidence, assessment)
392
 
393
  yield AgentEvent(
 
93
  self._enable_analysis = False
94
  return self._analyzer
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  async def _run_analysis_phase(
97
  self, query: str, evidence: list[Evidence], iteration: int
98
  ) -> AsyncGenerator[AgentEvent, None]:
 
207
  Yields:
208
  AgentEvent objects for each step of the process
209
  """
210
+ # Import here to avoid circular deps if any
211
+ from src.agents.graph.state import Hypothesis
212
+ from src.services.research_memory import ResearchMemory
213
+
214
  logger.info("Starting orchestrator", query=query)
215
 
216
  yield AgentEvent(
 
219
  iteration=0,
220
  )
221
 
222
+ # Initialize Shared Memory
223
+ # We keep 'all_evidence' for local tracking/reporting, but use Memory for intelligence
224
+ memory = ResearchMemory(query=query)
225
  all_evidence: list[Evidence] = []
226
  current_queries = [query]
227
  iteration = 0
 
259
  # Should not happen with return_exceptions=True but safe fallback
260
  errors.append(f"Unknown result type for '{q}': {type(result)}")
261
 
262
+ # === MEMORY INTEGRATION: Store and Deduplicate ===
263
+ # ResearchMemory handles semantic deduplication and persistence
264
+ # It returns IDs of actual NEW evidence
265
+ new_ids = await memory.store_evidence(new_evidence)
266
 
267
+ # Filter new_evidence to only keep what was actually new (based on IDs)
268
+ # Note: This assumes IDs are URLs, which match Citation.url
269
+ unique_new = [e for e in new_evidence if e.citation.url in new_ids]
 
 
270
 
271
  all_evidence.extend(unique_new)
272
 
 
295
  # === JUDGE PHASE ===
296
  yield AgentEvent(
297
  type="judging",
298
+ message=f"Evaluating evidence (Memory: {len(memory.evidence_ids)} docs)...",
299
  iteration=iteration,
300
  )
301
 
302
  try:
303
+ # Retrieve RELEVANT evidence from memory for the judge
304
+ # This keeps the context window manageable and focused
305
+ judge_context = await memory.get_relevant_evidence(n=30)
306
+
307
+ # Fallback if memory is empty (shouldn't happen if search worked)
308
+ if not judge_context and all_evidence:
309
+ judge_context = all_evidence[-30:]
310
+
311
  assessment = await self.judge.assess(
312
+ query, judge_context, iteration, self.config.max_iterations
313
  )
314
 
315
+ # === MEMORY INTEGRATION: Track Hypotheses ===
316
+ # Convert loose strings to structured Hypotheses
317
+ for candidate in assessment.details.drug_candidates:
318
+ h = Hypothesis(
319
+ id=candidate.replace(" ", "_").lower(),
320
+ statement=f"{candidate} is a potential candidate for {query}",
321
+ status="proposed",
322
+ confidence=assessment.confidence,
323
+ reasoning=f" identified in iteration {iteration}",
324
+ )
325
+ memory.add_hypothesis(h)
326
+
327
  yield AgentEvent(
328
  type="judge_complete",
329
  message=(
 
384
  )
385
 
386
  # Generate final response
387
+ # Use all gathered evidence for the final report
388
  final_response = self._generate_synthesis(query, all_evidence, assessment)
389
 
390
  yield AgentEvent(
src/services/research_memory.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared research memory layer for all orchestration modes."""
2
+
3
+ from typing import Any
4
+
5
+ import structlog
6
+
7
+ from src.agents.graph.state import Conflict, Hypothesis
8
+ from src.services.embeddings import EmbeddingService
9
+ from src.utils.models import Citation, Evidence
10
+
11
+ logger = structlog.get_logger()
12
+
13
+
14
+ class ResearchMemory:
15
+ """Shared cognitive state for research workflows.
16
+
17
+ This is the memory layer that ALL modes use.
18
+ It mimics the LangGraph state management but for manual orchestration.
19
+ """
20
+
21
+ def __init__(self, query: str, embedding_service: EmbeddingService | None = None):
22
+ """Initialize ResearchMemory with a query and optional embedding service.
23
+
24
+ Args:
25
+ query: The research query to track evidence for.
26
+ embedding_service: Service for semantic search and deduplication.
27
+ Creates a new instance if not provided.
28
+ """
29
+ self.query = query
30
+ self.hypotheses: list[Hypothesis] = []
31
+ self.conflicts: list[Conflict] = []
32
+ self.evidence_ids: list[str] = []
33
+ self._evidence_cache: dict[str, Evidence] = {}
34
+ self.iteration_count: int = 0
35
+
36
+ # Injected service
37
+ self._embedding_service = embedding_service or EmbeddingService()
38
+
39
+ async def store_evidence(self, evidence: list[Evidence]) -> list[str]:
40
+ """Store evidence and return new IDs (deduped)."""
41
+ if not self._embedding_service:
42
+ return []
43
+
44
+ unique = await self._embedding_service.deduplicate(evidence)
45
+ new_ids = []
46
+
47
+ for ev in unique:
48
+ ev_id = ev.citation.url
49
+ await self._embedding_service.add_evidence(
50
+ evidence_id=ev_id,
51
+ content=ev.content,
52
+ metadata={
53
+ "source": ev.citation.source,
54
+ "title": ev.citation.title,
55
+ "date": ev.citation.date,
56
+ "authors": ",".join(ev.citation.authors or []),
57
+ "url": ev.citation.url,
58
+ },
59
+ )
60
+ new_ids.append(ev_id)
61
+ self._evidence_cache[ev_id] = ev
62
+
63
+ self.evidence_ids.extend(new_ids)
64
+ if new_ids:
65
+ logger.info("Stored new evidence", count=len(new_ids))
66
+ return new_ids
67
+
68
+ def get_all_evidence(self) -> list[Evidence]:
69
+ """Get all accumulated evidence objects."""
70
+ return list(self._evidence_cache.values())
71
+
72
+ async def get_relevant_evidence(self, n: int = 20) -> list[Evidence]:
73
+ """Retrieve relevant evidence for current query."""
74
+ if not self._embedding_service:
75
+ return []
76
+
77
+ results = await self._embedding_service.search_similar(self.query, n_results=n)
78
+ evidence_list = []
79
+
80
+ for r in results:
81
+ meta = r.get("metadata", {})
82
+ authors_str = meta.get("authors", "")
83
+ authors = authors_str.split(",") if authors_str else []
84
+
85
+ # Reconstruct Evidence object
86
+ source_raw = meta.get("source", "web")
87
+
88
+ # Basic validation/fallback for source
89
+ valid_sources = [
90
+ "pubmed",
91
+ "clinicaltrials",
92
+ "europepmc",
93
+ "preprint",
94
+ "openalex",
95
+ "web",
96
+ ]
97
+ source_name: Any = source_raw if source_raw in valid_sources else "web"
98
+
99
+ citation = Citation(
100
+ source=source_name,
101
+ title=meta.get("title", "Unknown"),
102
+ url=meta.get("url", r.get("id", "")),
103
+ date=meta.get("date", "Unknown"),
104
+ authors=authors,
105
+ )
106
+
107
+ evidence_list.append(
108
+ Evidence(
109
+ content=r.get("content", ""),
110
+ citation=citation,
111
+ relevance=1.0 - r.get("distance", 0.5), # Approx conversion
112
+ )
113
+ )
114
+
115
+ return evidence_list
116
+
117
+ def add_hypothesis(self, hypothesis: Hypothesis) -> None:
118
+ """Add a hypothesis to tracking."""
119
+ self.hypotheses.append(hypothesis)
120
+ logger.info("Added hypothesis", id=hypothesis.id, confidence=hypothesis.confidence)
121
+
122
+ def add_conflict(self, conflict: Conflict) -> None:
123
+ """Add a detected conflict."""
124
+ self.conflicts.append(conflict)
125
+ logger.info("Added conflict", id=conflict.id)
126
+
127
+ def get_open_conflicts(self) -> list[Conflict]:
128
+ """Get unresolved conflicts."""
129
+ return [c for c in self.conflicts if c.status == "open"]
130
+
131
+ def get_confirmed_hypotheses(self) -> list[Hypothesis]:
132
+ """Get high-confidence hypotheses."""
133
+ return [h for h in self.hypotheses if h.confidence > 0.8]
tests/unit/services/test_research_memory.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the shared ResearchMemory service."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+
7
+ from src.agents.graph.state import Conflict, Hypothesis
8
+ from src.services.research_memory import ResearchMemory
9
+ from src.utils.models import Citation, Evidence
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_embedding_service():
14
+ service = MagicMock()
15
+ service.deduplicate = AsyncMock()
16
+ service.add_evidence = AsyncMock()
17
+ service.search_similar = AsyncMock()
18
+ return service
19
+
20
+
21
+ @pytest.fixture
22
+ def memory(mock_embedding_service):
23
+ return ResearchMemory(query="test query", embedding_service=mock_embedding_service)
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_store_evidence(memory, mock_embedding_service):
28
+ # Setup
29
+ ev1 = Evidence(
30
+ content="content1",
31
+ citation=Citation(source="pubmed", title="t1", url="u1", date="2023", authors=["a1"]),
32
+ )
33
+ ev2 = Evidence(
34
+ content="content2",
35
+ citation=Citation(source="pubmed", title="t2", url="u2", date="2023", authors=["a2"]),
36
+ )
37
+
38
+ # deduplicate returns only ev1 (simulating ev2 is duplicate)
39
+ mock_embedding_service.deduplicate.return_value = [ev1]
40
+
41
+ # Execute
42
+ new_ids = await memory.store_evidence([ev1, ev2])
43
+
44
+ # Verify
45
+ assert new_ids == ["u1"]
46
+ assert memory.evidence_ids == ["u1"]
47
+
48
+ # deduplicate called with both
49
+ mock_embedding_service.deduplicate.assert_called_once_with([ev1, ev2])
50
+
51
+ # add_evidence called only for ev1
52
+ mock_embedding_service.add_evidence.assert_called_once()
53
+ args = mock_embedding_service.add_evidence.call_args[1]
54
+ assert args["evidence_id"] == "u1"
55
+ assert args["content"] == "content1"
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_get_relevant_evidence(memory, mock_embedding_service):
60
+ # Setup mock return from ChromaDB format
61
+ mock_embedding_service.search_similar.return_value = [
62
+ {
63
+ "id": "u1",
64
+ "content": "content1",
65
+ "metadata": {
66
+ "source": "pubmed",
67
+ "title": "t1",
68
+ "date": "2023",
69
+ "authors": "a1,a2",
70
+ "url": "u1",
71
+ },
72
+ "distance": 0.1,
73
+ }
74
+ ]
75
+
76
+ # Execute
77
+ results = await memory.get_relevant_evidence(n=5)
78
+
79
+ # Verify
80
+ assert len(results) == 1
81
+ ev = results[0]
82
+ assert isinstance(ev, Evidence)
83
+ assert ev.content == "content1"
84
+ assert ev.citation.title == "t1"
85
+ assert ev.citation.authors == ["a1", "a2"]
86
+ assert ev.relevance > 0.8 # 1.0 - 0.1 = 0.9
87
+
88
+
89
+ def test_hypothesis_tracking(memory):
90
+ h1 = Hypothesis(id="h1", statement="drug -> target", status="confirmed", confidence=0.9)
91
+ h2 = Hypothesis(id="h2", statement="drug -> unknown", status="proposed", confidence=0.5)
92
+
93
+ memory.add_hypothesis(h1)
94
+ memory.add_hypothesis(h2)
95
+
96
+ assert len(memory.hypotheses) == 2
97
+ confirmed = memory.get_confirmed_hypotheses()
98
+ assert len(confirmed) == 1
99
+ assert confirmed[0].id == "h1"
100
+
101
+
102
+ def test_conflict_tracking(memory):
103
+ c1 = Conflict(id="c1", description="conflict", source_a_id="a", source_b_id="b", status="open")
104
+ c2 = Conflict(
105
+ id="c2",
106
+ description="resolved conflict",
107
+ source_a_id="a",
108
+ source_b_id="b",
109
+ status="resolved",
110
+ )
111
+
112
+ memory.add_conflict(c1)
113
+ memory.add_conflict(c2)
114
+
115
+ assert len(memory.conflicts) == 2
116
+ open_conflicts = memory.get_open_conflicts()
117
+ assert len(open_conflicts) == 1
118
+ assert open_conflicts[0].id == "c1"
tests/unit/test_ui_elements.py CHANGED
@@ -4,11 +4,11 @@ from src.app import create_demo
4
 
5
 
6
  def test_examples_include_advanced_mode():
7
- """Verify that one example entry uses 'god' or 'advanced' mode."""
8
  demo, _ = create_demo()
9
  assert any(
10
- example[1] in ["advanced", "god"] for example in demo.examples
11
- ), "Expected at least one example to be 'advanced' or 'god' mode"
12
 
13
 
14
  def test_accordion_label_updated():
@@ -24,7 +24,7 @@ def test_orchestrator_mode_info_text_updated():
24
  demo, _ = create_demo()
25
  # Assuming additional_inputs is a list and the Radio is the first element
26
  orchestrator_radio = demo.additional_inputs[0]
27
- expected_info = "⚡ Simple: Free/Any | 🔬 Advanced: OpenAI | 🧠 God: Graph + Llama 3.1 (Exp)"
28
  assert isinstance(
29
  orchestrator_radio, gr.Radio
30
  ), "Expected first additional input to be gr.Radio"
 
4
 
5
 
6
  def test_examples_include_advanced_mode():
7
+ """Verify that one example entry uses 'advanced' mode."""
8
  demo, _ = create_demo()
9
  assert any(
10
+ example[1] == "advanced" for example in demo.examples
11
+ ), "Expected at least one example to be 'advanced' mode"
12
 
13
 
14
  def test_accordion_label_updated():
 
24
  demo, _ = create_demo()
25
  # Assuming additional_inputs is a list and the Radio is the first element
26
  orchestrator_radio = demo.additional_inputs[0]
27
+ expected_info = "⚡ Simple: Free/Any | 🔬 Advanced: OpenAI (Deep Research)"
28
  assert isinstance(
29
  orchestrator_radio, gr.Radio
30
  ), "Expected first additional input to be gr.Radio"
uv.lock CHANGED
@@ -1138,6 +1138,7 @@ dependencies = [
1138
  { name = "requests" },
1139
  { name = "structlog" },
1140
  { name = "tenacity" },
 
1141
  { name = "xmltodict" },
1142
  ]
1143
 
@@ -1184,11 +1185,11 @@ requires-dist = [
1184
  { name = "gradio", extras = ["mcp"], specifier = ">=6.0.0" },
1185
  { name = "httpx", specifier = ">=0.27" },
1186
  { name = "huggingface-hub", specifier = ">=0.20.0" },
1187
- { name = "langchain", specifier = ">=0.3.9" },
1188
- { name = "langchain-core", specifier = ">=0.3.21" },
1189
- { name = "langchain-huggingface", specifier = ">=0.1.2" },
1190
- { name = "langgraph", specifier = ">=0.2.50" },
1191
- { name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" },
1192
  { name = "limits", specifier = ">=3.0" },
1193
  { name = "llama-index", marker = "extra == 'modal'", specifier = ">=0.11.0" },
1194
  { name = "llama-index-embeddings-openai", marker = "extra == 'modal'" },
@@ -1215,6 +1216,7 @@ requires-dist = [
1215
  { name = "structlog", specifier = ">=24.1" },
1216
  { name = "tenacity", specifier = ">=8.2" },
1217
  { name = "typer", marker = "extra == 'dev'", specifier = ">=0.9.0" },
 
1218
  { name = "xmltodict", specifier = ">=0.13" },
1219
  ]
1220
  provides-extras = ["dev", "magentic", "embeddings", "modal"]
@@ -2350,12 +2352,13 @@ wheels = [
2350
 
2351
  [[package]]
2352
  name = "kubernetes"
2353
- version = "34.1.0"
2354
  source = { registry = "https://pypi.org/simple" }
2355
  dependencies = [
2356
  { name = "certifi" },
2357
  { name = "durationpy" },
2358
  { name = "google-auth" },
 
2359
  { name = "python-dateutil" },
2360
  { name = "pyyaml" },
2361
  { name = "requests" },
@@ -2364,28 +2367,32 @@ dependencies = [
2364
  { name = "urllib3" },
2365
  { name = "websocket-client" },
2366
  ]
2367
- sdist = { url = "https://files.pythonhosted.org/packages/ef/55/3f880ef65f559cbed44a9aa20d3bdbc219a2c3a3bac4a30a513029b03ee9/kubernetes-34.1.0.tar.gz", hash = "sha256:8fe8edb0b5d290a2f3ac06596b23f87c658977d46b5f8df9d0f4ea83d0003912", size = 1083771 }
2368
  wheels = [
2369
- { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380 },
2370
  ]
2371
 
2372
  [[package]]
2373
  name = "langchain"
2374
- version = "1.1.0"
2375
  source = { registry = "https://pypi.org/simple" }
2376
  dependencies = [
2377
  { name = "langchain-core" },
2378
- { name = "langgraph" },
 
2379
  { name = "pydantic" },
 
 
 
2380
  ]
2381
- sdist = { url = "https://files.pythonhosted.org/packages/a1/06/be7273c6c15f5a7e64788ed2aa6329dd019170a176977acff7bcde2cdea2/langchain-1.1.0.tar.gz", hash = "sha256:583c892f59873c0329dbe04169fb3234ac794c50780e7c6fb62a61c7b86a981b", size = 528416 }
2382
  wheels = [
2383
- { url = "https://files.pythonhosted.org/packages/0b/6f/889c01d22c84934615fa3f2dcf94c2fe76fd0afa7a7d01f9b798059f0ecc/langchain-1.1.0-py3-none-any.whl", hash = "sha256:af080f3a4a779bfa5925de7aacb6dfab83249d4aab9a08f7aa7b9bec3766d8ea", size = 101797 },
2384
  ]
2385
 
2386
  [[package]]
2387
  name = "langchain-core"
2388
- version = "1.1.0"
2389
  source = { registry = "https://pypi.org/simple" }
2390
  dependencies = [
2391
  { name = "jsonpatch" },
@@ -2396,28 +2403,40 @@ dependencies = [
2396
  { name = "tenacity" },
2397
  { name = "typing-extensions" },
2398
  ]
2399
- sdist = { url = "https://files.pythonhosted.org/packages/1e/17/67c1cc2ace919e2b02dd9d783154d7fb3f1495a4ef835d9cd163b7855ac2/langchain_core-1.1.0.tar.gz", hash = "sha256:2b76a82d427922c8bc51c08404af4fc2a29e9f161dfe2297cb05091e810201e7", size = 781995 }
2400
  wheels = [
2401
- { url = "https://files.pythonhosted.org/packages/71/1e/e129fc471a2d2a7b3804480a937b5ab9319cab9f4142624fcb115f925501/langchain_core-1.1.0-py3-none-any.whl", hash = "sha256:2c9f27dadc6d21ed4aa46506a37a56e6a7e2d2f9141922dc5c251ba921822ee6", size = 473752 },
2402
  ]
2403
 
2404
  [[package]]
2405
  name = "langchain-huggingface"
2406
- version = "1.1.0"
2407
  source = { registry = "https://pypi.org/simple" }
2408
  dependencies = [
2409
  { name = "huggingface-hub" },
2410
  { name = "langchain-core" },
2411
  { name = "tokenizers" },
2412
  ]
2413
- sdist = { url = "https://files.pythonhosted.org/packages/9f/d7/ffcf97cd977c535df2c621c59eafa82df73f801323f670d88819c23fc304/langchain_huggingface-1.1.0.tar.gz", hash = "sha256:43c3b06413158b0cd1edcdbadf545c24d5f64f180bb71c80dc960959a728c1fd", size = 252295 }
2414
  wheels = [
2415
- { url = "https://files.pythonhosted.org/packages/b1/4b/2bdd63464a7bb3aa7911777636cff8e54a2a1edc7b7a85a4acb7decebb23/langchain_huggingface-1.1.0-py3-none-any.whl", hash = "sha256:a3a5218a839062941cb616992bcbc4fe73352454727bafc351a452e76aead1a8", size = 29925 },
 
 
 
 
 
 
 
 
 
 
 
 
2416
  ]
2417
 
2418
  [[package]]
2419
  name = "langgraph"
2420
- version = "1.0.4"
2421
  source = { registry = "https://pypi.org/simple" }
2422
  dependencies = [
2423
  { name = "langchain-core" },
@@ -2427,9 +2446,9 @@ dependencies = [
2427
  { name = "pydantic" },
2428
  { name = "xxhash" },
2429
  ]
2430
- sdist = { url = "https://files.pythonhosted.org/packages/d6/3c/af87902d300c1f467165558c8966d8b1e1f896dace271d3f35a410a5c26a/langgraph-1.0.4.tar.gz", hash = "sha256:86d08e25d7244340f59c5200fa69fdd11066aa999b3164b531e2a20036fac156", size = 484397 }
2431
  wheels = [
2432
- { url = "https://files.pythonhosted.org/packages/14/52/4eb25a3f60399da34ba34adff1b3e324cf0d87eb7a08cebf1882a9b5e0d5/langgraph-1.0.4-py3-none-any.whl", hash = "sha256:b1a835ceb0a8d69b9db48075e1939e28b1ad70ee23fa3fa8f90149904778bacf", size = 157271 },
2433
  ]
2434
 
2435
  [[package]]
@@ -2461,15 +2480,15 @@ wheels = [
2461
 
2462
  [[package]]
2463
  name = "langgraph-prebuilt"
2464
- version = "1.0.5"
2465
  source = { registry = "https://pypi.org/simple" }
2466
  dependencies = [
2467
  { name = "langchain-core" },
2468
  { name = "langgraph-checkpoint" },
2469
  ]
2470
- sdist = { url = "https://files.pythonhosted.org/packages/46/f9/54f8891b32159e4542236817aea2ee83de0de18bce28e9bdba08c7f93001/langgraph_prebuilt-1.0.5.tar.gz", hash = "sha256:85802675ad778cc7240fd02d47db1e0b59c0c86d8369447d77ce47623845db2d", size = 144453 }
2471
  wheels = [
2472
- { url = "https://files.pythonhosted.org/packages/87/5e/aeba4a5b39fe6e874e0dd003a82da71c7153e671312671a8dacc5cb7c1af/langgraph_prebuilt-1.0.5-py3-none-any.whl", hash = "sha256:22369563e1848862ace53fbc11b027c28dd04a9ac39314633bb95f2a7e258496", size = 35072 },
2473
  ]
2474
 
2475
  [[package]]
@@ -6302,11 +6321,11 @@ wheels = [
6302
 
6303
  [[package]]
6304
  name = "urllib3"
6305
- version = "2.3.0"
6306
  source = { registry = "https://pypi.org/simple" }
6307
- sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
6308
  wheels = [
6309
- { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
6310
  ]
6311
 
6312
  [[package]]
 
1138
  { name = "requests" },
1139
  { name = "structlog" },
1140
  { name = "tenacity" },
1141
+ { name = "urllib3" },
1142
  { name = "xmltodict" },
1143
  ]
1144
 
 
1185
  { name = "gradio", extras = ["mcp"], specifier = ">=6.0.0" },
1186
  { name = "httpx", specifier = ">=0.27" },
1187
  { name = "huggingface-hub", specifier = ">=0.20.0" },
1188
+ { name = "langchain", specifier = ">=0.3.9,<1.0" },
1189
+ { name = "langchain-core", specifier = ">=0.3.21,<1.0" },
1190
+ { name = "langchain-huggingface", specifier = ">=0.1.2,<1.0" },
1191
+ { name = "langgraph", specifier = ">=0.2.50,<1.0" },
1192
+ { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.0,<4.0" },
1193
  { name = "limits", specifier = ">=3.0" },
1194
  { name = "llama-index", marker = "extra == 'modal'", specifier = ">=0.11.0" },
1195
  { name = "llama-index-embeddings-openai", marker = "extra == 'modal'" },
 
1216
  { name = "structlog", specifier = ">=24.1" },
1217
  { name = "tenacity", specifier = ">=8.2" },
1218
  { name = "typer", marker = "extra == 'dev'", specifier = ">=0.9.0" },
1219
+ { name = "urllib3", specifier = ">=2.5.0" },
1220
  { name = "xmltodict", specifier = ">=0.13" },
1221
  ]
1222
  provides-extras = ["dev", "magentic", "embeddings", "modal"]
 
2352
 
2353
  [[package]]
2354
  name = "kubernetes"
2355
+ version = "33.1.0"
2356
  source = { registry = "https://pypi.org/simple" }
2357
  dependencies = [
2358
  { name = "certifi" },
2359
  { name = "durationpy" },
2360
  { name = "google-auth" },
2361
+ { name = "oauthlib" },
2362
  { name = "python-dateutil" },
2363
  { name = "pyyaml" },
2364
  { name = "requests" },
 
2367
  { name = "urllib3" },
2368
  { name = "websocket-client" },
2369
  ]
2370
+ sdist = { url = "https://files.pythonhosted.org/packages/ae/52/19ebe8004c243fdfa78268a96727c71e08f00ff6fe69a301d0b7fcbce3c2/kubernetes-33.1.0.tar.gz", hash = "sha256:f64d829843a54c251061a8e7a14523b521f2dc5c896cf6d65ccf348648a88993", size = 1036779 }
2371
  wheels = [
2372
+ { url = "https://files.pythonhosted.org/packages/89/43/d9bebfc3db7dea6ec80df5cb2aad8d274dd18ec2edd6c4f21f32c237cbbb/kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5", size = 1941335 },
2373
  ]
2374
 
2375
  [[package]]
2376
  name = "langchain"
2377
+ version = "0.3.27"
2378
  source = { registry = "https://pypi.org/simple" }
2379
  dependencies = [
2380
  { name = "langchain-core" },
2381
+ { name = "langchain-text-splitters" },
2382
+ { name = "langsmith" },
2383
  { name = "pydantic" },
2384
+ { name = "pyyaml" },
2385
+ { name = "requests" },
2386
+ { name = "sqlalchemy" },
2387
  ]
2388
+ sdist = { url = "https://files.pythonhosted.org/packages/83/f6/f4f7f3a56626fe07e2bb330feb61254dbdf06c506e6b59a536a337da51cf/langchain-0.3.27.tar.gz", hash = "sha256:aa6f1e6274ff055d0fd36254176770f356ed0a8994297d1df47df341953cec62", size = 10233809 }
2389
  wheels = [
2390
+ { url = "https://files.pythonhosted.org/packages/f6/d5/4861816a95b2f6993f1360cfb605aacb015506ee2090433a71de9cca8477/langchain-0.3.27-py3-none-any.whl", hash = "sha256:7b20c4f338826acb148d885b20a73a16e410ede9ee4f19bb02011852d5f98798", size = 1018194 },
2391
  ]
2392
 
2393
  [[package]]
2394
  name = "langchain-core"
2395
+ version = "0.3.80"
2396
  source = { registry = "https://pypi.org/simple" }
2397
  dependencies = [
2398
  { name = "jsonpatch" },
 
2403
  { name = "tenacity" },
2404
  { name = "typing-extensions" },
2405
  ]
2406
+ sdist = { url = "https://files.pythonhosted.org/packages/49/49/f76647b7ba1a6f9c11b0343056ab4d3e5fc445981d205237fed882b2ad60/langchain_core-0.3.80.tar.gz", hash = "sha256:29636b82513ab49e834764d023c4d18554d3d719a185d37b019d0a8ae948c6bb", size = 583629 }
2407
  wheels = [
2408
+ { url = "https://files.pythonhosted.org/packages/da/e8/e7a090ebe37f2b071c64e81b99fb1273b3151ae932f560bb94c22f191cde/langchain_core-0.3.80-py3-none-any.whl", hash = "sha256:2141e3838d100d17dce2359f561ec0df52c526bae0de6d4f469f8026c5747456", size = 450786 },
2409
  ]
2410
 
2411
  [[package]]
2412
  name = "langchain-huggingface"
2413
+ version = "0.3.1"
2414
  source = { registry = "https://pypi.org/simple" }
2415
  dependencies = [
2416
  { name = "huggingface-hub" },
2417
  { name = "langchain-core" },
2418
  { name = "tokenizers" },
2419
  ]
2420
+ sdist = { url = "https://files.pythonhosted.org/packages/3f/15/f832ae485707bf52f9a8f055db389850de06c46bc6e3e4420a0ef105fbbf/langchain_huggingface-0.3.1.tar.gz", hash = "sha256:0a145534ce65b5a723c8562c456100a92513bbbf212e6d8c93fdbae174b41341", size = 25154 }
2421
  wheels = [
2422
+ { url = "https://files.pythonhosted.org/packages/bf/26/7c5d4b4d3e1a7385863acc49fb6f96c55ccf941a750991d18e3f6a69a14a/langchain_huggingface-0.3.1-py3-none-any.whl", hash = "sha256:de10a692dc812885696fbaab607d28ac86b833b0f305bccd5d82d60336b07b7d", size = 27609 },
2423
+ ]
2424
+
2425
+ [[package]]
2426
+ name = "langchain-text-splitters"
2427
+ version = "0.3.11"
2428
+ source = { registry = "https://pypi.org/simple" }
2429
+ dependencies = [
2430
+ { name = "langchain-core" },
2431
+ ]
2432
+ sdist = { url = "https://files.pythonhosted.org/packages/11/43/dcda8fd25f0b19cb2835f2f6bb67f26ad58634f04ac2d8eae00526b0fa55/langchain_text_splitters-0.3.11.tar.gz", hash = "sha256:7a50a04ada9a133bbabb80731df7f6ddac51bc9f1b9cab7fa09304d71d38a6cc", size = 46458 }
2433
+ wheels = [
2434
+ { url = "https://files.pythonhosted.org/packages/58/0d/41a51b40d24ff0384ec4f7ab8dd3dcea8353c05c973836b5e289f1465d4f/langchain_text_splitters-0.3.11-py3-none-any.whl", hash = "sha256:cf079131166a487f1372c8ab5d0bfaa6c0a4291733d9c43a34a16ac9bcd6a393", size = 33845 },
2435
  ]
2436
 
2437
  [[package]]
2438
  name = "langgraph"
2439
+ version = "0.6.11"
2440
  source = { registry = "https://pypi.org/simple" }
2441
  dependencies = [
2442
  { name = "langchain-core" },
 
2446
  { name = "pydantic" },
2447
  { name = "xxhash" },
2448
  ]
2449
+ sdist = { url = "https://files.pythonhosted.org/packages/87/4d/8dfe5e0f9c69655dfb1f450922699ab683b3abbc038cfe38f769eaf871c2/langgraph-0.6.11.tar.gz", hash = "sha256:cd5373d0a59701ab39c9f8af33a33c5704553de815318387fa7f240511e0efd7", size = 492075 }
2450
  wheels = [
2451
+ { url = "https://files.pythonhosted.org/packages/df/94/430f0341c5c2fe3e3b9f5ab2622f35e2bda12c4a7d655c519468e853d1b0/langgraph-0.6.11-py3-none-any.whl", hash = "sha256:49268de69d85b7db3da9e2ca582a474516421c1c44be5cff390416cfa6967faa", size = 155424 },
2452
  ]
2453
 
2454
  [[package]]
 
2480
 
2481
  [[package]]
2482
  name = "langgraph-prebuilt"
2483
+ version = "0.6.5"
2484
  source = { registry = "https://pypi.org/simple" }
2485
  dependencies = [
2486
  { name = "langchain-core" },
2487
  { name = "langgraph-checkpoint" },
2488
  ]
2489
+ sdist = { url = "https://files.pythonhosted.org/packages/98/6a/76ed0f0d740b187ac2014beae929658881b8d18291bd107571aae5515b12/langgraph_prebuilt-0.6.5.tar.gz", hash = "sha256:9c63e9e867e62b345805fd1e8ea5c2df5cc112e939d714f277af84f2afe5950d", size = 125791 }
2490
  wheels = [
2491
+ { url = "https://files.pythonhosted.org/packages/8e/d1/e4727f4822943befc3b7046f79049b1086c9493a34b4d44a1adf78577693/langgraph_prebuilt-0.6.5-py3-none-any.whl", hash = "sha256:b6ceb5db31c16a30a3ee3c0b923667f02e7c9e27852621abf9d5bd5603534141", size = 28158 },
2492
  ]
2493
 
2494
  [[package]]
 
6321
 
6322
  [[package]]
6323
  name = "urllib3"
6324
+ version = "2.5.0"
6325
  source = { registry = "https://pypi.org/simple" }
6326
+ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
6327
  wheels = [
6328
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
6329
  ]
6330
 
6331
  [[package]]