chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:00 +08:00
commit 3de48288cb
2986 changed files with 1131193 additions and 0 deletions
@@ -0,0 +1,34 @@
"""Tool-authoring primitives for omnigent.
Use the :func:`tool` decorator to mark a module-level Python function
as a tool the agent can call. The decorator derives the LLM-facing
JSON schema from the function's signature and Google-style docstring;
the caller just writes Python::
from omnigent_client import tool
@tool
def get_current_time() -> dict[str, str]:
\"\"\"Return the current UTC time as ISO-8601.\"\"\"
return {"now": datetime.now(timezone.utc).isoformat()}
Pass decorated functions as the ``tools=`` argument to
:meth:`OmnigentClient.query` or :meth:`Session.query`.
Server-side runtime (``omnigent.tools.local``) also consumes this
decorator to load ``@tool``-decorated functions bundled inside agent
images, so the same decorator powers both authoring and runtime.
"""
from ._decorator import TOOL_MARKER_ATTR, ToolMetadata, get_tool_metadata, tool
from ._handler import build_tool_handler
from ._state import ToolState
__all__ = [
"TOOL_MARKER_ATTR",
"ToolMetadata",
"ToolState",
"build_tool_handler",
"get_tool_metadata",
"tool",
]
@@ -0,0 +1,238 @@
"""
The ``@tool`` decorator and its metadata.
A ``@tool``-decorated module-level function is the authoring
contract for custom Python tools in omnigent. The decorator:
1. Validates that the target is a module-level ``def`` or
``async def`` (rejects class methods, lambdas, and nested
functions; see :func:`_validate_decorator_target`).
2. Derives the function-calling JSON schema from the signature
and Google-style docstring (see :mod:`._schema`).
3. Attaches metadata to the function via the
:data:`TOOL_MARKER_ATTR` attribute so the framework can
discover decorated functions by scanning a module's namespace.
The decorator is intentionally pure metadata: it returns the
original function unwrapped. The framework's executor handles
sync vs async execution (wrapping plain ``def`` bodies in
``asyncio.to_thread`` so they don't block the event loop).
**Decorator stacking**: ``@tool`` should be the outermost
decorator. Inner decorators (``@retry``, ``@cache``, etc.) must
use ``functools.wraps`` so the signature and docstring survive
to the schema-derivation pass; otherwise the schema will reflect
the wrapper, not the underlying function.
"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, ParamSpec, TypeVar, overload
from ._schema import build_function_schema
# Marker attribute name. The framework's loader scans
# ``module.__dict__`` for objects carrying this attribute to
# enumerate the tools a Python file exports.
TOOL_MARKER_ATTR = "_omnigent_tool_metadata"
@dataclass(frozen=True)
class ToolMetadata:
"""
Metadata attached to a ``@tool``-decorated function.
Read by the framework loader and by the subprocess runner.
Read-only by convention — re-decoration or hand-editing is
not supported.
:param name: The tool name as the LLM sees it. Derived from
the function's ``__name__``, e.g. ``"word_count"``.
:param description: Human-readable description, derived from
the function's docstring's leading paragraph.
:param json_schema: The function-calling JSON schema for the
tool's parameters, in the OpenAI function-calling shape
(an ``object`` schema with ``properties`` / ``required``).
Already strict-mode-normalized if ``strict=True`` was
passed to the decorator.
:param strict: Whether the schema was normalized to strict
mode. Stored so the executor side can stay consistent with
the LLM's expectations.
:param return_annotation: The function's declared return type,
used by the executor to deserialize the return value via
``pydantic.TypeAdapter``. ``None`` if the function has no
return annotation (executor falls back to
``json.dumps(value, default=str)``).
"""
name: str
description: str
json_schema: dict[str, Any]
strict: bool
return_annotation: type[Any] | None
P = ParamSpec("P")
R = TypeVar("R")
@overload
def tool(fn: Callable[P, R]) -> Callable[P, R]: ...
@overload
def tool(
*,
strict: bool = ...,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def tool(
fn: Callable[P, R] | None = None,
*,
strict: bool = True,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
"""
Mark a module-level function as an omnigent tool.
The decorator infers the LLM-facing schema from the function's
type hints and Google-style docstring, then attaches the
derived metadata via the :data:`TOOL_MARKER_ATTR` attribute.
The framework's loader scans modules for this marker to
register tools at agent-image load time.
Every ``@tool``-decorated function is sync from the framework's
perspective. Authors who want a tool dispatched as background
work do not annotate the tool — the LLM picks per call site
via ``sys_call_async(tool=..., args=...)`` (see
``omnigent/tools/builtins/async_inbox.py``). The author-time
``@tool(synchronous=False)`` decoration was removed once
``sys_call_async`` shipped — keeping both surfaces would double
the dispatch paths without adding capability.
Usage::
@tool
async def word_count(text: str) -> dict[str, int]:
\"\"\"Count words, characters, and lines.\"\"\"
...
Restrictions:
- Target must be a module-level ``def`` or ``async def``.
Class methods, lambdas, and nested functions are rejected
with ``TypeError``.
- ``@tool`` should be the outermost decorator; inner
decorators must use ``functools.wraps`` for schema derivation
to see the underlying signature and docstring.
:param fn: When used as bare ``@tool`` (no parens), the
decorated function. ``None`` when used as
``@tool(strict=False)``.
:param strict: If ``True`` (default), the derived schema is
normalized to strict mode (``additionalProperties: false``
on objects, all properties required). Authors who hit a
schema strict mode breaks can opt out with
``@tool(strict=False)``.
:returns: The original function with the tool metadata
attached (when called with ``fn``), or a decorator
function (when called with keyword args).
"""
def wrap(target: Callable[P, R]) -> Callable[P, R]:
_validate_decorator_target(target)
schema_result = build_function_schema(target, strict=strict)
metadata = ToolMetadata(
name=target.__name__,
description=schema_result.description,
json_schema=schema_result.parameters_json_schema,
strict=strict,
return_annotation=schema_result.return_annotation,
)
# Attach metadata via setattr (the dynamic attribute name
# is intentional — it's the framework's discovery contract,
# see TOOL_MARKER_ATTR).
setattr(target, TOOL_MARKER_ATTR, metadata)
return target
if fn is None:
return wrap
return wrap(fn)
def _validate_decorator_target(target: Any) -> None:
"""
Reject decorator application to anything other than a
module-level ``def`` or ``async def``.
Class methods would include ``self`` / ``cls`` in the schema,
which the LLM has no way to fill. Lambdas have no name and
no docstring. Nested functions can close over enclosing scope
that doesn't survive subprocess invocation.
:param target: The object the decorator was applied to.
:raises TypeError: If ``target`` is not a module-level
function. The message names the offending construct so
agent authors get an actionable error.
"""
if not callable(target):
raise TypeError(f"@tool can only be applied to functions, got {type(target).__name__}.")
if isinstance(target, (staticmethod, classmethod)):
raise TypeError(
"@tool cannot be applied to staticmethod or classmethod. "
"Define the tool as a module-level function instead — "
"the framework has no way to bind 'self' or 'cls' from "
"an LLM-supplied argument set."
)
if not inspect.isfunction(target):
# Covers callables, methods of class instances, etc.
raise TypeError(
f"@tool requires a plain Python function, got "
f"{type(target).__name__}. Define the tool as a "
f"module-level def or async def."
)
if target.__name__ == "<lambda>":
raise TypeError(
"@tool cannot be applied to a lambda. Lambdas have no "
"name or docstring; the LLM has nothing to call. Define "
"the tool with `def` or `async def` instead."
)
# Nested-function detection: __qualname__ contains a dot path
# (e.g. "outer.<locals>.inner") for any function defined inside
# another function or class body.
qualname = target.__qualname__
if qualname != target.__name__:
# Allow class-level methods if someone re-binds them at module
# scope (rare); the staticmethod/classmethod check above is
# the primary defense. Otherwise reject as nested.
raise TypeError(
f"@tool cannot be applied to nested functions or methods "
f"({qualname!r}). Define the tool at module scope so the "
f"framework's subprocess runner can re-import it cleanly. "
f"State that needs to persist across invocations belongs "
f"in module-level globals, not closure variables."
)
def get_tool_metadata(obj: Any) -> ToolMetadata | None:
"""
Return the :class:`ToolMetadata` for an object if it is a
``@tool``-decorated function, else ``None``.
Used by the framework loader to filter a module's namespace
down to the decorated functions it should register.
:param obj: Any value pulled from a module's namespace.
:returns: The attached metadata, or ``None`` if the object
was not produced by ``@tool``.
"""
metadata = getattr(obj, TOOL_MARKER_ATTR, None)
if isinstance(metadata, ToolMetadata):
return metadata
return None
@@ -0,0 +1,189 @@
"""
Google-style docstring parsing for ``@tool``-decorated functions.
Extracts the function description (everything before the first
section header) and per-parameter descriptions (from the
``Args:`` / ``Arguments:`` / ``Parameters:`` section).
Used by the schema-derivation logic to populate the function-calling
JSON schema's ``description`` and ``properties[name].description``
fields.
We don't depend on a third-party docstring library because the
Google-style format is simple and our needs are narrow. NumPy and
Sphinx styles are intentionally not supported — authors should
use Google style or the explicit ``Annotated[T, Field(description=...)]``
form for per-param descriptions.
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
# Recognized section headers that terminate the description and
# the args section. Case-sensitive (Google convention).
_SECTION_HEADERS = (
"Args:",
"Arguments:",
"Parameters:",
"Returns:",
"Return:",
"Yields:",
"Yield:",
"Raises:",
"Raise:",
"Note:",
"Notes:",
"Example:",
"Examples:",
"See Also:",
"Warning:",
"Warnings:",
"Attributes:",
)
_ARGS_HEADERS = ("Args:", "Arguments:", "Parameters:")
@dataclass(frozen=True)
class ParsedDocstring:
"""
Result of parsing a Google-style docstring.
:param description: The function-level description, taken from the
text preceding the first section header. Whitespace-trimmed.
:param param_descriptions: Mapping from parameter name to its
description, extracted from the ``Args:`` / ``Arguments:`` /
``Parameters:`` section. Empty if no such section exists.
"""
description: str
param_descriptions: dict[str, str]
def parse_google_docstring(doc: str) -> ParsedDocstring:
"""
Parse a Google-style docstring into description and per-param docs.
Recognizes ``Args:`` / ``Arguments:`` / ``Parameters:`` as the
parameter-list section header. Within that section, lines like
`` name: description`` (or `` name (type): description``)
are parsed as parameter entries; subsequent more-indented lines
are treated as continuations of the current parameter's
description. The args section ends at the next recognized
section header.
:param doc: The raw docstring text (typically from
``fn.__doc__``). May be ``None``-equivalent (empty string).
:returns: A :class:`ParsedDocstring` with the extracted description
and parameter descriptions. Returns empty values rather than
raising for malformed input.
"""
if not doc:
return ParsedDocstring(description="", param_descriptions={})
cleaned = inspect.cleandoc(doc)
if not cleaned:
return ParsedDocstring(description="", param_descriptions={})
lines = cleaned.split("\n")
# Locate the first recognized section header. Everything before
# it is the description; the args section (if any) is what
# contains parameter docs.
description_lines: list[str] = []
args_section_lines: list[str] = []
in_args_section = False
in_other_section = False
for line in lines:
stripped = line.strip()
if stripped in _SECTION_HEADERS:
in_args_section = stripped in _ARGS_HEADERS
in_other_section = not in_args_section
continue
if in_args_section:
args_section_lines.append(line)
elif in_other_section:
# Skip non-args sections (Returns:, Raises:, etc.)
continue
else:
description_lines.append(line)
description = "\n".join(description_lines).strip()
param_descriptions = _parse_args_lines(args_section_lines)
return ParsedDocstring(
description=description,
param_descriptions=param_descriptions,
)
def _parse_args_lines(lines: list[str]) -> dict[str, str]:
"""
Parse the body of an ``Args:`` section into per-param descriptions.
Param lines have the form `` name: description`` or
`` name (type): description`` at the section's base indent.
Lines indented further are treated as continuations of the
current parameter's description.
:param lines: The lines following the ``Args:`` header (not
including the header itself), up to the next section.
:returns: Mapping from parameter name to its (whitespace-collapsed)
description.
"""
# Determine the base indent — the indent of the first non-empty line.
base_indent: int | None = None
for line in lines:
if line.strip():
base_indent = len(line) - len(line.lstrip())
break
if base_indent is None:
return {}
param_descriptions: dict[str, str] = {}
current_name: str | None = None
current_parts: list[str] = []
for line in lines:
if not line.strip():
# Blank lines within args section are continuation separators;
# they don't terminate a param.
continue
line_indent = len(line) - len(line.lstrip())
if line_indent == base_indent:
# Start of a new param entry.
if current_name is not None:
param_descriptions[current_name] = " ".join(current_parts).strip()
current_name = None
current_parts = []
stripped = line.strip()
if ":" not in stripped:
# Malformed entry; skip it.
continue
name_part, _, desc = stripped.partition(":")
# Handle "name (type)" form by trimming the parenthetical.
if "(" in name_part:
name_only = name_part.split("(", 1)[0].strip()
else:
name_only = name_part.strip()
if name_only.isidentifier():
current_name = name_only
current_parts = [desc.strip()]
# Else: not a valid param entry; ignore.
else:
# Continuation line for the current param.
if current_name is not None:
current_parts.append(line.strip())
if current_name is not None:
param_descriptions[current_name] = " ".join(current_parts).strip()
return param_descriptions
@@ -0,0 +1,114 @@
"""Adapter that turns ``@tool``-decorated functions into a ToolHandler.
The stream-layer ``ToolHandler`` takes a list of OpenAI-shape JSON
schemas and a single ``execute`` callable. Users who have written
tools with the ``@tool`` decorator (Python functions with type hints
and Google-style docstrings) shouldn't have to hand-roll that shape:
:func:`build_tool_handler` reads each function's tool metadata and
builds the handler for them.
Dispatch is by tool name. Calling an unknown tool raises — the SDK
surfaces the error back to the agent as a tool error.
"""
from __future__ import annotations
import asyncio
import inspect
import json
from collections.abc import Callable
from typing import Any
from .._tool_handler import ToolCallInfo, ToolHandler
from ._decorator import TOOL_MARKER_ATTR, ToolMetadata
def build_tool_handler(functions: list[Callable[..., Any]]) -> ToolHandler:
"""Build a :class:`ToolHandler` from ``@tool``-decorated functions.
Each function must carry tool metadata attached by the
:func:`~omnigent_client.tool` decorator (checked via
:data:`TOOL_MARKER_ATTR`). The returned handler exposes the
OpenAI-shape schemas the SDK sends to the server, and an
``execute`` callable that dispatches incoming tool calls by
name.
:param functions: List of ``@tool``-decorated Python functions,
e.g. ``[get_current_time, search_docs]``. Each must be a
module-level ``def`` or ``async def`` decorated with
``@tool``.
:returns: A :class:`ToolHandler` ready to pass as
``session.tool_handler`` or via the ``tools=`` keyword on
``OmnigentClient.query`` / ``Session.query``.
:raises TypeError: If any function is missing the ``@tool``
marker (i.e. wasn't decorated).
:raises ValueError: If two functions share the same tool name
— tool names must be unique per handler.
"""
if not functions:
raise ValueError("build_tool_handler() requires at least one function")
schemas: list[dict[str, object]] = []
funcs_by_name: dict[str, Callable[..., Any]] = {}
for fn in functions:
meta: ToolMetadata | None = getattr(fn, TOOL_MARKER_ATTR, None)
if meta is None:
raise TypeError(
f"{fn.__module__}.{fn.__qualname__} is not decorated with "
f"@tool. Decorate it with `from omnigent_client import tool` "
f"and apply @tool above the function definition."
)
if meta.name in funcs_by_name:
raise ValueError(
f"Duplicate tool name {meta.name!r}: "
f"{funcs_by_name[meta.name].__qualname__} and "
f"{fn.__qualname__} both export the same name."
)
funcs_by_name[meta.name] = fn
schema: dict[str, object] = {
"type": "function",
"function": {
"name": meta.name,
"description": meta.description,
"parameters": meta.json_schema,
},
}
schemas.append(schema)
async def execute(call: ToolCallInfo) -> str:
"""Dispatch ``call`` to the matching ``@tool`` function.
Async functions (``async def``) are awaited on the
event loop. Sync functions (``def``) are dispatched to
a worker thread via ``asyncio.to_thread`` so blocking
calls inside — ``time.sleep``, file I/O, subprocess,
``requests`` — don't stall the event loop. Without the
thread bounce, several concurrent ``@tool`` invocations
(e.g. a parallel fan-out of async client tools) would
serialize: each body would block every sibling AND any
caller render loop sharing the loop.
The return value is JSON-serialized unless the function
already returned a string (which is passed through).
"""
fn = funcs_by_name.get(call.name)
if fn is None:
# The SDK will surface this back to the agent as a tool
# error — this typically means the LLM invented a tool
# name that wasn't in the schemas we sent.
raise KeyError(f"Unknown tool {call.name!r}. Registered: {sorted(funcs_by_name)}")
if inspect.iscoroutinefunction(fn):
result = await fn(**call.arguments)
else:
# Sync body — route to a worker thread so it
# doesn't block the event loop (see the fan-out
# serialization case above).
result = await asyncio.to_thread(lambda: fn(**call.arguments))
if isinstance(result, str):
return result
# Pydantic models and dataclasses commonly aren't JSON-ready
# out of the box — ``default=str`` handles datetime/UUID/etc.
return json.dumps(result, default=str)
return ToolHandler(schemas=schemas, execute=execute)
@@ -0,0 +1,267 @@
"""
Schema derivation for ``@tool``-decorated functions.
Given a typed Python function, produce the function-calling JSON
schema the LLM sees. The pipeline:
1. Inspect the signature for parameters, annotations, and defaults.
2. Parse the Google-style docstring for description and per-param
descriptions (see :mod:`omnigent.tools._docstring`).
3. Build a Pydantic model from the parameters via ``create_model``;
Pydantic does the heavy lifting for type → schema (primitives,
Pydantic models, ``Optional``, ``Literal``, ``Annotated[..., Field]``,
etc.).
4. Apply strict-mode normalization (see
:mod:`omnigent.tools._strict`) when ``strict=True``.
Permissive types (``Any``, ``object``, missing annotations) are
allowed but produce an INFO-level warning so authors can find them.
"""
from __future__ import annotations
import inspect
import logging
import typing
from collections.abc import Callable
from dataclasses import dataclass
from typing import Annotated, Any, get_args, get_origin
from pydantic import Field, create_model
from pydantic.fields import FieldInfo
from ._docstring import parse_google_docstring
from ._state import ToolState
from ._strict import ensure_strict_schema
_logger = logging.getLogger(__name__)
# Reserved parameter name for framework-injected per-conversation
# per-agent tool state. A ``@tool`` function that declares a
# parameter with this name receives a live :class:`ToolState` at
# call time; the parameter is stripped from the LLM-facing JSON
# schema. Convention over configuration — every stateful tool uses
# the same identifier.
STATE_PARAM_NAME = "tool_state"
@dataclass(frozen=True)
class FunctionSchemaResult:
"""
Output of :func:`build_function_schema`.
:param description: Function-level description, derived from
the docstring's leading paragraph(s).
:param parameters_json_schema: JSON schema for the function's
parameters, in the OpenAI function-calling shape (an
``object`` schema with ``properties`` and ``required``).
Already normalized to strict mode if requested.
:param return_annotation: The function's return type annotation,
or ``None`` if no return annotation was provided. Used by
the executor to deserialize the tool's return value via
``pydantic.TypeAdapter``.
"""
description: str
parameters_json_schema: dict[str, Any]
return_annotation: type[Any] | None
def build_function_schema(
fn: Callable[..., Any],
*,
strict: bool = True,
) -> FunctionSchemaResult:
"""
Build the function-calling schema for a Python function.
:param fn: The Python function to derive a schema for. Must be
a module-level ``def`` or ``async def`` (the ``@tool``
decorator enforces this elsewhere; we do not re-validate
here).
:param strict: If ``True``, apply strict-mode normalization to
the resulting schema (see :mod:`._strict`).
:returns: A :class:`FunctionSchemaResult` with description,
JSON schema, and return-type annotation.
"""
sig = inspect.signature(fn)
type_hints = typing.get_type_hints(fn, include_extras=True)
parsed_doc = parse_google_docstring(fn.__doc__ or "")
fields: dict[str, tuple[Any, FieldInfo]] = {}
for name, param in sig.parameters.items():
ann = type_hints.get(name, Any)
# Framework-injected parameter — reserved by convention:
# any parameter named exactly ``tool_state`` is filled by
# the runtime with a :class:`ToolState`. Skipped from the
# LLM-facing schema; the LLM has no way to supply it.
# Enforce the convention: if someone types a param as
# ToolState but names it something else, fail loud so they
# know the right contract.
if name == STATE_PARAM_NAME:
if ann is not ToolState and ann is not Any:
raise TypeError(
f"@tool function {fn.__name__!r} declares parameter "
f"'{STATE_PARAM_NAME}' with unexpected type "
f"{ann!r}. It must be typed as ToolState "
f"(or left unannotated); any other type is a bug."
)
continue
if ann is ToolState:
raise TypeError(
f"@tool function {fn.__name__!r} types parameter "
f"{name!r} as ToolState but the parameter must be named "
f"{STATE_PARAM_NAME!r}. Rename it and the framework "
f"will inject a live ToolState at call time."
)
_warn_if_permissive(fn.__name__, name, ann)
doc_desc = parsed_doc.param_descriptions.get(name)
default = param.default if param.default is not inspect.Parameter.empty else ...
field_info = _build_field_info(ann, default, doc_desc)
fields[name] = (ann, field_info)
if fields:
# Pydantic uses the model name when generating $defs refs;
# capitalize so it looks reasonable in the schema output.
model_name = f"{_pascal_case(fn.__name__)}Args"
# mypy can't statically narrow create_model's overload for our
# dynamic field dict, but pydantic accepts (Type, FieldInfo)
# tuples here — they're the documented field-definition shape.
Model = create_model(model_name, **fields) # type: ignore[call-overload]
params_schema: dict[str, Any] = Model.model_json_schema()
else:
# Zero-arg tool: the schema is an empty object.
params_schema = {
"type": "object",
"properties": {},
"required": [],
}
if strict:
params_schema = ensure_strict_schema(params_schema)
return_annotation = type_hints.get("return")
return FunctionSchemaResult(
description=parsed_doc.description,
parameters_json_schema=params_schema,
return_annotation=return_annotation,
)
def _build_field_info(
annotation: Any,
default: Any,
doc_description: str | None,
) -> FieldInfo:
"""
Construct a Pydantic ``FieldInfo`` for one parameter.
Handles three description sources, with this priority:
1. An explicit ``Field(description=...)`` in
``Annotated[T, Field(description=...)]``.
2. A bare string in ``Annotated[T, "desc"]`` (a common shorthand
supported by some agent SDKs).
3. The docstring entry for this parameter (Google-style ``Args:``).
:param annotation: The parameter's type annotation, possibly
wrapped in ``Annotated[...]``.
:param default: The parameter's default value, or ``...`` if
the parameter is required.
:param doc_description: Description from the docstring's
``Args:`` section, or ``None`` if absent.
:returns: A ``FieldInfo`` ready to pass to
``pydantic.create_model``. The default (if any) and
description are baked in at construction time so
``model_json_schema`` picks them up correctly.
"""
# Pull metadata out of Annotated[T, ...] for description discovery.
annotated_str_desc: str | None = None
annotated_field: FieldInfo | None = None
if get_origin(annotation) is Annotated:
for extra in get_args(annotation)[1:]:
if isinstance(extra, FieldInfo) and annotated_field is None:
annotated_field = extra
elif isinstance(extra, str) and annotated_str_desc is None:
annotated_str_desc = extra
# Determine the effective description with the priority above.
description: str | None = None
if annotated_field is not None and annotated_field.description is not None:
description = annotated_field.description
elif annotated_str_desc is not None:
description = annotated_str_desc
elif doc_description:
description = doc_description
# Field(default=PydanticUndefined) is the marker for "required";
# we map our `...` sentinel to it via PydanticUndefined import.
# Easier: build the constructor kwargs and let Pydantic translate
# default=... directly (it accepts ``...`` as "required" too).
field_kwargs: dict[str, Any] = {}
if description is not None:
field_kwargs["description"] = description
if default is not ...:
field_kwargs["default"] = default
if annotated_field is not None:
# Merge: preserve other metadata (gt/lt/min_length/etc.) from
# the author's Field, but override description and default.
# merge_field_infos's stub return is too loose for mypy; cast.
merged: FieldInfo = FieldInfo.merge_field_infos(annotated_field, FieldInfo(**field_kwargs))
return merged
# pydantic.Field stub returns Any (it's polymorphic by default).
# We're constructing a fresh FieldInfo; cast to the documented type.
field_obj: FieldInfo = Field(**field_kwargs)
return field_obj
def _warn_if_permissive(fn_name: str, param_name: str, annotation: Any) -> None:
"""
Log a warning if a parameter's type provides no validation constraint.
``Any``, ``object``, and missing annotations all produce a
permissive schema (no ``type`` field) that the LLM can fill
with arbitrary structure. Useful but easy to write by accident.
:param fn_name: The decorated function's ``__name__``, for the
log message, e.g. ``"process_payload"``.
:param param_name: The offending parameter's name, e.g.
``"data"``.
:param annotation: The annotation as resolved by
``typing.get_type_hints``.
"""
# Strip Annotated[...] so we inspect the underlying type.
underlying = annotation
if get_origin(underlying) is Annotated:
underlying = get_args(underlying)[0]
if underlying is Any or underlying is object:
type_name = (
"Any" if underlying is Any else getattr(underlying, "__name__", str(underlying))
)
_logger.info(
"Tool '%s' parameter '%s' has no concrete type annotation "
"(resolved to %s); LLM will get a permissive schema with "
"no validation.",
fn_name,
param_name,
type_name,
)
def _pascal_case(snake: str) -> str:
"""
Convert a snake_case identifier to PascalCase.
Used to give the dynamically-created Pydantic model a readable
name in schema ``$defs`` references.
:param snake: A snake_case identifier, e.g. ``"word_count"``.
:returns: PascalCase form, e.g. ``"WordCount"``.
"""
return "".join(part.capitalize() for part in snake.split("_") if part)
@@ -0,0 +1,300 @@
"""Per-agent ToolState for stateful ``@tool`` functions.
See ``designs/TOOL_STATE.md`` for the full design. The primitive is
a simple key-value store, JSON-serialized, scoped to one
(conversation, agent) pair via the storage directory provided by
the framework. The ``@tool`` decorator hides ``ToolState``-typed
parameters from the LLM-facing schema; the subprocess runner
reconstructs a ``ToolState`` from the directory path and injects it
when the tool function is called.
Tool authors see::
from omnigent_client import tool, ToolState
@tool
def add_task(desc: str, state: ToolState) -> str:
with state.transaction("queue") as q:
q = q or []
q.append({"desc": desc})
return f"#{len(q) - 1}"
and nothing else.
"""
from __future__ import annotations
import json
import os
import threading
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Any
try:
import fcntl as _fcntl
except ImportError: # pragma: no cover - exercised on native Windows
_fcntl = None # type: ignore[assignment]
if os.name == "nt":
import msvcrt as _msvcrt
else: # pragma: no cover - exercised on POSIX
_msvcrt = None # type: ignore[assignment]
# Subdirectory segments reserved by the framework — JSON key files
# live at ``{root}/{key}.json``. Keys must not contain path
# separators; we sanitize eagerly rather than allowing the bug to
# surface as directory traversal.
_KEY_SUFFIX = ".json"
_THREAD_LOCKS_GUARD = threading.Lock()
_THREAD_LOCKS: dict[Path, threading.Lock] = {}
class ToolState:
"""Per-agent, per-conversation key-value state for ``@tool`` functions.
Values are JSON-serialized. The keyspace is shared across every
tool invoked for the same registered agent within the same
conversation. Use :meth:`transaction` for atomic read-modify-write;
plain :meth:`get` and :meth:`set` do not serialize concurrent
writers on the same key.
Instances are constructed by the framework. Tool authors receive
a ``ToolState`` by declaring a parameter of this type on their
``@tool``-decorated function; the decorator strips the parameter
from the LLM-facing schema and the subprocess runner injects the
live ``ToolState`` at call time.
:param root: The directory this namespace lives in, e.g.
``{workspace}/.tool_state/{agent_id}``. The directory does
not need to exist yet; it is created lazily on first write.
"""
def __init__(self, root: Path) -> None:
self._root = root
# ── Primary API ──────────────────────────────────────────
def get(self, key: str, *, default: Any = None) -> Any:
"""Return the stored value at ``key``, or ``default`` if absent.
:param key: The state key, e.g. ``"queue"``.
:param default: Value to return when the key has never been
written. ``None`` by default.
:returns: The deserialized JSON value, or ``default``.
"""
path = self._path_for(key)
if not path.exists():
return default
with path.open("r") as f:
# Shared lock: allow parallel reads, block concurrent writers
# briefly so we see a complete JSON payload.
_lock_file(f, exclusive=False)
try:
return json.loads(f.read() or "null")
finally:
_unlock_file(f)
def set(self, key: str, value: Any) -> None:
"""Replace (or create) the value at ``key``. JSON-serialized.
Non-atomic relative to concurrent writers on the same key —
use :meth:`transaction` for read-modify-write sequences.
:param key: The state key.
:param value: Any JSON-serializable value.
"""
path = self._path_for(key)
path.parent.mkdir(parents=True, exist_ok=True)
# Write through a temp file + rename so a reader never sees
# a half-written JSON payload even without a lock.
tmp = path.with_suffix(_KEY_SUFFIX + ".tmp")
with tmp.open("w") as f:
json.dump(value, f)
tmp.replace(path)
def delete(self, key: str) -> None:
"""Remove ``key``. No-op if absent.
:param key: The state key to remove.
"""
path = self._path_for(key)
# Idempotent delete — tools commonly don't know whether the
# key was ever set.
with suppress(FileNotFoundError):
path.unlink()
def keys(self) -> list[str]:
"""List all keys currently stored in this namespace.
:returns: Sorted list of keys, e.g. ``["counter", "queue"]``.
Empty list if nothing has been written yet.
"""
if not self._root.exists():
return []
return sorted(p.stem for p in self._root.iterdir() if p.suffix == _KEY_SUFFIX)
def __contains__(self, key: object) -> bool:
"""Return whether ``key`` currently exists in this namespace.
:param key: Candidate key, e.g. ``"queue"``.
:returns: ``True`` when ``key`` is a valid stored key, else ``False``.
"""
if not isinstance(key, str):
return False
return self._path_for(key).exists()
@contextmanager
def transaction(self, key: str, *, default: Any = None) -> Iterator[Any]:
"""Atomic read-modify-write for one key.
Typical usage — supply a ``default`` so first-time callers
get a usable container without a ``None`` check::
with state.transaction("queue", default=[]) as queue:
queue.append(item)
# queue is written back on normal exit.
The yielded value is the current contents, or a fresh
``default`` if the key was never set. Mutating the yielded
object in place is the expected pattern — the same object
is serialized back on exit. Rebinding the local name inside
the ``with`` block does NOT propagate (Python closures), so
for "replace the value" semantics use :meth:`set` explicitly.
On a normal exit the yielded object is JSON-serialized and
written back. On exception no write happens — the prior
value is preserved.
:param key: The state key to lock + read + write.
:param default: Value yielded when the key has never been
written. Defaults to ``None``. Pass ``[]`` or ``{}``
(or any JSON-serializable value) to skip the absent-key
branch in caller code.
:yields: The current value at ``key``, or ``default`` if
the key has no stored value yet. Mutate in place.
"""
path = self._path_for(key)
path.parent.mkdir(parents=True, exist_ok=True)
thread_lock = _get_thread_lock(path)
# ``a+`` creates the file if missing and positions at end;
# we seek to 0 before read. Opening with ``r+`` would fail
# when the file doesn't exist yet, which is a common first-
# call case for a tool.
with thread_lock, path.open("a+") as f:
_lock_file(f, exclusive=True)
try:
value = _read_transaction_value(f, default)
yield value
_write_transaction_value(f, value)
finally:
_unlock_file(f)
# ── Internals ────────────────────────────────────────────
def _path_for(self, key: str) -> Path:
"""Resolve ``key`` to the on-disk path, rejecting traversal.
:param key: Caller-supplied key.
:returns: The ``{root}/{key}.json`` path.
:raises ValueError: If ``key`` is empty, contains a path
separator, or starts with a dot (no hidden or
escaped paths).
"""
if not key:
raise ValueError("ToolState key must be a non-empty string")
if "/" in key or "\\" in key or key.startswith("."):
# Rejects traversal and hidden-file sigils. Authors who
# really need slashes can encode them (e.g. "a__b") —
# we'd rather break loudly than accept quiet bugs.
raise ValueError(
f"ToolState key {key!r} contains an illegal character. "
f"Keys must be plain names (no '/', '\\', or leading '.')."
)
return self._root / f"{key}{_KEY_SUFFIX}"
def _read_transaction_value(f: Any, default: Any) -> Any:
"""Seek to 0 and decode the JSON value under the open file handle.
Returns ``default`` when the file is empty (first-time use of
the key). Factored out of :meth:`ToolState.transaction` so the
context manager stays short.
:param f: Open file handle positioned anywhere; will be seek(0)ed.
:param default: Value to return on empty/whitespace content.
:returns: Decoded JSON value or ``default``.
"""
f.seek(0)
raw = f.read()
if raw.strip():
return json.loads(raw)
return default
def _write_transaction_value(f: Any, value: Any) -> None:
"""Truncate and re-serialize ``value`` as JSON under the file handle.
Caller must hold the exclusive flock before calling. Factored
out of :meth:`ToolState.transaction` so the context manager
stays short.
:param f: Open file handle (must support ``r+``-style truncate).
:param value: Any JSON-serializable value to persist.
"""
f.seek(0)
f.truncate()
json.dump(value, f)
# Flush before releasing the flock held by the caller. Python file objects
# buffer writes; if we unlock before flushing, another process can acquire
# the lock and read stale on-disk contents, losing the prior update.
f.flush()
os.fsync(f.fileno())
def _lock_file(f: Any, *, exclusive: bool) -> None:
"""Acquire an advisory file lock for ``f``.
POSIX uses ``fcntl.flock``. Native Windows has no ``fcntl`` module, so it
locks one byte with ``msvcrt.locking``; that API is exclusive-only, which
is conservative for reads but preserves cross-process serialization.
"""
if _fcntl is not None:
_fcntl.flock(f, _fcntl.LOCK_EX if exclusive else _fcntl.LOCK_SH)
return
if _msvcrt is None: # pragma: no cover - defensive for unusual platforms
return
f.seek(0)
_msvcrt.locking(f.fileno(), _msvcrt.LK_LOCK, 1)
def _unlock_file(f: Any) -> None:
"""Release a lock acquired by :func:`_lock_file`."""
if _fcntl is not None:
_fcntl.flock(f, _fcntl.LOCK_UN)
return
if _msvcrt is None: # pragma: no cover - defensive for unusual platforms
return
f.seek(0)
_msvcrt.locking(f.fileno(), _msvcrt.LK_UNLCK, 1)
def _get_thread_lock(path: Path) -> threading.Lock:
"""Return the per-key in-process mutex for ``path``.
``flock`` serializes across processes, but threads in the same
process can still interleave on separate file descriptors. This
helper layers a per-path ``threading.Lock`` on top so
``transaction()`` is atomic under both thread and process
contention.
:param path: The key file path, e.g. ``Path("/tmp/state/queue.json")``.
:returns: The shared mutex guarding that path within this process.
"""
with _THREAD_LOCKS_GUARD:
lock = _THREAD_LOCKS.get(path)
if lock is None:
lock = threading.Lock()
_THREAD_LOCKS[path] = lock
return lock
@@ -0,0 +1,64 @@
"""
Strict JSON-schema normalization for ``@tool``-derived schemas.
Strict-mode schemas have two extra constraints beyond ordinary
JSON Schema:
- Every object schema sets ``additionalProperties: false``.
- Every property in an object is listed in ``required`` (Python
defaults are still applied on the executor side after the LLM
emits a value).
This is the form most major LLM providers accept for
function-calling tool schemas without further coercion. Authors
who hit a real schema that strict mode breaks can opt out via
``@tool(strict=False)``.
"""
from __future__ import annotations
from typing import Any
def ensure_strict_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""
Recursively normalize a JSON schema to strict-mode rules.
Returns a new dict (does not mutate the input). Recurses into
``properties``, ``items``, ``$defs``, and union variants
(``anyOf`` / ``oneOf`` / ``allOf``).
:param schema: A JSON schema dict (typically produced by
:func:`pydantic.BaseModel.model_json_schema`).
:returns: A new dict with strict-mode constraints applied.
"""
if not isinstance(schema, dict):
return schema
out: dict[str, Any] = dict(schema)
# Recurse into nested $defs FIRST so the recursion sees the
# normalized definitions when it walks references.
if "$defs" in out:
out["$defs"] = {k: ensure_strict_schema(v) for k, v in out["$defs"].items()}
# Recurse into union variants.
for union_key in ("anyOf", "oneOf", "allOf"):
if union_key in out and isinstance(out[union_key], list):
out[union_key] = [ensure_strict_schema(variant) for variant in out[union_key]]
# Recurse into array items.
if "items" in out:
out["items"] = ensure_strict_schema(out["items"])
if out.get("type") == "object":
properties = out.get("properties", {}) or {}
out["additionalProperties"] = False
out["properties"] = {k: ensure_strict_schema(v) for k, v in properties.items()}
# Strict mode requires every property to be listed as required.
# The default value (if any) still applies on the executor side
# via Pydantic — strict mode only governs what the LLM emits.
if properties:
out["required"] = list(properties.keys())
return out