agentlings 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {agentlings-0.2.2 → agentlings-0.2.4}/PKG-INFO +1 -1
- {agentlings-0.2.2 → agentlings-0.2.4}/pyproject.toml +1 -1
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/config.py +1 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/a2a.py +16 -2
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/server.py +4 -0
- agentlings-0.2.4/src/agentlings/tools/__init__.py +10 -0
- agentlings-0.2.4/src/agentlings/tools/decorator.py +218 -0
- agentlings-0.2.4/src/agentlings/tools/examples/__init__.py +22 -0
- agentlings-0.2.4/src/agentlings/tools/examples/echo.py +14 -0
- agentlings-0.2.4/src/agentlings/tools/examples/geocode.py +57 -0
- agentlings-0.2.4/src/agentlings/tools/examples/http_get.py +29 -0
- agentlings-0.2.4/src/agentlings/tools/examples/set_severity.py +25 -0
- agentlings-0.2.4/src/agentlings/tools/loader.py +126 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/tools/registry.py +33 -1
- agentlings-0.2.4/tests/unit/test_a2a_executor.py +292 -0
- agentlings-0.2.4/tests/unit/test_tool_decorator.py +383 -0
- agentlings-0.2.4/tests/unit/test_tool_loader.py +291 -0
- agentlings-0.2.2/src/agentlings/tools/__init__.py +0 -1
- {agentlings-0.2.2 → agentlings-0.2.4}/.env.example +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/.github/workflows/ci.yml +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/.github/workflows/publish.yml +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/.gitignore +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/CLAUDE.md +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/DESIGN-memory-sleep.md +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/Dockerfile +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/LICENSE +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/README.md +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/agent.example.yaml +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/docker-compose.test.yml +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/logo.png +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/scripts/release.sh +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/sleep.png +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/__main__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/_migrations.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/_templates.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/_version.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/init.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/upgrade.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/completion.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/llm.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/loop.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/memory_models.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/memory_store.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/models.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/prompt.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/scheduler.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/sleep.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/store.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/task.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/telemetry.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/log.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/migrations/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/migrations/m0001_seed.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/a2a_task_store.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/agent_card.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/mcp.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/templates/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/templates/default/.env.example +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/templates/default/agent.yaml +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/tools/builtins.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/tools/memory.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/Dockerfile +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/agent.test.yaml +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/a2a_client.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/conftest.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/mcp_client.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_a2a.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_agent_card.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_mcp.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_ollama.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_task_flow.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/__init__.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/conftest.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_a2a_task_store.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_agent_card.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_cli_init.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_cli_upgrade.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_completion.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_config.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_live_api.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_llm.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_logging.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_loop.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_mcp_handler.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_memory_models.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_memory_store.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_memory_tool.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_models.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_prompt.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_scheduler.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_sleep.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_store.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_task.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_telemetry.py +0 -0
- {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentlings
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Lightweight A2A + MCP single-process agent framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/andyjmorgan/DonkeyWork-Agentlings
|
|
6
6
|
Project-URL: Repository, https://github.com/andyjmorgan/DonkeyWork-Agentlings
|
|
@@ -7,6 +7,11 @@ A2A ``Task`` object (``status.state == working``) is enqueued so the caller
|
|
|
7
7
|
can poll via ``GetTask`` — those GetTask calls are routed back to our engine
|
|
8
8
|
by ``EngineTaskStore`` so the SDK's answers always reflect live state.
|
|
9
9
|
|
|
10
|
+
Clients can opt out of the await window per-request by setting
|
|
11
|
+
``configuration.return_immediately = true`` on ``message/send``; in that
|
|
12
|
+
case the executor passes ``await_seconds=0`` to the engine and a ``Task``
|
|
13
|
+
object is enqueued without blocking.
|
|
14
|
+
|
|
10
15
|
Cancellation (``CancelTask``) hits the engine's cancel path by task id.
|
|
11
16
|
"""
|
|
12
17
|
|
|
@@ -78,10 +83,19 @@ class AgentlingExecutor(AgentExecutor):
|
|
|
78
83
|
# through EngineTaskStore) and the Task object we enqueue all agree.
|
|
79
84
|
sdk_task_id = context.task_id
|
|
80
85
|
|
|
86
|
+
# A2A 1.0: clients can opt out of blocking by setting
|
|
87
|
+
# ``configuration.return_immediately``. When set, skip the await
|
|
88
|
+
# window so a Task handle is enqueued immediately.
|
|
89
|
+
return_immediately = bool(
|
|
90
|
+
getattr(context.configuration, "return_immediately", False)
|
|
91
|
+
)
|
|
92
|
+
await_seconds = 0.0 if return_immediately else self._await_seconds
|
|
93
|
+
|
|
81
94
|
logger.debug(
|
|
82
|
-
"a2a execute: context_id=%s task_id=%s text=%r",
|
|
95
|
+
"a2a execute: context_id=%s task_id=%s return_immediately=%s text=%r",
|
|
83
96
|
context_id,
|
|
84
97
|
sdk_task_id,
|
|
98
|
+
return_immediately,
|
|
85
99
|
(user_text or "")[:100],
|
|
86
100
|
)
|
|
87
101
|
|
|
@@ -90,7 +104,7 @@ class AgentlingExecutor(AgentExecutor):
|
|
|
90
104
|
message=user_text,
|
|
91
105
|
context_id=context_id,
|
|
92
106
|
via="a2a",
|
|
93
|
-
await_seconds=
|
|
107
|
+
await_seconds=await_seconds,
|
|
94
108
|
task_id=sdk_task_id,
|
|
95
109
|
)
|
|
96
110
|
except ContextBusyError as e:
|
|
@@ -32,6 +32,7 @@ from agentlings.protocol.a2a import AgentlingExecutor
|
|
|
32
32
|
from agentlings.protocol.a2a_task_store import EngineTaskStore
|
|
33
33
|
from agentlings.protocol.agent_card import generate_agent_card
|
|
34
34
|
from agentlings.protocol.mcp import create_mcp_server
|
|
35
|
+
from agentlings.tools.loader import load_tools_from_directory
|
|
35
36
|
from agentlings.tools.memory import init_memory_tool
|
|
36
37
|
from agentlings.tools.registry import ToolRegistry
|
|
37
38
|
|
|
@@ -91,6 +92,9 @@ def _create_app(config: AgentConfig | None = None) -> Starlette:
|
|
|
91
92
|
tools = ToolRegistry()
|
|
92
93
|
tools.register_tools(config.enabled_tools, bash_timeout=config.definition.bash_timeout)
|
|
93
94
|
|
|
95
|
+
if config.agent_tools_dir is not None:
|
|
96
|
+
load_tools_from_directory(config.agent_tools_dir, tools)
|
|
97
|
+
|
|
94
98
|
if not tools.tool_names():
|
|
95
99
|
logger.warning(
|
|
96
100
|
"no tools enabled — add tools to agent.yaml or set AGENT_CONFIG "
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""``@tool`` decorator: turn a typed Python function into a self-describing tool.
|
|
2
|
+
|
|
3
|
+
A tool is a self-contained unit. The decorator infers everything the LLM needs
|
|
4
|
+
from the function itself:
|
|
5
|
+
|
|
6
|
+
- ``name`` from ``func.__name__`` (or override).
|
|
7
|
+
- ``description`` from the docstring (or override).
|
|
8
|
+
- ``input_schema`` derived from the parameter type annotations using Pydantic.
|
|
9
|
+
|
|
10
|
+
Per-parameter descriptions and constraints attach via
|
|
11
|
+
``Annotated[T, Field(description="...", ge=..., le=...)]`` — the idiomatic
|
|
12
|
+
Python way and the single source of truth (no separate docstring parser).
|
|
13
|
+
|
|
14
|
+
Sync and async functions are both supported; the resulting ``Tool`` exposes a
|
|
15
|
+
unified ``async call(args)`` regardless.
|
|
16
|
+
|
|
17
|
+
Output: ``tool.to_anthropic_dict()`` returns ``{name, description,
|
|
18
|
+
input_schema}`` — exactly the shape the agent registry already passes to
|
|
19
|
+
``messages.create(tools=...)``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import inspect
|
|
26
|
+
from inspect import Parameter
|
|
27
|
+
from typing import Any, Awaitable, Callable, Generic, TypeVar, get_type_hints, overload
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, ValidationError, create_model
|
|
30
|
+
|
|
31
|
+
R = TypeVar("R")
|
|
32
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolDefinitionError(TypeError):
|
|
36
|
+
"""Raised when a function cannot be turned into a tool.
|
|
37
|
+
|
|
38
|
+
Common causes: missing type annotations, ``*args``/``**kwargs``, or
|
|
39
|
+
positional-only parameters.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ToolInputError(ValueError):
|
|
44
|
+
"""Raised when ``Tool.call`` receives arguments that fail schema validation.
|
|
45
|
+
|
|
46
|
+
Carries the underlying ``pydantic.ValidationError`` for callers that want
|
|
47
|
+
structured error reporting; ``str(e)`` is a human-readable summary.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, validation_error: ValidationError) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.validation_error = validation_error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Tool(Generic[R]):
|
|
56
|
+
"""A typed, self-describing callable the agent can expose to the LLM.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
func: The underlying user function.
|
|
60
|
+
name: The identifier the LLM uses to invoke the tool.
|
|
61
|
+
description: Human-readable description for the LLM.
|
|
62
|
+
input_schema: JSON Schema describing the tool's input parameters.
|
|
63
|
+
is_async: ``True`` when ``func`` is a coroutine function.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
func: Callable[..., Any]
|
|
67
|
+
name: str
|
|
68
|
+
description: str
|
|
69
|
+
input_schema: dict[str, Any]
|
|
70
|
+
is_async: bool
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
func: Callable[..., R] | Callable[..., Awaitable[R]],
|
|
75
|
+
*,
|
|
76
|
+
name: str | None = None,
|
|
77
|
+
description: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.func = func
|
|
80
|
+
self.is_async = asyncio.iscoroutinefunction(func)
|
|
81
|
+
self.name = name or func.__name__
|
|
82
|
+
|
|
83
|
+
if description is not None:
|
|
84
|
+
self.description = description
|
|
85
|
+
elif func.__doc__:
|
|
86
|
+
self.description = inspect.cleandoc(func.__doc__)
|
|
87
|
+
else:
|
|
88
|
+
self.description = ""
|
|
89
|
+
|
|
90
|
+
self._input_model = _build_input_model(func)
|
|
91
|
+
self.input_schema = _clean_schema(
|
|
92
|
+
self._input_model.model_json_schema(),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def call(self, args: dict[str, Any]) -> R:
|
|
96
|
+
"""Validate ``args`` against the schema and invoke the tool.
|
|
97
|
+
|
|
98
|
+
Sync functions run on a worker thread so an async caller never blocks.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
validated = self._input_model.model_validate(args)
|
|
102
|
+
except ValidationError as e:
|
|
103
|
+
raise ToolInputError(
|
|
104
|
+
f"Invalid arguments for tool {self.name!r}: {e.error_count()} "
|
|
105
|
+
f"validation error(s).",
|
|
106
|
+
validation_error=e,
|
|
107
|
+
) from e
|
|
108
|
+
|
|
109
|
+
kwargs = {k: getattr(validated, k) for k in self._input_model.model_fields}
|
|
110
|
+
if self.is_async:
|
|
111
|
+
return await self.func(**kwargs) # type: ignore[no-any-return]
|
|
112
|
+
return await asyncio.to_thread(self.func, **kwargs)
|
|
113
|
+
|
|
114
|
+
def to_anthropic_dict(self) -> dict[str, Any]:
|
|
115
|
+
"""Render the tool in the shape ``messages.create(tools=...)`` expects."""
|
|
116
|
+
return {
|
|
117
|
+
"name": self.name,
|
|
118
|
+
"description": self.description,
|
|
119
|
+
"input_schema": self.input_schema,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
123
|
+
kind = "async" if self.is_async else "sync"
|
|
124
|
+
return f"<Tool {self.name!r} ({kind})>"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@overload
|
|
128
|
+
def tool(func: F) -> Tool[Any]: ...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@overload
|
|
132
|
+
def tool(
|
|
133
|
+
*,
|
|
134
|
+
name: str | None = None,
|
|
135
|
+
description: str | None = None,
|
|
136
|
+
) -> Callable[[F], Tool[Any]]: ...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def tool(
|
|
140
|
+
func: Callable[..., Any] | None = None,
|
|
141
|
+
*,
|
|
142
|
+
name: str | None = None,
|
|
143
|
+
description: str | None = None,
|
|
144
|
+
) -> Tool[Any] | Callable[[Callable[..., Any]], Tool[Any]]:
|
|
145
|
+
"""Wrap a function as a ``Tool``.
|
|
146
|
+
|
|
147
|
+
Usable bare or with overrides::
|
|
148
|
+
|
|
149
|
+
@tool
|
|
150
|
+
async def fetch(url: str) -> str:
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
@tool(name="custom", description="…")
|
|
154
|
+
def other(...): ...
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def _make(fn: Callable[..., Any]) -> Tool[Any]:
|
|
158
|
+
return Tool(fn, name=name, description=description)
|
|
159
|
+
|
|
160
|
+
if func is not None:
|
|
161
|
+
return _make(func)
|
|
162
|
+
return _make
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _build_input_model(func: Callable[..., Any]) -> type[BaseModel]:
|
|
166
|
+
"""Build a Pydantic model representing the function's keyword arguments.
|
|
167
|
+
|
|
168
|
+
Uses ``get_type_hints(..., include_extras=True)`` so ``Annotated`` metadata
|
|
169
|
+
(``pydantic.Field(...)``) is preserved and feeds ``model_json_schema``.
|
|
170
|
+
"""
|
|
171
|
+
sig = inspect.signature(func)
|
|
172
|
+
try:
|
|
173
|
+
type_hints = get_type_hints(func, include_extras=True)
|
|
174
|
+
except NameError as e:
|
|
175
|
+
raise ToolDefinitionError(
|
|
176
|
+
f"Tool {func.__name__!r}: could not resolve type hints ({e}). "
|
|
177
|
+
f"Use ``from __future__ import annotations`` and ensure all referenced "
|
|
178
|
+
f"types are importable from the function's module."
|
|
179
|
+
) from e
|
|
180
|
+
|
|
181
|
+
fields: dict[str, Any] = {}
|
|
182
|
+
for param_name, param in sig.parameters.items():
|
|
183
|
+
if param.kind == Parameter.VAR_POSITIONAL:
|
|
184
|
+
raise ToolDefinitionError(
|
|
185
|
+
f"Tool {func.__name__!r}: ``*{param_name}`` is not supported."
|
|
186
|
+
)
|
|
187
|
+
if param.kind == Parameter.VAR_KEYWORD:
|
|
188
|
+
raise ToolDefinitionError(
|
|
189
|
+
f"Tool {func.__name__!r}: ``**{param_name}`` is not supported."
|
|
190
|
+
)
|
|
191
|
+
if param.kind == Parameter.POSITIONAL_ONLY:
|
|
192
|
+
raise ToolDefinitionError(
|
|
193
|
+
f"Tool {func.__name__!r}: positional-only parameter "
|
|
194
|
+
f"{param_name!r} is not supported."
|
|
195
|
+
)
|
|
196
|
+
if param_name not in type_hints:
|
|
197
|
+
raise ToolDefinitionError(
|
|
198
|
+
f"Tool {func.__name__!r}: parameter {param_name!r} must have a "
|
|
199
|
+
f"type annotation."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
annotation = type_hints[param_name]
|
|
203
|
+
default: Any = param.default if param.default is not Parameter.empty else ...
|
|
204
|
+
fields[param_name] = (annotation, default)
|
|
205
|
+
|
|
206
|
+
return create_model(f"{func.__name__}__InputSchema", **fields)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _clean_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
210
|
+
"""Drop Pydantic's noisy top-level ``title`` field; keep everything else.
|
|
211
|
+
|
|
212
|
+
The top-level ``title`` is a Pydantic auto-generated identifier (e.g.
|
|
213
|
+
``foo__InputSchema``) that clutters the LLM-facing schema. Nested ``title``
|
|
214
|
+
fields are left alone — Pydantic generates them for enum classes and
|
|
215
|
+
sub-models where they may carry meaning.
|
|
216
|
+
"""
|
|
217
|
+
schema.pop("title", None)
|
|
218
|
+
return schema
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Reference tools demonstrating the ``@tool`` decorator surface.
|
|
2
|
+
|
|
3
|
+
These are intentionally small and illustrative — they exist to show
|
|
4
|
+
authors how to express common patterns:
|
|
5
|
+
|
|
6
|
+
- ``echo`` — simplest possible tool, no I/O.
|
|
7
|
+
- ``http_get``— async I/O + ``Annotated[..., Field(...)]`` per-param descriptions.
|
|
8
|
+
- ``set_severity`` — string ``Enum`` parameter.
|
|
9
|
+
- ``geocode`` — env-var-driven config + nested ``BaseModel`` parameter.
|
|
10
|
+
|
|
11
|
+
They are not registered by default; agent configs opt in by name once the
|
|
12
|
+
loader supports plugin discovery.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from agentlings.tools.examples.echo import echo
|
|
16
|
+
from agentlings.tools.examples.geocode import geocode
|
|
17
|
+
from agentlings.tools.examples.http_get import http_get
|
|
18
|
+
from agentlings.tools.examples.set_severity import set_severity
|
|
19
|
+
|
|
20
|
+
EXAMPLE_TOOLS = [echo, http_get, set_severity, geocode]
|
|
21
|
+
|
|
22
|
+
__all__ = ["EXAMPLE_TOOLS", "echo", "geocode", "http_get", "set_severity"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Smallest possible tool — echoes its input back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentlings.tools import tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@tool
|
|
9
|
+
def echo(message: str) -> str:
|
|
10
|
+
"""Echo a message back unchanged.
|
|
11
|
+
|
|
12
|
+
Useful for sanity-checking that the tool plumbing is working end-to-end.
|
|
13
|
+
"""
|
|
14
|
+
return message
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Env-var-driven config + nested ``BaseModel`` parameter.
|
|
2
|
+
|
|
3
|
+
Demonstrates the recommended pattern for tools that need credentials or
|
|
4
|
+
endpoint config: read them from the process environment inside the tool —
|
|
5
|
+
the framework stays out of secret plumbing entirely.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from agentlings.tools import tool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Address(BaseModel):
|
|
20
|
+
"""A postal address. Only the fields the geocoder needs are required."""
|
|
21
|
+
|
|
22
|
+
street: str
|
|
23
|
+
city: str
|
|
24
|
+
country: Annotated[str, Field(description="ISO 3166-1 alpha-2 country code, e.g. 'IE'.")]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Coordinates(BaseModel):
|
|
28
|
+
lat: float
|
|
29
|
+
lng: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@tool
|
|
33
|
+
async def geocode(address: Address) -> Coordinates:
|
|
34
|
+
"""Resolve a postal address to latitude/longitude.
|
|
35
|
+
|
|
36
|
+
Reads ``GEOCODE_API_KEY`` from the environment. Tools own their own
|
|
37
|
+
secret plumbing; the framework is intentionally not involved.
|
|
38
|
+
"""
|
|
39
|
+
api_key = os.environ.get("GEOCODE_API_KEY")
|
|
40
|
+
if not api_key:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"GEOCODE_API_KEY is not set. Configure the tool's environment "
|
|
43
|
+
"before enabling it on an agent."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
base_url = os.environ.get("GEOCODE_BASE_URL", "https://api.example.com/geocode")
|
|
47
|
+
params = {
|
|
48
|
+
"street": address.street,
|
|
49
|
+
"city": address.city,
|
|
50
|
+
"country": address.country,
|
|
51
|
+
"key": api_key,
|
|
52
|
+
}
|
|
53
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
54
|
+
response = await client.get(base_url, params=params)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
payload = response.json()
|
|
57
|
+
return Coordinates(lat=payload["lat"], lng=payload["lng"])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Async I/O tool with ``Annotated[..., Field(...)]`` parameter descriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from agentlings.tools import tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@tool
|
|
14
|
+
async def http_get(
|
|
15
|
+
url: Annotated[str, Field(description="Absolute URL to fetch (must be http/https).")],
|
|
16
|
+
timeout_seconds: Annotated[
|
|
17
|
+
float,
|
|
18
|
+
Field(ge=1.0, le=60.0, description="Per-request timeout in seconds."),
|
|
19
|
+
] = 10.0,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Fetch a URL via HTTP GET and return the response body as text.
|
|
22
|
+
|
|
23
|
+
The tool follows redirects and raises if the response status is >= 400 so
|
|
24
|
+
the LLM sees a clear failure signal rather than a misleading empty body.
|
|
25
|
+
"""
|
|
26
|
+
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout_seconds) as client:
|
|
27
|
+
response = await client.get(url)
|
|
28
|
+
response.raise_for_status()
|
|
29
|
+
return response.text
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""String ``Enum`` parameter — the LLM sees a constrained set of values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from agentlings.tools import tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
LOW = "low"
|
|
12
|
+
MEDIUM = "medium"
|
|
13
|
+
HIGH = "high"
|
|
14
|
+
CRITICAL = "critical"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@tool
|
|
18
|
+
def set_severity(severity: Severity, reason: str) -> str:
|
|
19
|
+
"""Record an incident's severity level.
|
|
20
|
+
|
|
21
|
+
The model picks one of the enum values; the framework validates the input
|
|
22
|
+
before the function is invoked, so the function body can rely on
|
|
23
|
+
``severity`` being a valid ``Severity``.
|
|
24
|
+
"""
|
|
25
|
+
return f"severity={severity.value} reason={reason}"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Folder-scan loader for ``@tool``-decorated user tools.
|
|
2
|
+
|
|
3
|
+
Discovery contract:
|
|
4
|
+
|
|
5
|
+
- The framework scans ``AGENT_TOOLS_DIR`` (a directory) for ``*.py`` files.
|
|
6
|
+
- Each file is imported as an isolated module under the synthetic package
|
|
7
|
+
``agentling_user_tools.<filename_stem>``; the directory is *not* added to
|
|
8
|
+
``sys.path`` so user tools cannot accidentally shadow installed packages.
|
|
9
|
+
- Files whose name begins with ``_`` are skipped (private/helpers).
|
|
10
|
+
- Every module-level attribute that is a ``Tool`` instance is registered
|
|
11
|
+
with the supplied ``ToolRegistry``.
|
|
12
|
+
- Import failures and registration failures are logged and skipped — one
|
|
13
|
+
broken tool must not crash the agent.
|
|
14
|
+
|
|
15
|
+
The loader is intentionally permissive about *what* a user file contains:
|
|
16
|
+
it only registers ``Tool`` instances and ignores everything else, so a file
|
|
17
|
+
can define helpers, classes, env-var reads, or whatever the tool author
|
|
18
|
+
needs.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib
|
|
24
|
+
import importlib.util
|
|
25
|
+
import logging
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from agentlings.tools.decorator import Tool
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from agentlings.tools.registry import ToolRegistry
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_USER_TOOLS_PACKAGE = "agentling_user_tools"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_tools_from_directory(
|
|
40
|
+
directory: Path,
|
|
41
|
+
registry: "ToolRegistry",
|
|
42
|
+
) -> list[str]:
|
|
43
|
+
"""Scan ``directory`` and register every ``Tool`` instance found.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
directory: Filesystem path to scan. ``.py`` files at the top level
|
|
47
|
+
are imported (no recursion, no sub-packages).
|
|
48
|
+
registry: The ``ToolRegistry`` to register discovered tools into.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The names of tools that were successfully registered. The list is in
|
|
52
|
+
directory-walk order, which the caller may sort if it wants stable
|
|
53
|
+
output.
|
|
54
|
+
|
|
55
|
+
The function never raises for content issues: missing directory, broken
|
|
56
|
+
imports, and registration failures are logged and the scan continues. It
|
|
57
|
+
only raises for argument-shape problems (wrong types passed in).
|
|
58
|
+
"""
|
|
59
|
+
if not directory.exists():
|
|
60
|
+
logger.info(
|
|
61
|
+
"tool directory %s does not exist — skipping scan", directory
|
|
62
|
+
)
|
|
63
|
+
return []
|
|
64
|
+
if not directory.is_dir():
|
|
65
|
+
logger.warning(
|
|
66
|
+
"AGENT_TOOLS_DIR=%s is not a directory — skipping scan", directory
|
|
67
|
+
)
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
registered: list[str] = []
|
|
71
|
+
for path in sorted(directory.iterdir()):
|
|
72
|
+
if not path.is_file():
|
|
73
|
+
continue
|
|
74
|
+
if path.suffix != ".py":
|
|
75
|
+
continue
|
|
76
|
+
if path.stem.startswith("_"):
|
|
77
|
+
logger.debug("skipping private tool file: %s", path)
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
module_name = f"{_USER_TOOLS_PACKAGE}.{path.stem}"
|
|
81
|
+
try:
|
|
82
|
+
module = _import_isolated_module(module_name, path)
|
|
83
|
+
except Exception: # noqa: BLE001 — log and continue to next file
|
|
84
|
+
logger.exception("failed to import tool file %s", path)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
for attr_name in dir(module):
|
|
88
|
+
attr = getattr(module, attr_name)
|
|
89
|
+
if not isinstance(attr, Tool):
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
registry.register_tool_object(attr)
|
|
93
|
+
registered.append(attr.name)
|
|
94
|
+
except Exception: # noqa: BLE001
|
|
95
|
+
logger.exception(
|
|
96
|
+
"failed to register tool %r from %s", attr.name, path
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if registered:
|
|
100
|
+
logger.info(
|
|
101
|
+
"loaded %d user tool(s) from %s: %s",
|
|
102
|
+
len(registered),
|
|
103
|
+
directory,
|
|
104
|
+
sorted(registered),
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
logger.info("no user tools registered from %s", directory)
|
|
108
|
+
|
|
109
|
+
return registered
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _import_isolated_module(module_name: str, path: Path) -> object:
|
|
113
|
+
"""Import a single file as ``module_name`` without polluting ``sys.path``.
|
|
114
|
+
|
|
115
|
+
Uses ``importlib.util.spec_from_file_location`` so the file is imported
|
|
116
|
+
by absolute path; the parent directory is never injected into the
|
|
117
|
+
interpreter's import paths.
|
|
118
|
+
"""
|
|
119
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
120
|
+
if spec is None or spec.loader is None:
|
|
121
|
+
raise ImportError(
|
|
122
|
+
f"could not build import spec for {path} as {module_name}"
|
|
123
|
+
)
|
|
124
|
+
module = importlib.util.module_from_spec(spec)
|
|
125
|
+
spec.loader.exec_module(module)
|
|
126
|
+
return module
|
|
@@ -5,7 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
-
from typing import Any, Callable
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from agentlings.tools.decorator import Tool
|
|
9
12
|
|
|
10
13
|
logger = logging.getLogger(__name__)
|
|
11
14
|
|
|
@@ -64,6 +67,35 @@ class ToolRegistry:
|
|
|
64
67
|
)
|
|
65
68
|
logger.info("registered tool: %s", name)
|
|
66
69
|
|
|
70
|
+
def register_tool_object(self, tool: "Tool[Any]") -> None:
|
|
71
|
+
"""Register a ``@tool``-decorated ``Tool`` instance.
|
|
72
|
+
|
|
73
|
+
Bridges the decorator surface to the existing registry shape: the
|
|
74
|
+
tool's input schema and metadata are extracted via
|
|
75
|
+
``to_anthropic_dict()``; execution is wrapped so a unified
|
|
76
|
+
``ToolResult`` is returned regardless of the underlying function's
|
|
77
|
+
return type.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
tool: The ``Tool`` produced by the ``@tool`` decorator.
|
|
81
|
+
"""
|
|
82
|
+
async def _execute(**kwargs: Any) -> ToolResult:
|
|
83
|
+
try:
|
|
84
|
+
output = await tool.call(kwargs)
|
|
85
|
+
except Exception as e: # noqa: BLE001 — surface to LLM, not crash
|
|
86
|
+
return ToolResult(
|
|
87
|
+
output=f"Tool {tool.name} failed: {e}",
|
|
88
|
+
is_error=True,
|
|
89
|
+
)
|
|
90
|
+
return ToolResult(output=str(output), is_error=False)
|
|
91
|
+
|
|
92
|
+
self.register(
|
|
93
|
+
name=tool.name,
|
|
94
|
+
description=tool.description,
|
|
95
|
+
input_schema=tool.input_schema,
|
|
96
|
+
execute_fn=_execute,
|
|
97
|
+
)
|
|
98
|
+
|
|
67
99
|
def list_schemas(self) -> list[dict[str, Any]]:
|
|
68
100
|
"""Return tool schemas in the format expected by the Anthropic API."""
|
|
69
101
|
return [
|