llm-agent-base 0.1.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,15 @@
1
+ from .agent_base import AgentBase
2
+ from .agent_pipeline_base import AgentPipelineBase
3
+ from .knowledge_base import DocumentChunk, KnowledgeBase
4
+ from .llm_connection_config import LLMConnectionConfig
5
+ from .tool_calling import build_tool_schema, execute_tool_loop
6
+
7
+ __all__ = [
8
+ "AgentBase",
9
+ "AgentPipelineBase",
10
+ "DocumentChunk",
11
+ "KnowledgeBase",
12
+ "LLMConnectionConfig",
13
+ "build_tool_schema",
14
+ "execute_tool_loop",
15
+ ]
@@ -0,0 +1,70 @@
1
+ from typing import Callable, Optional
2
+
3
+ from .knowledge_base import DocumentChunk, KnowledgeBase
4
+ from .llm_connection_config import LLMConnectionConfig
5
+ from .tool_calling import build_tool_schema, execute_tool_loop
6
+
7
+
8
+ class AgentBase:
9
+ def __init__(
10
+ self,
11
+ system_prompt: str,
12
+ llm_config: LLMConnectionConfig,
13
+ knowledge_folder_path: Optional[str] = None,
14
+ knowledge_index_dir: str = ".kb_index",
15
+ knowledge_top_k: int = 5,
16
+ debug: bool = False,
17
+ ):
18
+ self.system_prompt = system_prompt
19
+ self.llm_config = llm_config
20
+ self.knowledge_top_k = knowledge_top_k
21
+ self.debug = debug
22
+ self._client = llm_config.build_client()
23
+ self._kb: Optional[KnowledgeBase] = None
24
+ self._tools: dict[str, tuple[Callable, dict]] = {}
25
+
26
+ if knowledge_folder_path:
27
+ self._kb = KnowledgeBase(
28
+ folder_path=knowledge_folder_path,
29
+ llm_config=llm_config,
30
+ index_dir=knowledge_index_dir,
31
+ )
32
+
33
+ def register_tool(self, fn: Callable) -> Callable:
34
+ """Register a Python function as a callable tool. Can be used as a decorator."""
35
+ self._tools[fn.__name__] = (fn, build_tool_schema(fn))
36
+ return fn
37
+
38
+ def ingest_knowledge(self, save: bool = True) -> int:
39
+ if self._kb is None:
40
+ return 0
41
+ count = self._kb.ingest()
42
+ if save:
43
+ self._kb.save()
44
+ return count
45
+
46
+ def load_knowledge(self):
47
+ if self._kb is not None:
48
+ self._kb.load()
49
+
50
+ def retrieve_knowledge(self, query: str) -> list[DocumentChunk]:
51
+ if self._kb is None:
52
+ return []
53
+ if self.debug:
54
+ print("[debug] Retrieving knowledge")
55
+ return self._kb.retrieve(query, top_k=self.knowledge_top_k)
56
+
57
+ def run(self, prompt: str) -> str:
58
+ chunks = self.retrieve_knowledge(prompt)
59
+
60
+ system = self.system_prompt
61
+ if chunks:
62
+ context = "\n\n".join(f"[{c.source}]\n{c.text}" for c in chunks)
63
+ system = f"{system}\n\n<context>\n{context}\n</context>"
64
+
65
+ messages = [
66
+ {"role": "system", "content": system},
67
+ {"role": "user", "content": prompt},
68
+ ]
69
+
70
+ return execute_tool_loop(self._client, self.llm_config.model, messages, self._tools, debug=self.debug)
@@ -0,0 +1,12 @@
1
+ from .agent_base import AgentBase
2
+
3
+
4
+ class AgentPipelineBase:
5
+ def __init__(self, agents: list[AgentBase]):
6
+ self._agents = agents
7
+
8
+ def run(self, prompt: str) -> str:
9
+ result = prompt
10
+ for agent in self._agents:
11
+ result = agent.run(result)
12
+ return result
@@ -0,0 +1,145 @@
1
+ import json
2
+ import pickle
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+ import faiss
9
+
10
+ from .llm_connection_config import LLMConnectionConfig
11
+
12
+
13
+ @dataclass
14
+ class DocumentChunk:
15
+ text: str
16
+ source: str
17
+ chunk_index: int
18
+ metadata: dict = field(default_factory=dict)
19
+
20
+
21
+ class KnowledgeBase:
22
+ SUPPORTED_EXTENSIONS = {".txt", ".md", ".json", ".pdf"}
23
+
24
+ def __init__(
25
+ self,
26
+ folder_path: str,
27
+ llm_config: LLMConnectionConfig,
28
+ index_dir: str = ".kb_index",
29
+ chunk_size: int = 1000,
30
+ chunk_overlap: int = 100,
31
+ ):
32
+ self.folder_path = Path(folder_path)
33
+ self.index_dir = Path(index_dir)
34
+ self.chunk_size = chunk_size
35
+ self.chunk_overlap = chunk_overlap
36
+ self.embedding_model = llm_config.embedding_model
37
+ self._client = llm_config.build_client()
38
+
39
+ self._chunks: list[DocumentChunk] = []
40
+ self._index: Optional[faiss.IndexFlatIP] = None
41
+ self._dim: Optional[int] = None
42
+
43
+ # ------------------------------------------------------------------ #
44
+ # Public API #
45
+ # ------------------------------------------------------------------ #
46
+
47
+ def ingest(self) -> int:
48
+ """Parse all files in folder_path, chunk, embed, and build the index."""
49
+ self._chunks = []
50
+ vectors = []
51
+
52
+ for path in sorted(self.folder_path.rglob("*")):
53
+ if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
54
+ continue
55
+ text = self._parse_file(path)
56
+ if not text:
57
+ continue
58
+ for i, chunk_text in enumerate(self._chunk_text(text)):
59
+ chunk = DocumentChunk(
60
+ text=chunk_text,
61
+ source=str(path.relative_to(self.folder_path)),
62
+ chunk_index=i,
63
+ )
64
+ self._chunks.append(chunk)
65
+ vectors.append(self._embed(chunk_text))
66
+
67
+ if not vectors:
68
+ return 0
69
+
70
+ matrix = np.array(vectors, dtype="float32")
71
+ self._dim = matrix.shape[1]
72
+ faiss.normalize_L2(matrix)
73
+ self._index = faiss.IndexFlatIP(self._dim)
74
+ self._index.add(matrix)
75
+ return len(self._chunks)
76
+
77
+ def save(self):
78
+ """Persist the FAISS index and chunk catalog to disk."""
79
+ self.index_dir.mkdir(parents=True, exist_ok=True)
80
+ faiss.write_index(self._index, str(self.index_dir / "index.faiss"))
81
+ with open(self.index_dir / "catalog.pkl", "wb") as f:
82
+ pickle.dump({"chunks": self._chunks, "dim": self._dim}, f)
83
+
84
+ def load(self):
85
+ """Restore a previously saved index and catalog from disk."""
86
+ index_path = self.index_dir / "index.faiss"
87
+ catalog_path = self.index_dir / "catalog.pkl"
88
+ if not index_path.exists() or not catalog_path.exists():
89
+ raise FileNotFoundError(f"No saved index found in {self.index_dir}")
90
+ self._index = faiss.read_index(str(index_path))
91
+ with open(catalog_path, "rb") as f:
92
+ data = pickle.load(f)
93
+ self._chunks = data["chunks"]
94
+ self._dim = data["dim"]
95
+
96
+ def retrieve(self, query: str, top_k: int = 5) -> list[DocumentChunk]:
97
+ """Return the top_k most relevant chunks for the given query."""
98
+ if self._index is None or self._index.ntotal == 0:
99
+ return []
100
+ vec = np.array([self._embed(query)], dtype="float32")
101
+ faiss.normalize_L2(vec)
102
+ _scores, indices = self._index.search(vec, min(top_k, self._index.ntotal))
103
+ return [self._chunks[i] for i in indices[0] if i >= 0]
104
+
105
+ # ------------------------------------------------------------------ #
106
+ # Private helpers #
107
+ # ------------------------------------------------------------------ #
108
+
109
+ def _parse_file(self, path: Path) -> str:
110
+ ext = path.suffix.lower()
111
+ if ext == ".pdf":
112
+ return self._parse_pdf(path)
113
+ if ext == ".json":
114
+ return self._parse_json(path)
115
+ return path.read_text(encoding="utf-8", errors="ignore")
116
+
117
+ @staticmethod
118
+ def _parse_pdf(path: Path) -> str:
119
+ from pypdf import PdfReader
120
+ reader = PdfReader(str(path))
121
+ return "\n".join(page.extract_text() or "" for page in reader.pages)
122
+
123
+ @staticmethod
124
+ def _parse_json(path: Path) -> str:
125
+ with open(path, encoding="utf-8") as f:
126
+ data = json.load(f)
127
+ return json.dumps(data, ensure_ascii=False, indent=2)
128
+
129
+ def _chunk_text(self, text: str) -> list[str]:
130
+ chunks = []
131
+ start = 0
132
+ while start < len(text):
133
+ end = start + self.chunk_size
134
+ chunks.append(text[start:end])
135
+ start = end - self.chunk_overlap
136
+ if start >= len(text):
137
+ break
138
+ return [c.strip() for c in chunks if c.strip()]
139
+
140
+ def _embed(self, text: str) -> list[float]:
141
+ response = self._client.embeddings.create(
142
+ model=self.embedding_model,
143
+ input=text,
144
+ )
145
+ return response.data[0].embedding
@@ -0,0 +1,22 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ from openai import OpenAI
6
+
7
+
8
+ @dataclass
9
+ class LLMConnectionConfig:
10
+ model: str
11
+ base_url: str = "https://openrouter.ai/api/v1"
12
+ api_key: Optional[str] = None
13
+ embedding_model: str = "openai/text-embedding-3-small"
14
+
15
+ def get_api_key(self) -> str:
16
+ key = self.api_key or os.environ.get("OPENROUTER_API_KEY")
17
+ if not key:
18
+ raise ValueError("API key required: pass api_key or set OPENROUTER_API_KEY")
19
+ return key
20
+
21
+ def build_client(self) -> OpenAI:
22
+ return OpenAI(api_key=self.get_api_key(), base_url=self.base_url)
@@ -0,0 +1,100 @@
1
+ import inspect
2
+ import json
3
+ from typing import Callable, Union, get_args, get_origin, get_type_hints
4
+
5
+ _JSON_TYPE_MAP = {
6
+ str: "string",
7
+ int: "integer",
8
+ float: "number",
9
+ bool: "boolean",
10
+ list: "array",
11
+ dict: "object",
12
+ }
13
+
14
+
15
+ def _is_optional(tp) -> bool:
16
+ return get_origin(tp) is Union and type(None) in get_args(tp)
17
+
18
+
19
+ def _unwrap_optional(tp):
20
+ args = [a for a in get_args(tp) if a is not type(None)]
21
+ return args[0] if args else str
22
+
23
+
24
+ def _to_json_type(tp) -> str:
25
+ if _is_optional(tp):
26
+ tp = _unwrap_optional(tp)
27
+ return _JSON_TYPE_MAP.get(tp, "string")
28
+
29
+
30
+ def build_tool_schema(fn: Callable) -> dict:
31
+ hints = get_type_hints(fn)
32
+ hints.pop("return", None)
33
+ sig = inspect.signature(fn)
34
+
35
+ properties = {}
36
+ required = []
37
+ for name, param in sig.parameters.items():
38
+ tp = hints.get(name, str)
39
+ properties[name] = {"type": _to_json_type(tp)}
40
+ if not _is_optional(tp) and param.default is inspect.Parameter.empty:
41
+ required.append(name)
42
+
43
+ return {
44
+ "type": "function",
45
+ "function": {
46
+ "name": fn.__name__,
47
+ "description": inspect.getdoc(fn) or "",
48
+ "parameters": {
49
+ "type": "object",
50
+ "properties": properties,
51
+ "required": required,
52
+ },
53
+ },
54
+ }
55
+
56
+
57
+ def execute_tool_loop(
58
+ client,
59
+ model: str,
60
+ messages: list,
61
+ tools: dict[str, tuple[Callable, dict]],
62
+ debug: bool = False,
63
+ ) -> str:
64
+ """Run the agentic tool-calling loop and return the final text response."""
65
+ tool_schemas = [schema for _, schema in tools.values()]
66
+
67
+ while True:
68
+ kwargs = {"model": model, "messages": messages}
69
+ if tool_schemas:
70
+ kwargs["tools"] = tool_schemas
71
+
72
+ response = client.chat.completions.create(**kwargs)
73
+ choice = response.choices[0]
74
+ msg = choice.message
75
+
76
+ if choice.finish_reason != "tool_calls" or not msg.tool_calls:
77
+ return msg.content
78
+
79
+ messages.append(msg)
80
+
81
+ for tc in msg.tool_calls:
82
+ fn, _ = tools.get(tc.function.name, (None, None))
83
+ if fn is None:
84
+ result = f"Error: unknown tool '{tc.function.name}'"
85
+ if debug:
86
+ print(f"[debug] LLM called unknown tool '{tc.function.name}'")
87
+ else:
88
+ try:
89
+ args = json.loads(tc.function.arguments)
90
+ result = str(fn(**args))
91
+ if debug:
92
+ print(f"[debug] tool '{tc.function.name}' args={args} result={result}")
93
+ except Exception as e:
94
+ result = f"Error: {e}"
95
+
96
+ messages.append({
97
+ "role": "tool",
98
+ "tool_call_id": tc.id,
99
+ "content": result,
100
+ })
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-agent-base
3
+ Version: 0.1.0
4
+ Summary: Lightweight Python framework for building LLM agents with tool calling and RAG
5
+ Author-email: Paweł Gryka <pawel.m.gryka@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mksochota16/llm-agent-base
8
+ Project-URL: Repository, https://github.com/mksochota16/llm-agent-base
9
+ Keywords: llm,agent,rag,openai,tool-calling
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: openai>=2.0.0
21
+ Requires-Dist: faiss-cpu>=1.7.0
22
+ Requires-Dist: pypdf>=4.0.0
23
+ Requires-Dist: numpy>=1.24.0
24
+ Requires-Dist: python-dotenv>=1.0.0
25
+
26
+ # llm-agent-base
27
+
28
+ A lightweight Python base for building LLM agents with tool calling and retrieval-augmented generation (RAG). It works with any OpenAI-compatible API (OpenRouter, OpenAI, local models via Ollama, etc.).
29
+
30
+ ## Features
31
+
32
+ - **Tool calling** — register plain Python functions as LLM-callable tools; schemas are built automatically from type hints and docstrings
33
+ - **RAG** — ingest a folder of documents (`.txt`, `.md`, `.json`, `.pdf`) into a FAISS vector index and inject relevant chunks into every prompt
34
+ - **Pipelines** — chain multiple agents so each agent's output becomes the next agent's input
35
+ - **Debug mode** — optional logging of tool calls and knowledge retrievals
36
+
37
+ ## Project structure
38
+
39
+ ```
40
+ agent_base.py # AgentBase class
41
+ agent_pipeline_base.py # AgentPipelineBase class
42
+ tool_calling.py # Schema building and tool-call execution loop
43
+ knowledge_base.py # Document ingestion, embedding, and FAISS retrieval
44
+ llm_connection_config.py# LLM client configuration
45
+ knowledge/ # Knowledge files (subdirectory per topic)
46
+ ```
47
+
48
+ ## Setup
49
+
50
+ **1. Install dependencies**
51
+
52
+ ```bash
53
+ pip install -r requirements.txt
54
+ ```
55
+
56
+ **2. Configure environment**
57
+
58
+ Copy `.env` and fill in your credentials:
59
+
60
+ ```env
61
+ OPENROUTER_API_KEY=your-api-key-here
62
+ MODEL=openai/gpt-4o-mini
63
+ BASE_URL=https://openrouter.ai/api/v1
64
+ ```
65
+
66
+ The default `BASE_URL` points to [OpenRouter](https://openrouter.ai), which gives access to many models through a single API key. You can swap it for the OpenAI base URL or any other compatible endpoint.
67
+
68
+ ## Usage
69
+
70
+ ### Basic agent
71
+
72
+ ```python
73
+ from agent_base import AgentBase
74
+ from llm_connection_config import LLMConnectionConfig
75
+
76
+ config = LLMConnectionConfig(model="openai/gpt-4o-mini", api_key="...")
77
+
78
+ agent = AgentBase(
79
+ system_prompt="You are a helpful assistant.",
80
+ llm_config=config,
81
+ )
82
+
83
+ print(agent.run("What is the capital of France?"))
84
+ ```
85
+
86
+ ### Tool calling
87
+
88
+ Register any Python function as a tool. The function name becomes the tool name, the docstring becomes its description, and the type hints define the parameter schema.
89
+
90
+ ```python
91
+ def get_weather(city: str) -> str:
92
+ """Return the current weather for a given city."""
93
+ return f"The weather in {city} is sunny and 22°C."
94
+
95
+ def add(a: int, b: int) -> int:
96
+ """Add two integers and return the result."""
97
+ return a + b
98
+
99
+ agent = AgentBase(
100
+ system_prompt="You are a helpful assistant. Use the available tools when needed.",
101
+ llm_config=config,
102
+ )
103
+ agent.register_tool(get_weather)
104
+ agent.register_tool(add)
105
+
106
+ print(agent.run("What is the weather in Tokyo and what is 10 + 20?"))
107
+ ```
108
+
109
+ `register_tool` can also be used as a decorator:
110
+
111
+ ```python
112
+ @agent.register_tool
113
+ def search_orders(order_id: str) -> str:
114
+ """Look up an order by ID."""
115
+ ...
116
+ ```
117
+
118
+ ### RAG (knowledge base)
119
+
120
+ Place your documents in a folder (organised into subdirectories by topic). Call `ingest_knowledge` once to embed and index them, then `run` as normal — relevant chunks are automatically injected into the system prompt.
121
+
122
+ ```
123
+ knowledge/
124
+ ├── topic/
125
+ │ └── overview.txt
126
+ ├── products/
127
+ │ ├── faq.md
128
+ │ └── pricing.json
129
+ └── support/
130
+ └── sla.md
131
+ ```
132
+
133
+ ```python
134
+ agent = AgentBase(
135
+ system_prompt="You are a product assistant. Answer using only the provided context.",
136
+ llm_config=config,
137
+ knowledge_folder_path="knowledge",
138
+ knowledge_index_dir=".kb_index", # where the FAISS index is saved
139
+ knowledge_top_k=3, # number of chunks injected per prompt
140
+ )
141
+
142
+ # Build and persist the index (run once, or when documents change)
143
+ agent.ingest_knowledge(save=True)
144
+
145
+ # On subsequent runs, load from disk instead of re-embedding
146
+ # agent.load_knowledge()
147
+
148
+ print(agent.run("Who founded the company and when?"))
149
+ ```
150
+
151
+ ### Agent pipelines
152
+
153
+ Chain agents so the output of one becomes the input of the next:
154
+
155
+ ```python
156
+ from agent_pipeline_base import AgentPipelineBase
157
+
158
+ researcher = AgentBase(
159
+ system_prompt="Extract the key facts from the user's question.",
160
+ llm_config=config,
161
+ )
162
+ writer = AgentBase(
163
+ system_prompt="Turn the provided facts into a concise, friendly summary.",
164
+ llm_config=config,
165
+ )
166
+
167
+ pipeline = AgentPipelineBase(agents=[researcher, writer])
168
+ print(pipeline.run("Tell me about the Acme Corp product lineup."))
169
+ ```
170
+
171
+ ### Debug mode
172
+
173
+ Pass `debug=True` to any agent to print tool invocations and knowledge retrievals to stdout:
174
+
175
+ ```python
176
+ agent = AgentBase(..., debug=True)
177
+ ```
178
+
179
+ ```
180
+ [debug] Retrieving knowledge
181
+ [debug] tool 'get_weather' args={'city': 'Tokyo'} result=The weather in Tokyo is sunny and 22°C.
182
+ ```
@@ -0,0 +1,10 @@
1
+ llm_agent_base/__init__.py,sha256=OXswBWRHp2RbBAFqAOuKubnREFBzvJbp3x2JdXRSALY,436
2
+ llm_agent_base/agent_base.py,sha256=t49EfsnmlTwPSed_9w9DKdYsMoqjOWqfhtb4TgDuJ18,2364
3
+ llm_agent_base/agent_pipeline_base.py,sha256=99FEKfn9ZsKxbWFkNysGP7ubksWXOwrtYkFEYj_a9BI,300
4
+ llm_agent_base/knowledge_base.py,sha256=Y6zWzvTYw8lRFLFWnzEaPiDDCh-FBoxO9GgnXaXMOa0,5206
5
+ llm_agent_base/llm_connection_config.py,sha256=urd4HpwU2XkF7K_iC8zgryXsufUdkn7dxUKsiFie6Ko,641
6
+ llm_agent_base/tool_calling.py,sha256=n04S9XSViBj01zAUqmQBQLKVtGL2gs7MKVWbSaNkVa0,2851
7
+ llm_agent_base-0.1.0.dist-info/METADATA,sha256=UjRjdJE5PT7h7izM00QM9Z9ecyNMdpvp7XZByHfhhgY,5808
8
+ llm_agent_base-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ llm_agent_base-0.1.0.dist-info/top_level.txt,sha256=MFNYEHY1in6nbChJGeVLrzBIlGm9sJ-pXQpaXT5BmjM,15
10
+ llm_agent_base-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ llm_agent_base