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.
Files changed (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. 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"]
@@ -0,0 +1,7 @@
1
+ """Domain-specific packages for vertical ellements functionality.
2
+
3
+ Install the matching optional extras before importing a concrete domain
4
+ subpackage, for example ``ellements[finance]`` for Yahoo Finance tools.
5
+ """
6
+
7
+ __all__ = ["finance"]