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.
@@ -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
@@ -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)
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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any