332 lines
11 KiB
Python
332 lines
11 KiB
Python
"""Subprocess entry point for local Python tool execution.
|
|
|
|
Invoked by ``LocalPythonTool.invoke()`` as a child process.
|
|
Reads a JSON request from stdin, dynamically imports the tool
|
|
module, looks up the target ``@tool``-decorated function by name,
|
|
calls it with the deserialized arguments (wrapping plain ``def``
|
|
in ``asyncio.to_thread`` so it doesn't block the event loop),
|
|
serializes the return value, and writes a JSON response to file
|
|
descriptor 3.
|
|
|
|
The fd 3 protocol keeps stdout/stderr free for tool debugging
|
|
(``print()`` statements in tool code). In Docker mode (where fd 3
|
|
is not available), the ``_AP_RESPONSE_MODE=stdout`` env var
|
|
switches to a stdout-based protocol with a ``__AP_RESPONSE__:``
|
|
prefix.
|
|
|
|
Request format (stdin)::
|
|
|
|
{
|
|
"module_path": "/abs/path/to/tool.py",
|
|
"tool_name": "word_count",
|
|
"arguments": {"text": "..."}
|
|
}
|
|
|
|
Response format (fd 3 or stdout)::
|
|
|
|
{"result": "tool output as JSON-serialized string"}
|
|
{"error": "TypeError: missing required argument"}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import traceback
|
|
from types import ModuleType
|
|
from typing import Any
|
|
|
|
_RESPONSE_FD = 3
|
|
_STDOUT_PREFIX = "__AP_RESPONSE__:"
|
|
|
|
# The marker attribute the framework's @tool decorator attaches.
|
|
# We import the constant lazily inside main() to avoid importing
|
|
# the full omnigent.tools package in the subprocess (it pulls
|
|
# in heavy deps); the constant is a string literal anyway.
|
|
_TOOL_MARKER_ATTR = "_omnigent_tool_metadata"
|
|
|
|
# Reserved parameter name — kept in sync with
|
|
# ``omnigent_client.tools._schema.STATE_PARAM_NAME``. We hardcode
|
|
# it here instead of importing to keep the subprocess runner's
|
|
# import surface minimal on the hot path.
|
|
_STATE_PARAM_NAME = "tool_state"
|
|
|
|
|
|
def main() -> None:
|
|
"""
|
|
Entry point for the tool runner subprocess.
|
|
|
|
Reads a JSON request from stdin, imports the tool module,
|
|
dispatches to the named ``@tool`` function, serializes the
|
|
return value, and writes the result to fd 3.
|
|
"""
|
|
raw = sys.stdin.buffer.read()
|
|
try:
|
|
request = json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
_write_error(f"Invalid request JSON: {exc}")
|
|
return
|
|
|
|
module_path: str = request.get("module_path", "")
|
|
tool_name: str = request.get("tool_name", "")
|
|
arguments: dict[str, Any] = request.get("arguments", {})
|
|
# Per-agent tool-state directory (see designs/TOOL_STATE.md).
|
|
# ``None`` when no workspace is available (e.g. ad-hoc tests);
|
|
# _maybe_inject_tool_state handles that by raising if the tool
|
|
# actually asked for tool_state in that case.
|
|
state_root: str | None = request.get("state_root")
|
|
|
|
if not tool_name:
|
|
_write_error("Request missing 'tool_name' field — runner cannot dispatch.")
|
|
return
|
|
|
|
module = _load_module(module_path)
|
|
if module is None:
|
|
return
|
|
|
|
target = _resolve_tool_function(module, tool_name)
|
|
if target is None:
|
|
return
|
|
|
|
try:
|
|
_maybe_inject_tool_state(target, arguments, state_root)
|
|
result = _invoke_tool(target, arguments)
|
|
except Exception as exc:
|
|
traceback.print_exc()
|
|
_write_error(f"{type(exc).__name__}: {exc}")
|
|
return
|
|
|
|
serialized = _serialize_result(target, result)
|
|
_write_response({"result": serialized})
|
|
|
|
|
|
def _load_module(path: str) -> ModuleType | None:
|
|
"""
|
|
Import a Python file as a standalone module.
|
|
|
|
:param path: Absolute path to the tool Python file.
|
|
:returns: The loaded module, or ``None`` on failure (error
|
|
already written to fd 3).
|
|
"""
|
|
if not path:
|
|
_write_error("Empty module_path in request")
|
|
return None
|
|
spec = importlib.util.spec_from_file_location("_tool_module", path)
|
|
if spec is None or spec.loader is None:
|
|
_write_error(f"Cannot create module spec from {path}")
|
|
return None
|
|
module = importlib.util.module_from_spec(spec)
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except Exception as exc:
|
|
traceback.print_exc()
|
|
_write_error(f"Import error: {type(exc).__name__}: {exc}")
|
|
return None
|
|
return module
|
|
|
|
|
|
def _resolve_tool_function(module: ModuleType, tool_name: str) -> Any:
|
|
"""
|
|
Find the ``@tool``-decorated function named ``tool_name`` in ``module``.
|
|
|
|
Looks up by attribute name first; if found, verifies the
|
|
object carries the ``@tool`` marker attribute (defense in
|
|
depth — prevents accidentally invoking a non-tool function
|
|
with the same name).
|
|
|
|
:param module: The loaded tool module.
|
|
:param tool_name: The function name to dispatch to.
|
|
:returns: The callable if found and properly decorated, else
|
|
``None`` (error already written to fd 3).
|
|
"""
|
|
target = getattr(module, tool_name, None)
|
|
if target is None:
|
|
_write_error(f"Tool function '{tool_name}' not found in module.")
|
|
return None
|
|
if not callable(target):
|
|
_write_error(f"Object '{tool_name}' in module is not callable.")
|
|
return None
|
|
if not hasattr(target, _TOOL_MARKER_ATTR):
|
|
_write_error(f"Function '{tool_name}' is not decorated with @tool.")
|
|
return None
|
|
return target
|
|
|
|
|
|
def _maybe_inject_tool_state(
|
|
target: Any,
|
|
arguments: dict[str, Any],
|
|
state_root: str | None,
|
|
) -> None:
|
|
"""Inject a ``ToolState`` kwarg into ``arguments`` if the tool asks for it.
|
|
|
|
Inspects the tool function's signature; if it declares a
|
|
parameter named :data:`_STATE_PARAM_NAME`, constructs a live
|
|
``ToolState`` rooted at ``state_root`` and adds it to
|
|
``arguments`` in place.
|
|
|
|
:param target: The ``@tool`` callable to be invoked.
|
|
:param arguments: Kwargs dict splatted into the call. Mutated
|
|
in place when state is injected.
|
|
:param state_root: Parent-provided directory for this agent's
|
|
state, or ``None`` when no workspace is available.
|
|
:raises RuntimeError: If the tool declares ``tool_state`` but
|
|
the parent didn't provide a ``state_root``.
|
|
"""
|
|
import inspect as _inspect
|
|
|
|
try:
|
|
sig = _inspect.signature(target)
|
|
except (TypeError, ValueError):
|
|
# Non-introspectable (builtin, C-extension). Not an @tool,
|
|
# but defense-in-depth.
|
|
return
|
|
if _STATE_PARAM_NAME not in sig.parameters:
|
|
return
|
|
arguments[_STATE_PARAM_NAME] = _construct_tool_state(state_root)
|
|
|
|
|
|
def _construct_tool_state(state_root: str | None) -> Any:
|
|
"""Build a :class:`ToolState` for the given root.
|
|
|
|
Split out of :func:`_maybe_inject_tool_state` so the injector
|
|
stays under the 40-line limit and so the lazy imports happen
|
|
on the one path that needs them.
|
|
|
|
:param state_root: Directory provided by the parent.
|
|
:returns: A new ``ToolState`` instance.
|
|
:raises RuntimeError: If ``state_root`` is ``None``. Silently
|
|
falling back to a temp dir would crash the tool deep
|
|
inside its body with a less helpful error.
|
|
"""
|
|
if state_root is None:
|
|
raise RuntimeError(
|
|
f"Tool declares a '{_STATE_PARAM_NAME}' parameter but no "
|
|
f"state_root was provided by the parent. This usually means "
|
|
f"the invocation has no workspace (e.g. an ad-hoc test). "
|
|
f"ToolState is only available inside a conversation."
|
|
)
|
|
# Lazy imports so stateless tools don't pay the cost. ToolState's
|
|
# own deps are stdlib-only (fcntl, json, pathlib).
|
|
from pathlib import Path as _Path
|
|
|
|
from omnigent_client.tools import ToolState as _ToolState
|
|
|
|
return _ToolState(_Path(state_root))
|
|
|
|
|
|
def _invoke_tool(target: Any, arguments: dict[str, Any]) -> Any:
|
|
"""
|
|
Call the tool function with deserialized arguments.
|
|
|
|
For ``async def`` bodies, schedules the coroutine on a fresh
|
|
event loop. For plain ``def`` bodies, calls directly (we're
|
|
already in a subprocess; blocking is fine here — the parent
|
|
framework wraps the subprocess invocation in
|
|
``asyncio.to_thread`` for event-loop friendliness).
|
|
|
|
:param target: The ``@tool``-decorated callable.
|
|
:param arguments: Deserialized argument dict (already validated
|
|
on the parent side via Pydantic during decoration).
|
|
:returns: The function's return value.
|
|
"""
|
|
result = target(**arguments)
|
|
if asyncio.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
return result
|
|
|
|
|
|
def _serialize_result(target: Any, result: Any) -> str:
|
|
"""
|
|
Serialize the tool's return value to a JSON string.
|
|
|
|
Tries ``pydantic.TypeAdapter`` keyed on the function's
|
|
declared return annotation (handles BaseModel, dataclasses,
|
|
primitives, datetime, UUID natively). Falls back to
|
|
``json.dumps(value, default=str)`` for un-annotated returns or
|
|
when TypeAdapter rejects the value (e.g. open file handles).
|
|
|
|
:param target: The decorated function — its
|
|
``ToolMetadata.return_annotation`` drives serialization.
|
|
:param result: The function's return value.
|
|
:returns: A JSON string suitable for the LLM-facing tool result.
|
|
"""
|
|
metadata = getattr(target, _TOOL_MARKER_ATTR, None)
|
|
return_annotation = (
|
|
getattr(metadata, "return_annotation", None) if metadata is not None else None
|
|
)
|
|
|
|
# If the return is already a string, pass it through unchanged
|
|
# (avoids wrapping JSON-string returns in extra quoting). This
|
|
# mirrors how authors of "stringly-typed" tools expect their
|
|
# output to appear in the LLM context.
|
|
if isinstance(result, str):
|
|
return result
|
|
|
|
if return_annotation is not None:
|
|
try:
|
|
from pydantic import TypeAdapter # local import — heavy module
|
|
|
|
adapter = TypeAdapter(return_annotation)
|
|
return adapter.dump_json(result).decode("utf-8")
|
|
except Exception:
|
|
# Fall through to json.dumps fallback.
|
|
pass
|
|
|
|
try:
|
|
return json.dumps(result, default=str)
|
|
except (TypeError, ValueError) as exc:
|
|
return f"<unserializable return value: {type(result).__name__}: {exc}>"
|
|
|
|
|
|
def _write_response(data: dict[str, Any]) -> None:
|
|
"""
|
|
Write a JSON response to the output channel.
|
|
|
|
:param data: The response dict (must contain ``"result"``
|
|
or ``"error"``).
|
|
"""
|
|
encoded = json.dumps(data).encode()
|
|
fd = _get_output_fd()
|
|
if fd == sys.stdout.fileno():
|
|
# Docker mode: prefix so parent can find the response
|
|
# in stdout mixed with tool debug output.
|
|
sys.stdout.buffer.write(
|
|
f"{_STDOUT_PREFIX}".encode() + encoded + b"\n",
|
|
)
|
|
sys.stdout.buffer.flush()
|
|
else:
|
|
os.write(fd, encoded)
|
|
os.close(fd)
|
|
|
|
|
|
def _write_error(message: str) -> None:
|
|
"""
|
|
Write an error response to the output channel.
|
|
|
|
:param message: Human-readable error description.
|
|
"""
|
|
_write_response({"error": message})
|
|
|
|
|
|
def _get_output_fd() -> int:
|
|
"""
|
|
Return the file descriptor for writing the response.
|
|
|
|
Reads from ``_AP_RESPONSE_FD`` env var (set by the parent
|
|
to the actual fd number passed via ``pass_fds``). Falls back
|
|
to fd 3 if not set. When ``_AP_RESPONSE_MODE=stdout``, returns
|
|
stdout's fd instead (Docker mode).
|
|
|
|
:returns: The file descriptor number.
|
|
"""
|
|
if os.environ.get("_AP_RESPONSE_MODE") == "stdout":
|
|
return sys.stdout.fileno()
|
|
return int(os.environ.get("_AP_RESPONSE_FD", str(_RESPONSE_FD)))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|