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.
Files changed (55) hide show
  1. toolbase/__init__.py +22 -0
  2. toolbase/_setup_host.py +243 -0
  3. toolbase/_toolkit_host.py +585 -0
  4. toolbase/astro.py +9 -0
  5. toolbase/auth.py +631 -0
  6. toolbase/cli.py +5510 -0
  7. toolbase/config.py +41 -0
  8. toolbase/envs/__init__.py +147 -0
  9. toolbase/envs/cache.py +326 -0
  10. toolbase/envs/config.py +122 -0
  11. toolbase/envs/discovery.py +115 -0
  12. toolbase/envs/manifest.py +209 -0
  13. toolbase/envs/paths.py +163 -0
  14. toolbase/envs/schema.py +348 -0
  15. toolbase/hep.py +8 -0
  16. toolbase/ingest.py +913 -0
  17. toolbase/logging/__init__.py +5 -0
  18. toolbase/logging/logger.py +558 -0
  19. toolbase/neutrino.py +7 -0
  20. toolbase/quantum.py +7 -0
  21. toolbase/serve/__init__.py +7 -0
  22. toolbase/serve/config.py +436 -0
  23. toolbase/serve/orchestrator.py +1526 -0
  24. toolbase/serve/proxy_tool.py +134 -0
  25. toolbase/serve/tool_groups.py +189 -0
  26. toolbase/setup/__init__.py +91 -0
  27. toolbase/setup/_rpc.py +326 -0
  28. toolbase/setup/context.py +379 -0
  29. toolbase/setup/declarative.py +363 -0
  30. toolbase/setup/downloads.py +416 -0
  31. toolbase/setup/prompts.py +271 -0
  32. toolbase/setup/runner.py +1179 -0
  33. toolbase/setup/schema.py +465 -0
  34. toolbase/setup/storage.py +364 -0
  35. toolbase/setup/validate_cache.py +152 -0
  36. toolbase/skills.py +256 -0
  37. toolbase/templates/Dockerfile.template +25 -0
  38. toolbase/templates/README.md.template +50 -0
  39. toolbase/templates/__init__.py.template +10 -0
  40. toolbase/templates/mcp/__init__.py.template +4 -0
  41. toolbase/templates/mcp/server_stdio.py.template +46 -0
  42. toolbase/templates/requirements.txt.template +11 -0
  43. toolbase/templates/setup.py.template +94 -0
  44. toolbase/templates/skills/example_skill.md +44 -0
  45. toolbase/templates/tool_example.py +100 -0
  46. toolbase/templates/toolkit.yaml.template +68 -0
  47. toolbase/toolkit.py +387 -0
  48. toolbase/validation.py +801 -0
  49. toolbase/versioning.py +100 -0
  50. toolbase-0.1.0.dist-info/METADATA +247 -0
  51. toolbase-0.1.0.dist-info/RECORD +55 -0
  52. toolbase-0.1.0.dist-info/WHEEL +5 -0
  53. toolbase-0.1.0.dist-info/entry_points.txt +3 -0
  54. toolbase-0.1.0.dist-info/licenses/LICENSE +21 -0
  55. 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, ...