ellements 0.2.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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- ellements-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tool-call runtime helpers used by :class:`~ellements.core.LLMClient`.
|
|
2
|
+
|
|
3
|
+
The executor type and the result-stringification helper live here
|
|
4
|
+
together because they are always used in concert by the tool-calling
|
|
5
|
+
loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
ToolExecutor = Callable[[str, dict[str, Any]], Awaitable[str]]
|
|
17
|
+
"""Async ``(tool_name, arguments) -> result_string`` runtime callable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def stringify_tool_result(result: Any) -> str:
|
|
21
|
+
"""Convert arbitrary tool output into a stable string payload."""
|
|
22
|
+
if result is None:
|
|
23
|
+
return ""
|
|
24
|
+
if isinstance(result, str):
|
|
25
|
+
return result
|
|
26
|
+
if isinstance(result, bytes):
|
|
27
|
+
return result.decode("utf-8", errors="replace")
|
|
28
|
+
if isinstance(result, BaseModel):
|
|
29
|
+
return result.model_dump_json()
|
|
30
|
+
if isinstance(result, Mapping):
|
|
31
|
+
return json.dumps(dict(result), default=_json_default)
|
|
32
|
+
if isinstance(result, (list, tuple, set)):
|
|
33
|
+
return json.dumps(list(result), default=_json_default)
|
|
34
|
+
try:
|
|
35
|
+
return json.dumps(result, default=_json_default)
|
|
36
|
+
except TypeError:
|
|
37
|
+
return str(result)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _json_default(value: Any) -> Any:
|
|
41
|
+
if isinstance(value, BaseModel):
|
|
42
|
+
return value.model_dump(mode="json")
|
|
43
|
+
if isinstance(value, bytes):
|
|
44
|
+
return value.decode("utf-8", errors="replace")
|
|
45
|
+
return str(value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["ToolExecutor", "stringify_tool_result"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The :class:`Tool` Protocol — what every tool looks like to ellements.
|
|
2
|
+
|
|
3
|
+
A tool is any object that exposes a stable name, a description, a
|
|
4
|
+
provider-neutral JSON Schema for its parameters, and an awaitable
|
|
5
|
+
``invoke`` method that runs it.
|
|
6
|
+
|
|
7
|
+
Anything matching this shape is a tool. Inheritance is not required.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class Tool(Protocol):
|
|
17
|
+
"""Provider-neutral tool surface consumed by every backend.
|
|
18
|
+
|
|
19
|
+
Implementations must expose:
|
|
20
|
+
|
|
21
|
+
- ``name`` — stable identifier (matches the function name the LLM emits).
|
|
22
|
+
- ``description`` — human-readable description shown to the model.
|
|
23
|
+
- ``params_json_schema`` — JSON Schema (Draft-2020-12 compatible) for
|
|
24
|
+
the parameters object. Provider-specific dialects are produced
|
|
25
|
+
from this by :class:`~ellements.core.tools.ToolDialect`.
|
|
26
|
+
- ``invoke`` — async invocation accepting keyword arguments.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
description: str
|
|
31
|
+
params_json_schema: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
async def invoke(self, **kwargs: Any) -> Any: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["Tool"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Result records for multi-turn tool-calling completions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolCallRecord(BaseModel):
|
|
11
|
+
"""One tool invocation made during a multi-turn completion."""
|
|
12
|
+
|
|
13
|
+
name: str = Field(description="Tool/function name that was invoked")
|
|
14
|
+
arguments: dict[str, Any] = Field(description="Arguments passed to the tool")
|
|
15
|
+
result: str = Field(description="String result returned by the tool executor")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolCallResponse(BaseModel):
|
|
19
|
+
"""Final response of a multi-turn completion with tool calling."""
|
|
20
|
+
|
|
21
|
+
content: str = Field(description="Final text response from the model")
|
|
22
|
+
tool_calls: list[ToolCallRecord] = Field(
|
|
23
|
+
default_factory=list,
|
|
24
|
+
description="Ordered log of every tool invocation during the session",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["ToolCallRecord", "ToolCallResponse"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The :class:`ToolRegistry` — a typed, composable tool collection.
|
|
2
|
+
|
|
3
|
+
Every tool factory should return a ``ToolRegistry``. Apps compose
|
|
4
|
+
registries with :meth:`ToolRegistry.merge` (or with the
|
|
5
|
+
``Mapping`` interface — ``{**r1, **r2}`` and ``dict.update(r)`` both
|
|
6
|
+
work because :class:`ToolRegistry` is a read-only ``Mapping[str,
|
|
7
|
+
ToolSpec]``). LLM clients call :meth:`ToolRegistry.to_dialect` to
|
|
8
|
+
produce the provider-specific wire format, and
|
|
9
|
+
:meth:`ToolRegistry.executor` to obtain the async runtime.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .protocol import Tool
|
|
18
|
+
from .simple import SimpleTool
|
|
19
|
+
from .spec import ToolSpec
|
|
20
|
+
|
|
21
|
+
ToolInput = Tool | ToolSpec | Callable[..., Any]
|
|
22
|
+
"""Anything accepted by :meth:`ToolRegistry.register`."""
|
|
23
|
+
|
|
24
|
+
ToolExecutorFn = Callable[[str, dict[str, Any]], Awaitable[str]]
|
|
25
|
+
"""Async ``(tool_name, args) -> result_string`` runtime executor."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolRegistry(Mapping[str, ToolSpec]):
|
|
29
|
+
"""An ordered, named collection of tools.
|
|
30
|
+
|
|
31
|
+
The registry preserves insertion order so the order of tools shown
|
|
32
|
+
to a model is deterministic across runs. It implements the
|
|
33
|
+
read-only :class:`~collections.abc.Mapping` interface, which means
|
|
34
|
+
every standard dict idiom works without surprises:
|
|
35
|
+
|
|
36
|
+
>>> combined = {**search_tools(), **crawler_tools()}
|
|
37
|
+
>>> for name in registry:
|
|
38
|
+
... spec = registry[name]
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, tools: Iterable[ToolInput] | Mapping[str, ToolInput] | None = None) -> None:
|
|
42
|
+
self._specs: dict[str, ToolSpec] = {}
|
|
43
|
+
if tools is None:
|
|
44
|
+
return
|
|
45
|
+
if isinstance(tools, Mapping):
|
|
46
|
+
for name, tool in tools.items():
|
|
47
|
+
self.register(tool, name=name)
|
|
48
|
+
else:
|
|
49
|
+
for tool in tools:
|
|
50
|
+
self.register(tool)
|
|
51
|
+
|
|
52
|
+
# ── registration ─────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def register(
|
|
55
|
+
self,
|
|
56
|
+
tool: ToolInput,
|
|
57
|
+
*,
|
|
58
|
+
name: str | None = None,
|
|
59
|
+
description: str | None = None,
|
|
60
|
+
) -> ToolSpec:
|
|
61
|
+
"""Add *tool* to the registry and return the materialized spec.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
tool: A :class:`Tool`, :class:`ToolSpec`, or plain callable.
|
|
65
|
+
name: Override the resulting tool name.
|
|
66
|
+
description: Override the description.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The :class:`ToolSpec` that was registered.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If a tool with the same name already exists.
|
|
73
|
+
"""
|
|
74
|
+
spec = self._to_spec(tool, name=name, description=description)
|
|
75
|
+
if spec.name in self._specs:
|
|
76
|
+
raise ValueError(f"Tool {spec.name!r} is already registered.")
|
|
77
|
+
self._specs[spec.name] = spec
|
|
78
|
+
return spec
|
|
79
|
+
|
|
80
|
+
def merge(self, other: Mapping[str, ToolInput]) -> ToolRegistry:
|
|
81
|
+
"""Return a new registry containing this and *other*.
|
|
82
|
+
|
|
83
|
+
*other* may be another :class:`ToolRegistry` or any
|
|
84
|
+
``{name: tool}`` mapping.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the two registries share a tool name.
|
|
88
|
+
"""
|
|
89
|
+
other_specs: dict[str, ToolSpec]
|
|
90
|
+
if isinstance(other, ToolRegistry):
|
|
91
|
+
other_specs = dict(other._specs)
|
|
92
|
+
else:
|
|
93
|
+
other_specs = {
|
|
94
|
+
name: self._to_spec(tool, name=name, description=None)
|
|
95
|
+
for name, tool in other.items()
|
|
96
|
+
}
|
|
97
|
+
overlap = set(self._specs) & set(other_specs)
|
|
98
|
+
if overlap:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Cannot merge registries: duplicate tool names {sorted(overlap)}."
|
|
101
|
+
)
|
|
102
|
+
merged = ToolRegistry()
|
|
103
|
+
merged._specs.update(self._specs)
|
|
104
|
+
merged._specs.update(other_specs)
|
|
105
|
+
return merged
|
|
106
|
+
|
|
107
|
+
# ── Mapping[str, ToolSpec] protocol ──────────────────────────────
|
|
108
|
+
|
|
109
|
+
def __len__(self) -> int:
|
|
110
|
+
return len(self._specs)
|
|
111
|
+
|
|
112
|
+
def __iter__(self) -> Iterator[str]:
|
|
113
|
+
return iter(self._specs)
|
|
114
|
+
|
|
115
|
+
def __contains__(self, name: object) -> bool:
|
|
116
|
+
return isinstance(name, str) and name in self._specs
|
|
117
|
+
|
|
118
|
+
def __getitem__(self, name: str) -> ToolSpec:
|
|
119
|
+
return self._specs[name]
|
|
120
|
+
|
|
121
|
+
# ── consumption ──────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def to_dialect(self, dialect: Any) -> list[dict[str, Any]]:
|
|
124
|
+
"""Render every tool as a provider-specific definition.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
dialect: Any object with an ``emit(spec) -> dict`` method.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The list of provider-specific tool definitions in
|
|
131
|
+
registration order.
|
|
132
|
+
"""
|
|
133
|
+
return [dialect.emit(spec) for spec in self._specs.values()]
|
|
134
|
+
|
|
135
|
+
def executor(self) -> ToolExecutorFn:
|
|
136
|
+
"""Return an async ``(name, args) -> result_string`` executor."""
|
|
137
|
+
from .executor import stringify_tool_result
|
|
138
|
+
|
|
139
|
+
specs = dict(self._specs)
|
|
140
|
+
|
|
141
|
+
async def execute(tool_name: str, arguments: dict[str, Any]) -> str:
|
|
142
|
+
spec = specs.get(tool_name)
|
|
143
|
+
if spec is None:
|
|
144
|
+
available = ", ".join(sorted(specs)) or "(none)"
|
|
145
|
+
raise KeyError(
|
|
146
|
+
f"Unknown tool {tool_name!r}. Available: {available}."
|
|
147
|
+
)
|
|
148
|
+
result = await spec.invoke(**arguments)
|
|
149
|
+
return stringify_tool_result(result)
|
|
150
|
+
|
|
151
|
+
return execute
|
|
152
|
+
|
|
153
|
+
# ── construction helpers ─────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _to_spec(
|
|
157
|
+
tool: ToolInput,
|
|
158
|
+
*,
|
|
159
|
+
name: str | None,
|
|
160
|
+
description: str | None,
|
|
161
|
+
) -> ToolSpec:
|
|
162
|
+
if isinstance(tool, ToolSpec):
|
|
163
|
+
if name is None and description is None:
|
|
164
|
+
return tool
|
|
165
|
+
return ToolSpec(
|
|
166
|
+
name=name or tool.name,
|
|
167
|
+
description=description or tool.description,
|
|
168
|
+
params_json_schema=tool.params_json_schema,
|
|
169
|
+
invoke=tool.invoke,
|
|
170
|
+
)
|
|
171
|
+
if isinstance(tool, SimpleTool):
|
|
172
|
+
spec = tool.to_spec()
|
|
173
|
+
if name is None and description is None:
|
|
174
|
+
return spec
|
|
175
|
+
return ToolSpec(
|
|
176
|
+
name=name or spec.name,
|
|
177
|
+
description=description or spec.description,
|
|
178
|
+
params_json_schema=spec.params_json_schema,
|
|
179
|
+
invoke=spec.invoke,
|
|
180
|
+
)
|
|
181
|
+
if isinstance(tool, Tool):
|
|
182
|
+
return ToolSpec(
|
|
183
|
+
name=name or tool.name,
|
|
184
|
+
description=description or tool.description,
|
|
185
|
+
params_json_schema=tool.params_json_schema,
|
|
186
|
+
invoke=tool.invoke,
|
|
187
|
+
)
|
|
188
|
+
if callable(tool):
|
|
189
|
+
return SimpleTool(tool, name=name, description=description).to_spec()
|
|
190
|
+
raise TypeError(
|
|
191
|
+
f"Cannot register {type(tool).__name__}; expected Tool, ToolSpec, or callable."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_mapping(cls, tools: Mapping[str, ToolInput]) -> ToolRegistry:
|
|
196
|
+
"""Build a registry from a ``{name: tool}`` mapping."""
|
|
197
|
+
return cls(tools)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_callables(cls, tools: Iterable[Callable[..., Any]]) -> ToolRegistry:
|
|
201
|
+
"""Build a registry from an iterable of plain callables."""
|
|
202
|
+
return cls(tools)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = ["ToolExecutorFn", "ToolInput", "ToolRegistry"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""JSON Schema generation from Python callable signatures.
|
|
2
|
+
|
|
3
|
+
A callable's parameters are introspected via :mod:`inspect` and type
|
|
4
|
+
hints. Pydantic ``create_model`` then produces the canonical JSON Schema
|
|
5
|
+
that every dialect re-emits in its own wire format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, get_type_hints
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, create_model
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_callable_signature(func: Callable[..., Any]) -> inspect.Signature:
|
|
18
|
+
"""Resolve postponed annotations in *func*'s signature when possible."""
|
|
19
|
+
signature = inspect.signature(func)
|
|
20
|
+
resolved_hints = resolve_type_hints(func)
|
|
21
|
+
if not resolved_hints:
|
|
22
|
+
return signature
|
|
23
|
+
parameters = [
|
|
24
|
+
parameter.replace(annotation=resolved_hints.get(parameter.name, parameter.annotation))
|
|
25
|
+
for parameter in signature.parameters.values()
|
|
26
|
+
]
|
|
27
|
+
return signature.replace(
|
|
28
|
+
parameters=parameters,
|
|
29
|
+
return_annotation=resolved_hints.get("return", signature.return_annotation),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_type_hints(func: Callable[..., Any]) -> dict[str, Any]:
|
|
34
|
+
"""Best-effort ``get_type_hints`` that never raises."""
|
|
35
|
+
try:
|
|
36
|
+
return get_type_hints(func)
|
|
37
|
+
except Exception:
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_schema_for_callable(func: Callable[..., Any], *, tool_name: str) -> dict[str, Any]:
|
|
42
|
+
"""Build a JSON Schema for *func*'s keyword-bound parameters.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
func: The callable whose signature is converted to a schema.
|
|
46
|
+
tool_name: Stable tool name used to derive the Pydantic model
|
|
47
|
+
name (purely cosmetic in the schema).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A JSON Schema dictionary describing the parameters object.
|
|
51
|
+
"""
|
|
52
|
+
fields: dict[str, tuple[Any, Any]] = {}
|
|
53
|
+
signature = resolve_callable_signature(func)
|
|
54
|
+
resolved_hints = resolve_type_hints(func)
|
|
55
|
+
|
|
56
|
+
for parameter in signature.parameters.values():
|
|
57
|
+
if parameter.kind in {
|
|
58
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
59
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
60
|
+
}:
|
|
61
|
+
continue
|
|
62
|
+
annotation = resolved_hints.get(parameter.name, parameter.annotation)
|
|
63
|
+
if annotation is inspect.Signature.empty:
|
|
64
|
+
annotation = Any
|
|
65
|
+
default = parameter.default
|
|
66
|
+
if default is inspect.Signature.empty:
|
|
67
|
+
default = ...
|
|
68
|
+
fields[parameter.name] = (annotation, default)
|
|
69
|
+
|
|
70
|
+
model_name = f"{tool_name.title().replace('_', '')}Params"
|
|
71
|
+
model: type[BaseModel] = create_model(model_name, **fields) # type: ignore[call-overload]
|
|
72
|
+
schema: dict[str, Any] = model.model_json_schema()
|
|
73
|
+
return schema
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"json_schema_for_callable",
|
|
78
|
+
"resolve_callable_signature",
|
|
79
|
+
"resolve_type_hints",
|
|
80
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The :class:`SimpleTool` convenience builder.
|
|
2
|
+
|
|
3
|
+
``SimpleTool`` wraps an arbitrary Python callable so it satisfies the
|
|
4
|
+
:class:`~ellements.core.tools.Tool` Protocol and can be materialized as
|
|
5
|
+
a :class:`~ellements.core.tools.ToolSpec`.
|
|
6
|
+
|
|
7
|
+
Construction is cheap; the JSON Schema is computed lazily on first
|
|
8
|
+
access and cached. The instance is also callable, so simple in-process
|
|
9
|
+
test harnesses can drive it without going through the schema path.
|
|
10
|
+
|
|
11
|
+
The module also exposes :func:`bind_json_invoker`, a helper used by
|
|
12
|
+
agent backends that bridge between a provider's JSON-payload calling
|
|
13
|
+
convention and a Python callable's positional/keyword binding.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .schemas import json_schema_for_callable, resolve_callable_signature
|
|
24
|
+
from .spec import ToolSpec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimpleTool:
|
|
28
|
+
"""Adapt a callable as a :class:`~ellements.core.tools.Tool`.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
func: The callable to expose as a tool.
|
|
32
|
+
name: Override the tool name. Defaults to ``func.__name__``.
|
|
33
|
+
description: Override the description. Defaults to the
|
|
34
|
+
callable's docstring.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
func: Callable[..., Any],
|
|
40
|
+
*,
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
description: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.func = func
|
|
45
|
+
self.name: str = name or getattr(func, "__name__", None) or "tool"
|
|
46
|
+
self.description: str = description or inspect.getdoc(func) or self.name
|
|
47
|
+
self.__name__ = self.name
|
|
48
|
+
self.__doc__ = self.description
|
|
49
|
+
self.__signature__ = resolve_callable_signature(func)
|
|
50
|
+
self._params_json_schema: dict[str, Any] | None = None
|
|
51
|
+
|
|
52
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
53
|
+
return self.func(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
async def invoke(self, *args: Any, **kwargs: Any) -> Any:
|
|
56
|
+
"""Run the underlying callable, awaiting it if needed."""
|
|
57
|
+
result = self.func(*args, **kwargs)
|
|
58
|
+
if inspect.isawaitable(result):
|
|
59
|
+
return await result
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
async def on_invoke_tool(self, _context: Any, input_json: str) -> Any:
|
|
63
|
+
"""Invoke the tool from a JSON payload (OpenAI Agents SDK shape)."""
|
|
64
|
+
return await _invoke_from_json(self.invoke, input_json)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def params_json_schema(self) -> dict[str, Any]:
|
|
68
|
+
"""JSON Schema of the wrapped callable's parameters (cached)."""
|
|
69
|
+
if self._params_json_schema is None:
|
|
70
|
+
self._params_json_schema = json_schema_for_callable(self.func, tool_name=self.name)
|
|
71
|
+
return self._params_json_schema
|
|
72
|
+
|
|
73
|
+
def to_spec(self) -> ToolSpec:
|
|
74
|
+
"""Materialize this tool as a :class:`ToolSpec`."""
|
|
75
|
+
return ToolSpec(
|
|
76
|
+
name=self.name,
|
|
77
|
+
description=self.description,
|
|
78
|
+
params_json_schema=self.params_json_schema,
|
|
79
|
+
invoke=self.invoke,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def bind_json_invoker(
|
|
84
|
+
invoke: Callable[..., Awaitable[Any]],
|
|
85
|
+
) -> Callable[[Any, str], Awaitable[Any]]:
|
|
86
|
+
"""Wrap an awaitable to accept an OpenAI-Agents-style JSON payload.
|
|
87
|
+
|
|
88
|
+
Many agent SDKs hand the tool a single JSON string and expect the
|
|
89
|
+
adapter to parse it into ``*args``/``**kwargs``. This helper does
|
|
90
|
+
that translation once so adapters don't reinvent it.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
async def on_invoke(_context: Any, input_json: str) -> Any:
|
|
94
|
+
return await _invoke_from_json(invoke, input_json)
|
|
95
|
+
|
|
96
|
+
return on_invoke
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _invoke_from_json(
|
|
100
|
+
invoke: Callable[..., Awaitable[Any]],
|
|
101
|
+
input_json: str,
|
|
102
|
+
) -> Any:
|
|
103
|
+
text = str(input_json or "").strip()
|
|
104
|
+
if not text:
|
|
105
|
+
return await invoke()
|
|
106
|
+
try:
|
|
107
|
+
payload = json.loads(text)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
return await invoke(text)
|
|
110
|
+
if isinstance(payload, Mapping):
|
|
111
|
+
return await invoke(**dict(payload))
|
|
112
|
+
if isinstance(payload, list):
|
|
113
|
+
return await invoke(*payload)
|
|
114
|
+
if payload is None:
|
|
115
|
+
return await invoke()
|
|
116
|
+
return await invoke(payload)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = ["SimpleTool", "bind_json_invoker"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The canonical in-memory tool representation: :class:`ToolSpec`.
|
|
2
|
+
|
|
3
|
+
Every tool — built from a callable, declared via a builder, or adapted
|
|
4
|
+
from an external system — is converted to a ``ToolSpec`` before it
|
|
5
|
+
reaches an LLM provider or an agent runtime. This is the seam every
|
|
6
|
+
dialect and backend adapter speaks to.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, frozen=True)
|
|
17
|
+
class ToolSpec:
|
|
18
|
+
"""Provider-neutral canonical tool record.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
name: Stable identifier (the function name the LLM emits).
|
|
22
|
+
description: Human-readable description shown to the model.
|
|
23
|
+
params_json_schema: JSON Schema for the keyword parameters.
|
|
24
|
+
invoke: Async callable executing the tool given keyword args.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
description: str
|
|
29
|
+
params_json_schema: dict[str, Any]
|
|
30
|
+
invoke: Callable[..., Awaitable[Any]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["ToolSpec"]
|