org.slashlib.py.agent 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.
- org/slashlib/py/agent/__init__.py +46 -0
- org/slashlib/py/agent/__main__.py +91 -0
- org/slashlib/py/agent/agent.py +334 -0
- org/slashlib/py/agent/agent_response.py +207 -0
- org/slashlib/py/agent/inference_bases.py +120 -0
- org/slashlib/py/agent/inference_complements_for_ollama.py +186 -0
- org/slashlib/py/agent/tool.py +158 -0
- org_slashlib_py_agent-0.1.0.dist-info/METADATA +100 -0
- org_slashlib_py_agent-0.1.0.dist-info/RECORD +13 -0
- org_slashlib_py_agent-0.1.0.dist-info/WHEEL +5 -0
- org_slashlib_py_agent-0.1.0.dist-info/licenses/LICENSE.md +23 -0
- org_slashlib_py_agent-0.1.0.dist-info/org.slashlib.py.agent.egg-info.md +0 -0
- org_slashlib_py_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/__init__.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
|
|
16
|
+
from src.org.slashlib.py.agent.agent import Agent
|
|
17
|
+
from src.org.slashlib.py.agent.tool import tool
|
|
18
|
+
from src.org.slashlib.py.agent.agent_response import AgentResponse
|
|
19
|
+
from src.org.slashlib.py.agent.inference_bases import (
|
|
20
|
+
InferenceAdapter,
|
|
21
|
+
InferenceResult,
|
|
22
|
+
InferenceError,
|
|
23
|
+
InferenceConnectionError,
|
|
24
|
+
InferenceConfigError,
|
|
25
|
+
InferencePayloadError
|
|
26
|
+
)
|
|
27
|
+
from src.org.slashlib.py.agent.inference_complements_for_ollama import (
|
|
28
|
+
OllamaInferenceAdapter,
|
|
29
|
+
OllamaInferenceResult
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Agent",
|
|
34
|
+
"tool",
|
|
35
|
+
"AgentResponse",
|
|
36
|
+
"InferenceAdapter",
|
|
37
|
+
"InferenceResult",
|
|
38
|
+
"InferenceError",
|
|
39
|
+
"InferenceConnectionError",
|
|
40
|
+
"InferenceConfigError",
|
|
41
|
+
"InferencePayloadError",
|
|
42
|
+
"OllamaInferenceAdapter",
|
|
43
|
+
"OllamaInferenceResult"
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# end of file src/org/slashlib/py/agent/__init__.py
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/__main__.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
#
|
|
16
|
+
# pragma: no cover
|
|
17
|
+
#
|
|
18
|
+
"""
|
|
19
|
+
Entry point for the org.slashlib.py.agent package.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Import python packages
|
|
23
|
+
import argparse
|
|
24
|
+
import logging
|
|
25
|
+
import pathlib
|
|
26
|
+
import sys
|
|
27
|
+
from importlib import metadata
|
|
28
|
+
|
|
29
|
+
# Compatibility layer for TOML (Python 3.11+ uses tomllib, older use tomli)
|
|
30
|
+
if sys.version_info >= (3, 11):
|
|
31
|
+
import tomllib
|
|
32
|
+
else:
|
|
33
|
+
try:
|
|
34
|
+
import tomli as tomllib
|
|
35
|
+
except ImportError:
|
|
36
|
+
tomllib = None
|
|
37
|
+
|
|
38
|
+
# Import thirdparty packages
|
|
39
|
+
import org.slashlib.py.configloader
|
|
40
|
+
|
|
41
|
+
# setup logging
|
|
42
|
+
org.slashlib.py.configloader.setup_logging()
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger(f"org.slashlib.py.agent.{pathlib.Path(__file__).stem}")
|
|
45
|
+
|
|
46
|
+
def get_version() -> str:
|
|
47
|
+
"""
|
|
48
|
+
Retrieve the version of the package from metadata or local pyproject.toml.
|
|
49
|
+
"""
|
|
50
|
+
# 1. Try metadata (works if installed via pip)
|
|
51
|
+
try:
|
|
52
|
+
return metadata.version("org.slashlib.py.agent")
|
|
53
|
+
except metadata.PackageNotFoundError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# 2. Fallback: Parse pyproject.toml directly (works during development)
|
|
57
|
+
if tomllib is not None:
|
|
58
|
+
try:
|
|
59
|
+
# Search for pyproject.toml relative to this file's location
|
|
60
|
+
# Path: src/org/slashlib/py/agent/__main__.py -> root is 4 levels up
|
|
61
|
+
root_path = pathlib.Path(__file__).parents[5]
|
|
62
|
+
toml_path = root_path / "pyproject.toml"
|
|
63
|
+
|
|
64
|
+
if toml_path.exists():
|
|
65
|
+
with open(toml_path, "rb") as f:
|
|
66
|
+
data = tomllib.load(f)
|
|
67
|
+
return data.get("project", {}).get("version", "unknown (local)")
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
def start():
|
|
74
|
+
"""
|
|
75
|
+
Bootstrap function to instantiate and run the bot.
|
|
76
|
+
"""
|
|
77
|
+
parser = argparse.ArgumentParser(description="org.slashlib.py.agent CLI")
|
|
78
|
+
parser.add_argument("--version", action="store_true", help="Show the package version and exit")
|
|
79
|
+
|
|
80
|
+
args, unknown = parser.parse_known_args()
|
|
81
|
+
|
|
82
|
+
if args.version:
|
|
83
|
+
print(f"org.slashlib.py.agent version {get_version()}")
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
else:
|
|
86
|
+
parser.print_help()
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
start()
|
|
90
|
+
|
|
91
|
+
# end of file src/org/slashlib/py/agent/__main__.py
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/agent.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
# Python imports
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import pathlib
|
|
21
|
+
import typing
|
|
22
|
+
|
|
23
|
+
# Third party imports
|
|
24
|
+
import org.slashlib.py.configloader as config
|
|
25
|
+
|
|
26
|
+
# Internal imports
|
|
27
|
+
import src.org.slashlib.py.agent.tool as tool
|
|
28
|
+
import src.org.slashlib.py.agent.agent_response as response
|
|
29
|
+
import src.org.slashlib.py.agent.inference_bases as inference
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Module-level instance registry to implement the Multiton pattern
|
|
33
|
+
_instances: typing.Dict[str, "Agent"] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Agent:
|
|
37
|
+
"""
|
|
38
|
+
Base class for an AI Agent.
|
|
39
|
+
|
|
40
|
+
This class orchestrates the interaction between an inference adapter and a set of tools.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __new__(cls, identifier: str, tools: typing.List[tool.Tool], adapter: inference.InferenceAdapter, multi: bool = True):
|
|
44
|
+
"""
|
|
45
|
+
Create a new instance or return an existing one based on the identifier.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
identifier (str): A unique identifier for the agent instance.
|
|
49
|
+
tools (typing.List[tool.Tool]): A list of Tool objects the agent can use.
|
|
50
|
+
adapter (inference.InferenceAdapter): The adapter to use for inference.
|
|
51
|
+
multi (bool): Whether the agent can run multiple tasks simultaneously.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Agent: The new or existing agent instance.
|
|
55
|
+
"""
|
|
56
|
+
if identifier in _instances:
|
|
57
|
+
return _instances[identifier]
|
|
58
|
+
|
|
59
|
+
instance = super(Agent, cls).__new__(cls)
|
|
60
|
+
_instances[identifier] = instance
|
|
61
|
+
return instance
|
|
62
|
+
|
|
63
|
+
def __init__(self, identifier: str, tools: typing.List[tool.Tool], adapter: inference.InferenceAdapter, multi: bool = True):
|
|
64
|
+
"""
|
|
65
|
+
Initialize the Agent.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
identifier (str): A unique identifier for the agent instance.
|
|
69
|
+
tools (typing.List[tool.Tool]): A list of Tool objects. Must not be empty.
|
|
70
|
+
adapter (inference.InferenceAdapter): The adapter providing the inference capabilities.
|
|
71
|
+
multi (bool): If False, the agent allows only one active task at a time. Defaults to True.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If tools or adapter is None.
|
|
75
|
+
"""
|
|
76
|
+
# Skip initialization if already initialized (for __new__ multiton logic)
|
|
77
|
+
if hasattr(self, "_initialized") and self._initialized:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
self.log = logging.getLogger(f"org.slashlib.py.agent.{pathlib.Path(__file__).stem}.{self.__class__.__name__}")
|
|
81
|
+
self._identifier = identifier
|
|
82
|
+
self._multi = multi
|
|
83
|
+
|
|
84
|
+
if not tools:
|
|
85
|
+
raise ValueError("The tools list must not be empty.")
|
|
86
|
+
|
|
87
|
+
if not adapter:
|
|
88
|
+
raise ValueError("An InferenceAdapter is required.")
|
|
89
|
+
|
|
90
|
+
self._tools = tools
|
|
91
|
+
self._adapter = adapter
|
|
92
|
+
self._active_tasks: typing.Set[asyncio.Task] = set()
|
|
93
|
+
self._initialized = True
|
|
94
|
+
self.log.debug(f"Agent initialized with {len(self._tools)} tools and adapter {type(adapter).__name__} (multi={self._multi})")
|
|
95
|
+
|
|
96
|
+
def __del__(self):
|
|
97
|
+
"""
|
|
98
|
+
Destructor called when the agent object is about to be destroyed.
|
|
99
|
+
Ensures all running tasks are cancelled.
|
|
100
|
+
"""
|
|
101
|
+
# Note: During interpreter shutdown, globals/imports might be None.
|
|
102
|
+
try:
|
|
103
|
+
if hasattr(self, "_active_tasks") and self._active_tasks:
|
|
104
|
+
self.cancel()
|
|
105
|
+
except Exception:
|
|
106
|
+
# Destructors should not raise exceptions
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def identifier(self) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Get the identifier of the agent.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
str: The agent's identifier.
|
|
116
|
+
"""
|
|
117
|
+
return self._identifier
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def tools(self) -> typing.List[tool.Tool]:
|
|
121
|
+
"""
|
|
122
|
+
Get the list of tools available to the agent.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List[tool.Tool]: The registered tools.
|
|
126
|
+
"""
|
|
127
|
+
return self._tools
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def has_active_tasks(self) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Check if the agent is currently processing any tasks.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
bool: True if there are running background tasks.
|
|
136
|
+
"""
|
|
137
|
+
return len(self._active_tasks) > 0
|
|
138
|
+
|
|
139
|
+
def get_tool_schemas(self) -> typing.List[dict]:
|
|
140
|
+
"""
|
|
141
|
+
Collects all JSON schemas from the registered tools.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
typing.List[dict]: A list of tool schemas for LLM consumption.
|
|
145
|
+
"""
|
|
146
|
+
return [tool_obj.get_schema() for tool_obj in self._tools]
|
|
147
|
+
|
|
148
|
+
def _init_response_object(self, **kwargs) -> response.AgentResponse:
|
|
149
|
+
"""
|
|
150
|
+
Initializes a new AgentResponse object and sets up the initial context.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
**kwargs: Arguments containing 'user_prompt' and optional 'system_prompt'.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
response.AgentResponse: The initialized response object.
|
|
157
|
+
"""
|
|
158
|
+
response_obj = response.AgentResponse()
|
|
159
|
+
|
|
160
|
+
user_prompt = kwargs.get("user_prompt")
|
|
161
|
+
system_prompt = kwargs.get("system_prompt")
|
|
162
|
+
|
|
163
|
+
if not user_prompt:
|
|
164
|
+
err = ValueError("A 'user_prompt' is required to run the agent.")
|
|
165
|
+
response_obj.append_error(err)
|
|
166
|
+
return response_obj
|
|
167
|
+
|
|
168
|
+
if system_prompt:
|
|
169
|
+
response_obj.append_context(role="system", content=system_prompt)
|
|
170
|
+
|
|
171
|
+
response_obj.append_context(role="user", content=user_prompt)
|
|
172
|
+
return response_obj
|
|
173
|
+
|
|
174
|
+
async def _execute_tool(self, name: str, arguments: dict) -> typing.Any:
|
|
175
|
+
"""
|
|
176
|
+
Internal helper to find and execute a tool by its name.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
name (str): The name of the tool to execute.
|
|
180
|
+
arguments (dict): The arguments to pass to the tool.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Any: The result of the tool execution.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If no tool with the given name is found.
|
|
187
|
+
"""
|
|
188
|
+
for tool_obj in self._tools:
|
|
189
|
+
if tool_obj.name == name:
|
|
190
|
+
self.log.info(f"Agent {self.identifier} executing tool '{name}' with {arguments}")
|
|
191
|
+
return await tool_obj(**arguments)
|
|
192
|
+
|
|
193
|
+
raise ValueError(f"Tool '{name}' not found in agent's toolbox.")
|
|
194
|
+
|
|
195
|
+
async def _run_tool_calls(self, response_obj: response.AgentResponse, tool_calls: typing.Optional[typing.List[dict]]) -> bool:
|
|
196
|
+
"""
|
|
197
|
+
Processes tool calls if present.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
response_obj (response.AgentResponse): The current response object to update.
|
|
201
|
+
tool_calls (Optional[List[dict]]): The list of tool calls from the LLM.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
bool: True if tool calls were processed, False otherwise.
|
|
205
|
+
"""
|
|
206
|
+
if not tool_calls:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
self.log.info(f"Agent {self.identifier} received {len(tool_calls)} tool call(s).")
|
|
210
|
+
|
|
211
|
+
for call in tool_calls:
|
|
212
|
+
tool_name = call.get("function", {}).get("name")
|
|
213
|
+
tool_args = call.get("function", {}).get("arguments")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
result = await self._execute_tool(tool_name, tool_args)
|
|
217
|
+
response_obj.append_context(
|
|
218
|
+
role="tool",
|
|
219
|
+
content=result,
|
|
220
|
+
name=tool_name
|
|
221
|
+
)
|
|
222
|
+
except Exception as tool_err:
|
|
223
|
+
self.log.error(f"Tool execution failed: {tool_err}")
|
|
224
|
+
response_obj.append_tool_error(tool_err)
|
|
225
|
+
response_obj.append_context(
|
|
226
|
+
role="tool",
|
|
227
|
+
content=f"Error: {str(tool_err)}",
|
|
228
|
+
name=tool_name
|
|
229
|
+
)
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
async def _run(self, **kwargs) -> response.AgentResponse:
|
|
233
|
+
"""
|
|
234
|
+
Internal asynchronous task execution.
|
|
235
|
+
|
|
236
|
+
Orchestrates the loop between the inference adapter and tool execution.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
**kwargs: Arbitrary keyword arguments.
|
|
240
|
+
Supports 'user_prompt', 'system_prompt', 'model', 'timeout' and 'think'.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
response.AgentResponse: The result object containing the history and any errors.
|
|
244
|
+
"""
|
|
245
|
+
response_obj = self._init_response_object(**kwargs)
|
|
246
|
+
|
|
247
|
+
if response_obj.has_error:
|
|
248
|
+
return response_obj
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
is_running = True
|
|
252
|
+
while is_running:
|
|
253
|
+
self.log.debug(f"Agent {self.identifier} initiating inference call...")
|
|
254
|
+
|
|
255
|
+
# The adapter handles all provider-specific details and config resolution
|
|
256
|
+
inference_result = await self._adapter.chat(
|
|
257
|
+
messages=response_obj.get_context(),
|
|
258
|
+
tools=self.get_tool_schemas(),
|
|
259
|
+
**kwargs
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Use the standardized to_dict() for AgentResponse
|
|
263
|
+
response_obj.append_context(**inference_result.to_dict())
|
|
264
|
+
|
|
265
|
+
if await self._run_tool_calls(response_obj, inference_result.tool_calls):
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
is_running = False
|
|
269
|
+
self.log.info(f"Agent {self.identifier} finished task.")
|
|
270
|
+
|
|
271
|
+
except asyncio.CancelledError:
|
|
272
|
+
self.log.debug(f"Task for agent {self.identifier} was cancelled.")
|
|
273
|
+
raise
|
|
274
|
+
except inference.InferenceError as ie:
|
|
275
|
+
self.log.error(f"Inference failed for agent {self.identifier}: {ie}")
|
|
276
|
+
response_obj.append_error(ie)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self.log.error(f"Unexpected error in background task for agent {self.identifier}: {e}", exc_info=True)
|
|
279
|
+
response_obj.append_error(e)
|
|
280
|
+
finally:
|
|
281
|
+
current_task = asyncio.current_task()
|
|
282
|
+
if current_task in self._active_tasks:
|
|
283
|
+
self._active_tasks.remove(current_task)
|
|
284
|
+
|
|
285
|
+
return response_obj
|
|
286
|
+
|
|
287
|
+
def run(self, **kwargs) -> asyncio.Task:
|
|
288
|
+
"""
|
|
289
|
+
Starts the agent logic in a non-blocking background task.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
**kwargs: Arbitrary keyword arguments passed to the internal _run method.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
asyncio.Task: The task object managing the background execution.
|
|
296
|
+
The result of this task will be an AgentResponse object.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
RuntimeError: If multi is False and a task is already running.
|
|
300
|
+
"""
|
|
301
|
+
if not self._multi and self.has_active_tasks:
|
|
302
|
+
raise RuntimeError(f"Agent {self.identifier} is already running a task and multi-tasking is disabled.")
|
|
303
|
+
|
|
304
|
+
task = asyncio.create_task(self._run(**kwargs))
|
|
305
|
+
self._active_tasks.add(task)
|
|
306
|
+
return task
|
|
307
|
+
|
|
308
|
+
def cancel(self, task: typing.Optional[asyncio.Task] = None):
|
|
309
|
+
"""
|
|
310
|
+
Cancels running tasks.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
task (Optional[asyncio.Task]): The specific task to cancel.
|
|
314
|
+
"""
|
|
315
|
+
if task is not None:
|
|
316
|
+
if task in self._active_tasks:
|
|
317
|
+
task.cancel()
|
|
318
|
+
self.log.debug(f"Specific task cancelled for agent {self.identifier}.")
|
|
319
|
+
else:
|
|
320
|
+
self.log.warning(f"Task cancel requested but task not found in active tasks for agent {self.identifier}.")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
if not self._active_tasks:
|
|
324
|
+
self.log.debug(f"No active tasks to cancel for agent {self.identifier}.")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
self.log.debug(f"Cancelling all ({len(self._active_tasks)}) active tasks for agent {self.identifier}.")
|
|
328
|
+
for t in list(self._active_tasks):
|
|
329
|
+
t.cancel()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
__all__ = ["Agent"]
|
|
333
|
+
|
|
334
|
+
# end of file src/org/slashlib/py/agent/agent.py
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/agent_response.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
|
|
16
|
+
# Python imports
|
|
17
|
+
import copy
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import pathlib
|
|
21
|
+
import typing
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentResponse:
|
|
25
|
+
"""
|
|
26
|
+
Represents the result of an Agent execution, including history and errors.
|
|
27
|
+
|
|
28
|
+
Separates process errors (fatal to the run) from tool errors (non-fatal execution errors).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
"""
|
|
33
|
+
Initialize a new AgentResponse instance.
|
|
34
|
+
"""
|
|
35
|
+
self.log = logging.getLogger(f"org.slashlib.py.agent.{pathlib.Path(__file__).stem}.{self.__class__.__name__}")
|
|
36
|
+
self._context: typing.List[typing.Dict[str, typing.Any]] = []
|
|
37
|
+
self._errors: typing.List[Exception] = []
|
|
38
|
+
self._tool_errors: typing.List[Exception] = []
|
|
39
|
+
|
|
40
|
+
def append_context(self, **kwargs):
|
|
41
|
+
"""
|
|
42
|
+
Public interface to add a message to the context.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
**kwargs: Arbitrary keyword arguments (e.g., role, content).
|
|
46
|
+
"""
|
|
47
|
+
self._append_context(**kwargs)
|
|
48
|
+
|
|
49
|
+
def append_error(self, error: Exception):
|
|
50
|
+
"""
|
|
51
|
+
Public interface to record a process error.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
error (Exception): The exception instance to record.
|
|
55
|
+
"""
|
|
56
|
+
self._append_error(error)
|
|
57
|
+
|
|
58
|
+
def append_tool_error(self, error: Exception):
|
|
59
|
+
"""
|
|
60
|
+
Public interface to record a tool error.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
error (Exception): The exception instance to record.
|
|
64
|
+
"""
|
|
65
|
+
self._append_tool_error(error)
|
|
66
|
+
|
|
67
|
+
def _append_context(self, **kwargs):
|
|
68
|
+
"""
|
|
69
|
+
Internal method to append a message to the context.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
**kwargs: Arbitrary keyword arguments. Expected to contain 'role' (str)
|
|
73
|
+
and 'content' (str, dict, or list).
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
TypeError: If 'content' is not a string, dict, or list.
|
|
77
|
+
ValueError: If 'content' cannot be serialized to JSON.
|
|
78
|
+
"""
|
|
79
|
+
role = kwargs.get("role")
|
|
80
|
+
content = kwargs.get("content")
|
|
81
|
+
|
|
82
|
+
if (not role) or (content is None):
|
|
83
|
+
self.log.warning(f"Skipped context update: 'role' or 'content' missing in {kwargs}")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Ensure content is a string (convert if it's a JSON-like structure)
|
|
87
|
+
if isinstance(content, (dict, list)):
|
|
88
|
+
try:
|
|
89
|
+
kwargs["content"] = json.dumps(content, ensure_ascii=False)
|
|
90
|
+
except (TypeError, ValueError) as e:
|
|
91
|
+
self.log.error(f"Failed to serialize context content to JSON: {e}")
|
|
92
|
+
raise
|
|
93
|
+
elif not isinstance(content, str):
|
|
94
|
+
msg = f"Unsupported content type: {type(content)}. Expected str, dict or list."
|
|
95
|
+
self.log.error(msg)
|
|
96
|
+
raise TypeError(msg)
|
|
97
|
+
|
|
98
|
+
self._context.append(kwargs)
|
|
99
|
+
|
|
100
|
+
def _append_error(self, error: Exception):
|
|
101
|
+
"""
|
|
102
|
+
Appends a process exception (fatal) to the error list.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
error (Exception): The exception instance to record.
|
|
106
|
+
"""
|
|
107
|
+
if isinstance(error, Exception):
|
|
108
|
+
self._errors.append(error)
|
|
109
|
+
self.log.debug(f"Process error recorded in AgentResponse: {error}")
|
|
110
|
+
|
|
111
|
+
def _append_tool_error(self, error: Exception):
|
|
112
|
+
"""
|
|
113
|
+
Appends a tool-specific exception (non-fatal) to the tool error list.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
error (Exception): The exception instance to record.
|
|
117
|
+
"""
|
|
118
|
+
if isinstance(error, Exception):
|
|
119
|
+
self._tool_errors.append(error)
|
|
120
|
+
self.log.debug(f"Tool error recorded in AgentResponse: {error}")
|
|
121
|
+
|
|
122
|
+
def get_context(self, index: typing.Optional[int] = None) -> typing.Union[typing.List[dict], dict]:
|
|
123
|
+
"""
|
|
124
|
+
Returns a deep copy of the context.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
index (Optional[int]): If provided, returns only the node at this index.
|
|
128
|
+
Otherwise, returns the entire list. Defaults to None.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Union[List[dict], dict]: A deep copy of the entire context list or a single
|
|
132
|
+
context dictionary if an index was provided.
|
|
133
|
+
"""
|
|
134
|
+
if index is not None:
|
|
135
|
+
return copy.deepcopy(self._context[index])
|
|
136
|
+
return copy.deepcopy(self._context)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def response(self) -> typing.Optional[str]:
|
|
140
|
+
"""
|
|
141
|
+
Returns the content of the last assistant message in the context.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Optional[str]: The final response string from the assistant,
|
|
145
|
+
or None if no assistant message exists in the history.
|
|
146
|
+
"""
|
|
147
|
+
for msg in reversed(self._context):
|
|
148
|
+
if msg.get("role") == "assistant":
|
|
149
|
+
return msg.get("content")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def context_size(self) -> int:
|
|
154
|
+
"""
|
|
155
|
+
Returns the number of messages in the context.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
int: The total count of context entries.
|
|
159
|
+
"""
|
|
160
|
+
return len(self._context)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def has_error(self) -> bool:
|
|
164
|
+
"""
|
|
165
|
+
Checks if any process errors occurred during execution.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
bool: True if one or more process errors were recorded.
|
|
169
|
+
"""
|
|
170
|
+
return len(self._errors) > 0
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def has_tool_error(self) -> bool:
|
|
174
|
+
"""
|
|
175
|
+
Checks if any tool-specific errors occurred during execution.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
bool: True if one or more tool errors were recorded.
|
|
179
|
+
"""
|
|
180
|
+
return len(self._tool_errors) > 0
|
|
181
|
+
|
|
182
|
+
def raise_errors(self):
|
|
183
|
+
"""
|
|
184
|
+
Raises the first process error if any exist.
|
|
185
|
+
Tool errors are ignored by this method as they are non-fatal.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
Exception: The first process exception stored in the error list.
|
|
189
|
+
"""
|
|
190
|
+
if self.has_error:
|
|
191
|
+
if len(self._errors) > 1:
|
|
192
|
+
self.log.warning(f"Multiple process errors ({len(self._errors)}) found. Raising the first one.")
|
|
193
|
+
raise self._errors[0]
|
|
194
|
+
|
|
195
|
+
def get_tool_errors(self) -> typing.List[Exception]:
|
|
196
|
+
"""
|
|
197
|
+
Returns all recorded tool errors.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List[Exception]: A list of exceptions that occurred during tool execution.
|
|
201
|
+
"""
|
|
202
|
+
return self._tool_errors
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = ["AgentResponse"]
|
|
206
|
+
|
|
207
|
+
# end of file src/org/slashlib/py/agent/agent_response.py
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/inference_bases.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
|
|
16
|
+
# Python imports
|
|
17
|
+
import abc
|
|
18
|
+
import typing
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InferenceError(Exception):
|
|
22
|
+
"""Base exception for all inference related errors."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InferenceConnectionError(InferenceError):
|
|
27
|
+
"""Raised when the connection to the AI provider fails."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InferenceConfigError(InferenceError):
|
|
32
|
+
"""Raised when the model configuration (e.g. model name) is invalid."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InferencePayloadError(InferenceError):
|
|
37
|
+
"""Raised when the response payload is malformed or invalid."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InferenceResult(abc.ABC):
|
|
42
|
+
"""
|
|
43
|
+
Abstract interface for the result of an inference execution.
|
|
44
|
+
Standardizes how the Agent accesses model responses and tool calls.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
@abc.abstractmethod
|
|
49
|
+
def role(self) -> str:
|
|
50
|
+
"""Returns the role of the message (e.g., 'assistant')."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
@abc.abstractmethod
|
|
55
|
+
def content(self) -> typing.Optional[str]:
|
|
56
|
+
"""Returns the text content of the response."""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abc.abstractmethod
|
|
61
|
+
def tool_calls(self) -> typing.Optional[typing.List[typing.Dict[str, typing.Any]]]:
|
|
62
|
+
"""
|
|
63
|
+
Returns tool calls in a standardized format:
|
|
64
|
+
[{"function": {"name": str, "arguments": dict}}]
|
|
65
|
+
"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abc.abstractmethod
|
|
69
|
+
def to_dict(self) -> typing.Dict[str, typing.Any]:
|
|
70
|
+
"""
|
|
71
|
+
Converts the result into a dictionary compatible with the
|
|
72
|
+
internal storage format of AgentResponse.
|
|
73
|
+
"""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InferenceAdapter(abc.ABC):
|
|
78
|
+
"""
|
|
79
|
+
Abstract interface for an Inference Adapter.
|
|
80
|
+
Encapsulates the communication with a specific AI model provider.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@abc.abstractmethod
|
|
84
|
+
async def chat(
|
|
85
|
+
self,
|
|
86
|
+
model: typing.Optional[str] = None,
|
|
87
|
+
messages: typing.List[typing.Dict[str, typing.Any]] = None,
|
|
88
|
+
tools: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = None,
|
|
89
|
+
**kwargs
|
|
90
|
+
) -> InferenceResult:
|
|
91
|
+
"""
|
|
92
|
+
Executes a chat request and returns a standardized InferenceResult.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
model (str): The name/identifier of the model to use.
|
|
96
|
+
messages (list): The full conversation context.
|
|
97
|
+
tools (list, optional): Available tool schemas.
|
|
98
|
+
**kwargs: Provider-specific options (timeout, think, temperature, etc.)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
InferenceResult: The validated and standardized result.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
InferenceConnectionError: If the provider is unreachable.
|
|
105
|
+
InferencePayloadError: If the response is malformed.
|
|
106
|
+
InferenceConfigError: If configuration/model is wrong.
|
|
107
|
+
"""
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = [
|
|
112
|
+
"InferenceResult",
|
|
113
|
+
"InferenceAdapter",
|
|
114
|
+
"InferenceError",
|
|
115
|
+
"InferenceConnectionError",
|
|
116
|
+
"InferenceConfigError",
|
|
117
|
+
"InferencePayloadError"
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# end of file src/org/slashlib/py/agent/inference_bases.py
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/inference_complements_for_ollama.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
# Python imports
|
|
18
|
+
import logging
|
|
19
|
+
import pathlib
|
|
20
|
+
import typing
|
|
21
|
+
|
|
22
|
+
# Third party imports
|
|
23
|
+
import ollama
|
|
24
|
+
import org.slashlib.py.configloader as config
|
|
25
|
+
|
|
26
|
+
# Internal imports
|
|
27
|
+
import src.org.slashlib.py.agent.inference_bases as inference
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OllamaInferenceResult(inference.InferenceResult):
|
|
31
|
+
"""
|
|
32
|
+
Specific implementation of InferenceResult for Ollama.
|
|
33
|
+
|
|
34
|
+
This class takes the raw dictionary response from the Ollama API,
|
|
35
|
+
validates its mandatory fields (role, content/tool_calls), and
|
|
36
|
+
provides standardized accessors for the Agent.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, raw_response: typing.Dict[str, typing.Any]):
|
|
40
|
+
"""
|
|
41
|
+
Initialize and validate the Ollama response.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
raw_response (Dict[str, Any]): The 'message' part of the Ollama API response.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
inference.InferencePayloadError: If the 'role' is missing or if both
|
|
48
|
+
'content' and 'tool_calls' are empty/None.
|
|
49
|
+
"""
|
|
50
|
+
self._role = raw_response.get("role")
|
|
51
|
+
self._content = raw_response.get("content")
|
|
52
|
+
self._tool_calls = raw_response.get("tool_calls")
|
|
53
|
+
|
|
54
|
+
if not self._role:
|
|
55
|
+
raise inference.InferencePayloadError(f"Invalid Ollama response: 'role' is missing. Data: {raw_response}")
|
|
56
|
+
|
|
57
|
+
# Note: content can be None if tool_calls are present, which is valid.
|
|
58
|
+
if self._content is None and not self._tool_calls:
|
|
59
|
+
raise inference.InferencePayloadError(f"Invalid Ollama response: Both 'content' and 'tool_calls' are empty.")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def role(self) -> str:
|
|
63
|
+
"""
|
|
64
|
+
The role of the message sender.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: Typically 'assistant'.
|
|
68
|
+
"""
|
|
69
|
+
return self._role
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def content(self) -> typing.Optional[str]:
|
|
73
|
+
"""
|
|
74
|
+
The text content of the model's response.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Optional[str]: The message text or None if only tool calls are present.
|
|
78
|
+
"""
|
|
79
|
+
return self._content
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def tool_calls(self) -> typing.Optional[typing.List[typing.Dict[str, typing.Any]]]:
|
|
83
|
+
"""
|
|
84
|
+
A list of tool calls generated by the model.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Optional[List[Dict[str, Any]]]: Standardized tool call objects or None.
|
|
88
|
+
"""
|
|
89
|
+
return self._tool_calls
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> typing.Dict[str, typing.Any]:
|
|
92
|
+
"""
|
|
93
|
+
Converts the result into a standardized dictionary.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dict[str, Any]: A dictionary containing 'role', 'content', and 'tool_calls'.
|
|
97
|
+
"""
|
|
98
|
+
return {
|
|
99
|
+
"role": self._role,
|
|
100
|
+
"content": self._content,
|
|
101
|
+
"tool_calls": self._tool_calls
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class OllamaInferenceAdapter(inference.InferenceAdapter):
|
|
106
|
+
"""
|
|
107
|
+
Inference Adapter for the Ollama API.
|
|
108
|
+
|
|
109
|
+
Handles asynchronous communication with an Ollama server, including
|
|
110
|
+
parameter resolution from config and error mapping to the neutral
|
|
111
|
+
inference exception hierarchy.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self):
|
|
115
|
+
"""
|
|
116
|
+
Initialize the adapter and its logger.
|
|
117
|
+
"""
|
|
118
|
+
self.log = logging.getLogger(f"org.slashlib.py.agent.{pathlib.Path(__file__).stem}.{self.__class__.__name__}")
|
|
119
|
+
|
|
120
|
+
async def chat(
|
|
121
|
+
self,
|
|
122
|
+
model: typing.Optional[str] = None,
|
|
123
|
+
messages: typing.List[typing.Dict[str, typing.Any]] = None,
|
|
124
|
+
tools: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = None,
|
|
125
|
+
**kwargs
|
|
126
|
+
) -> inference.InferenceResult:
|
|
127
|
+
"""
|
|
128
|
+
Sends a chat request to Ollama and returns a validated result.
|
|
129
|
+
|
|
130
|
+
Resolves model parameters using a priority chain:
|
|
131
|
+
1. Explicit kwargs, 2. Method arguments, 3. pyproject.json via configloader.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
model (Optional[str]): The model name. Defaults to config value.
|
|
135
|
+
messages (List[Dict[str, Any]]): The message history/context.
|
|
136
|
+
tools (Optional[List[Dict[str, Any]]]): JSON schemas of available tools.
|
|
137
|
+
**kwargs: Additional parameters like 'timeout' or 'think'.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
inference.InferenceResult: An instance of OllamaInferenceResult.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
inference.InferenceConfigError: If the model is not found or config is invalid.
|
|
144
|
+
inference.InferenceConnectionError: If the Ollama server is unreachable or times out.
|
|
145
|
+
inference.InferencePayloadError: If the response from Ollama is malformed.
|
|
146
|
+
inference.InferenceError: For any other unexpected errors during inference.
|
|
147
|
+
"""
|
|
148
|
+
# Resolve parameters: priority is kwargs -> method arg -> pyproject.json
|
|
149
|
+
target_model = model or kwargs.get("model", config.resolve("adapter.ollama.model"))
|
|
150
|
+
timeout = kwargs.get("timeout", config.resolve("adapter.ollama.timeout"))
|
|
151
|
+
think = kwargs.get("think", config.resolve("adapter.ollama.think"))
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
self.log.debug(f"Ollama request: model={target_model}, timeout={timeout}, think={think}")
|
|
155
|
+
|
|
156
|
+
client = ollama.AsyncClient(timeout=timeout)
|
|
157
|
+
response = await client.chat(
|
|
158
|
+
model=target_model,
|
|
159
|
+
messages=messages,
|
|
160
|
+
tools=tools,
|
|
161
|
+
think=think
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
message = response.get("message")
|
|
165
|
+
if not message:
|
|
166
|
+
raise inference.InferencePayloadError(f"Ollama API returned an empty response for model '{target_model}'.")
|
|
167
|
+
|
|
168
|
+
return OllamaInferenceResult(message)
|
|
169
|
+
|
|
170
|
+
except (ollama.ResponseError) as e:
|
|
171
|
+
self.log.error(f"Ollama logical/config error: {e}")
|
|
172
|
+
raise inference.InferenceConfigError(f"Ollama model or config invalid: {str(e)}")
|
|
173
|
+
except (ollama.RequestError) as e:
|
|
174
|
+
self.log.error(f"Ollama communication error: {e}")
|
|
175
|
+
raise inference.InferenceConnectionError(f"Failed to connect to Ollama: {str(e)}")
|
|
176
|
+
except inference.InferenceError:
|
|
177
|
+
# Re-raise internal inference errors to prevent them from being caught by the general Exception block
|
|
178
|
+
raise
|
|
179
|
+
except Exception as e:
|
|
180
|
+
self.log.error(f"Unexpected error in OllamaInferenceAdapter: {e}", exc_info=True)
|
|
181
|
+
raise inference.InferenceError(f"Internal adapter error: {str(e)}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = ["OllamaInferenceAdapter", "OllamaInferenceResult"]
|
|
185
|
+
|
|
186
|
+
# end of file src/org/slashlib/py/agent/inference_complements_for_ollama.py
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# file src/org/slashlib/py/agent/tool.py
|
|
3
|
+
# @AI:
|
|
4
|
+
# - INTEGRITY RULES:
|
|
5
|
+
# - STRICT PRESERVATION: Do not remove, move, or modify ANY existing lines of code or comments
|
|
6
|
+
# unless they are the explicit target of the requested change.
|
|
7
|
+
# - DEBUG MARKERS: Commented-out code (e.g., debug prints) MUST be kept exactly where they are.
|
|
8
|
+
# - WHITESPACE & STRUCTURE: Maintain all original empty lines and the existing file structure.
|
|
9
|
+
# Structural integrity takes precedence over "clean code" or "elegance".
|
|
10
|
+
# - LEAD-IN/OUT: The very first and last lines (and all comments in between) are immutable anchors.
|
|
11
|
+
# - MAINTENANCE:
|
|
12
|
+
# - Only update pydoc strings (args, returns, raises) if the function signature changes.
|
|
13
|
+
# - Do NOT delete existing examples or descriptions in pydoc.
|
|
14
|
+
# - LANGUAGE: en-US for all comments and documentation.
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
# Python imports
|
|
18
|
+
import functools
|
|
19
|
+
import inspect
|
|
20
|
+
import typing
|
|
21
|
+
|
|
22
|
+
# Third party imports
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Tool:
|
|
26
|
+
"""
|
|
27
|
+
A wrapper class that transforms a Python function into a schema-aware tool.
|
|
28
|
+
|
|
29
|
+
This class is designed to be used by AI agents (like Gemma 4) to understand
|
|
30
|
+
the capabilities, parameters, and requirements of a specific function.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, func, name=None, description=None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the Tool instance.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
func (Callable): The function to be wrapped as a tool.
|
|
39
|
+
name (Optional[str]): Override for the function's name. Defaults to func.__name__.
|
|
40
|
+
description (Optional[str]): Override for the function's description.
|
|
41
|
+
Defaults to the function's docstring.
|
|
42
|
+
"""
|
|
43
|
+
self._func = func
|
|
44
|
+
self.name = name or func.__name__
|
|
45
|
+
self.description = description or func.__doc__ or "No description provided."
|
|
46
|
+
# Wir kopieren Metadaten der Originalfunktion (für Dokumentation etc.)
|
|
47
|
+
functools.update_wrapper(self, func)
|
|
48
|
+
|
|
49
|
+
def _map_type(self, annotation: typing.Any) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Maps Python type annotations to JSON schema compatible type strings.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
annotation (Any): The Python type annotation to map.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
str: The corresponding JSON schema type (e.g., 'string', 'integer', 'array').
|
|
58
|
+
"""
|
|
59
|
+
# Handle Optional[T] or Union[T, None]
|
|
60
|
+
origin = typing.get_origin(annotation)
|
|
61
|
+
if origin is typing.Union:
|
|
62
|
+
args = typing.get_args(annotation)
|
|
63
|
+
# Filter out NoneType to find the actual type
|
|
64
|
+
actual_types = [a for a in args if a is not type(None)]
|
|
65
|
+
if actual_types:
|
|
66
|
+
return self._map_type(actual_types[0])
|
|
67
|
+
|
|
68
|
+
mapping = {
|
|
69
|
+
int: "integer",
|
|
70
|
+
float: "number",
|
|
71
|
+
str: "string",
|
|
72
|
+
bool: "boolean",
|
|
73
|
+
list: "array",
|
|
74
|
+
dict: "object",
|
|
75
|
+
typing.List: "array",
|
|
76
|
+
typing.Dict: "object",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return mapping.get(annotation if origin is None else origin, "string")
|
|
80
|
+
|
|
81
|
+
def get_schema(self) -> dict:
|
|
82
|
+
"""
|
|
83
|
+
Generates a JSON schema based on the function's signature and annotations.
|
|
84
|
+
|
|
85
|
+
The schema follows the standard expected by modern LLMs for tool calling.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
dict: A dictionary representing the tool's schema, including name,
|
|
89
|
+
description, and parameter definitions.
|
|
90
|
+
"""
|
|
91
|
+
sig = inspect.signature(self._func)
|
|
92
|
+
parameters = {"type": "object", "properties": {}, "required": []}
|
|
93
|
+
|
|
94
|
+
for param_name, param in sig.parameters.items():
|
|
95
|
+
# Skip 'self' or 'cls' if decorated inside a class (though unlikely here)
|
|
96
|
+
if param_name in ("self", "cls"):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
param_type = self._map_type(param.annotation)
|
|
100
|
+
|
|
101
|
+
param_info = {
|
|
102
|
+
"type": param_type,
|
|
103
|
+
"description": f"Parameter {param_name}"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# If there's a default value, mention it in the description
|
|
107
|
+
if param.default is not inspect.Parameter.empty:
|
|
108
|
+
param_info["default"] = param.default
|
|
109
|
+
param_info["description"] += f" (defaults to {param.default})"
|
|
110
|
+
else:
|
|
111
|
+
parameters["required"].append(param_name)
|
|
112
|
+
|
|
113
|
+
parameters["properties"][param_name] = param_info
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"name": self.name,
|
|
117
|
+
"description": self.description.strip(),
|
|
118
|
+
"parameters": parameters
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async def __call__(self, *args, **kwargs):
|
|
122
|
+
"""
|
|
123
|
+
Executes the wrapped function.
|
|
124
|
+
|
|
125
|
+
Supports both synchronous and asynchronous functions transparently.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
*args: Positional arguments for the wrapped function.
|
|
129
|
+
**kwargs: Keyword arguments for the wrapped function.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Any: The result of the function execution.
|
|
133
|
+
"""
|
|
134
|
+
if inspect.iscoroutinefunction(self._func):
|
|
135
|
+
return await self._func(*args, **kwargs)
|
|
136
|
+
return self._func(*args, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def tool(name: str = None, description: str = None):
|
|
140
|
+
"""
|
|
141
|
+
A decorator that converts a function into a Tool object.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name (Optional[str]): A custom name for the tool.
|
|
145
|
+
description (Optional[str]): A custom description for the tool.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Callable: A decorator function that wraps the target function in a Tool instance.
|
|
149
|
+
"""
|
|
150
|
+
def decorator(func):
|
|
151
|
+
# Wir geben eine Instanz von Tool zurück
|
|
152
|
+
return Tool(func, name=name, description=description)
|
|
153
|
+
return decorator
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["tool", "Tool"]
|
|
157
|
+
|
|
158
|
+
# end of file src/org/slashlib/py/agent/tool.py
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: org.slashlib.py.agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: python agent for inference handling
|
|
5
|
+
Author-email: Dirk Brenckmann <db.developer@gmx.de>
|
|
6
|
+
Project-URL: Homepage, https://github.com/org-slashlib/org.slashlib.py.agent
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE.md
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
[Bottom](#license) [AI](AI.md) [CHANGELOG](CHANGELOG.md) [LICENSE](LICENSE.md)
|
|
16
|
+
# org.slashlib.py.agent
|
|
17
|
+
|
|
18
|
+
A highly decoupled, asynchronous framework for building AI agents in Python.
|
|
19
|
+
|
|
20
|
+
[](https://pypi.org/project/org.slashlib.py.agent/)
|
|
21
|
+
[](https://opensource.org/licenses/MIT)
|
|
22
|
+
|
|
23
|
+
## Core Concept
|
|
24
|
+
|
|
25
|
+
This package provides a robust infrastructure to connect AI models (Inference Engines) with functional tools. The focus lies on **Provider Agnosticism**: The agent does not need to know whether it is communicating with Ollama, OpenAI, or a local model—it uses standardized adapters to ensure seamless integration.
|
|
26
|
+
|
|
27
|
+
### Key Features
|
|
28
|
+
- **Asynchronous Core**: Built on `asyncio` for non-blocking task execution.
|
|
29
|
+
- **Provider Agnostic**: Easily swap the AI engine using the Adapter pattern.
|
|
30
|
+
- **Automatic Tool Schemas**: Automatically transforms Python functions into JSON schemas for LLMs via decorators.
|
|
31
|
+
- **Multiton Pattern**: Ensures unique agent instances by identifier, preventing redundant resource allocation.
|
|
32
|
+
- **Robust Exception Hierarchy**: Clearly separates connection, configuration, and tool execution errors.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Install the package via pip:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install org.slashlib.py.agent
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
Setting up an agent with a tool and the Ollama adapter is straightforward:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
from org.slashlib.py.agent import Agent, OllamaInferenceAdapter, tool
|
|
51
|
+
|
|
52
|
+
# 1. Define a tool
|
|
53
|
+
@tool(description="Adds two numbers.")
|
|
54
|
+
async def add_numbers(a: int, b: int) -> int:
|
|
55
|
+
return a + b
|
|
56
|
+
|
|
57
|
+
async def main():
|
|
58
|
+
# 2. Configure Adapter and Agent
|
|
59
|
+
adapter = OllamaInferenceAdapter()
|
|
60
|
+
my_agent = Agent(
|
|
61
|
+
identifier="MathExpert",
|
|
62
|
+
tools=[add_numbers],
|
|
63
|
+
adapter=adapter
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# 3. Start task (non-blocking)
|
|
67
|
+
task = my_agent.run(user_prompt="What is 123 + 456?")
|
|
68
|
+
|
|
69
|
+
# 4. Retrieve result
|
|
70
|
+
response = await task
|
|
71
|
+
print(f"Response: {response.get_last_content()}")
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
asyncio.run(main())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
## Documentation & Obsidian
|
|
79
|
+
|
|
80
|
+
The project root is pre-configured as an **Obsidian Vault**. If you open this folder directly in Obsidian, all settings and documentation links will be available immediately via the included `.obsidian` directory.
|
|
81
|
+
|
|
82
|
+
The following community plugins are pre-configured in the vault to enhance the documentation experience:
|
|
83
|
+
|
|
84
|
+
* **[File Include](https://github.com/tillahoffmann/obsidian-file-include)**: Embed code files directly into your markdown documentation.
|
|
85
|
+
* **[Folder Notes](https://github.com/LostPaul/obsidian-folder-notes)**: Add descriptions at the folder level.
|
|
86
|
+
* **[Front Matter Title](https://github.com/snezhig/obsidian-front-matter-title)**: Use metadata for descriptive file titles.
|
|
87
|
+
* **[Hide Folders](https://github.com/JonasDoesThings/obsidian-hide-folders)**: Keeps the structure clean by hiding internal directories.
|
|
88
|
+
* **[Iconic](https://github.com/gfxholo/iconic)** & **[Icons](https://github.com/visini/obsidian-icons-plugin)**: Improved visual navigation.
|
|
89
|
+
|
|
90
|
+
[More docs](docs)
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
© 2026 org.slashlib
|
|
99
|
+
|
|
100
|
+
[TOP](#org-slashlib-py-agent) [AI](AI.md) [CHANGELOG](CHANGELOG.md) [LICENSE](LICENSE.md)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
org/slashlib/py/agent/__init__.py,sha256=XMV_X4I7bKJjRYrEOMf9_Hs9Tw94lDDaylpKJ_2HNzo,1738
|
|
2
|
+
org/slashlib/py/agent/__main__.py,sha256=_Ldk6mqVZO9gEDD_Tq7GvY__GMdoqafOO8iWuSMxY2A,3105
|
|
3
|
+
org/slashlib/py/agent/agent.py,sha256=eGBXVc804dlJ8TA_Veg6SkTbqnZCYYczv65eLuq_tXI,12665
|
|
4
|
+
org/slashlib/py/agent/agent_response.py,sha256=Km1AA8ZrQ8heG42EbfIAcWAOGWn61H3y26_PLBXTtEk,7274
|
|
5
|
+
org/slashlib/py/agent/inference_bases.py,sha256=MdbbxBwAQGJKGxOYB6VKvLQYJcWxZ0ULFS7Ylekhxuc,3920
|
|
6
|
+
org/slashlib/py/agent/inference_complements_for_ollama.py,sha256=4dosSeZIiLr9jQGX3JPw9bxXv0rv9kzbMboWnktQCuo,7477
|
|
7
|
+
org/slashlib/py/agent/tool.py,sha256=rvBt-zCeaBtY9GNrQ-uaDCje2YOMNUX3diO8c6tn7DQ,5838
|
|
8
|
+
org_slashlib_py_agent-0.1.0.dist-info/licenses/LICENSE.md,sha256=u_SrMZcAG0bjypv4AKPAgE9VFf-2vn8L0fRdizujhfI,1144
|
|
9
|
+
org_slashlib_py_agent-0.1.0.dist-info/METADATA,sha256=mdkOpZF9eCTxxVCAsEGA5Ref9YBFam-6xwt8cm92WgQ,3992
|
|
10
|
+
org_slashlib_py_agent-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
org_slashlib_py_agent-0.1.0.dist-info/org.slashlib.py.agent.egg-info.md,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
org_slashlib_py_agent-0.1.0.dist-info/top_level.txt,sha256=Y-Q3pQ6MMlBKzHVfq3nXGvODPPpbfj5OBXrcr17O02w,4
|
|
13
|
+
org_slashlib_py_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[AI](AI.md) [CHANGELOG](CHANGELOG.md) [README](README.md)
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2019 Dirk Brenckmann, db-developer
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
org
|