toolbase 0.1.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.
- toolbase/__init__.py +22 -0
- toolbase/_setup_host.py +243 -0
- toolbase/_toolkit_host.py +585 -0
- toolbase/astro.py +9 -0
- toolbase/auth.py +631 -0
- toolbase/cli.py +5510 -0
- toolbase/config.py +41 -0
- toolbase/envs/__init__.py +147 -0
- toolbase/envs/cache.py +326 -0
- toolbase/envs/config.py +122 -0
- toolbase/envs/discovery.py +115 -0
- toolbase/envs/manifest.py +209 -0
- toolbase/envs/paths.py +163 -0
- toolbase/envs/schema.py +348 -0
- toolbase/hep.py +8 -0
- toolbase/ingest.py +913 -0
- toolbase/logging/__init__.py +5 -0
- toolbase/logging/logger.py +558 -0
- toolbase/neutrino.py +7 -0
- toolbase/quantum.py +7 -0
- toolbase/serve/__init__.py +7 -0
- toolbase/serve/config.py +436 -0
- toolbase/serve/orchestrator.py +1526 -0
- toolbase/serve/proxy_tool.py +134 -0
- toolbase/serve/tool_groups.py +189 -0
- toolbase/setup/__init__.py +91 -0
- toolbase/setup/_rpc.py +326 -0
- toolbase/setup/context.py +379 -0
- toolbase/setup/declarative.py +363 -0
- toolbase/setup/downloads.py +416 -0
- toolbase/setup/prompts.py +271 -0
- toolbase/setup/runner.py +1179 -0
- toolbase/setup/schema.py +465 -0
- toolbase/setup/storage.py +364 -0
- toolbase/setup/validate_cache.py +152 -0
- toolbase/skills.py +256 -0
- toolbase/templates/Dockerfile.template +25 -0
- toolbase/templates/README.md.template +50 -0
- toolbase/templates/__init__.py.template +10 -0
- toolbase/templates/mcp/__init__.py.template +4 -0
- toolbase/templates/mcp/server_stdio.py.template +46 -0
- toolbase/templates/requirements.txt.template +11 -0
- toolbase/templates/setup.py.template +94 -0
- toolbase/templates/skills/example_skill.md +44 -0
- toolbase/templates/tool_example.py +100 -0
- toolbase/templates/toolkit.yaml.template +68 -0
- toolbase/toolkit.py +387 -0
- toolbase/validation.py +801 -0
- toolbase/versioning.py +100 -0
- toolbase-0.1.0.dist-info/METADATA +247 -0
- toolbase-0.1.0.dist-info/RECORD +55 -0
- toolbase-0.1.0.dist-info/WHEEL +5 -0
- toolbase-0.1.0.dist-info/entry_points.txt +3 -0
- toolbase-0.1.0.dist-info/licenses/LICENSE +21 -0
- toolbase-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-toolkit subprocess host for ``toolbase serve``.
|
|
3
|
+
|
|
4
|
+
This module is the entrypoint that runs *inside* a toolkit's own Python
|
|
5
|
+
interpreter (its venv or conda env). The orchestrator process spawns one of
|
|
6
|
+
these per active toolkit and talks to it over MCP **stdio** (Orchestral
|
|
7
|
+
1.4's persistent-stdio MCPClient owns the subprocess lifecycle). Prior
|
|
8
|
+
to 0.4.1 this used HTTP loopback with FastMCP; the cleanup landed when
|
|
9
|
+
Orchestral 1.4 made persistent stdio reliable.
|
|
10
|
+
|
|
11
|
+
Stdin/stdout are reserved for the MCP wire — this module MUST NEVER
|
|
12
|
+
write to stdout (that corrupts the MCP byte stream). All host output —
|
|
13
|
+
diagnostics, import errors, runtime tracebacks — goes to stderr. The
|
|
14
|
+
orchestrator passes a per-toolkit log path via the
|
|
15
|
+
``TOOLBASE_HOST_LOG`` env var; we redirect stderr to that file at
|
|
16
|
+
startup so MCPClient's stderr forwarding (whatever it does on its end)
|
|
17
|
+
doesn't matter — we never write to the inherited stderr.
|
|
18
|
+
|
|
19
|
+
Why this lives in the toolbase package: every installed toolkit has
|
|
20
|
+
``orchestral-ai`` and ``mcp`` in its environment (we install them at toolkit
|
|
21
|
+
install time), but it does NOT have the toolbase package installed there.
|
|
22
|
+
So the orchestrator launches us via ``python -m toolbase._toolkit_host``
|
|
23
|
+
*using the orchestrator's interpreter* — wait, no: the orchestrator launches
|
|
24
|
+
us using the *toolkit's* interpreter, which means toolbase must be
|
|
25
|
+
importable inside the toolkit env too.
|
|
26
|
+
|
|
27
|
+
The simplest way to make that work without polluting the toolkit env: this
|
|
28
|
+
module is intentionally self-contained — it imports only stdlib +
|
|
29
|
+
``orchestral`` + ``mcp``, all of which are guaranteed to be in the toolkit
|
|
30
|
+
env. The orchestrator passes us the toolkit directory; we add it to
|
|
31
|
+
``sys.path``, import its ``tools`` package, and serve.
|
|
32
|
+
|
|
33
|
+
To make ``python -m toolbase._toolkit_host`` work inside the toolkit env,
|
|
34
|
+
we don't actually need the full toolbase package installed there — we just
|
|
35
|
+
need this single file plus a stub package init. That's handled by the
|
|
36
|
+
orchestrator at spawn time: it copies this file (and a tiny ``__init__.py``)
|
|
37
|
+
into a known cache location inside the toolkit's env (e.g.
|
|
38
|
+
``<toolkit_dir>/.stk_host/toolbase/_toolkit_host.py``) and launches with
|
|
39
|
+
``PYTHONPATH=<toolkit_dir>/.stk_host``. See orchestrator.py for that wiring.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
A note on stateful tools (Orchestral 1.3.0):
|
|
44
|
+
|
|
45
|
+
The shipped Orchestral 1.3.0 supports stateful tools by subclassing
|
|
46
|
+
``BaseTool`` and declaring fields with ``StateField(...)``. The ``_setup()``
|
|
47
|
+
method runs at instance construction and can use those fields. Earlier
|
|
48
|
+
documentation referred to a ``@define_tool(state=[...])`` decorator and
|
|
49
|
+
``Agent(tool_config=...)`` injection API — those are NOT in the shipped
|
|
50
|
+
1.3.0 release. They may land later; if and when they do, this module
|
|
51
|
+
continues to work without changes.
|
|
52
|
+
|
|
53
|
+
For now, toolbase performs state injection itself: after constructing the
|
|
54
|
+
tool instances exposed by ``tools/__init__.py``, we look at each tool's
|
|
55
|
+
declared state fields, set values from the (currently empty) state config
|
|
56
|
+
passed in, and re-run ``_setup()`` so the tool sees the injected values.
|
|
57
|
+
When Orchestral ships the convenience layer, this manual injection is still
|
|
58
|
+
correct; if/when toolkit authors switch to the new decorator form, the
|
|
59
|
+
``_get_state_fields`` mechanism remains the discovery API and our injection
|
|
60
|
+
loop continues to work.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import argparse
|
|
66
|
+
import importlib
|
|
67
|
+
import importlib.util
|
|
68
|
+
import json
|
|
69
|
+
import os
|
|
70
|
+
import sys
|
|
71
|
+
import traceback
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
from typing import Any, Iterable
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _redirect_stderr_to_log() -> None:
|
|
77
|
+
"""Redirect this process's stderr to ``$TOOLBASE_HOST_LOG`` if set.
|
|
78
|
+
|
|
79
|
+
The orchestrator opens (or pre-creates) a per-toolkit log file at
|
|
80
|
+
``~/.toolbase/logs/<toolkit>.log`` and passes its path via this
|
|
81
|
+
env var. We replace ``sys.stderr`` with a line-buffered append-mode
|
|
82
|
+
handle to that file so:
|
|
83
|
+
|
|
84
|
+
1. Anything Python or imported libraries write to stderr lands in
|
|
85
|
+
the per-toolkit log file rather than being interleaved with the
|
|
86
|
+
orchestrator's own stderr.
|
|
87
|
+
2. MCPClient's subprocess stderr forwarding (which by default routes
|
|
88
|
+
to the orchestrator's stderr) is moot — we never write to the
|
|
89
|
+
inherited stderr after this redirect.
|
|
90
|
+
|
|
91
|
+
No-op when the env var is unset (development scenarios — e.g.,
|
|
92
|
+
running ``python -m toolbase._toolkit_host`` by hand for
|
|
93
|
+
debugging).
|
|
94
|
+
"""
|
|
95
|
+
log_path = os.environ.get("TOOLBASE_HOST_LOG")
|
|
96
|
+
if not log_path:
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
# Line-buffered append so each diagnostic line flushes
|
|
100
|
+
# immediately; the orchestrator can tail the file in real time.
|
|
101
|
+
log_fh = open(log_path, "a", buffering=1, encoding="utf-8")
|
|
102
|
+
except OSError:
|
|
103
|
+
# Can't open the log; leave stderr as-is. Better to keep
|
|
104
|
+
# working than to crash on a logging-only failure.
|
|
105
|
+
return
|
|
106
|
+
sys.stderr = log_fh
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _import_tools_package(toolkit_dir: Path) -> Any:
|
|
110
|
+
"""Import the toolkit's ``tools`` package without polluting sys.path.
|
|
111
|
+
|
|
112
|
+
DO NOT change this to add ``toolkit_dir`` to ``sys.path``. The naive
|
|
113
|
+
approach (sys.path.insert(0, str(toolkit_dir))) makes EVERY top-level
|
|
114
|
+
directory the toolkit ships compete with installed packages of the same
|
|
115
|
+
name. Real failures we've seen and theoretical ones to keep in mind:
|
|
116
|
+
|
|
117
|
+
- **Confirmed: ``mcp/``** — the toolkit template generates an ``mcp/``
|
|
118
|
+
directory (see ``toolbase/templates/mcp/``). With ``toolkit_dir``
|
|
119
|
+
on sys.path, ``import mcp`` resolves to *the toolkit's* ``mcp/``
|
|
120
|
+
package, which then can't satisfy ``from mcp.server import Server``.
|
|
121
|
+
Orchestral's ``_check_mcp_installed()`` raises a misleading
|
|
122
|
+
"MCP integration requires the 'mcp' package" error. This is the
|
|
123
|
+
bug that motivated the spec_from_file_location approach.
|
|
124
|
+
|
|
125
|
+
- **Plausible: ``data/``, ``tests/``, ``scripts/``, ``docs/``** —
|
|
126
|
+
common toolkit-author directory names. All have same-named packages
|
|
127
|
+
on PyPI (``data`` is a real package). If the toolkit's venv installs
|
|
128
|
+
one of those as a transitive dep and the toolkit also has a
|
|
129
|
+
same-named directory, you get the same shadowing class of bug.
|
|
130
|
+
|
|
131
|
+
- **Plausible: any dependency of the toolkit's own deps.** The toolkit
|
|
132
|
+
env contains all of its requirements and their transitive deps. Any
|
|
133
|
+
of those names colliding with a toolkit's top-level directory =
|
|
134
|
+
same bug.
|
|
135
|
+
|
|
136
|
+
The fix (this function) builds an explicit module spec for the
|
|
137
|
+
``tools/__init__.py`` path and registers the loaded module under the
|
|
138
|
+
name ``"tools"``. ``submodule_search_locations`` tells the loader
|
|
139
|
+
where to find ``arxiv_tools.py`` etc. for relative imports. Nothing
|
|
140
|
+
goes on ``sys.path``, so no shadowing is possible.
|
|
141
|
+
|
|
142
|
+
Side effect for toolkit authors: code inside ``tools/`` cannot do
|
|
143
|
+
absolute imports of sibling top-level dirs (``import data``, etc.).
|
|
144
|
+
If a toolkit needs a ``data/`` directory of pure-data files, they
|
|
145
|
+
should access it by path (``Path(__file__).parent.parent / "data"``),
|
|
146
|
+
not by import. If they need a sibling Python *package*, they should
|
|
147
|
+
nest it under ``tools/`` (e.g. ``tools/helpers/__init__.py``) so
|
|
148
|
+
relative imports work.
|
|
149
|
+
|
|
150
|
+
See also: ``_collect_tool_instances`` which walks the loaded module
|
|
151
|
+
for ``BaseTool`` instances or a ``TOOLS`` list.
|
|
152
|
+
"""
|
|
153
|
+
tools_dir = toolkit_dir / "tools"
|
|
154
|
+
init_file = tools_dir / "__init__.py"
|
|
155
|
+
if not init_file.exists():
|
|
156
|
+
raise ImportError(f"missing tools/__init__.py in {toolkit_dir}")
|
|
157
|
+
|
|
158
|
+
# Drop any cached import from a previous invocation (paranoid; one host
|
|
159
|
+
# process only ever imports one toolkit, but safer for tests).
|
|
160
|
+
for k in list(sys.modules):
|
|
161
|
+
if k == "tools" or k.startswith("tools."):
|
|
162
|
+
del sys.modules[k]
|
|
163
|
+
|
|
164
|
+
spec = importlib.util.spec_from_file_location(
|
|
165
|
+
"tools",
|
|
166
|
+
str(init_file),
|
|
167
|
+
submodule_search_locations=[str(tools_dir)],
|
|
168
|
+
)
|
|
169
|
+
if spec is None or spec.loader is None:
|
|
170
|
+
raise ImportError(f"could not build module spec for {init_file}")
|
|
171
|
+
module = importlib.util.module_from_spec(spec)
|
|
172
|
+
# Register before exec so that relative imports inside the package
|
|
173
|
+
# find their parent in sys.modules.
|
|
174
|
+
sys.modules["tools"] = module
|
|
175
|
+
spec.loader.exec_module(module)
|
|
176
|
+
return module
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _import_module_no_syspath(
|
|
180
|
+
dotted: str, toolkit_dir: Path
|
|
181
|
+
) -> Any:
|
|
182
|
+
"""Import a dotted module path resolved against ``toolkit_dir`` without
|
|
183
|
+
polluting ``sys.path``.
|
|
184
|
+
|
|
185
|
+
Supports the explicit-form ``tools:`` entries emitted by
|
|
186
|
+
``toolbase ingest``. For the same reason as ``_import_tools_package``
|
|
187
|
+
(HANDOFF gotcha #2 — adding ``toolkit_dir`` to ``sys.path`` lets a
|
|
188
|
+
toolkit's top-level dirs shadow installed packages of the same name),
|
|
189
|
+
we resolve and load each module by file path using
|
|
190
|
+
``importlib.util.spec_from_file_location``.
|
|
191
|
+
|
|
192
|
+
Walks ``dotted`` against the filesystem from ``toolkit_dir``: each
|
|
193
|
+
segment must either be a sub-package (directory with ``__init__.py``)
|
|
194
|
+
or, for the leaf, a ``.py`` file.
|
|
195
|
+
|
|
196
|
+
Modules are registered under their dotted name so relative imports
|
|
197
|
+
inside them resolve correctly. Parent packages are loaded
|
|
198
|
+
transparently the same way.
|
|
199
|
+
|
|
200
|
+
Raises ``ImportError`` with a clear message if the module is not
|
|
201
|
+
reachable from ``toolkit_dir``.
|
|
202
|
+
"""
|
|
203
|
+
parts = dotted.split('.')
|
|
204
|
+
if not all(p.isidentifier() for p in parts):
|
|
205
|
+
raise ImportError(
|
|
206
|
+
f"invalid dotted module path: {dotted!r}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Walk packages first.
|
|
210
|
+
cur_dir = toolkit_dir
|
|
211
|
+
cur_dotted_parts: list[str] = []
|
|
212
|
+
for part in parts[:-1]:
|
|
213
|
+
cur_dotted_parts.append(part)
|
|
214
|
+
sub = cur_dir / part
|
|
215
|
+
init = sub / "__init__.py"
|
|
216
|
+
if not init.is_file():
|
|
217
|
+
raise ImportError(
|
|
218
|
+
f"cannot resolve {dotted!r}: "
|
|
219
|
+
f"{sub} has no __init__.py "
|
|
220
|
+
f"(walked from {toolkit_dir})"
|
|
221
|
+
)
|
|
222
|
+
full_dotted = ".".join(cur_dotted_parts)
|
|
223
|
+
if full_dotted not in sys.modules:
|
|
224
|
+
spec = importlib.util.spec_from_file_location(
|
|
225
|
+
full_dotted,
|
|
226
|
+
str(init),
|
|
227
|
+
submodule_search_locations=[str(sub)],
|
|
228
|
+
)
|
|
229
|
+
if spec is None or spec.loader is None:
|
|
230
|
+
raise ImportError(
|
|
231
|
+
f"could not build module spec for {init}"
|
|
232
|
+
)
|
|
233
|
+
mod = importlib.util.module_from_spec(spec)
|
|
234
|
+
sys.modules[full_dotted] = mod
|
|
235
|
+
spec.loader.exec_module(mod)
|
|
236
|
+
cur_dir = sub
|
|
237
|
+
|
|
238
|
+
# Leaf: either a submodule .py, an __init__.py inside a sub-package,
|
|
239
|
+
# or the dotted path may itself be a package whose attribute we want.
|
|
240
|
+
leaf = parts[-1]
|
|
241
|
+
leaf_dotted = ".".join(parts)
|
|
242
|
+
leaf_pyfile = cur_dir / f"{leaf}.py"
|
|
243
|
+
leaf_pkg_init = cur_dir / leaf / "__init__.py"
|
|
244
|
+
|
|
245
|
+
if leaf_pyfile.is_file():
|
|
246
|
+
if leaf_dotted in sys.modules:
|
|
247
|
+
return sys.modules[leaf_dotted]
|
|
248
|
+
spec = importlib.util.spec_from_file_location(
|
|
249
|
+
leaf_dotted, str(leaf_pyfile)
|
|
250
|
+
)
|
|
251
|
+
if spec is None or spec.loader is None:
|
|
252
|
+
raise ImportError(
|
|
253
|
+
f"could not build module spec for {leaf_pyfile}"
|
|
254
|
+
)
|
|
255
|
+
mod = importlib.util.module_from_spec(spec)
|
|
256
|
+
sys.modules[leaf_dotted] = mod
|
|
257
|
+
spec.loader.exec_module(mod)
|
|
258
|
+
return mod
|
|
259
|
+
if leaf_pkg_init.is_file():
|
|
260
|
+
if leaf_dotted in sys.modules:
|
|
261
|
+
return sys.modules[leaf_dotted]
|
|
262
|
+
spec = importlib.util.spec_from_file_location(
|
|
263
|
+
leaf_dotted,
|
|
264
|
+
str(leaf_pkg_init),
|
|
265
|
+
submodule_search_locations=[str(cur_dir / leaf)],
|
|
266
|
+
)
|
|
267
|
+
if spec is None or spec.loader is None:
|
|
268
|
+
raise ImportError(
|
|
269
|
+
f"could not build module spec for {leaf_pkg_init}"
|
|
270
|
+
)
|
|
271
|
+
mod = importlib.util.module_from_spec(spec)
|
|
272
|
+
sys.modules[leaf_dotted] = mod
|
|
273
|
+
spec.loader.exec_module(mod)
|
|
274
|
+
return mod
|
|
275
|
+
raise ImportError(
|
|
276
|
+
f"cannot find module {dotted!r} under {toolkit_dir}: "
|
|
277
|
+
f"neither {leaf_pyfile} nor {leaf_pkg_init} exists"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _import_explicit_tools(
|
|
282
|
+
tools_spec: list, toolkit_dir: Path
|
|
283
|
+
) -> list:
|
|
284
|
+
"""Load tools listed in the explicit ``tools:`` form.
|
|
285
|
+
|
|
286
|
+
``tools_spec`` is a list of dicts of shape
|
|
287
|
+
``{"name": str, "module": str, ...}`` (the ``description`` field is
|
|
288
|
+
not consumed here — it lives on the toolkit.yaml for human readers
|
|
289
|
+
and the registry; the runtime tool object's docstring is what
|
|
290
|
+
Orchestral surfaces to the agent).
|
|
291
|
+
|
|
292
|
+
For each entry, imports the module and pulls the named attribute.
|
|
293
|
+
The attribute is expected to be either:
|
|
294
|
+
|
|
295
|
+
- a ``BaseTool`` instance (already-instantiated tool, including
|
|
296
|
+
``@define_tool``-decorated functions which the decorator wraps
|
|
297
|
+
into instances at module load time);
|
|
298
|
+
- a ``BaseTool`` subclass (we instantiate it with no args);
|
|
299
|
+
|
|
300
|
+
Anything else raises a ``TypeError`` with a clear pointer.
|
|
301
|
+
"""
|
|
302
|
+
from orchestral.tools.base.tool import BaseTool
|
|
303
|
+
import inspect
|
|
304
|
+
|
|
305
|
+
tools: list = []
|
|
306
|
+
for entry in tools_spec:
|
|
307
|
+
module_path = entry.get("module")
|
|
308
|
+
attr_name = entry.get("name")
|
|
309
|
+
if not module_path or not attr_name:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"explicit tool entry missing 'module' or 'name': {entry!r}"
|
|
312
|
+
)
|
|
313
|
+
mod = _import_module_no_syspath(module_path, toolkit_dir)
|
|
314
|
+
if not hasattr(mod, attr_name):
|
|
315
|
+
raise AttributeError(
|
|
316
|
+
f"module {module_path!r} has no attribute {attr_name!r} "
|
|
317
|
+
"(named in toolkit.yaml's tools: list)"
|
|
318
|
+
)
|
|
319
|
+
obj = getattr(mod, attr_name)
|
|
320
|
+
if isinstance(obj, BaseTool):
|
|
321
|
+
tools.append(obj)
|
|
322
|
+
elif inspect.isclass(obj) and issubclass(obj, BaseTool):
|
|
323
|
+
tools.append(obj())
|
|
324
|
+
else:
|
|
325
|
+
raise TypeError(
|
|
326
|
+
f"{module_path}.{attr_name} is not a BaseTool instance "
|
|
327
|
+
f"or subclass; got {type(obj).__name__}. "
|
|
328
|
+
"Tools must be either @define_tool-decorated functions "
|
|
329
|
+
"or BaseTool subclasses."
|
|
330
|
+
)
|
|
331
|
+
return tools
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _collect_tool_instances(tools_module: Any) -> list:
|
|
335
|
+
"""Return tool instances exposed by the toolkit's ``tools/__init__.py``.
|
|
336
|
+
|
|
337
|
+
Convention (per the package's existing template): the module either
|
|
338
|
+
exports a ``TOOLS`` list, or every public attribute that's a BaseTool
|
|
339
|
+
instance is treated as a tool. We honor both.
|
|
340
|
+
"""
|
|
341
|
+
from orchestral.tools.base.tool import BaseTool
|
|
342
|
+
|
|
343
|
+
if hasattr(tools_module, "TOOLS"):
|
|
344
|
+
candidates: Iterable = tools_module.TOOLS
|
|
345
|
+
else:
|
|
346
|
+
candidates = (
|
|
347
|
+
getattr(tools_module, name)
|
|
348
|
+
for name in dir(tools_module)
|
|
349
|
+
if not name.startswith("_")
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
tools = []
|
|
353
|
+
seen_ids = set()
|
|
354
|
+
for obj in candidates:
|
|
355
|
+
if isinstance(obj, BaseTool) and id(obj) not in seen_ids:
|
|
356
|
+
tools.append(obj)
|
|
357
|
+
seen_ids.add(id(obj))
|
|
358
|
+
return tools
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _inject_state_into_tools(tools: list, state_config: dict) -> None:
|
|
362
|
+
"""Set state-field values on each tool, then re-run ``_setup()``.
|
|
363
|
+
|
|
364
|
+
See module docstring for the rationale (manual injection because
|
|
365
|
+
Orchestral 1.3.0 doesn't ship Agent(tool_config=...) yet). This is a
|
|
366
|
+
no-op when ``state_config`` is empty — which it is today, until Phase 3C
|
|
367
|
+
delivers the setup system that produces it.
|
|
368
|
+
"""
|
|
369
|
+
if not state_config:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
for tool in tools:
|
|
373
|
+
state_fields = tool.__class__._get_state_fields()
|
|
374
|
+
touched = False
|
|
375
|
+
for field_name in state_fields:
|
|
376
|
+
if field_name in state_config:
|
|
377
|
+
setattr(tool, field_name, state_config[field_name])
|
|
378
|
+
touched = True
|
|
379
|
+
if touched:
|
|
380
|
+
tool._setup()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _emit_error(message: str, **fields) -> None:
|
|
384
|
+
"""Write a startup-failure JSON line to stderr and let the caller exit.
|
|
385
|
+
|
|
386
|
+
With stdio MCP, the orchestrator distinguishes "host startup failed"
|
|
387
|
+
from "host running normally" by whether ``MCPClient.connect()``
|
|
388
|
+
succeeded. The orchestrator then reads recent lines from the
|
|
389
|
+
per-toolkit log file to surface the underlying error to the user.
|
|
390
|
+
Format here is JSON-on-stderr (one line) so the orchestrator can
|
|
391
|
+
parse the most recent failure structurally if it wants.
|
|
392
|
+
|
|
393
|
+
Pre-0.4.1, this same function wrote to stdout as part of the HTTP
|
|
394
|
+
handshake; with stdio that would corrupt the MCP wire.
|
|
395
|
+
(``error`` key present) and surfaces the error to the user.
|
|
396
|
+
"""
|
|
397
|
+
payload = {"error": message, **fields}
|
|
398
|
+
sys.stderr.write(json.dumps(payload) + "\n")
|
|
399
|
+
sys.stderr.flush()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def main(argv: list[str] | None = None) -> int:
|
|
403
|
+
# Redirect stderr to ~/.toolbase/logs/<toolkit>.log BEFORE doing
|
|
404
|
+
# anything else. From this point on, sys.stderr writes go to the
|
|
405
|
+
# log file; nothing of ours ever lands on stdout (which is the
|
|
406
|
+
# MCP wire) or the inherited stderr (which we don't control).
|
|
407
|
+
_redirect_stderr_to_log()
|
|
408
|
+
|
|
409
|
+
parser = argparse.ArgumentParser(
|
|
410
|
+
prog="python -m toolbase._toolkit_host",
|
|
411
|
+
description="Per-toolkit subprocess host for toolbase serve.",
|
|
412
|
+
)
|
|
413
|
+
parser.add_argument(
|
|
414
|
+
"--toolkit-dir",
|
|
415
|
+
required=True,
|
|
416
|
+
type=Path,
|
|
417
|
+
help="Path to the installed toolkit directory.",
|
|
418
|
+
)
|
|
419
|
+
parser.add_argument(
|
|
420
|
+
"--name",
|
|
421
|
+
required=True,
|
|
422
|
+
help="Toolkit name (used in the MCPServer name).",
|
|
423
|
+
)
|
|
424
|
+
parser.add_argument(
|
|
425
|
+
"--state-config",
|
|
426
|
+
default="",
|
|
427
|
+
help=(
|
|
428
|
+
"JSON object of state-field values to inject into tool instances. "
|
|
429
|
+
"Empty for now; populated by Phase 3C's setup system."
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
parser.add_argument(
|
|
433
|
+
"--tools-spec",
|
|
434
|
+
default="",
|
|
435
|
+
help=(
|
|
436
|
+
"JSON list of explicit tool entries from toolkit.yaml's "
|
|
437
|
+
"tools: field, each shaped like "
|
|
438
|
+
"{'name': str, 'module': str} (explicit form) or "
|
|
439
|
+
"{'name': str, 'function': str} (implicit form, ignored — "
|
|
440
|
+
"implicit-form toolkits use tools/__init__.py discovery). "
|
|
441
|
+
"Empty/absent triggers the implicit fallback."
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
args = parser.parse_args(argv)
|
|
445
|
+
|
|
446
|
+
state_config: dict = {}
|
|
447
|
+
if args.state_config:
|
|
448
|
+
try:
|
|
449
|
+
state_config = json.loads(args.state_config)
|
|
450
|
+
if not isinstance(state_config, dict):
|
|
451
|
+
raise ValueError("state-config must be a JSON object")
|
|
452
|
+
except Exception as e:
|
|
453
|
+
_emit_error(f"invalid --state-config: {e}")
|
|
454
|
+
return 2
|
|
455
|
+
|
|
456
|
+
tools_spec: list = []
|
|
457
|
+
if args.tools_spec:
|
|
458
|
+
try:
|
|
459
|
+
tools_spec = json.loads(args.tools_spec)
|
|
460
|
+
if not isinstance(tools_spec, list):
|
|
461
|
+
raise ValueError("tools-spec must be a JSON list")
|
|
462
|
+
except Exception as e:
|
|
463
|
+
_emit_error(f"invalid --tools-spec: {e}")
|
|
464
|
+
return 2
|
|
465
|
+
|
|
466
|
+
# Import the toolkit's tools. Two modes:
|
|
467
|
+
# 1) Explicit form: --tools-spec contains entries with 'module' keys.
|
|
468
|
+
# We import each module by file-resolution from toolkit_dir.
|
|
469
|
+
# 2) Implicit form (default / fallback): import tools/__init__.py.
|
|
470
|
+
#
|
|
471
|
+
# Mixed yaml is supported by importing both paths and merging.
|
|
472
|
+
explicit_entries = [
|
|
473
|
+
e for e in tools_spec
|
|
474
|
+
if isinstance(e, dict) and e.get("module")
|
|
475
|
+
]
|
|
476
|
+
implicit_entries = [
|
|
477
|
+
e for e in tools_spec
|
|
478
|
+
if isinstance(e, dict) and e.get("function")
|
|
479
|
+
]
|
|
480
|
+
use_implicit_discovery = bool(implicit_entries) or not tools_spec
|
|
481
|
+
|
|
482
|
+
tool_instances: list = []
|
|
483
|
+
|
|
484
|
+
if explicit_entries:
|
|
485
|
+
try:
|
|
486
|
+
tool_instances.extend(
|
|
487
|
+
_import_explicit_tools(explicit_entries, args.toolkit_dir)
|
|
488
|
+
)
|
|
489
|
+
except Exception as e:
|
|
490
|
+
_emit_error(
|
|
491
|
+
f"failed to import explicit-form tools: {e}",
|
|
492
|
+
traceback=traceback.format_exc(),
|
|
493
|
+
)
|
|
494
|
+
return 3
|
|
495
|
+
|
|
496
|
+
if use_implicit_discovery:
|
|
497
|
+
# Either the toolkit declares implicit-form tools in its yaml, or
|
|
498
|
+
# tools_spec is empty (legacy / no yaml passthrough). Fall back to
|
|
499
|
+
# the historical tools/__init__.py discovery.
|
|
500
|
+
try:
|
|
501
|
+
tools_module = _import_tools_package(args.toolkit_dir)
|
|
502
|
+
tool_instances.extend(_collect_tool_instances(tools_module))
|
|
503
|
+
except Exception as e:
|
|
504
|
+
if not explicit_entries:
|
|
505
|
+
_emit_error(
|
|
506
|
+
f"failed to import tools from {args.toolkit_dir}: {e}",
|
|
507
|
+
traceback=traceback.format_exc(),
|
|
508
|
+
)
|
|
509
|
+
return 3
|
|
510
|
+
# Mixed-form: explicit imports succeeded; implicit-side failure
|
|
511
|
+
# is unexpected but we already have something. Surface a
|
|
512
|
+
# warning via stderr and proceed.
|
|
513
|
+
sys.stderr.write(
|
|
514
|
+
f"[toolbase-host] WARN: implicit tools/ discovery "
|
|
515
|
+
f"failed for mixed-form toolkit: {e}\n"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not tool_instances:
|
|
519
|
+
_emit_error(
|
|
520
|
+
f"no Orchestral tools found in {args.toolkit_dir}",
|
|
521
|
+
hint=(
|
|
522
|
+
"For the implicit form: export a TOOLS list from "
|
|
523
|
+
"tools/__init__.py or ensure the package re-exports "
|
|
524
|
+
"your @define_tool-decorated tools as module "
|
|
525
|
+
"attributes. For the explicit form: ensure each "
|
|
526
|
+
"entry's module: dotted path resolves under the "
|
|
527
|
+
"toolkit root and the named attribute is a BaseTool "
|
|
528
|
+
"instance or subclass."
|
|
529
|
+
),
|
|
530
|
+
)
|
|
531
|
+
return 4
|
|
532
|
+
|
|
533
|
+
# Inject state-field values (no-op when state_config is empty).
|
|
534
|
+
try:
|
|
535
|
+
_inject_state_into_tools(tool_instances, state_config)
|
|
536
|
+
except Exception as e:
|
|
537
|
+
_emit_error(
|
|
538
|
+
f"state-field injection failed: {e}",
|
|
539
|
+
traceback=traceback.format_exc(),
|
|
540
|
+
)
|
|
541
|
+
return 5
|
|
542
|
+
|
|
543
|
+
# Build the stdio MCP server. As of 0.4.1 we use Orchestral 1.4's
|
|
544
|
+
# ``MCPServer`` which wraps the MCP SDK's ``stdio_server``; the
|
|
545
|
+
# orchestrator owns the subprocess lifecycle via
|
|
546
|
+
# ``MCPClient(server_command=...)`` and talks to us over the
|
|
547
|
+
# process's stdin/stdout pipe.
|
|
548
|
+
try:
|
|
549
|
+
from orchestral.mcp import MCPServer
|
|
550
|
+
except ImportError as e:
|
|
551
|
+
_emit_error(
|
|
552
|
+
"orchestral.mcp not available — toolkit env missing 'mcp' dep",
|
|
553
|
+
detail=str(e),
|
|
554
|
+
)
|
|
555
|
+
return 6
|
|
556
|
+
|
|
557
|
+
server = MCPServer(
|
|
558
|
+
tools=tool_instances,
|
|
559
|
+
name=f"toolbase-{args.name}",
|
|
560
|
+
# Snake_case names — the orchestrator does its own namespacing
|
|
561
|
+
# with double-underscore prefixes; a separate PascalCase rename
|
|
562
|
+
# layered on top would make the agent-visible names confusing.
|
|
563
|
+
use_display_names=False,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Block on the stdio loop. The MCPClient on the orchestrator side
|
|
567
|
+
# tears us down by closing stdin / sending SIGTERM at session end.
|
|
568
|
+
try:
|
|
569
|
+
server.run()
|
|
570
|
+
except KeyboardInterrupt:
|
|
571
|
+
return 0
|
|
572
|
+
except Exception:
|
|
573
|
+
# Runtime failure after MCP init — log the traceback so the
|
|
574
|
+
# orchestrator can surface it. We don't write a JSON error
|
|
575
|
+
# line here because the orchestrator detects "subprocess
|
|
576
|
+
# died" via MCPSubprocessDiedError on the next call_tool
|
|
577
|
+
# rather than parsing structured stderr.
|
|
578
|
+
traceback.print_exc(file=sys.stderr)
|
|
579
|
+
return 7
|
|
580
|
+
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
if __name__ == "__main__":
|
|
585
|
+
sys.exit(main())
|
toolbase/astro.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Astrophysics Tools
|
|
3
|
+
|
|
4
|
+
This module will contain tools for astrophysics and exoplanet research,
|
|
5
|
+
including the ASTER (Agentic Science Toolkit for Exoplanet Research) toolkit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Placeholder for future ASTER toolkit integration
|
|
9
|
+
# from aster_toolkit import RunForwardModelTool, ...
|