nexusagent-runtime 0.4.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.
- nexusagent/__init__.py +15 -0
- nexusagent/__main__.py +3 -0
- nexusagent/agent.py +175 -0
- nexusagent/application.py +11 -0
- nexusagent/calculator_tool.py +42 -0
- nexusagent/config.py +81 -0
- nexusagent/echo_tool.py +11 -0
- nexusagent/factory.py +13 -0
- nexusagent/http_provider.py +189 -0
- nexusagent/logging.py +60 -0
- nexusagent/main.py +75 -0
- nexusagent/provider.py +101 -0
- nexusagent/providers.py +58 -0
- nexusagent/py.typed +0 -0
- nexusagent/runtime.py +54 -0
- nexusagent/runtime_factory.py +12 -0
- nexusagent/tool.py +24 -0
- nexusagent/tool_executor.py +26 -0
- nexusagent/tool_factory.py +14 -0
- nexusagent/tool_registry.py +49 -0
- nexusagent/uppercase_tool.py +11 -0
- nexusagent_runtime-0.4.0.dist-info/METADATA +192 -0
- nexusagent_runtime-0.4.0.dist-info/RECORD +27 -0
- nexusagent_runtime-0.4.0.dist-info/WHEEL +5 -0
- nexusagent_runtime-0.4.0.dist-info/entry_points.txt +2 -0
- nexusagent_runtime-0.4.0.dist-info/licenses/LICENSE +21 -0
- nexusagent_runtime-0.4.0.dist-info/top_level.txt +1 -0
nexusagent/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from nexusagent.agent import Agent, AgentResult
|
|
2
|
+
from nexusagent.application import create_application_runtime
|
|
3
|
+
from nexusagent.config import Settings
|
|
4
|
+
from nexusagent.runtime import Runtime, create_runtime
|
|
5
|
+
|
|
6
|
+
__version__ = "0.4.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Agent",
|
|
10
|
+
"AgentResult",
|
|
11
|
+
"Runtime",
|
|
12
|
+
"Settings",
|
|
13
|
+
"create_application_runtime",
|
|
14
|
+
"create_runtime",
|
|
15
|
+
]
|
nexusagent/__main__.py
ADDED
nexusagent/agent.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Agent Core for NexusAgent.
|
|
2
|
+
|
|
3
|
+
Defines the contract for running an agent: input text in, an AgentResult out.
|
|
4
|
+
The Agent delegates generation to a Provider. When it is given a ToolRegistry
|
|
5
|
+
and a ToolExecutor it also runs the autonomous tool loop:
|
|
6
|
+
|
|
7
|
+
provider responds -> if it asks for a tool, run it -> feed the result back
|
|
8
|
+
-> provider responds again -> ... until it answers in plain text
|
|
9
|
+
|
|
10
|
+
The loop is bounded by ``max_iterations`` so a misbehaving backend can never
|
|
11
|
+
keep the agent running forever. Explicit ``execute_tool()`` remains available.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from nexusagent.logging import get_logger
|
|
19
|
+
from nexusagent.provider import (
|
|
20
|
+
Provider,
|
|
21
|
+
ProviderResponse,
|
|
22
|
+
TextResponse,
|
|
23
|
+
ToolCallRequest,
|
|
24
|
+
ToolResult,
|
|
25
|
+
ToolSpec,
|
|
26
|
+
)
|
|
27
|
+
from nexusagent.tool_executor import ToolExecutionError, ToolExecutor
|
|
28
|
+
from nexusagent.tool_registry import ToolRegistry
|
|
29
|
+
|
|
30
|
+
DEFAULT_MAX_ITERATIONS = 10
|
|
31
|
+
|
|
32
|
+
_logger = get_logger("nexusagent.agent")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class AgentResult:
|
|
37
|
+
"""The result of a single agent run."""
|
|
38
|
+
|
|
39
|
+
output: str
|
|
40
|
+
success: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Agent:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
provider: Provider,
|
|
47
|
+
tool_registry: ToolRegistry | None = None,
|
|
48
|
+
tool_executor: ToolExecutor | None = None,
|
|
49
|
+
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
|
50
|
+
) -> None:
|
|
51
|
+
if isinstance(max_iterations, bool) or not isinstance(max_iterations, int):
|
|
52
|
+
raise TypeError("max_iterations must be an integer")
|
|
53
|
+
|
|
54
|
+
if max_iterations < 1:
|
|
55
|
+
raise ValueError("max_iterations must be at least 1")
|
|
56
|
+
|
|
57
|
+
self.provider = provider
|
|
58
|
+
self.tool_registry = tool_registry
|
|
59
|
+
self.tool_executor = tool_executor
|
|
60
|
+
self.max_iterations = max_iterations
|
|
61
|
+
|
|
62
|
+
def run(self, input_text: str) -> AgentResult:
|
|
63
|
+
"""Run the agent on the given input and return an AgentResult.
|
|
64
|
+
|
|
65
|
+
If the Provider requests tools and this Agent has a ToolRegistry and a
|
|
66
|
+
ToolExecutor, the requested tools are executed and their results are
|
|
67
|
+
fed back to the Provider until it returns a plain text answer or
|
|
68
|
+
``max_iterations`` provider calls have been made.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(input_text, str):
|
|
71
|
+
raise TypeError("input_text must be a string")
|
|
72
|
+
|
|
73
|
+
if not input_text.strip():
|
|
74
|
+
raise ValueError("input_text must not be empty or whitespace-only")
|
|
75
|
+
|
|
76
|
+
can_use_tools = self.tool_registry is not None and self.tool_executor is not None
|
|
77
|
+
tools = self._tool_specs() if can_use_tools else ()
|
|
78
|
+
tool_results: list[ToolResult] = []
|
|
79
|
+
|
|
80
|
+
for iteration in range(1, self.max_iterations + 1):
|
|
81
|
+
try:
|
|
82
|
+
response = self._ask_provider(input_text, tools, tuple(tool_results))
|
|
83
|
+
except Exception as exc: # noqa: BLE001
|
|
84
|
+
return AgentResult(output=f"provider error: {exc}", success=False)
|
|
85
|
+
|
|
86
|
+
if isinstance(response, TextResponse):
|
|
87
|
+
_logger.debug("iteration %d: provider answered with text", iteration)
|
|
88
|
+
return AgentResult(output=response.text, success=True)
|
|
89
|
+
|
|
90
|
+
if not isinstance(response, ToolCallRequest):
|
|
91
|
+
return AgentResult(
|
|
92
|
+
output=f"provider error: unexpected response type {type(response).__name__}",
|
|
93
|
+
success=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if not can_use_tools:
|
|
97
|
+
return AgentResult(
|
|
98
|
+
output=(
|
|
99
|
+
f"provider requested tool {response.tool_name!r} "
|
|
100
|
+
"but the agent has no tools configured"
|
|
101
|
+
),
|
|
102
|
+
success=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
_logger.info("iteration %d: provider requested tool %r", iteration, response.tool_name)
|
|
106
|
+
tool_results.append(self._run_tool_call(response))
|
|
107
|
+
|
|
108
|
+
return AgentResult(
|
|
109
|
+
output=f"agent stopped: exceeded the maximum of {self.max_iterations} iterations",
|
|
110
|
+
success=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def execute_tool(self, tool_name: str, input_data: str) -> str:
|
|
114
|
+
"""Execute a specific, explicitly named tool and return its output."""
|
|
115
|
+
if self.tool_registry is None:
|
|
116
|
+
raise RuntimeError("Agent requires a ToolRegistry to execute tools")
|
|
117
|
+
|
|
118
|
+
if self.tool_executor is None:
|
|
119
|
+
raise RuntimeError("Agent requires a ToolExecutor to execute tools")
|
|
120
|
+
|
|
121
|
+
tool = self.tool_registry.get(tool_name)
|
|
122
|
+
return self.tool_executor.execute(tool, input_data)
|
|
123
|
+
|
|
124
|
+
def has_tool(self, tool_name: str) -> bool:
|
|
125
|
+
"""Return whether a tool is currently available through the Agent."""
|
|
126
|
+
if self.tool_registry is None:
|
|
127
|
+
raise RuntimeError("Agent requires a ToolRegistry to check tool availability")
|
|
128
|
+
|
|
129
|
+
return self.tool_registry.has(tool_name)
|
|
130
|
+
|
|
131
|
+
def _ask_provider(
|
|
132
|
+
self,
|
|
133
|
+
input_text: str,
|
|
134
|
+
tools: tuple[ToolSpec, ...],
|
|
135
|
+
tool_results: tuple[ToolResult, ...],
|
|
136
|
+
) -> ProviderResponse:
|
|
137
|
+
"""Ask the provider for its next response.
|
|
138
|
+
|
|
139
|
+
Any object with a ``generate(prompt) -> str`` method is accepted as a
|
|
140
|
+
provider; only ones that also define ``generate_response`` can request
|
|
141
|
+
tools.
|
|
142
|
+
"""
|
|
143
|
+
generate_response = getattr(self.provider, "generate_response", None)
|
|
144
|
+
if generate_response is None:
|
|
145
|
+
return TextResponse(self.provider.generate(input_text))
|
|
146
|
+
|
|
147
|
+
response: ProviderResponse = generate_response(
|
|
148
|
+
input_text, tools=tools, tool_results=tool_results
|
|
149
|
+
)
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
def _tool_specs(self) -> tuple[ToolSpec, ...]:
|
|
153
|
+
assert self.tool_registry is not None
|
|
154
|
+
return tuple(ToolSpec(t.name, t.description) for t in self.tool_registry.list_tools())
|
|
155
|
+
|
|
156
|
+
def _run_tool_call(self, request: ToolCallRequest) -> ToolResult:
|
|
157
|
+
"""Run one requested tool. Failures become error results, never crashes.
|
|
158
|
+
|
|
159
|
+
The Provider (usually a model) is told what went wrong so it can retry
|
|
160
|
+
with better input, pick another tool, or explain the problem.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
output = self.execute_tool(request.tool_name, request.tool_input)
|
|
164
|
+
except KeyError:
|
|
165
|
+
return self._error_result(request, f"Tool {request.tool_name!r} is not registered")
|
|
166
|
+
except (TypeError, ValueError, ToolExecutionError) as exc:
|
|
167
|
+
return self._error_result(request, str(exc))
|
|
168
|
+
|
|
169
|
+
_logger.debug("tool %r succeeded", request.tool_name)
|
|
170
|
+
return ToolResult(request.tool_name, request.tool_input, output)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _error_result(request: ToolCallRequest, message: str) -> ToolResult:
|
|
174
|
+
_logger.warning("tool %r failed: %s", request.tool_name, message)
|
|
175
|
+
return ToolResult(request.tool_name, request.tool_input, message, is_error=True)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.config import Settings
|
|
4
|
+
from nexusagent.runtime import Runtime
|
|
5
|
+
from nexusagent.runtime_factory import create_tool_runtime
|
|
6
|
+
|
|
7
|
+
__all__ = ["Runtime", "create_application_runtime"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_application_runtime(settings: Settings | None = None) -> Runtime:
|
|
11
|
+
return create_tool_runtime(settings)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from nexusagent.tool import Tool
|
|
6
|
+
|
|
7
|
+
_EXPRESSION_PATTERN = re.compile(r"\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)\s*")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CalculatorTool(Tool):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
super().__init__(name="calculator", description="Performs basic arithmetic operations.")
|
|
13
|
+
|
|
14
|
+
def execute(self, input_data: str) -> str:
|
|
15
|
+
match = _EXPRESSION_PATTERN.fullmatch(input_data)
|
|
16
|
+
if not match:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
"Invalid expression: expected 'number operator number' with operator one of + - * /"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
left = float(match.group(1))
|
|
22
|
+
operator = match.group(2)
|
|
23
|
+
right = float(match.group(3))
|
|
24
|
+
|
|
25
|
+
if operator == "+":
|
|
26
|
+
result = left + right
|
|
27
|
+
elif operator == "-":
|
|
28
|
+
result = left - right
|
|
29
|
+
elif operator == "*":
|
|
30
|
+
result = left * right
|
|
31
|
+
else:
|
|
32
|
+
if right == 0:
|
|
33
|
+
raise ZeroDivisionError("Division by zero is not allowed")
|
|
34
|
+
result = left / right
|
|
35
|
+
|
|
36
|
+
return _format_result(result)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_result(value: float) -> str:
|
|
40
|
+
if value.is_integer():
|
|
41
|
+
return str(int(value))
|
|
42
|
+
return str(value)
|
nexusagent/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Application configuration for NexusAgent.
|
|
2
|
+
|
|
3
|
+
Settings are read from environment variables. No .env loading and no
|
|
4
|
+
external configuration frameworks are used at this stage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
SUPPORTED_PROVIDERS = {"fake", "http"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, repr=False)
|
|
18
|
+
class Settings:
|
|
19
|
+
"""Basic application settings, loaded from environment variables."""
|
|
20
|
+
|
|
21
|
+
env: str = "development"
|
|
22
|
+
log_level: str = "INFO"
|
|
23
|
+
model: str | None = None
|
|
24
|
+
api_key: str | None = None
|
|
25
|
+
provider: str = "fake"
|
|
26
|
+
endpoint: str | None = None
|
|
27
|
+
timeout: float = 30.0
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_env(cls) -> Settings:
|
|
31
|
+
"""Build Settings from the current process environment."""
|
|
32
|
+
raw_timeout = os.getenv("NEXUS_TIMEOUT", "30.0")
|
|
33
|
+
try:
|
|
34
|
+
timeout = float(raw_timeout)
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Invalid NEXUS_TIMEOUT: {raw_timeout!r} — "
|
|
38
|
+
"must be a number of seconds (e.g. 30 or 4.5)"
|
|
39
|
+
) from None
|
|
40
|
+
|
|
41
|
+
return cls(
|
|
42
|
+
env=os.getenv("NEXUS_ENV", "development"),
|
|
43
|
+
log_level=os.getenv("NEXUS_LOG_LEVEL", "INFO"),
|
|
44
|
+
model=os.getenv("NEXUS_MODEL"),
|
|
45
|
+
api_key=os.getenv("NEXUS_API_KEY"),
|
|
46
|
+
provider=os.getenv("NEXUS_PROVIDER", "fake"),
|
|
47
|
+
endpoint=os.getenv("NEXUS_ENDPOINT"),
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
api_key_display = "set" if self.api_key else "None"
|
|
53
|
+
return (
|
|
54
|
+
f"Settings(env={self.env!r}, log_level={self.log_level!r}, "
|
|
55
|
+
f"model={self.model!r}, api_key={api_key_display}, "
|
|
56
|
+
f"provider={self.provider!r}, endpoint={self.endpoint!r}, "
|
|
57
|
+
f"timeout={self.timeout!r})"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def validate(self) -> None:
|
|
61
|
+
"""Validate the configuration locally, without any network access.
|
|
62
|
+
|
|
63
|
+
Raises ValueError with a clear, safe message on the first
|
|
64
|
+
problem found. Never includes the API key.
|
|
65
|
+
"""
|
|
66
|
+
if self.provider not in SUPPORTED_PROVIDERS:
|
|
67
|
+
raise ValueError(f"Unsupported provider: {self.provider!r}")
|
|
68
|
+
|
|
69
|
+
if self.model is not None and not self.model.strip():
|
|
70
|
+
raise ValueError("Model must not be empty")
|
|
71
|
+
|
|
72
|
+
if not math.isfinite(self.timeout) or self.timeout <= 0:
|
|
73
|
+
raise ValueError("Timeout must be a positive finite number")
|
|
74
|
+
|
|
75
|
+
if self.provider == "http":
|
|
76
|
+
if not self.endpoint or not self.endpoint.strip():
|
|
77
|
+
raise ValueError("HTTP provider requires an endpoint")
|
|
78
|
+
|
|
79
|
+
scheme = urlparse(self.endpoint).scheme
|
|
80
|
+
if scheme not in ("http", "https"):
|
|
81
|
+
raise ValueError("Endpoint must use http or https")
|
nexusagent/echo_tool.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.tool import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EchoTool(Tool):
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
super().__init__(name="echo", description="Returns its input unchanged.")
|
|
9
|
+
|
|
10
|
+
def execute(self, input_data: str) -> str:
|
|
11
|
+
return input_data
|
nexusagent/factory.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from nexusagent.http_provider import HttpProvider
|
|
2
|
+
from nexusagent.provider import Provider, ProviderConfig, ProviderConfigurationError
|
|
3
|
+
from nexusagent.providers import FakeProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_provider(name: str, config: ProviderConfig | None = None) -> Provider:
|
|
7
|
+
if name == "fake":
|
|
8
|
+
return FakeProvider(config)
|
|
9
|
+
|
|
10
|
+
if name == "http":
|
|
11
|
+
return HttpProvider(config)
|
|
12
|
+
|
|
13
|
+
raise ProviderConfigurationError(f"Unsupported provider: {name!r}")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Generic HTTP Provider.
|
|
2
|
+
|
|
3
|
+
Wire format (see docs/protocol.md):
|
|
4
|
+
|
|
5
|
+
Request ``POST <endpoint>`` with a JSON body::
|
|
6
|
+
|
|
7
|
+
{"model": ..., "input": "<prompt>"}
|
|
8
|
+
|
|
9
|
+
and, only when relevant, two extra keys::
|
|
10
|
+
|
|
11
|
+
"tools": [{"name": ..., "description": ...}, ...]
|
|
12
|
+
"tool_results": [{"tool_name": ..., "tool_input": ..., "output": ...,
|
|
13
|
+
"is_error": false}, ...]
|
|
14
|
+
|
|
15
|
+
Response a JSON object that is exactly one of::
|
|
16
|
+
|
|
17
|
+
{"output": "<text answer>"}
|
|
18
|
+
{"tool_name": "<tool>", "tool_input": "<input for the tool>"}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
|
|
28
|
+
from nexusagent.provider import (
|
|
29
|
+
Provider,
|
|
30
|
+
ProviderConfig,
|
|
31
|
+
ProviderConfigurationError,
|
|
32
|
+
ProviderRequestError,
|
|
33
|
+
ProviderResponse,
|
|
34
|
+
ProviderResponseError,
|
|
35
|
+
TextResponse,
|
|
36
|
+
ToolCallRequest,
|
|
37
|
+
ToolResult,
|
|
38
|
+
ToolSpec,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_BODY_SNIPPET_MAX_CHARS = 200
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _response_body_snippet(exc: urllib.error.HTTPError) -> str:
|
|
45
|
+
"""Return a short, single-line snippet of an HTTP error body (best effort)."""
|
|
46
|
+
try:
|
|
47
|
+
body = exc.read().decode("utf-8", errors="replace").strip()
|
|
48
|
+
except Exception: # noqa: BLE001 — the body is optional context, never fatal
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
if not body:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
snippet = " ".join(body.split())
|
|
55
|
+
if len(snippet) > _BODY_SNIPPET_MAX_CHARS:
|
|
56
|
+
snippet = snippet[:_BODY_SNIPPET_MAX_CHARS] + "..."
|
|
57
|
+
return f": {snippet}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HttpProvider(Provider):
|
|
61
|
+
def __init__(self, config: ProviderConfig | None = None) -> None:
|
|
62
|
+
self.config = config or ProviderConfig()
|
|
63
|
+
|
|
64
|
+
def generate(self, prompt: str) -> str:
|
|
65
|
+
response = self.generate_response(prompt)
|
|
66
|
+
|
|
67
|
+
if isinstance(response, ToolCallRequest):
|
|
68
|
+
raise ProviderResponseError(
|
|
69
|
+
"HTTP response requested a tool call; use generate_response() to handle it"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return response.text
|
|
73
|
+
|
|
74
|
+
def generate_response(
|
|
75
|
+
self,
|
|
76
|
+
prompt: str,
|
|
77
|
+
*,
|
|
78
|
+
tools: Sequence[ToolSpec] = (),
|
|
79
|
+
tool_results: Sequence[ToolResult] = (),
|
|
80
|
+
) -> ProviderResponse:
|
|
81
|
+
payload = self._post(self._build_body(prompt, tools, tool_results))
|
|
82
|
+
return self._parse_payload(payload)
|
|
83
|
+
|
|
84
|
+
def _build_body(
|
|
85
|
+
self,
|
|
86
|
+
prompt: str,
|
|
87
|
+
tools: Sequence[ToolSpec],
|
|
88
|
+
tool_results: Sequence[ToolResult],
|
|
89
|
+
) -> dict[str, object]:
|
|
90
|
+
body: dict[str, object] = {"model": self.config.model, "input": prompt}
|
|
91
|
+
|
|
92
|
+
if tools:
|
|
93
|
+
body["tools"] = [{"name": t.name, "description": t.description} for t in tools]
|
|
94
|
+
|
|
95
|
+
if tool_results:
|
|
96
|
+
body["tool_results"] = [
|
|
97
|
+
{
|
|
98
|
+
"tool_name": r.tool_name,
|
|
99
|
+
"tool_input": r.tool_input,
|
|
100
|
+
"output": r.output,
|
|
101
|
+
"is_error": r.is_error,
|
|
102
|
+
}
|
|
103
|
+
for r in tool_results
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
return body
|
|
107
|
+
|
|
108
|
+
def _post(self, body: dict[str, object]) -> object:
|
|
109
|
+
if not self.config.endpoint:
|
|
110
|
+
raise ProviderConfigurationError("HttpProvider requires a configured endpoint")
|
|
111
|
+
|
|
112
|
+
if self.config.timeout <= 0:
|
|
113
|
+
raise ProviderConfigurationError("HttpProvider requires a positive timeout")
|
|
114
|
+
|
|
115
|
+
headers = {"Content-Type": "application/json"}
|
|
116
|
+
if self.config.api_key:
|
|
117
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
118
|
+
|
|
119
|
+
request = urllib.request.Request(
|
|
120
|
+
self.config.endpoint,
|
|
121
|
+
data=json.dumps(body).encode("utf-8"),
|
|
122
|
+
headers=headers,
|
|
123
|
+
method="POST",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with urllib.request.urlopen(request, timeout=self.config.timeout) as response:
|
|
128
|
+
raw = response.read()
|
|
129
|
+
except urllib.error.HTTPError as exc:
|
|
130
|
+
# Include a short snippet of the response body when available: real
|
|
131
|
+
# backends put useful context there (rate limits, auth failures, ...).
|
|
132
|
+
detail = _response_body_snippet(exc)
|
|
133
|
+
raise ProviderRequestError(
|
|
134
|
+
f"HTTP request failed with status {exc.code}{detail}"
|
|
135
|
+
) from None
|
|
136
|
+
except urllib.error.URLError as exc:
|
|
137
|
+
raise ProviderRequestError(f"HTTP request failed: {exc.reason}") from None
|
|
138
|
+
except TimeoutError:
|
|
139
|
+
raise ProviderRequestError("HTTP request timed out") from None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return json.loads(raw)
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
raise ProviderResponseError("HTTP response was not valid JSON") from None
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _parse_payload(payload: object) -> ProviderResponse:
|
|
148
|
+
if not isinstance(payload, dict):
|
|
149
|
+
raise ProviderResponseError("HTTP response must be a JSON object")
|
|
150
|
+
|
|
151
|
+
has_output = "output" in payload
|
|
152
|
+
has_tool_call = "tool_name" in payload
|
|
153
|
+
|
|
154
|
+
if has_output and has_tool_call:
|
|
155
|
+
raise ProviderResponseError(
|
|
156
|
+
"HTTP response must contain either 'output' or 'tool_name', not both"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if has_tool_call:
|
|
160
|
+
return HttpProvider._parse_tool_call(payload)
|
|
161
|
+
|
|
162
|
+
if not has_output:
|
|
163
|
+
raise ProviderResponseError("HTTP response is missing the 'output' field")
|
|
164
|
+
|
|
165
|
+
output = payload["output"]
|
|
166
|
+
if not isinstance(output, str):
|
|
167
|
+
raise ProviderResponseError("HTTP response 'output' field must be a string")
|
|
168
|
+
|
|
169
|
+
if not output.strip():
|
|
170
|
+
raise ProviderResponseError("HTTP response 'output' field must not be empty")
|
|
171
|
+
|
|
172
|
+
return TextResponse(output)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _parse_tool_call(payload: dict[str, object]) -> ToolCallRequest:
|
|
176
|
+
tool_name = payload["tool_name"]
|
|
177
|
+
if not isinstance(tool_name, str) or not tool_name.strip():
|
|
178
|
+
raise ProviderResponseError(
|
|
179
|
+
"HTTP response 'tool_name' field must be a non-empty string"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if "tool_input" not in payload:
|
|
183
|
+
raise ProviderResponseError("HTTP response is missing the 'tool_input' field")
|
|
184
|
+
|
|
185
|
+
tool_input = payload["tool_input"]
|
|
186
|
+
if not isinstance(tool_input, str):
|
|
187
|
+
raise ProviderResponseError("HTTP response 'tool_input' field must be a string")
|
|
188
|
+
|
|
189
|
+
return ToolCallRequest(tool_name=tool_name, tool_input=tool_input)
|
nexusagent/logging.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Centralized logging setup for NexusAgent.
|
|
2
|
+
|
|
3
|
+
Provides get_logger() for obtaining namespaced loggers, and
|
|
4
|
+
configure_logging() to attach a single console handler with a
|
|
5
|
+
level derived from the existing Settings configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from nexusagent.config import Settings
|
|
13
|
+
|
|
14
|
+
_ROOT_LOGGER_NAME = "nexusagent"
|
|
15
|
+
_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
|
16
|
+
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
20
|
+
"""Return a logger under the nexusagent namespace.
|
|
21
|
+
|
|
22
|
+
Without a name, returns the main "nexusagent" logger.
|
|
23
|
+
With a name (e.g. "nexusagent.agent"), returns that named logger.
|
|
24
|
+
"""
|
|
25
|
+
if name is None:
|
|
26
|
+
return logging.getLogger(_ROOT_LOGGER_NAME)
|
|
27
|
+
return logging.getLogger(name)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_level(level: str) -> int:
|
|
31
|
+
resolved = logging.getLevelName(level.upper())
|
|
32
|
+
if not isinstance(resolved, int):
|
|
33
|
+
raise ValueError(f"Invalid log level: {level!r}")
|
|
34
|
+
return resolved
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure_logging(level: str | None = None) -> logging.Logger:
|
|
38
|
+
"""Configure the nexusagent root logger with a console handler.
|
|
39
|
+
|
|
40
|
+
The log level defaults to the value provided by Settings
|
|
41
|
+
(NEXUS_LOG_LEVEL) unless explicitly overridden. Calling this
|
|
42
|
+
function multiple times is safe and will not attach duplicate
|
|
43
|
+
handlers.
|
|
44
|
+
"""
|
|
45
|
+
if level is None:
|
|
46
|
+
level = Settings.from_env().log_level
|
|
47
|
+
|
|
48
|
+
resolved_level = _resolve_level(level)
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(_ROOT_LOGGER_NAME)
|
|
51
|
+
logger.setLevel(resolved_level)
|
|
52
|
+
logger.propagate = False
|
|
53
|
+
|
|
54
|
+
has_console_handler = any(isinstance(h, logging.StreamHandler) for h in logger.handlers)
|
|
55
|
+
if not has_console_handler:
|
|
56
|
+
handler = logging.StreamHandler()
|
|
57
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
|
58
|
+
logger.addHandler(handler)
|
|
59
|
+
|
|
60
|
+
return logger
|
nexusagent/main.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
from nexusagent.application import Runtime, create_application_runtime
|
|
6
|
+
from nexusagent.config import Settings
|
|
7
|
+
from nexusagent.provider import ProviderError
|
|
8
|
+
from nexusagent.tool_executor import ToolExecutionError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_tool_runtime() -> Runtime:
|
|
12
|
+
return create_application_runtime()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run_tool_list_command() -> None:
|
|
16
|
+
runtime = _build_tool_runtime()
|
|
17
|
+
for tool in runtime.list_tools():
|
|
18
|
+
print(f"{tool.name} - {tool.description}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_tool_command(argv: list[str]) -> None:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="nexusagent tool")
|
|
23
|
+
subparsers = parser.add_subparsers(dest="tool_command", required=True)
|
|
24
|
+
|
|
25
|
+
run_parser = subparsers.add_parser("run", help="run a registered tool")
|
|
26
|
+
run_parser.add_argument("tool_name", help="name of the tool to run")
|
|
27
|
+
run_parser.add_argument("input_data", help="input passed to the tool")
|
|
28
|
+
|
|
29
|
+
subparsers.add_parser("list", help="list registered tools")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
if args.tool_command == "list":
|
|
34
|
+
_run_tool_list_command()
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
runtime = _build_tool_runtime()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
result = runtime.execute_tool(args.tool_name, args.input_data)
|
|
41
|
+
except (KeyError, TypeError, ValueError, ToolExecutionError, RuntimeError) as exc:
|
|
42
|
+
print(f"nexusagent: error: {exc}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
print(result)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> None:
|
|
49
|
+
argv = sys.argv[1:]
|
|
50
|
+
|
|
51
|
+
if argv and argv[0] == "tool":
|
|
52
|
+
_run_tool_command(argv[1:])
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
parser = argparse.ArgumentParser(prog="nexusagent")
|
|
56
|
+
parser.add_argument("input_text", help="input text to run through the agent")
|
|
57
|
+
parser.add_argument("--provider", help="provider to use (overrides NEXUS_PROVIDER)")
|
|
58
|
+
args = parser.parse_args(argv)
|
|
59
|
+
|
|
60
|
+
settings = Settings.from_env()
|
|
61
|
+
if args.provider is not None:
|
|
62
|
+
settings = replace(settings, provider=args.provider)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
runtime = create_application_runtime(settings)
|
|
66
|
+
except (ValueError, ProviderError) as exc:
|
|
67
|
+
print(f"nexusagent: error: {exc}", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
result = runtime.run(args.input_text)
|
|
71
|
+
if not result.success:
|
|
72
|
+
print(f"nexusagent: error: {result.output}", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
print(result.output)
|
nexusagent/provider.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, repr=False)
|
|
9
|
+
class ProviderConfig:
|
|
10
|
+
model: str | None = None
|
|
11
|
+
api_key: str | None = None
|
|
12
|
+
endpoint: str | None = None
|
|
13
|
+
timeout: float = 30.0
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
api_key_display = "set" if self.api_key else "None"
|
|
17
|
+
return (
|
|
18
|
+
f"ProviderConfig(model={self.model!r}, api_key={api_key_display}, "
|
|
19
|
+
f"endpoint={self.endpoint!r}, timeout={self.timeout!r})"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TextResponse:
|
|
25
|
+
"""A plain text response from a Provider."""
|
|
26
|
+
|
|
27
|
+
text: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ToolCallRequest:
|
|
32
|
+
"""A request from a Provider to call a specific Tool."""
|
|
33
|
+
|
|
34
|
+
tool_name: str
|
|
35
|
+
tool_input: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ProviderResponse = TextResponse | ToolCallRequest
|
|
39
|
+
"""What a Provider can answer with: either a plain text answer or a request
|
|
40
|
+
to call a tool. This is what ``Provider.generate_response()`` returns and what
|
|
41
|
+
the Agent's tool loop consumes."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ToolSpec:
|
|
46
|
+
"""A tool advertised to the Provider so it knows what it may request."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
description: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ToolResult:
|
|
54
|
+
"""The outcome of a ``ToolCallRequest``, fed back to the Provider.
|
|
55
|
+
|
|
56
|
+
``is_error`` is True when the tool could not be run or failed; ``output``
|
|
57
|
+
then holds the error message so the model can recover or explain.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
tool_name: str
|
|
61
|
+
tool_input: str
|
|
62
|
+
output: str
|
|
63
|
+
is_error: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Provider(ABC):
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def generate(self, prompt: str) -> str: ...
|
|
69
|
+
|
|
70
|
+
def generate_response(
|
|
71
|
+
self,
|
|
72
|
+
prompt: str,
|
|
73
|
+
*,
|
|
74
|
+
tools: Sequence[ToolSpec] = (),
|
|
75
|
+
tool_results: Sequence[ToolResult] = (),
|
|
76
|
+
) -> ProviderResponse:
|
|
77
|
+
"""Return a structured response for ``prompt``.
|
|
78
|
+
|
|
79
|
+
``tools`` lists the tools the Provider may request; ``tool_results``
|
|
80
|
+
holds the results of tool calls it already requested in this run, in
|
|
81
|
+
order. The default implementation wraps ``generate()`` and therefore
|
|
82
|
+
only ever answers with text, so text-only Providers need no changes.
|
|
83
|
+
Providers that can request tools override this method.
|
|
84
|
+
"""
|
|
85
|
+
return TextResponse(self.generate(prompt))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProviderError(Exception):
|
|
89
|
+
"""Base exception for provider-related failures."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProviderConfigurationError(ProviderError):
|
|
93
|
+
"""Raised when provider configuration is invalid for a provider."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ProviderRequestError(ProviderError):
|
|
97
|
+
"""Raised when a provider request fails."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ProviderResponseError(ProviderError):
|
|
101
|
+
"""Raised when a provider response is invalid."""
|
nexusagent/providers.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
|
|
5
|
+
from nexusagent.provider import (
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderConfig,
|
|
8
|
+
ProviderResponse,
|
|
9
|
+
ProviderResponseError,
|
|
10
|
+
TextResponse,
|
|
11
|
+
ToolResult,
|
|
12
|
+
ToolSpec,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeProvider(Provider):
|
|
17
|
+
def __init__(self, config: ProviderConfig | None = None) -> None:
|
|
18
|
+
self.config = config or ProviderConfig()
|
|
19
|
+
|
|
20
|
+
def generate(self, prompt: str) -> str:
|
|
21
|
+
return f"fake response: {prompt}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScriptedProvider(Provider):
|
|
25
|
+
"""A deterministic, network-free Provider that replays a fixed script.
|
|
26
|
+
|
|
27
|
+
Each call to ``generate_response()`` returns the next scripted
|
|
28
|
+
``ProviderResponse``. It is meant for demos and tests of the tool loop:
|
|
29
|
+
script a ``ToolCallRequest`` followed by a ``TextResponse`` and the Agent
|
|
30
|
+
will run the tool and finish. Every call is recorded in ``calls``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, responses: Iterable[ProviderResponse]) -> None:
|
|
34
|
+
self._responses = list(responses)
|
|
35
|
+
self._position = 0
|
|
36
|
+
self.calls: list[tuple[str, tuple[ToolSpec, ...], tuple[ToolResult, ...]]] = []
|
|
37
|
+
|
|
38
|
+
def generate(self, prompt: str) -> str:
|
|
39
|
+
response = self.generate_response(prompt)
|
|
40
|
+
if not isinstance(response, TextResponse):
|
|
41
|
+
raise ProviderResponseError("scripted response is a tool call, not text")
|
|
42
|
+
return response.text
|
|
43
|
+
|
|
44
|
+
def generate_response(
|
|
45
|
+
self,
|
|
46
|
+
prompt: str,
|
|
47
|
+
*,
|
|
48
|
+
tools: Sequence[ToolSpec] = (),
|
|
49
|
+
tool_results: Sequence[ToolResult] = (),
|
|
50
|
+
) -> ProviderResponse:
|
|
51
|
+
self.calls.append((prompt, tuple(tools), tuple(tool_results)))
|
|
52
|
+
|
|
53
|
+
if self._position >= len(self._responses):
|
|
54
|
+
raise ProviderResponseError("ScriptedProvider has no more scripted responses")
|
|
55
|
+
|
|
56
|
+
response = self._responses[self._position]
|
|
57
|
+
self._position += 1
|
|
58
|
+
return response
|
nexusagent/py.typed
ADDED
|
File without changes
|
nexusagent/runtime.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.agent import Agent, AgentResult
|
|
4
|
+
from nexusagent.config import Settings
|
|
5
|
+
from nexusagent.factory import create_provider
|
|
6
|
+
from nexusagent.provider import ProviderConfig
|
|
7
|
+
from nexusagent.tool import Tool
|
|
8
|
+
from nexusagent.tool_executor import ToolExecutor
|
|
9
|
+
from nexusagent.tool_registry import ToolRegistry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Runtime:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
agent: Agent,
|
|
16
|
+
tool_registry: ToolRegistry | None = None,
|
|
17
|
+
tool_executor: ToolExecutor | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.agent = agent
|
|
20
|
+
self.tool_registry = tool_registry
|
|
21
|
+
self.tool_executor = tool_executor
|
|
22
|
+
|
|
23
|
+
def run(self, input_text: str) -> AgentResult:
|
|
24
|
+
return self.agent.run(input_text)
|
|
25
|
+
|
|
26
|
+
def execute_tool(self, tool_name: str, input_data: str) -> str:
|
|
27
|
+
return self.agent.execute_tool(tool_name, input_data)
|
|
28
|
+
|
|
29
|
+
def list_tools(self) -> list[Tool]:
|
|
30
|
+
if self.tool_registry is None:
|
|
31
|
+
raise RuntimeError("Runtime requires a ToolRegistry to list tools")
|
|
32
|
+
|
|
33
|
+
return self.tool_registry.list_tools()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_runtime(
|
|
37
|
+
settings: Settings | None = None,
|
|
38
|
+
tool_registry: ToolRegistry | None = None,
|
|
39
|
+
tool_executor: ToolExecutor | None = None,
|
|
40
|
+
) -> Runtime:
|
|
41
|
+
if settings is None:
|
|
42
|
+
settings = Settings.from_env()
|
|
43
|
+
|
|
44
|
+
settings.validate()
|
|
45
|
+
|
|
46
|
+
config = ProviderConfig(
|
|
47
|
+
model=settings.model,
|
|
48
|
+
api_key=settings.api_key,
|
|
49
|
+
endpoint=settings.endpoint,
|
|
50
|
+
timeout=settings.timeout,
|
|
51
|
+
)
|
|
52
|
+
provider = create_provider(settings.provider, config)
|
|
53
|
+
agent = Agent(provider, tool_registry=tool_registry, tool_executor=tool_executor)
|
|
54
|
+
return Runtime(agent, tool_registry=tool_registry, tool_executor=tool_executor)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.config import Settings
|
|
4
|
+
from nexusagent.runtime import Runtime, create_runtime
|
|
5
|
+
from nexusagent.tool_executor import ToolExecutor
|
|
6
|
+
from nexusagent.tool_factory import create_tool_registry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_tool_runtime(settings: Settings | None = None) -> Runtime:
|
|
10
|
+
registry = create_tool_registry()
|
|
11
|
+
executor = ToolExecutor()
|
|
12
|
+
return create_runtime(settings, tool_registry=registry, tool_executor=executor)
|
nexusagent/tool.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Tool(ABC):
|
|
7
|
+
def __init__(self, name: str, description: str) -> None:
|
|
8
|
+
if not isinstance(name, str):
|
|
9
|
+
raise TypeError("Tool name must be a string")
|
|
10
|
+
|
|
11
|
+
if not name.strip():
|
|
12
|
+
raise ValueError("Tool name must not be empty or whitespace-only")
|
|
13
|
+
|
|
14
|
+
if not isinstance(description, str):
|
|
15
|
+
raise TypeError("Tool description must be a string")
|
|
16
|
+
|
|
17
|
+
if not description.strip():
|
|
18
|
+
raise ValueError("Tool description must not be empty or whitespace-only")
|
|
19
|
+
|
|
20
|
+
self.name = name
|
|
21
|
+
self.description = description
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def execute(self, input_data: str) -> str: ...
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.tool import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolExecutionError(Exception):
|
|
7
|
+
"""Raised when a Tool's execute() call fails."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolExecutor:
|
|
11
|
+
def execute(self, tool: Tool, input_data: str) -> str:
|
|
12
|
+
if not isinstance(tool, Tool):
|
|
13
|
+
raise TypeError("ToolExecutor requires a Tool instance")
|
|
14
|
+
|
|
15
|
+
if not isinstance(input_data, str):
|
|
16
|
+
raise TypeError("Tool input must be a string")
|
|
17
|
+
|
|
18
|
+
if not input_data.strip():
|
|
19
|
+
raise ValueError("Tool input must not be empty or whitespace-only")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
return tool.execute(input_data)
|
|
23
|
+
except Exception as exc:
|
|
24
|
+
# Preserve the original error class in the message: the chain keeps the
|
|
25
|
+
# exception itself, but callers often only see the ToolExecutionError text.
|
|
26
|
+
raise ToolExecutionError(f"{type(exc).__name__}: {exc}") from exc
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.calculator_tool import CalculatorTool
|
|
4
|
+
from nexusagent.echo_tool import EchoTool
|
|
5
|
+
from nexusagent.tool_registry import ToolRegistry
|
|
6
|
+
from nexusagent.uppercase_tool import UppercaseTool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_tool_registry() -> ToolRegistry:
|
|
10
|
+
registry = ToolRegistry()
|
|
11
|
+
registry.register(EchoTool())
|
|
12
|
+
registry.register(UppercaseTool())
|
|
13
|
+
registry.register(CalculatorTool())
|
|
14
|
+
return registry
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.tool import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolRegistry:
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
self._tools: dict[str, Tool] = {}
|
|
9
|
+
|
|
10
|
+
def register(self, tool: Tool) -> None:
|
|
11
|
+
if not isinstance(tool, Tool):
|
|
12
|
+
raise TypeError("ToolRegistry requires a Tool instance")
|
|
13
|
+
|
|
14
|
+
if tool.name in self._tools:
|
|
15
|
+
raise ValueError(f"Tool {tool.name!r} is already registered")
|
|
16
|
+
|
|
17
|
+
self._tools[tool.name] = tool
|
|
18
|
+
|
|
19
|
+
def get(self, name: str) -> Tool:
|
|
20
|
+
self._validate_name(name)
|
|
21
|
+
|
|
22
|
+
if name not in self._tools:
|
|
23
|
+
raise KeyError(f"Tool {name!r} is not registered")
|
|
24
|
+
|
|
25
|
+
return self._tools[name]
|
|
26
|
+
|
|
27
|
+
def has(self, name: str) -> bool:
|
|
28
|
+
self._validate_name(name)
|
|
29
|
+
|
|
30
|
+
return name in self._tools
|
|
31
|
+
|
|
32
|
+
def unregister(self, name: str) -> None:
|
|
33
|
+
self._validate_name(name)
|
|
34
|
+
|
|
35
|
+
if name not in self._tools:
|
|
36
|
+
raise KeyError(f"Tool {name!r} is not registered")
|
|
37
|
+
|
|
38
|
+
del self._tools[name]
|
|
39
|
+
|
|
40
|
+
def list_tools(self) -> list[Tool]:
|
|
41
|
+
return list(self._tools.values())
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _validate_name(name: str) -> None:
|
|
45
|
+
if not isinstance(name, str):
|
|
46
|
+
raise TypeError("Tool name must be a string")
|
|
47
|
+
|
|
48
|
+
if not name.strip():
|
|
49
|
+
raise ValueError("Tool name must not be empty or whitespace-only")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nexusagent.tool import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UppercaseTool(Tool):
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
super().__init__(name="uppercase", description="Converts input to uppercase.")
|
|
9
|
+
|
|
10
|
+
def execute(self, input_data: str) -> str:
|
|
11
|
+
return input_data.upper()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexusagent-runtime
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: A small, modular AI agent runtime in Python — agents, providers, tools and a readable core.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Abolfazlrwm/nexusagent
|
|
7
|
+
Project-URL: Repository, https://github.com/Abolfazlrwm/nexusagent
|
|
8
|
+
Project-URL: Changelog, https://github.com/Abolfazlrwm/nexusagent/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Abolfazlrwm/nexusagent/issues
|
|
10
|
+
Keywords: ai-agents,agent-runtime,llm,tools,providers
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
29
|
+
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
|
30
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# NexusAgent
|
|
34
|
+
|
|
35
|
+
[](https://github.com/Abolfazlrwm/nexusagent/actions/workflows/ci.yml)
|
|
36
|
+
[](https://github.com/Abolfazlrwm/nexusagent/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/nexusagent-runtime/)
|
|
38
|
+
[](https://pypi.org/project/nexusagent-runtime/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
**A modular runtime for AI agents in Python — zero runtime dependencies, a small readable core, and an honest feature list.**
|
|
42
|
+
|
|
43
|
+
Most agent frameworks hand you a galaxy of abstractions. NexusAgent is the opposite: ~500 lines of typed, contract-tested core that you can read in one sitting — providers, tools, a bounded autonomous tool loop, and a CLI. Nothing hidden, nothing magic.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from nexusagent import create_application_runtime
|
|
47
|
+
|
|
48
|
+
runtime = create_application_runtime() # offline `fake` provider by default
|
|
49
|
+
|
|
50
|
+
result = runtime.run("Hello NexusAgent")
|
|
51
|
+
print(result.output) # fake response: Hello NexusAgent
|
|
52
|
+
print(result.success) # True
|
|
53
|
+
|
|
54
|
+
print(runtime.execute_tool("calculator", "2 + 3")) # 5
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Why NexusAgent
|
|
58
|
+
|
|
59
|
+
- **Zero runtime dependencies** — the whole core is the Python standard library. `pip install nexusagent-runtime` pulls nothing else.
|
|
60
|
+
- **Readable by design** — the core is small enough to audit in an afternoon; architecture rules are enforced by tests, not comments.
|
|
61
|
+
- **Honest** — the feature list below says what exists *and what doesn't*. No fake benchmarks, no aspirational docs.
|
|
62
|
+
- **Typed** — ships `py.typed`; the source itself passes `mypy --strict`.
|
|
63
|
+
- **Tested** — 600+ contract tests, ≥95% coverage floor enforced in CI.
|
|
64
|
+
|
|
65
|
+
## Features
|
|
66
|
+
|
|
67
|
+
| Capability | Status |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| Provider abstraction (bring any backend) | ✅ |
|
|
70
|
+
| Autonomous tool loop, bounded by `max_iterations` | ✅ |
|
|
71
|
+
| Vendor-neutral HTTP wire protocol | ✅ [docs/protocol.md](docs/protocol.md) |
|
|
72
|
+
| Built-in tools: `echo`, `uppercase`, `calculator` | ✅ |
|
|
73
|
+
| Custom tools / custom providers via small ABCs | ✅ [guides](docs/) |
|
|
74
|
+
| Configuration via `NEXUS_*` environment variables | ✅ |
|
|
75
|
+
| CLI (`nexusagent`, `python -m nexusagent`) | ✅ |
|
|
76
|
+
| Fully typed (`py.typed`), strict mypy clean | ✅ |
|
|
77
|
+
| Async execution / streaming | ❌ |
|
|
78
|
+
| Memory, planning, multi-agent orchestration | ❌ |
|
|
79
|
+
| Vendor adapters (OpenAI, Anthropic, …) | ❌ |
|
|
80
|
+
| Retries / backoff on HTTP failures | ❌ |
|
|
81
|
+
|
|
82
|
+
## Architecture
|
|
83
|
+
|
|
84
|
+
```mermaid
|
|
85
|
+
flowchart LR
|
|
86
|
+
U["your code / CLI"] --> R["Runtime"]
|
|
87
|
+
R --> A["Agent"]
|
|
88
|
+
A -- "generate_response()" --> P["Provider<br/>(fake · scripted · http)"]
|
|
89
|
+
P -- "TextResponse" --> A
|
|
90
|
+
P -- "ToolCallRequest" --> A
|
|
91
|
+
A --> X["ToolExecutor"]
|
|
92
|
+
X --> T["ToolRegistry<br/>echo · uppercase · calculator"]
|
|
93
|
+
X -- "ToolResult" --> A
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The Provider answers with text or requests a tool; the Agent runs the tool
|
|
97
|
+
and feeds the result back, repeating until a text answer or the iteration
|
|
98
|
+
limit. Deeper details: [docs/architecture.md](docs/architecture.md) ·
|
|
99
|
+
concepts: [docs/concepts.md](docs/concepts.md) · decisions:
|
|
100
|
+
[docs/adr/](docs/adr/).
|
|
101
|
+
|
|
102
|
+
## Installation
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install nexusagent-runtime
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
> The **import name** is `nexusagent`; the **PyPI distribution** is
|
|
109
|
+
> `nexusagent-runtime` — the exact name `nexusagent` is blocked on PyPI by an
|
|
110
|
+
> unrelated placeholder package (`nexus-agent`), see
|
|
111
|
+
> [ADR 0004](docs/adr/0004-pypi-distribution-name.md).
|
|
112
|
+
|
|
113
|
+
Requires Python 3.10–3.13. From source:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
git clone https://github.com/Abolfazlrwm/nexusagent
|
|
117
|
+
cd nexusagent
|
|
118
|
+
pip install -e ".[dev]"
|
|
119
|
+
python scripts/check.py # lint + format + mypy --strict + tests
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Usage
|
|
123
|
+
|
|
124
|
+
### Agent with tools
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from nexusagent import create_application_runtime
|
|
128
|
+
|
|
129
|
+
runtime = create_application_runtime()
|
|
130
|
+
|
|
131
|
+
print(runtime.list_tools())
|
|
132
|
+
# [EchoTool, UppercaseTool, CalculatorTool]
|
|
133
|
+
|
|
134
|
+
# The provider decides when to request a tool; the agent runs the loop.
|
|
135
|
+
result = runtime.run("What is 19 + 23?")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### The tool loop, end to end, offline
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python examples/tool_loop_demo.py
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Starts a local "model backend" speaking the [HTTP protocol](docs/protocol.md),
|
|
145
|
+
requests the calculator, and turns the result into a final answer — no API
|
|
146
|
+
key, no internet.
|
|
147
|
+
|
|
148
|
+
### CLI
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
nexusagent "your prompt" # or: python -m nexusagent "your prompt"
|
|
152
|
+
nexusagent tool list
|
|
153
|
+
nexusagent tool run calculator "2 + 3"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Configuration
|
|
157
|
+
|
|
158
|
+
| Variable | Meaning | Default |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| `NEXUS_PROVIDER` | `fake` or `http` | `fake` |
|
|
161
|
+
| `NEXUS_ENDPOINT` | HTTP provider URL | — |
|
|
162
|
+
| `NEXUS_MODEL` | model identifier passed to the provider | — |
|
|
163
|
+
| `NEXUS_API_KEY` | sent as `Bearer` token (never logged, masked in reprs) | — |
|
|
164
|
+
| `NEXUS_TIMEOUT` | HTTP timeout in seconds | `30` |
|
|
165
|
+
|
|
166
|
+
## Examples
|
|
167
|
+
|
|
168
|
+
All examples run offline, with no API keys:
|
|
169
|
+
|
|
170
|
+
- [`examples/basic.py`](examples/basic.py) — smallest possible run
|
|
171
|
+
- [`examples/custom_tool.py`](examples/custom_tool.py) — define and register your own tool
|
|
172
|
+
- [`examples/tool_loop_demo.py`](examples/tool_loop_demo.py) — the autonomous tool loop over local HTTP
|
|
173
|
+
- [`examples/http_provider_local.py`](examples/http_provider_local.py) — `http` provider against a local backend
|
|
174
|
+
|
|
175
|
+
## Documentation
|
|
176
|
+
|
|
177
|
+
- [Architecture](docs/architecture.md) — layers, module map, dependency rules
|
|
178
|
+
- [Concepts](docs/concepts.md) — Agent, Provider, Tool, Runtime, Settings
|
|
179
|
+
- [Guides](docs/) — write a custom [provider](docs/guides/custom-provider.md) or [tool](docs/guides/custom-tool.md)
|
|
180
|
+
- [HTTP protocol](docs/protocol.md) — wire format for backends
|
|
181
|
+
- [ADRs](docs/adr/) — architecture decision records
|
|
182
|
+
- [Security policy](SECURITY.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
|
|
183
|
+
|
|
184
|
+
## Project status
|
|
185
|
+
|
|
186
|
+
Actively developed, pre-1.0. See [PROJECT_STATUS.md](PROJECT_STATUS.md) for
|
|
187
|
+
a candid snapshot of what works and what the next steps are. Breaking public
|
|
188
|
+
API changes require an ADR and are discussed in the open.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
nexusagent/__init__.py,sha256=7FZQXiKE_ZNHrX2qrDVHIccab94TZQ28_UTZ0qw5oWI,361
|
|
2
|
+
nexusagent/__main__.py,sha256=e85R9MeC3LyBNDBOd8UPQkCPUWBMxbAuM5AU-T90C4Y,41
|
|
3
|
+
nexusagent/agent.py,sha256=9k2fQAOW-nCp-b7d9RXHi-E4EpGTVDDv6qS33wRzGhs,6806
|
|
4
|
+
nexusagent/application.py,sha256=rDHWdTFLSCVcjH-aSgxfg4Zb8sTPtJ_yLsNwOiS2Ygg,346
|
|
5
|
+
nexusagent/calculator_tool.py,sha256=IlP5QmH29_5a3aA9ylK9ASRfbcX2f0qvR6YrZ6RdNo0,1219
|
|
6
|
+
nexusagent/config.py,sha256=8Mu8StfwXYI_-GuUdmy-r71sJWmWXWfdjuiwUZZWaYw,2819
|
|
7
|
+
nexusagent/echo_tool.py,sha256=Cu6B4oAdtZ9r1cyrsc_NSlEfYbxQfpp-ncwXcQ3tyj4,281
|
|
8
|
+
nexusagent/factory.py,sha256=F-Lv9V0Z431jwBWvIedkEHlAqyJAg8summ-jdaFrnCc,457
|
|
9
|
+
nexusagent/http_provider.py,sha256=5WPRdzEthckQjothOH1csHo_S3Ri1aKY6xSuFIMCHHY,6292
|
|
10
|
+
nexusagent/logging.py,sha256=2DiDJezI0luO79Krq7MlwIJ3PqauOtbGFbFNaZcR-HM,1912
|
|
11
|
+
nexusagent/main.py,sha256=yZfbMUneBSUCgtb2vAj_zXYt0RkQDuxr73vOXNEi6SI,2323
|
|
12
|
+
nexusagent/provider.py,sha256=ef1_s7g6YNbiZfrdqi79TE6rMJkMvdbC5roxr_yAb0c,2779
|
|
13
|
+
nexusagent/providers.py,sha256=39mX_gG3gQB0p9OslmqyeSl-8DG6dLteAYV-cqU0C4w,1899
|
|
14
|
+
nexusagent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
nexusagent/runtime.py,sha256=Fq_uPrZBkwZXB94UB4C6xfuH7zrYtZlJPq9ZU9pJtjs,1739
|
|
16
|
+
nexusagent/runtime_factory.py,sha256=BAiiMpOViCZQCjS8Gy8GwA4vRmYqfSK-sFxQePnNEgs,461
|
|
17
|
+
nexusagent/tool.py,sha256=6I9Nxi0v7xxtkIlx3S_hV_SwoRjc-6JC9qgM2W_sixs,728
|
|
18
|
+
nexusagent/tool_executor.py,sha256=5-RXIIms2NV7cAsa16XYBtepG8RSqTD34f-OhkZnRXY,911
|
|
19
|
+
nexusagent/tool_factory.py,sha256=oOK_YlNeomycXxuIE4XSC0XHGOD9Z336SiDRZGen7TU,443
|
|
20
|
+
nexusagent/tool_registry.py,sha256=1nW77MWUce8pxFYKN_Bu9yncqtSZYS8bp5SSZsyKtSw,1346
|
|
21
|
+
nexusagent/uppercase_tool.py,sha256=jvIHQ9ebdNVDE6gAaRp7t0YldyCkrHqqoBiy8J4mnZ4,299
|
|
22
|
+
nexusagent_runtime-0.4.0.dist-info/licenses/LICENSE,sha256=YnGz4nqnN3q5lB6AqSa_-tpK4btjj0dolg8D7ZtIIFI,1069
|
|
23
|
+
nexusagent_runtime-0.4.0.dist-info/METADATA,sha256=f64Cca2IwtdGvmZksF0ug46ANy5lga9woMYuyXbaPp4,7622
|
|
24
|
+
nexusagent_runtime-0.4.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
25
|
+
nexusagent_runtime-0.4.0.dist-info/entry_points.txt,sha256=YksSV12J-TqF8zt9QMo78_EjZMmJgnkJLG8cio6TrZU,52
|
|
26
|
+
nexusagent_runtime-0.4.0.dist-info/top_level.txt,sha256=sXZ4p_tmv61VOgQG2VcxWV-PUmzCgOcewTKJhQLSPOA,11
|
|
27
|
+
nexusagent_runtime-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abolfazl RWM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nexusagent
|