agents-builder 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.
- agents_builder/__init__.py +95 -0
- agents_builder/constants.py +24 -0
- agents_builder/exceptions.py +87 -0
- agents_builder/langgraph/__init__.py +96 -0
- agents_builder/langgraph/nodes.py +124 -0
- agents_builder/langgraph/states.py +17 -0
- agents_builder/llm/__init__.py +20 -0
- agents_builder/llm/factory.py +44 -0
- agents_builder/llm/llm.py +46 -0
- agents_builder/mixins.py +76 -0
- agents_builder/py.typed +0 -0
- agents_builder/settings.py +86 -0
- agents_builder/utils.py +91 -0
- agents_builder-1.0.0.dist-info/METADATA +23 -0
- agents_builder-1.0.0.dist-info/RECORD +16 -0
- agents_builder-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from langchain.tools import BaseTool
|
|
8
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
9
|
+
|
|
10
|
+
from agents_builder.exceptions import (
|
|
11
|
+
MCPNotConfiguredError,
|
|
12
|
+
MCPToolNotFoundError,
|
|
13
|
+
)
|
|
14
|
+
from agents_builder.llm.factory import LLMFactory
|
|
15
|
+
from agents_builder.mixins import FromConfigMixin
|
|
16
|
+
from agents_builder.settings import AgentSettings
|
|
17
|
+
from agents_builder.utils import _cached_mcp_tools, load_class
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Agent[TConfig: AgentSettings](FromConfigMixin[TConfig], ABC):
|
|
23
|
+
"""
|
|
24
|
+
Initializes and runs a LangChain agent backed by VLLM and Qdrant embeddings.
|
|
25
|
+
Minimal version: no exception catching, f-string logs only.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: TConfig) -> None:
|
|
29
|
+
self.config = config
|
|
30
|
+
self._graph: CompiledStateGraph | None = None
|
|
31
|
+
self.init_components()
|
|
32
|
+
_logger.info("Agent initialized")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def graph(self) -> CompiledStateGraph:
|
|
36
|
+
if self._graph is None:
|
|
37
|
+
_logger.info("Graph not initialized → building graph")
|
|
38
|
+
self._graph = self.build_graph()
|
|
39
|
+
return self._graph
|
|
40
|
+
|
|
41
|
+
async def load_tool(self, mcp_name: str, key: str) -> BaseTool:
|
|
42
|
+
# ✅ cache hit
|
|
43
|
+
if key in self._tools_by_name:
|
|
44
|
+
return self._tools_by_name[key]
|
|
45
|
+
async with self._tool_lock:
|
|
46
|
+
# double-check (important)
|
|
47
|
+
if key in self._tools_by_name:
|
|
48
|
+
return self._tools_by_name[key]
|
|
49
|
+
# 🔥 find the correct MCP
|
|
50
|
+
mcp = next(
|
|
51
|
+
(m for m in self.config.mcps if m.name == mcp_name),
|
|
52
|
+
None,
|
|
53
|
+
)
|
|
54
|
+
if mcp is None:
|
|
55
|
+
raise MCPNotConfiguredError(tool_key=key)
|
|
56
|
+
# 🔥 load ONLY this MCP
|
|
57
|
+
tools = await _cached_mcp_tools(
|
|
58
|
+
mcps_fingerprint=f"{mcp.name}:{mcp.url}",
|
|
59
|
+
mcps_config=[mcp], # 👈 only ONE MCP
|
|
60
|
+
)
|
|
61
|
+
# cache all tools from this MCP (usually few)
|
|
62
|
+
for tool in tools:
|
|
63
|
+
self._tools_by_name[tool.name] = tool
|
|
64
|
+
if key not in self._tools_by_name:
|
|
65
|
+
raise MCPToolNotFoundError(
|
|
66
|
+
tool_key=key,
|
|
67
|
+
mcp_name=mcp.name,
|
|
68
|
+
available=list(self._tools_by_name.keys()),
|
|
69
|
+
)
|
|
70
|
+
return self._tools_by_name[key]
|
|
71
|
+
|
|
72
|
+
def init_components(self) -> None:
|
|
73
|
+
self.llm_factory: LLMFactory = load_class(self.config.llm_factory.module_path).from_config(
|
|
74
|
+
self.config.llm_factory
|
|
75
|
+
)
|
|
76
|
+
self._tools_by_name: dict[str, BaseTool] = {}
|
|
77
|
+
self._tool_lock = asyncio.Lock()
|
|
78
|
+
_logger.info("Agent created")
|
|
79
|
+
|
|
80
|
+
def push(self, path: str | None = None) -> None:
|
|
81
|
+
# Timestamp: YYYYMMDD_HHMMSS
|
|
82
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
83
|
+
# Optional: include agent name / class
|
|
84
|
+
agent_name = self.__class__.__name__
|
|
85
|
+
# Build filename
|
|
86
|
+
filename = f"{agent_name}_graph_{timestamp}.png"
|
|
87
|
+
# Full path
|
|
88
|
+
full_path: Path = Path(path) / filename if path else Path(".") / filename
|
|
89
|
+
# Draw + save
|
|
90
|
+
png_bytes = self.graph.get_graph().draw_mermaid_png()
|
|
91
|
+
full_path.write_bytes(png_bytes)
|
|
92
|
+
_logger.info(f"Graph saved locally at {full_path}")
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def build_graph(self) -> CompiledStateGraph: ...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
3
|
+
|
|
4
|
+
from langchain_core.tools.base import BaseTool
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from agents_builder.settings import (
|
|
8
|
+
AgentSettings,
|
|
9
|
+
DeletionStrategySettings,
|
|
10
|
+
FromConfigMixinSettings,
|
|
11
|
+
LLMSettings,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
TNode = TypeVar("TNode")
|
|
15
|
+
ToolLike = dict[str, Any] | type | Callable | BaseTool
|
|
16
|
+
StructuredOutput = dict[str, Any] | type[Any] | None
|
|
17
|
+
TCFromConfigMixin = TypeVar("TCFromConfigMixin", bound="FromConfigMixinSettings")
|
|
18
|
+
TCAgent = TypeVar("TCAgent", bound="AgentSettings")
|
|
19
|
+
TCDeletionStrategy = TypeVar("TCDeletionStrategy", bound="DeletionStrategySettings")
|
|
20
|
+
|
|
21
|
+
TCLLM = TypeVar("TCLLM", bound="LLMSettings")
|
|
22
|
+
TCAgentSettings = TypeVar("TCAgentSettings", bound="AgentSettings")
|
|
23
|
+
|
|
24
|
+
CONFIG_PATH = "/config/config.yaml"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# agents_builder/exceptions.py
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgenticBuilderError(Exception):
|
|
5
|
+
"""Base exception for the package"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# -------------------------
|
|
9
|
+
# MESSAGE / STATE
|
|
10
|
+
# -------------------------
|
|
11
|
+
class MessageError(AgenticBuilderError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NoHumanMessageError(MessageError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidStateError(AgenticBuilderError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissingStateKeyError(InvalidStateError):
|
|
24
|
+
def __init__(self, key: str):
|
|
25
|
+
super().__init__(f"Missing or None key '{key}' in state dict")
|
|
26
|
+
self.key = key
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -------------------------
|
|
30
|
+
# MCP / TOOLS
|
|
31
|
+
# -------------------------
|
|
32
|
+
class MCPError(AgenticBuilderError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MCPNotConfiguredError(MCPError):
|
|
37
|
+
def __init__(self, tool_key: str):
|
|
38
|
+
super().__init__(f"No MCP configured for tool '{tool_key}'")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MCPToolNotFoundError(MCPError):
|
|
42
|
+
def __init__(self, tool_key: str, mcp_name: str, available: list[str]):
|
|
43
|
+
super().__init__(f"Tool '{tool_key}' not found in MCP '{mcp_name}'. Available: {available}")
|
|
44
|
+
self.tool_key = tool_key
|
|
45
|
+
self.mcp_name = mcp_name
|
|
46
|
+
self.available = available
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# -------------------------
|
|
50
|
+
# GRAPH / AGENT
|
|
51
|
+
# -------------------------
|
|
52
|
+
class GraphError(AgenticBuilderError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -------------------------
|
|
57
|
+
# PROMPTS
|
|
58
|
+
# -------------------------
|
|
59
|
+
class PromptError(AgenticBuilderError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# -------------------------
|
|
64
|
+
# STRATEGIES
|
|
65
|
+
# -------------------------
|
|
66
|
+
class StrategyError(AgenticBuilderError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# -------------------------
|
|
71
|
+
# LLM
|
|
72
|
+
# -------------------------
|
|
73
|
+
class LLMError(AgenticBuilderError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InvalidLLMRoleError(LLMError):
|
|
78
|
+
def __init__(self, role: str, available: list[str]):
|
|
79
|
+
super().__init__(f"LLM role '{role}' not defined. Available roles: {available}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class InvalidLLMClassError(LLMError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class StreamingNotSupportedError(LLMError):
|
|
87
|
+
pass
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
6
|
+
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage
|
|
7
|
+
from langchain_core.messages.utils import AnyMessage
|
|
8
|
+
from langchain_core.runnables import Runnable
|
|
9
|
+
from langgraph._internal._typing import StateLike
|
|
10
|
+
from langgraph.graph._node import _Node
|
|
11
|
+
|
|
12
|
+
from agents_builder import Agent
|
|
13
|
+
from agents_builder.mixins import FromConfigMixin
|
|
14
|
+
from agents_builder.settings import DeletionStrategySettings
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Prompt[TState: StateLike](ABC):
|
|
20
|
+
@classmethod
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def format_prompt(cls, state: TState) -> str: ...
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def render(cls, state: TState) -> list[BaseMessage]:
|
|
26
|
+
content = cls.format_prompt(state)
|
|
27
|
+
return [
|
|
28
|
+
*state["messages"], # ty: ignore[not-subscriptable]
|
|
29
|
+
SystemMessage(content=content),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SchemaBasedPrompt[TState: StateLike](Prompt[TState], ABC):
|
|
34
|
+
@classmethod
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def get_schema(cls) -> Any: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeletionStrategy[TConfig: DeletionStrategySettings](FromConfigMixin[TConfig]):
|
|
40
|
+
def __init__(self, config: TConfig) -> None:
|
|
41
|
+
self.config = config
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def _delete(self, messages: list[AnyMessage]) -> list[AnyMessage]: ...
|
|
45
|
+
|
|
46
|
+
def delete(self, messages: list[AnyMessage]) -> list[AnyMessage]:
|
|
47
|
+
before_count = len(messages)
|
|
48
|
+
logger.debug(f"DeletionStrategy.start | strategy={self.__class__.__name__} | messages_before={before_count}")
|
|
49
|
+
new_messages = self._delete(messages)
|
|
50
|
+
after_count = len(new_messages)
|
|
51
|
+
removed = before_count - after_count
|
|
52
|
+
logger.info(
|
|
53
|
+
f"DeletionStrategy.done | strategy={self.__class__.__name__} | removed={removed} | remaining={after_count}"
|
|
54
|
+
)
|
|
55
|
+
return new_messages
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Node[TState: StateLike](_Node[TState]):
|
|
59
|
+
def __init__(self, name: str) -> None:
|
|
60
|
+
self.name = name
|
|
61
|
+
|
|
62
|
+
async def __call__(self, state: TState) -> Any: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RouterNode[TState: StateLike](Node[TState]):
|
|
66
|
+
def __init__(self, name: str) -> None:
|
|
67
|
+
super().__init__(name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ToolBasedNode[TState: StateLike](Node[TState]):
|
|
71
|
+
def __init__(self, name: str, agent: Agent[Any], mcp_name: str, tool_key: str):
|
|
72
|
+
super().__init__(name)
|
|
73
|
+
self.agent = agent
|
|
74
|
+
self.mcp_name = mcp_name
|
|
75
|
+
self.tool_key = tool_key
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LLMNode[TState: StateLike](Node[TState]):
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
name: str,
|
|
82
|
+
model: BaseChatModel,
|
|
83
|
+
prompt: type[Prompt[TState]],
|
|
84
|
+
structured_output: dict | type | None,
|
|
85
|
+
):
|
|
86
|
+
super().__init__(name)
|
|
87
|
+
runnable: Runnable[list[BaseMessage], AIMessage] = model
|
|
88
|
+
|
|
89
|
+
if structured_output:
|
|
90
|
+
runnable = runnable.with_structured_output(structured_output) # ty: ignore[invalid-assignment]
|
|
91
|
+
self.runnable = runnable
|
|
92
|
+
self.prompt = prompt
|
|
93
|
+
|
|
94
|
+
async def _ainvoke(self, state: TState) -> AIMessage:
|
|
95
|
+
messages = self.prompt.render(state)
|
|
96
|
+
return await self.runnable.ainvoke(messages)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
5
|
+
from langchain_core.tools.base import BaseTool
|
|
6
|
+
|
|
7
|
+
from agents_builder import Agent
|
|
8
|
+
from agents_builder.exceptions import MissingStateKeyError
|
|
9
|
+
from agents_builder.langgraph import (
|
|
10
|
+
LLMNode,
|
|
11
|
+
Prompt,
|
|
12
|
+
RouterNode,
|
|
13
|
+
SchemaBasedPrompt,
|
|
14
|
+
ToolBasedNode,
|
|
15
|
+
)
|
|
16
|
+
from agents_builder.langgraph.states import AgentState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GenerateQueryLLMNode(LLMNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
20
|
+
def __init__(self, name: str, model: BaseChatModel, prompt: type[SchemaBasedPrompt]):
|
|
21
|
+
super().__init__(
|
|
22
|
+
name=name,
|
|
23
|
+
model=model,
|
|
24
|
+
prompt=prompt,
|
|
25
|
+
structured_output=prompt.get_schema(),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
29
|
+
query = await self._ainvoke(state)
|
|
30
|
+
return {"query": query}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SearchDocumentsNode(ToolBasedNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
34
|
+
def __init__(self, name: str, agent: Agent[Any], mcp_name: str, tool_key: str):
|
|
35
|
+
super().__init__(name=name, agent=agent, mcp_name=mcp_name, tool_key=tool_key)
|
|
36
|
+
self.retriever_tool: BaseTool | None = None
|
|
37
|
+
|
|
38
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
39
|
+
if self.retriever_tool is None:
|
|
40
|
+
self.retriever_tool = await self.agent.load_tool(self.mcp_name, self.tool_key)
|
|
41
|
+
if state["query"] is None:
|
|
42
|
+
raise MissingStateKeyError("query")
|
|
43
|
+
# Call the retriever tool asynchronously
|
|
44
|
+
tool_result = (await self.retriever_tool.arun({"query": state["query"]["content"]}))[0]
|
|
45
|
+
# Parse tool output
|
|
46
|
+
payload = json.loads(tool_result["text"])
|
|
47
|
+
evidence = payload["retrieval"]["evidence"]
|
|
48
|
+
# Return state update
|
|
49
|
+
return {"documents": evidence}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GraderLLMNode(LLMNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
53
|
+
def __init__(self, name: str, model: BaseChatModel, prompt: type[SchemaBasedPrompt]):
|
|
54
|
+
super().__init__(
|
|
55
|
+
name=name,
|
|
56
|
+
model=model,
|
|
57
|
+
prompt=prompt,
|
|
58
|
+
structured_output=prompt.get_schema(),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
62
|
+
return {"is_answerable": (await self._ainvoke(state))}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GenerateAnswerLLMNode(LLMNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
66
|
+
def __init__(self, name: str, model: BaseChatModel, prompt: type[Prompt]):
|
|
67
|
+
super().__init__(
|
|
68
|
+
name=name,
|
|
69
|
+
model=model,
|
|
70
|
+
prompt=prompt,
|
|
71
|
+
structured_output=None,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
75
|
+
response = await self._ainvoke(state)
|
|
76
|
+
return {"messages": [response]}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AnswerFailureLLMNode(LLMNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
80
|
+
def __init__(self, name: str, model: BaseChatModel, prompt: type[Prompt]):
|
|
81
|
+
super().__init__(
|
|
82
|
+
name=name,
|
|
83
|
+
model=model,
|
|
84
|
+
prompt=prompt,
|
|
85
|
+
structured_output=None,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
89
|
+
response = await self._ainvoke(state)
|
|
90
|
+
return {"messages": [response]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class IrrelevantQueryLLMNode(LLMNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
94
|
+
def __init__(self, name: str, model: BaseChatModel, prompt: type[Prompt]):
|
|
95
|
+
super().__init__(
|
|
96
|
+
name=name,
|
|
97
|
+
model=model,
|
|
98
|
+
prompt=prompt,
|
|
99
|
+
structured_output=None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
103
|
+
response = await self._ainvoke(state)
|
|
104
|
+
return {"messages": [response]}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IsQueryRelevantRouterNode(RouterNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
108
|
+
def __init__(self, name: str) -> None:
|
|
109
|
+
super().__init__(name=name)
|
|
110
|
+
|
|
111
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
112
|
+
if state["query"] and state["query"]["content"] != "[NAN]": # type: ignore[index]
|
|
113
|
+
return "yes"
|
|
114
|
+
return "no"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IsDocumentRelevantRouterNode(RouterNode[AgentState]): # ty: ignore[invalid-type-arguments]
|
|
118
|
+
def __init__(self, name: str) -> None:
|
|
119
|
+
super().__init__(name=name)
|
|
120
|
+
|
|
121
|
+
async def __call__(self, state: AgentState) -> Any:
|
|
122
|
+
if state["is_answerable"] is None:
|
|
123
|
+
raise MissingStateKeyError("is_answerable")
|
|
124
|
+
return state["is_answerable"]["response"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Literal, TypedDict
|
|
2
|
+
|
|
3
|
+
from langgraph.graph import MessagesState
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Query(TypedDict):
|
|
7
|
+
content: str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IsAnswerable(TypedDict):
|
|
11
|
+
response: Literal["yes", "no"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentState(MessagesState):
|
|
15
|
+
query: Query | None
|
|
16
|
+
documents: list[dict[str, str]] | None
|
|
17
|
+
is_answerable: IsAnswerable | None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
5
|
+
|
|
6
|
+
from agents_builder.exceptions import StreamingNotSupportedError
|
|
7
|
+
from agents_builder.mixins import FromConfigMixin
|
|
8
|
+
from agents_builder.settings import LLMSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LLM[TConfig: LLMSettings](FromConfigMixin[TConfig], ABC):
|
|
12
|
+
def __init__(self, config: TConfig):
|
|
13
|
+
super().__init__(config)
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def to_langchain(self) -> BaseChatModel:
|
|
17
|
+
raise NotImplementedError()
|
|
18
|
+
|
|
19
|
+
def with_streaming(self, streaming: bool = True) -> Self:
|
|
20
|
+
raise StreamingNotSupportedError("Streaming not supported for this LLM")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from agents_builder.exceptions import InvalidLLMClassError, InvalidLLMRoleError
|
|
5
|
+
from agents_builder.llm import LLM
|
|
6
|
+
from agents_builder.mixins import FromConfigMixin
|
|
7
|
+
from agents_builder.settings import LLMFactorySettings
|
|
8
|
+
from agents_builder.utils import load_class
|
|
9
|
+
|
|
10
|
+
_logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMFactory(FromConfigMixin[LLMFactorySettings]):
|
|
14
|
+
def __init__(self, config: LLMFactorySettings):
|
|
15
|
+
super().__init__(config)
|
|
16
|
+
self._models: dict[str, LLM[Any]] = {}
|
|
17
|
+
_logger.info(f"LLMFactory initialized with roles: {list(self.config.roles.keys())}")
|
|
18
|
+
|
|
19
|
+
def get(self, role: str) -> LLM[Any]:
|
|
20
|
+
self._validate_role(role)
|
|
21
|
+
if role in self._models:
|
|
22
|
+
return self._models[role]
|
|
23
|
+
model = self._build_model(role)
|
|
24
|
+
self._models[role] = model
|
|
25
|
+
_logger.info(f"LLMFactory initialized role '{role}' successfully")
|
|
26
|
+
return model
|
|
27
|
+
|
|
28
|
+
def _validate_role(self, role: str) -> None:
|
|
29
|
+
if role not in self.config.roles:
|
|
30
|
+
available = list(self.config.roles.keys())
|
|
31
|
+
_logger.error(f"Invalid LLM role '{role}'. Available roles: {available}")
|
|
32
|
+
raise InvalidLLMRoleError(role, available)
|
|
33
|
+
_logger.debug(f"Validated LLM role '{role}'")
|
|
34
|
+
|
|
35
|
+
def _build_model(self, role: str) -> LLM[Any]:
|
|
36
|
+
role_config = self.config.roles[role]
|
|
37
|
+
llm_class = load_class(role_config.module_path)
|
|
38
|
+
if not isinstance(llm_class, type) or not issubclass(llm_class, LLM):
|
|
39
|
+
_logger.error(f"Loaded class '{role_config.module_path}' is not a subclass of LLM. Got: {llm_class}")
|
|
40
|
+
raise InvalidLLMClassError(
|
|
41
|
+
f"Loaded class '{role_config.module_path}' is not a subclass of LLM. Got: {llm_class}"
|
|
42
|
+
)
|
|
43
|
+
llm_instance = llm_class.from_config(role_config)
|
|
44
|
+
return llm_instance
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
5
|
+
from langchain_ollama import ChatOllama
|
|
6
|
+
from langchain_openrouter import ChatOpenRouter
|
|
7
|
+
|
|
8
|
+
from agents_builder.llm import LLM
|
|
9
|
+
from agents_builder.settings import OllamaLLMSettings, OpenRouterLLMSettings
|
|
10
|
+
|
|
11
|
+
_logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OllamaLLM(LLM[OllamaLLMSettings]):
|
|
15
|
+
def __init__(self, config: OllamaLLMSettings):
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
|
|
18
|
+
def to_langchain(self) -> BaseChatModel:
|
|
19
|
+
model = ChatOllama(model=self.config.model, base_url=self.config.base_url)
|
|
20
|
+
_logger.info(f"Chat model ready: {self.config.model}")
|
|
21
|
+
return model
|
|
22
|
+
|
|
23
|
+
def with_streaming(self, streaming: bool = True) -> Self:
|
|
24
|
+
# Ollama streams based on call method (astream), not config
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenRouterLLM(LLM[OpenRouterLLMSettings]):
|
|
29
|
+
def __init__(self, config: OpenRouterLLMSettings):
|
|
30
|
+
super().__init__(config)
|
|
31
|
+
self.streaming = False
|
|
32
|
+
|
|
33
|
+
def with_streaming(self, streaming: bool = True) -> Self:
|
|
34
|
+
self.streaming = streaming
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def to_langchain(self) -> BaseChatModel:
|
|
38
|
+
_logger.info(f"Initializing OpenRouter model: {self.config.model}")
|
|
39
|
+
model = ChatOpenRouter(
|
|
40
|
+
model=self.config.model,
|
|
41
|
+
model_kwargs=self.config.model_kwargs,
|
|
42
|
+
openrouter_provider=self.config.provider, # raw dict
|
|
43
|
+
streaming=self.streaming,
|
|
44
|
+
)
|
|
45
|
+
_logger.info(f"OpenRouter Chat model ready: {self.config.model}")
|
|
46
|
+
return model
|
agents_builder/mixins.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Self, get_args
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from agents_builder.settings import FromConfigMixinSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FromConfigMixin[TConfig: FromConfigMixinSettings]:
|
|
10
|
+
def __init__(self, config: TConfig) -> None:
|
|
11
|
+
self.config = config
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def get_config_class(cls) -> type[TConfig]:
|
|
15
|
+
return get_args(cls.__orig_bases__[0])[0] # type: ignore
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_config(
|
|
19
|
+
cls,
|
|
20
|
+
config: TConfig,
|
|
21
|
+
) -> Self:
|
|
22
|
+
return cls(config)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_yaml(
|
|
26
|
+
cls,
|
|
27
|
+
path: str,
|
|
28
|
+
key: str | None = None,
|
|
29
|
+
) -> Self:
|
|
30
|
+
"""
|
|
31
|
+
Load a runtime object from YAML.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
yaml_path: Path to YAML config file
|
|
35
|
+
settings_cls: Pydantic Settings model to validate config
|
|
36
|
+
key: Optional top-level YAML key (e.g. "retriever")
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Instantiated runtime object
|
|
40
|
+
"""
|
|
41
|
+
yaml_path = Path(path)
|
|
42
|
+
with yaml_path.open("r") as f:
|
|
43
|
+
raw: dict[str, Any] = yaml.safe_load(f)
|
|
44
|
+
if key is not None:
|
|
45
|
+
raw = raw[key]
|
|
46
|
+
config = cls.get_config_class().model_validate(raw)
|
|
47
|
+
return cls.from_config(config)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
async def create(cls, config: TConfig) -> Self:
|
|
51
|
+
return cls.from_config(config)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
async def from_yaml_async(
|
|
55
|
+
cls,
|
|
56
|
+
path: str,
|
|
57
|
+
key: str | None = None,
|
|
58
|
+
) -> Self:
|
|
59
|
+
yaml_path = Path(path)
|
|
60
|
+
with yaml_path.open("r") as f:
|
|
61
|
+
raw: dict[str, Any] = yaml.safe_load(f)
|
|
62
|
+
|
|
63
|
+
if key is not None:
|
|
64
|
+
raw = raw[key]
|
|
65
|
+
config_cls: type[TConfig] = cls.get_config_class()
|
|
66
|
+
config = config_cls.model_validate(raw)
|
|
67
|
+
return await cls.create(config)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_settings(cls) -> Self:
|
|
71
|
+
"""
|
|
72
|
+
Load runtime object using Pydantic Settings resolution:
|
|
73
|
+
init > yaml > env > dotenv > secrets
|
|
74
|
+
"""
|
|
75
|
+
config = cls.get_config_class()() # ty: ignore[missing-argument]
|
|
76
|
+
return cls.from_config(config)
|
agents_builder/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import (
|
|
5
|
+
BaseSettings,
|
|
6
|
+
PydanticBaseSettingsSource,
|
|
7
|
+
SettingsConfigDict,
|
|
8
|
+
YamlConfigSettingsSource,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from agents_builder.constants import CONFIG_PATH
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FromConfigMixinSettings(BaseSettings):
|
|
15
|
+
module_path: str
|
|
16
|
+
model_config = SettingsConfigDict(
|
|
17
|
+
yaml_file=CONFIG_PATH,
|
|
18
|
+
extra="ignore",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def settings_customise_sources(
|
|
23
|
+
cls,
|
|
24
|
+
settings_cls: type[BaseSettings],
|
|
25
|
+
init_settings: PydanticBaseSettingsSource,
|
|
26
|
+
env_settings: PydanticBaseSettingsSource,
|
|
27
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
28
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
29
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
30
|
+
return (
|
|
31
|
+
init_settings,
|
|
32
|
+
YamlConfigSettingsSource(settings_cls),
|
|
33
|
+
env_settings,
|
|
34
|
+
dotenv_settings,
|
|
35
|
+
file_secret_settings,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MCPServerSettings(BaseSettings):
|
|
40
|
+
name: str
|
|
41
|
+
url: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LLMSettings(FromConfigMixinSettings):
|
|
45
|
+
model: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OllamaLLMSettings(LLMSettings):
|
|
49
|
+
kind: Literal["ollama"]
|
|
50
|
+
base_url: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OpenRouterLLMSettings(LLMSettings):
|
|
54
|
+
kind: Literal["openrouter"]
|
|
55
|
+
model_kwargs: dict[str, Any]
|
|
56
|
+
provider: dict[str, Any] | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
AnyLLMSettings = Annotated[
|
|
60
|
+
OllamaLLMSettings | OpenRouterLLMSettings,
|
|
61
|
+
Field(discriminator="kind"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LLMFactorySettings(FromConfigMixinSettings):
|
|
66
|
+
roles: dict[str, AnyLLMSettings]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DeletionStrategySettings(FromConfigMixinSettings):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AgentSettings(FromConfigMixinSettings):
|
|
74
|
+
llm_factory: LLMFactorySettings
|
|
75
|
+
mcps: list[MCPServerSettings]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TrimDeletionStrategySettings(DeletionStrategySettings):
|
|
79
|
+
strategy: Literal["first", "last"]
|
|
80
|
+
max_tokens: int
|
|
81
|
+
start_on: str
|
|
82
|
+
end_on: tuple[str, str]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BaseAgentSettings(AgentSettings):
|
|
86
|
+
pass
|
agents_builder/utils.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from langchain.tools import BaseTool
|
|
6
|
+
from langchain_core.messages import HumanMessage, ToolMessage
|
|
7
|
+
from langchain_core.messages.utils import AnyMessage
|
|
8
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
9
|
+
|
|
10
|
+
from agents_builder.exceptions import NoHumanMessageError
|
|
11
|
+
from agents_builder.settings import MCPServerSettings
|
|
12
|
+
|
|
13
|
+
_mcp_tools_cache: dict[str, list[BaseTool]] = {}
|
|
14
|
+
_mcp_tools_lock = asyncio.Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_class(path: str) -> Any:
|
|
18
|
+
module_path, class_name = path.rsplit(".", 1)
|
|
19
|
+
module = importlib.import_module(module_path)
|
|
20
|
+
return getattr(module, class_name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_latest_human_question(messages: list[AnyMessage]) -> Any:
|
|
24
|
+
for msg in reversed(messages):
|
|
25
|
+
if isinstance(msg, HumanMessage):
|
|
26
|
+
return msg.content
|
|
27
|
+
raise NoHumanMessageError("No HumanMessage found in messages")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def message_content_to_str(content: str | list[str | dict[str, Any]]) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Normalize LangChain message content to a string.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(content, str):
|
|
35
|
+
return content
|
|
36
|
+
|
|
37
|
+
parts: list[str] = []
|
|
38
|
+
for item in content:
|
|
39
|
+
if isinstance(item, str):
|
|
40
|
+
parts.append(item)
|
|
41
|
+
elif isinstance(item, dict):
|
|
42
|
+
# Common LC formats: {"text": "..."} or tool payloads
|
|
43
|
+
if "text" in item and isinstance(item["text"], str):
|
|
44
|
+
parts.append(item["text"])
|
|
45
|
+
else:
|
|
46
|
+
parts.append(str(item))
|
|
47
|
+
return "\n".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_retrieved_context(messages: list[AnyMessage]) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Collect ALL retrieved document contents from the conversation history.
|
|
53
|
+
"""
|
|
54
|
+
contexts = []
|
|
55
|
+
for msg in messages:
|
|
56
|
+
# Tool-based retriever output
|
|
57
|
+
if isinstance(msg, ToolMessage):
|
|
58
|
+
contexts.append(message_content_to_str(msg.content))
|
|
59
|
+
return "\n\n".join(c for c in contexts if c.strip())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _cached_mcp_tools(
|
|
63
|
+
mcps_fingerprint: str,
|
|
64
|
+
mcps_config: list[MCPServerSettings],
|
|
65
|
+
) -> list[BaseTool]:
|
|
66
|
+
# Fast path (already cached)
|
|
67
|
+
if mcps_fingerprint in _mcp_tools_cache:
|
|
68
|
+
return _mcp_tools_cache[mcps_fingerprint]
|
|
69
|
+
|
|
70
|
+
async with _mcp_tools_lock:
|
|
71
|
+
# Double-check inside lock
|
|
72
|
+
if mcps_fingerprint in _mcp_tools_cache:
|
|
73
|
+
return _mcp_tools_cache[mcps_fingerprint]
|
|
74
|
+
|
|
75
|
+
client = MultiServerMCPClient(
|
|
76
|
+
{ # ty: ignore[invalid-argument-type]
|
|
77
|
+
mcp.name: {
|
|
78
|
+
"transport": "http",
|
|
79
|
+
"url": mcp.url,
|
|
80
|
+
}
|
|
81
|
+
for mcp in mcps_config
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
tools = await client.get_tools()
|
|
86
|
+
_mcp_tools_cache[mcps_fingerprint] = tools
|
|
87
|
+
return tools
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_context(documents: list[str]) -> str:
|
|
91
|
+
return "\n\n".join(documents)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agents-builder
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: jalal
|
|
6
|
+
Author-email: jalalkhaldi3@gmail.com
|
|
7
|
+
Requires-Python: >=3.12,<3.14
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Dist: dotenv (>=0.9.9,<0.10.0)
|
|
12
|
+
Requires-Dist: fastapi (>=0.128.0,<0.129.0)
|
|
13
|
+
Requires-Dist: langchain (>=1.1.0,<2.0.0)
|
|
14
|
+
Requires-Dist: langchain-mcp-adapters (>=0.2.1,<0.3.0)
|
|
15
|
+
Requires-Dist: langchain-ollama (>=1.0.0,<2.0.0)
|
|
16
|
+
Requires-Dist: langchain-openai (>=1.1.5,<2.0.0)
|
|
17
|
+
Requires-Dist: langchain-openrouter (>=0.0.2,<0.0.3)
|
|
18
|
+
Requires-Dist: openinference-instrumentation-langchain (>=0.1.56,<0.2.0)
|
|
19
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
20
|
+
Requires-Dist: pydantic-settings (>=2.12.0,<3.0.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
agents_builder/__init__.py,sha256=3GjVUZL-GDNK9bBvBcwQ_qRuZ_uwANxtst4gec8xUlI,3447
|
|
2
|
+
agents_builder/constants.py,sha256=tA4eBkVQHXH3EDOHNjUlsPcwUxFATgclCYNmvUoBRPY,815
|
|
3
|
+
agents_builder/exceptions.py,sha256=YEeu2LVuH2x1Cue0a5hiIJrgo8DdfrbQ7kWx1CQQVck,1869
|
|
4
|
+
agents_builder/langgraph/__init__.py,sha256=EZsIWnvrLmyYnkXOPiftArXPoD_WoYJgdmWt63IKc0s,3153
|
|
5
|
+
agents_builder/langgraph/nodes.py,sha256=alH9CwthJDEHWIzqFfpZfiF3_wZICSmH18u3Xq9cuSc,4414
|
|
6
|
+
agents_builder/langgraph/states.py,sha256=cQHP71RyXrYHLhuQLD-3JE6kTKtemq-N253eddOWqfM,333
|
|
7
|
+
agents_builder/llm/__init__.py,sha256=5IQuMC-hreFv7Br2jPyRA7f3xsbI0laGuh4J9wlvF2M,680
|
|
8
|
+
agents_builder/llm/factory.py,sha256=hi5azO355XcJiZVuqFLTLoxEbE_o_Fi9RJGJZ5q75mY,1874
|
|
9
|
+
agents_builder/llm/llm.py,sha256=rJ-gmGTFl5yqRydEljdC0vK0TRfbOSNct3s4EDPidPI,1579
|
|
10
|
+
agents_builder/mixins.py,sha256=64Bh_bZYQ9gDF88S2xbbaSsdUVWw6LozoV8IOKlDrjE,2099
|
|
11
|
+
agents_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
agents_builder/settings.py,sha256=vH5cf2Evn13nUv40U65PutHojhTS8EKaibtPP6Ye_1Q,1960
|
|
13
|
+
agents_builder/utils.py,sha256=R9kwNjnjMnWKeMlYLgvwVJktQrov1vYYRybRtVRgEtU,2800
|
|
14
|
+
agents_builder-1.0.0.dist-info/METADATA,sha256=8erFOzKBJlrHh2Fr6BSado2B1MH7XDOHQgrF1wcLWtk,839
|
|
15
|
+
agents_builder-1.0.0.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
|
|
16
|
+
agents_builder-1.0.0.dist-info/RECORD,,
|