prashflow 1.0.0__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.
prashflow/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .__version__ import __version__
2
+ from .rag import RAG
3
+ from .chat import Chat
4
+ from .inmemory import InMemoryDB, InMemoryVectorStore
5
+ from .agent import Agent
6
+ from .agentchat import AgentChat
7
+ from .multiagent import MultiAgent
8
+ from .tools import tool, ToolManager
9
+ from .config import load_config
10
+ from .errors import PrashFlowError
11
+
12
+ __all__ = [
13
+ "RAG",
14
+ "Chat",
15
+ "InMemoryDB",
16
+ "InMemoryVectorStore",
17
+ "Agent",
18
+ "AgentChat",
19
+ "MultiAgent",
20
+ "tool",
21
+ "ToolManager",
22
+ "load_config",
23
+ "PrashFlowError",
24
+ "__version__",
25
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "1.4.0"
prashflow/agent.py ADDED
@@ -0,0 +1,112 @@
1
+ from langchain_core.messages import HumanMessage
2
+ from .llm import create_llm
3
+ from .tools import calculator_tool, web_search_tool
4
+ from .mcp import MCPManager
5
+ from .errors import ConfigurationError, AgentError
6
+ from .display import success
7
+ from .session import SessionStore
8
+
9
+ class Agent:
10
+ def __init__(
11
+ self,
12
+ llm=None,
13
+ tools=None,
14
+ mcp_servers=None,
15
+ system_prompt="You are a helpful AI assistant.",
16
+ temperature=0,
17
+ max_retries=1,
18
+ session_id="default",
19
+ memory=None,
20
+ **legacy,
21
+ ):
22
+ if llm is None:
23
+ llm = legacy.get("model", "ollama:qwen3:8b")
24
+ self.model = create_llm(llm)
25
+ self.system_prompt = system_prompt
26
+ self.max_retries = max_retries
27
+ self.session_id = session_id
28
+ self.sessions = memory if isinstance(memory, SessionStore) else SessionStore(memory)
29
+ self.tools = self._build_tools(tools or [])
30
+ self.mcp = MCPManager(mcp_servers or [])
31
+ self.mcp.validate()
32
+ self._build_graph()
33
+ success("Agent initialized.")
34
+
35
+ def _build_graph(self):
36
+ try:
37
+ from langgraph.prebuilt import create_react_agent
38
+ try:
39
+ self.graph = create_react_agent(
40
+ self.model, self.tools, prompt=self.system_prompt
41
+ )
42
+ except TypeError:
43
+ self.graph = create_react_agent(self.model, self.tools)
44
+ except Exception as exc:
45
+ raise AgentError("Could not initialize LangGraph agent.") from exc
46
+
47
+ def _build_tools(self, items):
48
+ result = []
49
+ for item in items:
50
+ if isinstance(item, str):
51
+ if item == "calculator":
52
+ result.append(calculator_tool())
53
+ elif item == "web_search":
54
+ result.append(web_search_tool())
55
+ else:
56
+ raise ConfigurationError(f"Unknown built-in tool '{item}'.")
57
+ elif callable(item):
58
+ result.append(item)
59
+ else:
60
+ raise ConfigurationError("Tool must be a string or callable.")
61
+ return result
62
+
63
+ def add_tool(self, custom_tool):
64
+ if not callable(custom_tool):
65
+ raise ConfigurationError("Custom tool must be callable.")
66
+ self.tools.append(custom_tool)
67
+ self._build_graph()
68
+ return self
69
+
70
+ async def discover_mcp_tools(self):
71
+ return await self.mcp.discover_tools()
72
+
73
+ def run(self, query):
74
+ if not query.strip():
75
+ raise ConfigurationError("Agent query cannot be empty.")
76
+ history = self.sessions.history(self.session_id)
77
+ messages = history + [{"role": "user", "content": query}]
78
+ last = None
79
+ for _ in range(self.max_retries + 1):
80
+ try:
81
+ result = self.graph.invoke({"messages": messages})
82
+ outputs = result.get("messages", [])
83
+ if not outputs:
84
+ raise AgentError("Agent returned no messages.")
85
+ answer = getattr(outputs[-1], "content", "") or ""
86
+ self.sessions.append(self.session_id, "user", query)
87
+ self.sessions.append(self.session_id, "assistant", answer)
88
+ return answer
89
+ except Exception as exc:
90
+ last = exc
91
+ raise AgentError(f"Agent failed after {self.max_retries + 1} attempt(s).") from last
92
+
93
+ def stream(self, query):
94
+ if not query.strip():
95
+ raise ConfigurationError("Agent query cannot be empty.")
96
+ history = self.sessions.history(self.session_id)
97
+ messages = history + [{"role": "user", "content": query}]
98
+ parts = []
99
+ try:
100
+ for item in self.graph.stream({"messages": messages}, stream_mode="messages"):
101
+ msg = item[0] if isinstance(item, tuple) else item
102
+ text = getattr(msg, "content", "") or ""
103
+ if text:
104
+ parts.append(text)
105
+ yield text
106
+ self.sessions.append(self.session_id, "user", query)
107
+ self.sessions.append(self.session_id, "assistant", "".join(parts))
108
+ except Exception as exc:
109
+ raise AgentError(f"Agent streaming failed: {exc}") from exc
110
+
111
+ def clear_session(self):
112
+ self.sessions.clear(self.session_id)
prashflow/agentchat.py ADDED
@@ -0,0 +1,5 @@
1
+ from .agent import Agent
2
+
3
+ class AgentChat(Agent):
4
+ """Friendly alias for agentic conversational use."""
5
+ pass
prashflow/chat.py ADDED
@@ -0,0 +1,38 @@
1
+ from .llm import create_llm
2
+ from .session import SessionStore
3
+ from .errors import ConfigurationError
4
+
5
+ class Chat:
6
+ """Provider-agnostic chat with session memory and streaming."""
7
+ def __init__(self, llm=None, memory=None, session_id="default", max_messages=30, **legacy):
8
+ llm = llm or legacy.get("model") or "ollama:qwen3:8b"
9
+ self.llm = create_llm(llm)
10
+ self.sessions = memory if isinstance(memory, SessionStore) else SessionStore(memory, max_messages=max_messages)
11
+ self.session_id = session_id
12
+
13
+ def _messages(self, prompt):
14
+ return self.sessions.history(self.session_id) + [{"role": "user", "content": prompt}]
15
+
16
+ def chat(self, prompt):
17
+ if not prompt or not prompt.strip():
18
+ raise ConfigurationError("Chat prompt cannot be empty.")
19
+ response = self.llm.invoke(self._messages(prompt))
20
+ content = response.content or ""
21
+ self.sessions.append(self.session_id, "user", prompt)
22
+ self.sessions.append(self.session_id, "assistant", content)
23
+ return content
24
+
25
+ def stream(self, prompt):
26
+ if not prompt or not prompt.strip():
27
+ raise ConfigurationError("Chat prompt cannot be empty.")
28
+ parts = []
29
+ for chunk in self.llm.stream(self._messages(prompt)):
30
+ text = getattr(chunk, "content", "") or ""
31
+ if text:
32
+ parts.append(text)
33
+ yield text
34
+ self.sessions.append(self.session_id, "user", prompt)
35
+ self.sessions.append(self.session_id, "assistant", "".join(parts))
36
+
37
+ def clear(self):
38
+ self.sessions.clear(self.session_id)
prashflow/cli.py ADDED
@@ -0,0 +1,43 @@
1
+ import sys
2
+ import typer
3
+ from .display import info, success, error
4
+ from .rag import RAG
5
+
6
+ app = typer.Typer(help="PrashFlow CLI")
7
+
8
+ @app.command()
9
+ def doctor():
10
+ import shutil, urllib.request
11
+ info(f"Python: {sys.version.split()[0]}")
12
+ if shutil.which("ollama"):
13
+ success("Ollama executable found.")
14
+ else:
15
+ error("Ollama not found", "Ollama is not on PATH.", "Install Ollama.")
16
+ raise typer.Exit(1)
17
+ try:
18
+ with urllib.request.urlopen("http://localhost:11434/api/tags", timeout=3) as r:
19
+ if r.status == 200:
20
+ success("Ollama server is reachable.")
21
+ except Exception:
22
+ error("Ollama unavailable", "Cannot reach localhost:11434.", "Run: ollama serve")
23
+ raise typer.Exit(1)
24
+
25
+ @app.command()
26
+ def ingest(source: str, db: str = "./chroma_db"):
27
+ try:
28
+ RAG(vector_db={"provider": "chroma", "path": db}).ingest(source)
29
+ except Exception as exc:
30
+ error("Ingestion failed", str(exc))
31
+ raise typer.Exit(1)
32
+
33
+ @app.command()
34
+ def ask(question: str, db: str = "./chroma_db", search_type: str = "hybrid"):
35
+ try:
36
+ rag = RAG(
37
+ vector_db={"provider": "chroma", "path": db},
38
+ retrieval={"type": search_type, "top_k": 5, "candidate_k": 20},
39
+ )
40
+ print(rag.ask(question))
41
+ except Exception as exc:
42
+ error("Question failed", str(exc))
43
+ raise typer.Exit(1)
prashflow/config.py ADDED
@@ -0,0 +1,26 @@
1
+ import os
2
+ from pathlib import Path
3
+ import yaml
4
+ from .errors import ConfigurationError
5
+
6
+ def expand_env(value):
7
+ if isinstance(value, dict):
8
+ return {k: expand_env(v) for k, v in value.items()}
9
+ if isinstance(value, list):
10
+ return [expand_env(v) for v in value]
11
+ if isinstance(value, str):
12
+ if value.startswith("${") and value.endswith("}"):
13
+ name = value[2:-1]
14
+ return os.getenv(name, "")
15
+ return value
16
+ return value
17
+
18
+ def load_config(path: str):
19
+ p = Path(path)
20
+ if not p.exists():
21
+ raise ConfigurationError(f"Configuration file not found: {path}")
22
+ try:
23
+ with p.open("r", encoding="utf-8") as f:
24
+ return expand_env(yaml.safe_load(f) or {})
25
+ except Exception as exc:
26
+ raise ConfigurationError(f"Invalid YAML configuration: {path}") from exc
prashflow/display.py ADDED
@@ -0,0 +1,14 @@
1
+ from rich.console import Console
2
+ from rich.panel import Panel
3
+
4
+ console = Console()
5
+
6
+ def info(msg): console.print(f"[cyan]ℹ[/cyan] {msg}")
7
+ def success(msg): console.print(f"[green]✓[/green] {msg}")
8
+ def warning(msg): console.print(f"[yellow]⚠[/yellow] {msg}")
9
+
10
+ def error(title, msg, fix=None):
11
+ body = msg
12
+ if fix:
13
+ body += f"\n\n[bold]Fix:[/bold]\n{fix}"
14
+ console.print(Panel(body, title=f"❌ {title}", border_style="red"))
prashflow/errors.py ADDED
@@ -0,0 +1,15 @@
1
+ class PrashFlowError(Exception):
2
+ """Base exception for the framework."""
3
+
4
+ class ConfigurationError(PrashFlowError): pass
5
+ class LLMError(PrashFlowError): pass
6
+ class LLMConnectionError(LLMError): pass
7
+ class EmbeddingError(PrashFlowError): pass
8
+ class VectorDBError(PrashFlowError): pass
9
+ class DocumentLoadError(PrashFlowError): pass
10
+ class RetrievalError(PrashFlowError): pass
11
+ class RerankerError(PrashFlowError): pass
12
+ class DataSourceError(PrashFlowError): pass
13
+ class ToolExecutionError(PrashFlowError): pass
14
+ class AgentError(PrashFlowError): pass
15
+ class MCPError(PrashFlowError): pass
prashflow/inmemory.py ADDED
@@ -0,0 +1,87 @@
1
+ import math
2
+ from collections import defaultdict
3
+ from .errors import ConfigurationError
4
+
5
+ class InMemoryDB:
6
+ """Simple process-local key/value database for chat state, sessions and metadata."""
7
+ def __init__(self):
8
+ self.data = defaultdict(dict)
9
+ def set(self, namespace, key, value):
10
+ self.data[namespace][key] = value
11
+ def get(self, namespace, key, default=None):
12
+ return self.data.get(namespace, {}).get(key, default)
13
+ def delete(self, namespace, key):
14
+ self.data.get(namespace, {}).pop(key, None)
15
+ def list(self, namespace):
16
+ return dict(self.data.get(namespace, {}))
17
+ def clear(self):
18
+ self.data.clear()
19
+
20
+ class InMemoryVectorStore:
21
+ """Ephemeral vector store; useful for tests, demos and short-lived agents."""
22
+ def __init__(self, embeddings, **kwargs):
23
+ self.embeddings = embeddings
24
+ self.documents = []
25
+ self.vectors = []
26
+
27
+ def add_documents(self, documents):
28
+ if not documents:
29
+ return []
30
+ vectors = self.embeddings.embed_documents([d.page_content for d in documents])
31
+ self.documents.extend(documents)
32
+ self.vectors.extend(vectors)
33
+ return [d.metadata.get("chunk_id", str(i)) for i, d in enumerate(documents)]
34
+
35
+ def _cosine(self, a, b):
36
+ dot = sum(x*y for x, y in zip(a, b))
37
+ na = math.sqrt(sum(x*x for x in a))
38
+ nb = math.sqrt(sum(x*x for x in b))
39
+ return dot / (na * nb) if na and nb else 0.0
40
+
41
+ def _filtered(self, filter):
42
+ if not filter:
43
+ return list(range(len(self.documents)))
44
+ return [
45
+ i for i, d in enumerate(self.documents)
46
+ if all(d.metadata.get(k) == v for k, v in filter.items())
47
+ ]
48
+
49
+ def similarity_search(self, query, k=4, filter=None):
50
+ q = self.embeddings.embed_query(query)
51
+ ids = self._filtered(filter)
52
+ ranked = sorted(ids, key=lambda i: self._cosine(q, self.vectors[i]), reverse=True)
53
+ return [self.documents[i] for i in ranked[:k]]
54
+
55
+ def similarity_search_with_score(self, query, k=4, filter=None):
56
+ q = self.embeddings.embed_query(query)
57
+ ids = self._filtered(filter)
58
+ ranked = sorted(
59
+ ((i, self._cosine(q, self.vectors[i])) for i in ids),
60
+ key=lambda x: x[1], reverse=True
61
+ )
62
+ return [(self.documents[i], score) for i, score in ranked[:k]]
63
+
64
+ def max_marginal_relevance_search(self, query, k=4, fetch_k=20, lambda_mult=.5, filter=None):
65
+ q = self.embeddings.embed_query(query)
66
+ ids = self._filtered(filter)
67
+ candidates = sorted(ids, key=lambda i: self._cosine(q, self.vectors[i]), reverse=True)[:fetch_k]
68
+ selected = []
69
+ while candidates and len(selected) < k:
70
+ if not selected:
71
+ best = candidates[0]
72
+ else:
73
+ def mmr(i):
74
+ relevance = self._cosine(q, self.vectors[i])
75
+ diversity = max(
76
+ self._cosine(self.vectors[i], self.vectors[j])
77
+ for j in selected
78
+ )
79
+ return lambda_mult * relevance - (1-lambda_mult) * diversity
80
+ best = max(candidates, key=mmr)
81
+ selected.append(best)
82
+ candidates.remove(best)
83
+ return [self.documents[i] for i in selected]
84
+
85
+ def delete_collection(self):
86
+ self.documents.clear()
87
+ self.vectors.clear()
prashflow/llm.py ADDED
@@ -0,0 +1,206 @@
1
+ from typing import Any, Iterator
2
+ from .errors import ConfigurationError, LLMConnectionError
3
+
4
+ def _message_dict(message):
5
+ role = getattr(message, "type", "human")
6
+ role = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}.get(role, role)
7
+ content = message.content
8
+ return {"role": role, "content": content}
9
+
10
+ class LiteLLMChatModel:
11
+ """
12
+ Small LangChain-compatible ChatModel adapter over LiteLLM.
13
+ LiteLLM provides one OpenAI-style interface across many providers,
14
+ including OpenAI, Azure, Anthropic, Gemini, OpenRouter, Ollama and
15
+ OpenAI-compatible endpoints.
16
+ """
17
+ def __init__(self, model, api_key=None, api_base=None, temperature=0, **kwargs):
18
+ self.model_name = model
19
+ self.api_key = api_key
20
+ self.api_base = api_base
21
+ self.temperature = temperature
22
+ self.kwargs = kwargs
23
+
24
+ @property
25
+ def _llm_type(self):
26
+ return "litellm"
27
+
28
+ def _call(self, messages, stream=False, **kwargs):
29
+ try:
30
+ import litellm
31
+ except ImportError as exc:
32
+ raise ConfigurationError(
33
+ "LiteLLM is not installed. Run: pip install 'prashflow[litellm]'"
34
+ ) from exc
35
+ params = dict(self.kwargs)
36
+ params.update(kwargs)
37
+ params.update({
38
+ "model": self.model_name,
39
+ "messages": [_message_dict(m) for m in messages],
40
+ "temperature": self.temperature,
41
+ "stream": stream,
42
+ })
43
+ if self.api_key:
44
+ params["api_key"] = self.api_key
45
+ if self.api_base:
46
+ params["api_base"] = self.api_base
47
+ return litellm.completion(**params)
48
+
49
+ def invoke(self, messages, **kwargs):
50
+ from langchain_core.messages import AIMessage
51
+ if isinstance(messages, str):
52
+ messages = [{"role": "user", "content": messages}]
53
+ try:
54
+ if messages and isinstance(messages[0], dict):
55
+ class M:
56
+ def __init__(self, d): self.type, self.content = d.get("role"), d.get("content")
57
+ messages = [M(m) for m in messages]
58
+ response = self._call(messages, **kwargs)
59
+ content = response.choices[0].message.content or ""
60
+ return AIMessage(content=content)
61
+ except Exception as exc:
62
+ raise LLMConnectionError(
63
+ f"LiteLLM call failed for model '{self.model_name}': {exc}"
64
+ ) from exc
65
+
66
+ def stream(self, messages, **kwargs) -> Iterator[Any]:
67
+ from langchain_core.messages import AIMessageChunk
68
+ try:
69
+ if isinstance(messages, str):
70
+ class M:
71
+ type = "human"
72
+ content = messages
73
+ messages = [M()]
74
+ if messages and isinstance(messages[0], dict):
75
+ class M:
76
+ def __init__(self, d): self.type, self.content = d.get("role"), d.get("content")
77
+ messages = [M(m) for m in messages]
78
+ response = self._call(messages, stream=True, **kwargs)
79
+ for chunk in response:
80
+ content = ""
81
+ try:
82
+ content = chunk.choices[0].delta.content or ""
83
+ except Exception:
84
+ pass
85
+ if content:
86
+ yield AIMessageChunk(content=content)
87
+ except Exception as exc:
88
+ raise LLMConnectionError(
89
+ f"LiteLLM streaming failed for model '{self.model_name}': {exc}"
90
+ ) from exc
91
+
92
+ def create_llm(config):
93
+ if isinstance(config, str):
94
+ if ":" in config:
95
+ provider, model = config.split(":", 1)
96
+ else:
97
+ provider, model = "ollama", config
98
+ config = {"provider": provider, "model": model}
99
+ config = dict(config or {})
100
+ provider = config.get("provider", "ollama").lower()
101
+ model = config.get("model")
102
+ if not model:
103
+ raise ConfigurationError("LLM model is required.")
104
+
105
+ if provider == "litellm":
106
+ return LiteLLMChatModel(
107
+ model=model,
108
+ api_key=config.get("api_key"),
109
+ api_base=config.get("base_url") or config.get("api_base"),
110
+ temperature=config.get("temperature", 0),
111
+ **config.get("extra", {}),
112
+ )
113
+
114
+ if provider == "ollama":
115
+ try:
116
+ from langchain_ollama import ChatOllama
117
+ return ChatOllama(
118
+ model=model,
119
+ base_url=config.get("base_url", "http://localhost:11434"),
120
+ temperature=config.get("temperature", 0),
121
+ )
122
+ except Exception as exc:
123
+ raise LLMConnectionError(
124
+ f"Could not initialize Ollama model '{model}'."
125
+ ) from exc
126
+
127
+ if provider in {"openai", "openai-compatible"}:
128
+ try:
129
+ from langchain_openai import ChatOpenAI
130
+ except ImportError as exc:
131
+ raise ConfigurationError(
132
+ "Install OpenAI support: pip install 'prashflow[openai]'"
133
+ ) from exc
134
+ kwargs = {
135
+ "model": model,
136
+ "temperature": config.get("temperature", 0),
137
+ }
138
+ if config.get("base_url"):
139
+ kwargs["base_url"] = config["base_url"]
140
+ if config.get("api_key"):
141
+ kwargs["api_key"] = config["api_key"]
142
+ return ChatOpenAI(**kwargs)
143
+
144
+ raise ConfigurationError(
145
+ f"Unsupported LLM provider '{provider}'. "
146
+ "Supported: ollama, openai, openai-compatible, litellm."
147
+ )
148
+
149
+ def create_embeddings(config):
150
+ if isinstance(config, str):
151
+ provider, model = config.split(":", 1)
152
+ config = {"provider": provider, "model": model}
153
+ config = dict(config or {})
154
+ provider = config.get("provider", "ollama").lower()
155
+ model = config.get("model")
156
+ if not model:
157
+ raise ConfigurationError("Embedding model is required.")
158
+
159
+ if provider == "litellm":
160
+ try:
161
+ import litellm
162
+ except ImportError as exc:
163
+ raise ConfigurationError(
164
+ "Install LiteLLM support: pip install 'prashflow[litellm]'"
165
+ ) from exc
166
+
167
+ class LiteLLMEmbeddings:
168
+ def embed_documents(self, texts):
169
+ result = litellm.embedding(
170
+ model=model,
171
+ input=texts,
172
+ api_key=config.get("api_key"),
173
+ api_base=config.get("base_url") or config.get("api_base"),
174
+ )
175
+ return [x["embedding"] for x in result["data"]]
176
+ def embed_query(self, text):
177
+ return self.embed_documents([text])[0]
178
+ return LiteLLMEmbeddings()
179
+
180
+ if provider == "ollama":
181
+ try:
182
+ from langchain_ollama import OllamaEmbeddings
183
+ return OllamaEmbeddings(
184
+ model=model,
185
+ base_url=config.get("base_url", "http://localhost:11434"),
186
+ )
187
+ except Exception as exc:
188
+ raise LLMConnectionError(
189
+ f"Could not initialize Ollama embeddings '{model}'."
190
+ ) from exc
191
+
192
+ if provider in {"openai", "openai-compatible"}:
193
+ try:
194
+ from langchain_openai import OpenAIEmbeddings
195
+ except ImportError as exc:
196
+ raise ConfigurationError(
197
+ "Install OpenAI support: pip install 'prashflow[openai]'"
198
+ ) from exc
199
+ kwargs = {"model": model}
200
+ if config.get("api_key"):
201
+ kwargs["api_key"] = config["api_key"]
202
+ if config.get("base_url"):
203
+ kwargs["base_url"] = config["base_url"]
204
+ return OpenAIEmbeddings(**kwargs)
205
+
206
+ raise ConfigurationError(f"Unsupported embedding provider '{provider}'.")
prashflow/loaders.py ADDED
@@ -0,0 +1,40 @@
1
+ from pathlib import Path
2
+ from langchain_community.document_loaders import (
3
+ PyPDFLoader, TextLoader, CSVLoader, Docx2txtLoader, WebBaseLoader
4
+ )
5
+ from .errors import DocumentLoadError
6
+
7
+ SUPPORTED = {".pdf", ".txt", ".md", ".csv", ".docx"}
8
+
9
+ def load_file(path: Path):
10
+ try:
11
+ suffix = path.suffix.lower()
12
+ if suffix == ".pdf": return PyPDFLoader(str(path)).load()
13
+ if suffix in {".txt", ".md"}: return TextLoader(str(path), encoding="utf-8").load()
14
+ if suffix == ".csv": return CSVLoader(str(path)).load()
15
+ if suffix == ".docx": return Docx2txtLoader(str(path)).load()
16
+ except Exception as exc:
17
+ raise DocumentLoadError(f"Failed to load {path}: {exc}") from exc
18
+ raise DocumentLoadError(f"Unsupported file type: {suffix}")
19
+
20
+ def load_source(source: str):
21
+ p = Path(source)
22
+ if p.exists():
23
+ if p.is_file():
24
+ return load_file(p)
25
+ docs = []
26
+ for f in p.rglob("*"):
27
+ if f.is_file() and f.suffix.lower() in SUPPORTED:
28
+ try:
29
+ docs.extend(load_file(f))
30
+ except Exception:
31
+ pass
32
+ if not docs:
33
+ raise DocumentLoadError(f"No supported documents found in {source}")
34
+ return docs
35
+ if source.startswith(("http://", "https://")):
36
+ try:
37
+ return WebBaseLoader(source).load()
38
+ except Exception as exc:
39
+ raise DocumentLoadError(f"Could not load URL {source}") from exc
40
+ raise DocumentLoadError(f"Source not found: {source}")
prashflow/mcp.py ADDED
@@ -0,0 +1,55 @@
1
+ from .errors import MCPError, ConfigurationError
2
+
3
+ class MCPManager:
4
+ """
5
+ MCP integration boundary.
6
+
7
+ The exact Python MCP SDK transport APIs have changed across SDK releases.
8
+ This adapter keeps server configuration in one place and exposes an
9
+ explicit async method for tool discovery. The synchronous Agent API can
10
+ consume pre-created LangChain tools, while applications using a specific
11
+ MCP SDK release can extend this adapter without changing Agent.
12
+ """
13
+ def __init__(self, servers=None):
14
+ self.servers = servers or []
15
+
16
+ def validate(self):
17
+ for server in self.servers:
18
+ if not server.get("name"):
19
+ raise ConfigurationError("Every MCP server needs a name.")
20
+ transport = server.get("transport", "stdio")
21
+ if transport == "stdio":
22
+ if not server.get("command"):
23
+ raise ConfigurationError(
24
+ f"MCP stdio server '{server['name']}' requires command."
25
+ )
26
+ elif transport in {"streamable_http", "sse"}:
27
+ if not server.get("url"):
28
+ raise ConfigurationError(
29
+ f"MCP HTTP server '{server['name']}' requires url."
30
+ )
31
+ else:
32
+ raise ConfigurationError(
33
+ f"Unsupported MCP transport '{transport}'."
34
+ )
35
+ return True
36
+
37
+ async def discover_tools(self):
38
+ """
39
+ Return MCP tools when the installed MCP SDK/server adapter supports them.
40
+
41
+ This method deliberately fails clearly instead of pretending to have
42
+ connected to a server. Extend this method for your pinned MCP SDK.
43
+ """
44
+ self.validate()
45
+ try:
46
+ import mcp # noqa: F401
47
+ except ImportError as exc:
48
+ raise MCPError(
49
+ "MCP support is not installed. Run: pip install 'prashflow[mcp]'"
50
+ ) from exc
51
+ raise MCPError(
52
+ "MCP server discovery requires an SDK transport adapter. "
53
+ "The server configuration was validated, but no transport session "
54
+ "was opened by this reference implementation."
55
+ )