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.
Files changed (101) hide show
  1. {agentlings-0.2.2 → agentlings-0.2.4}/PKG-INFO +1 -1
  2. {agentlings-0.2.2 → agentlings-0.2.4}/pyproject.toml +1 -1
  3. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/config.py +1 -0
  4. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/a2a.py +16 -2
  5. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/server.py +4 -0
  6. agentlings-0.2.4/src/agentlings/tools/__init__.py +10 -0
  7. agentlings-0.2.4/src/agentlings/tools/decorator.py +218 -0
  8. agentlings-0.2.4/src/agentlings/tools/examples/__init__.py +22 -0
  9. agentlings-0.2.4/src/agentlings/tools/examples/echo.py +14 -0
  10. agentlings-0.2.4/src/agentlings/tools/examples/geocode.py +57 -0
  11. agentlings-0.2.4/src/agentlings/tools/examples/http_get.py +29 -0
  12. agentlings-0.2.4/src/agentlings/tools/examples/set_severity.py +25 -0
  13. agentlings-0.2.4/src/agentlings/tools/loader.py +126 -0
  14. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/tools/registry.py +33 -1
  15. agentlings-0.2.4/tests/unit/test_a2a_executor.py +292 -0
  16. agentlings-0.2.4/tests/unit/test_tool_decorator.py +383 -0
  17. agentlings-0.2.4/tests/unit/test_tool_loader.py +291 -0
  18. agentlings-0.2.2/src/agentlings/tools/__init__.py +0 -1
  19. {agentlings-0.2.2 → agentlings-0.2.4}/.env.example +0 -0
  20. {agentlings-0.2.2 → agentlings-0.2.4}/.github/workflows/ci.yml +0 -0
  21. {agentlings-0.2.2 → agentlings-0.2.4}/.github/workflows/publish.yml +0 -0
  22. {agentlings-0.2.2 → agentlings-0.2.4}/.gitignore +0 -0
  23. {agentlings-0.2.2 → agentlings-0.2.4}/CLAUDE.md +0 -0
  24. {agentlings-0.2.2 → agentlings-0.2.4}/DESIGN-memory-sleep.md +0 -0
  25. {agentlings-0.2.2 → agentlings-0.2.4}/Dockerfile +0 -0
  26. {agentlings-0.2.2 → agentlings-0.2.4}/LICENSE +0 -0
  27. {agentlings-0.2.2 → agentlings-0.2.4}/README.md +0 -0
  28. {agentlings-0.2.2 → agentlings-0.2.4}/agent.example.yaml +0 -0
  29. {agentlings-0.2.2 → agentlings-0.2.4}/docker-compose.test.yml +0 -0
  30. {agentlings-0.2.2 → agentlings-0.2.4}/logo.png +0 -0
  31. {agentlings-0.2.2 → agentlings-0.2.4}/scripts/release.sh +0 -0
  32. {agentlings-0.2.2 → agentlings-0.2.4}/sleep.png +0 -0
  33. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/__init__.py +0 -0
  34. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/__main__.py +0 -0
  35. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/__init__.py +0 -0
  36. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/_migrations.py +0 -0
  37. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/_templates.py +0 -0
  38. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/_version.py +0 -0
  39. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/init.py +0 -0
  40. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/cli/upgrade.py +0 -0
  41. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/__init__.py +0 -0
  42. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/completion.py +0 -0
  43. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/llm.py +0 -0
  44. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/loop.py +0 -0
  45. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/memory_models.py +0 -0
  46. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/memory_store.py +0 -0
  47. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/models.py +0 -0
  48. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/prompt.py +0 -0
  49. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/scheduler.py +0 -0
  50. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/sleep.py +0 -0
  51. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/store.py +0 -0
  52. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/task.py +0 -0
  53. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/core/telemetry.py +0 -0
  54. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/log.py +0 -0
  55. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/migrations/__init__.py +0 -0
  56. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/migrations/m0001_seed.py +0 -0
  57. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/__init__.py +0 -0
  58. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/a2a_task_store.py +0 -0
  59. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/agent_card.py +0 -0
  60. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/protocol/mcp.py +0 -0
  61. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/templates/__init__.py +0 -0
  62. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/templates/default/.env.example +0 -0
  63. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/templates/default/agent.yaml +0 -0
  64. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/tools/builtins.py +0 -0
  65. {agentlings-0.2.2 → agentlings-0.2.4}/src/agentlings/tools/memory.py +0 -0
  66. {agentlings-0.2.2 → agentlings-0.2.4}/tests/Dockerfile +0 -0
  67. {agentlings-0.2.2 → agentlings-0.2.4}/tests/__init__.py +0 -0
  68. {agentlings-0.2.2 → agentlings-0.2.4}/tests/agent.test.yaml +0 -0
  69. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/__init__.py +0 -0
  70. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/a2a_client.py +0 -0
  71. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/conftest.py +0 -0
  72. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/mcp_client.py +0 -0
  73. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_a2a.py +0 -0
  74. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_agent_card.py +0 -0
  75. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_mcp.py +0 -0
  76. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_ollama.py +0 -0
  77. {agentlings-0.2.2 → agentlings-0.2.4}/tests/integration/test_task_flow.py +0 -0
  78. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/__init__.py +0 -0
  79. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/conftest.py +0 -0
  80. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_a2a_task_store.py +0 -0
  81. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_agent_card.py +0 -0
  82. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_cli_init.py +0 -0
  83. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_cli_upgrade.py +0 -0
  84. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_completion.py +0 -0
  85. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_config.py +0 -0
  86. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_live_api.py +0 -0
  87. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_llm.py +0 -0
  88. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_logging.py +0 -0
  89. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_loop.py +0 -0
  90. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_mcp_handler.py +0 -0
  91. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_memory_models.py +0 -0
  92. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_memory_store.py +0 -0
  93. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_memory_tool.py +0 -0
  94. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_models.py +0 -0
  95. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_prompt.py +0 -0
  96. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_scheduler.py +0 -0
  97. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_sleep.py +0 -0
  98. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_store.py +0 -0
  99. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_task.py +0 -0
  100. {agentlings-0.2.2 → agentlings-0.2.4}/tests/unit/test_telemetry.py +0 -0
  101. {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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentlings"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Lightweight A2A + MCP single-process agent framework"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -162,6 +162,7 @@ class AgentConfig(BaseSettings):
162
162
  agent_otel_insecure: bool = True
163
163
  agent_otel_headers: str = ""
164
164
  agent_task_await_seconds: int = 60
165
+ agent_tools_dir: Path | None = None
165
166
 
166
167
  _definition: AgentDefinition = AgentDefinition()
167
168
 
@@ -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=self._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,10 @@
1
+ """Pluggable tool system with built-in bash and filesystem implementations."""
2
+
3
+ from agentlings.tools.decorator import (
4
+ Tool,
5
+ ToolDefinitionError,
6
+ ToolInputError,
7
+ tool,
8
+ )
9
+
10
+ __all__ = ["Tool", "ToolDefinitionError", "ToolInputError", "tool"]
@@ -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 [