omnigent-ai--omnigent
250 行
9.9 KiB
Python
250 行
9.9 KiB
Python
"""Abstract base class for agent tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path # used by ToolContext.workspace type hint
|
|
from typing import Any
|
|
|
|
# Tool name constraint: alphanumeric plus ``_`` and ``-``, up to
|
|
# 256 characters. OpenAI enforces 1–64 but other providers allow
|
|
# longer names, and client-side tools come from the user.
|
|
TOOL_NAME_RE: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9_-]{1,256}$")
|
|
|
|
|
|
def is_valid_tool_name(name: str) -> bool:
|
|
"""
|
|
Check whether a tool name is valid: 1–256 characters,
|
|
alphanumeric plus ``_`` and ``-``.
|
|
|
|
:param name: The tool name to validate, e.g. ``"get_weather"``.
|
|
:returns: ``True`` if the name is valid, ``False`` otherwise.
|
|
"""
|
|
return TOOL_NAME_RE.match(name) is not None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolContext:
|
|
"""
|
|
Execution context passed to every tool invocation.
|
|
|
|
Provides server-side metadata that tools may need but
|
|
which the LLM does not supply (task identity, agent
|
|
identity, workspace path). Individual tools read the
|
|
fields they need and ignore the rest.
|
|
|
|
:param task_id: The current task/workflow ID,
|
|
e.g. ``"task_abc123"``.
|
|
:param agent_id: The registered agent ID,
|
|
e.g. ``"ag_xyz789"``.
|
|
:param workspace: Per-conversation persistent working
|
|
directory. ``upload_file`` resolves paths against it;
|
|
``sys_terminal_launch`` uses it as the cwd default
|
|
(per ``designs/OMNIGENT_TERMINAL_BRIDGE.md`` §4.6 cwd-resolution
|
|
precedence). ``None`` when no workspace is available (e.g.
|
|
tests).
|
|
:param conversation_id: The current conversation's ID,
|
|
e.g. ``"conv_abc123"``. Used by conversation-scoped
|
|
tools (the ``sys_terminal_*`` family looks up its
|
|
:class:`TerminalRegistry` entries by this id; the
|
|
``sys_session_*`` family uses it for sub-agent
|
|
addressing). ``None`` when not available (older workflow
|
|
paths, unit tests) — tools that require it should fail
|
|
loud.
|
|
"""
|
|
|
|
task_id: str
|
|
agent_id: str
|
|
workspace: Path | None = None
|
|
conversation_id: str | None = None
|
|
|
|
|
|
class Tool(abc.ABC):
|
|
"""
|
|
Abstract base class for all tools available to the agent.
|
|
|
|
Each tool has a unique name, an OpenAI-format schema for the
|
|
LLM, and an ``invoke`` method that executes the tool and
|
|
returns a string result.
|
|
|
|
Subclasses must implement ``name()`` as a ``@classmethod``
|
|
(for tools with a fixed name, e.g. ``SpawnTool.name()``)
|
|
or as a regular method (for tools whose name depends on
|
|
instance state, e.g. ``McpTool``).
|
|
"""
|
|
|
|
@classmethod
|
|
@abc.abstractmethod
|
|
def name(cls) -> str:
|
|
"""
|
|
Unique tool name used for dispatch and schema registration.
|
|
|
|
:returns: The tool name, e.g. ``"load_skill"``.
|
|
"""
|
|
|
|
@classmethod
|
|
@abc.abstractmethod
|
|
def description(cls) -> str:
|
|
"""
|
|
Human-readable description of what the tool does.
|
|
|
|
Must be readable without instantiation — used for tool
|
|
discovery (e.g. the onboarding assistant's
|
|
``list_builtin_tools``) and should match the description
|
|
in :meth:`get_schema`.
|
|
|
|
:returns: The tool's description string.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def get_schema(self) -> dict[str, Any]:
|
|
"""
|
|
Return the OpenAI Chat Completions tool schema.
|
|
|
|
:returns: A dict with ``"type": "function"`` and a
|
|
``"function"`` sub-dict describing the tool's name,
|
|
description, and parameters.
|
|
"""
|
|
|
|
def invoke(self, arguments: str, ctx: ToolContext) -> str:
|
|
"""
|
|
Execute the tool with the given arguments.
|
|
|
|
Optional override. Tools that ship as schema-only
|
|
(because the runner dispatches them out-of-band — e.g.
|
|
``sys_cancel_task``, ``sys_session_send``, async-inbox
|
|
family) inherit this default, which fails loud if the
|
|
AP-side path ever reaches them.
|
|
|
|
:param arguments: JSON-encoded arguments string from the
|
|
LLM, e.g. ``'{"name": "summarize"}'``.
|
|
:param ctx: Server-side execution context with task and
|
|
agent identity.
|
|
:returns: The tool's string result.
|
|
:raises NotImplementedError: When the subclass is
|
|
runner-dispatched and Omnigent misroutes here.
|
|
"""
|
|
del arguments, ctx
|
|
raise NotImplementedError(
|
|
f"{type(self).__name__}.invoke is runner-dispatched; the "
|
|
f"AP-side path should not reach this method. The runner "
|
|
f"handles dispatch via omnigent/runner/tool_dispatch.py."
|
|
)
|
|
|
|
def cancel(self) -> None: # noqa: B027 — optional override hook; default is a no-op
|
|
"""
|
|
Cancel an in-progress invocation.
|
|
|
|
Called by ``call_tool_with_timeout`` when the deadline
|
|
expires. Subprocess-based tools override this to kill
|
|
the child process. Default is a no-op.
|
|
"""
|
|
|
|
def shutdown(self) -> None: # noqa: B027 — optional override hook; default is a no-op
|
|
"""
|
|
Release resources held by this tool instance.
|
|
|
|
Called by :meth:`ToolManager.shutdown` during teardown.
|
|
Tools that hold subprocesses, file handles, or other
|
|
long-lived resources override this to clean up.
|
|
Default is a no-op.
|
|
"""
|
|
|
|
def is_async(self, arguments: str | None = None) -> bool:
|
|
"""
|
|
Return ``True`` if this invocation runs in a background workflow.
|
|
|
|
Tools that NEVER run async (the common case) leave the
|
|
default. ``arguments`` is passed in case a future tool's
|
|
async-ness depends on call-time choice — current tools either
|
|
ignore the parameter (fixed at the class level, e.g.
|
|
``SysCallAsyncTool``) or never go async at all.
|
|
|
|
Async invocations bypass the inline ``invoke()`` path. Instead,
|
|
the runtime calls :meth:`dispatch_async` which starts a child
|
|
workflow and returns a handle immediately; the real result
|
|
arrives later via the async-work drain.
|
|
|
|
:param arguments: JSON-encoded argument string from the LLM,
|
|
same shape as what ``invoke`` would receive. ``None``
|
|
means the caller only wants the tool-level default (used
|
|
by tool manifest generation that doesn't have call-time
|
|
arguments). Tools that ignore per-call semantics can
|
|
safely ignore this parameter.
|
|
:returns: ``True`` iff this invocation should dispatch as a
|
|
background workflow.
|
|
"""
|
|
return False
|
|
|
|
async def dispatch_async(
|
|
self,
|
|
*,
|
|
parent_task_id: str,
|
|
parent_conversation_id: str,
|
|
agent_id: str,
|
|
agent_name: str,
|
|
arguments: str,
|
|
workspace_path: str | None,
|
|
) -> Any:
|
|
"""
|
|
Run an async invocation — either by spawning a child workflow
|
|
or by producing the result inline.
|
|
|
|
Called by the runtime (from an async workflow body) when
|
|
:meth:`is_async` returned True. Two return shapes are
|
|
supported, and the runtime's ``_execute_tools`` dispatcher
|
|
accepts both:
|
|
|
|
- **Background dispatch (the common case).** The tool creates
|
|
a child ``task_store`` row, starts the runner-side async task pinned to
|
|
that task_id, and returns an ``_AsyncToolHandle`` (defined
|
|
in ``omnigent.runtime.workflow``). The handle is
|
|
serialized via ``to_handle_json`` and surfaced to the LLM
|
|
as the tool-call output; the real result arrives later
|
|
through the async-work drain.
|
|
- **Inline async work.** The tool returns a ``str`` directly.
|
|
The runtime treats it as a normal sync tool result without
|
|
spawning any child. Used by tools whose work needs the
|
|
parent's async loop (e.g., draining a runner inbox via
|
|
``the async-work drain``) but produces a result immediately —
|
|
``sys_read_inbox`` is the canonical example. Also used by
|
|
tools like ``sys_call_async`` to surface argument-validation
|
|
errors that don't justify a child workflow.
|
|
|
|
The default raises ``NotImplementedError`` — subclasses that
|
|
override :meth:`is_async` to return True MUST override this
|
|
as well. The split lets synchronous tools ignore the async
|
|
path entirely without import-cycle headaches around
|
|
``_AsyncToolHandle``.
|
|
|
|
:param parent_task_id: The currently-executing parent
|
|
workflow's task_id. The new child task points at it via
|
|
``root_task_id``; the background workflow signals it via
|
|
the ``async_work_complete`` topic. Inline-result tools
|
|
may ignore this.
|
|
:param parent_conversation_id: The owning conversation's id.
|
|
Recorded on the child task row for conversation-scoped
|
|
queries. Inline-result tools may ignore this.
|
|
:param agent_id: The owning agent's id. Recorded on the
|
|
child task row. Inline-result tools may ignore this.
|
|
:param agent_name: The tool's name (same as ``self.name()``
|
|
in most cases) — recorded as ``agent_name`` on the task
|
|
so ``list_tasks`` results show what produced the work.
|
|
Inline-result tools may ignore this.
|
|
:param arguments: JSON-encoded argument string from the LLM.
|
|
:param workspace_path: Per-conversation workspace directory
|
|
(or ``None`` if this tool doesn't need it).
|
|
:returns: Either an ``_AsyncToolHandle`` instance (the
|
|
runtime's shared handle shape, for the background path)
|
|
or a ``str`` (for inline-result tools). Typed as ``Any``
|
|
to avoid importing the runtime module from the tool
|
|
base.
|
|
:raises NotImplementedError: When called on a tool that
|
|
didn't override this method.
|
|
"""
|
|
raise NotImplementedError(
|
|
f"{type(self).__name__} declared is_async() true but did not override dispatch_async()"
|
|
)
|