Skip to content

symfonic.core.tools.decorator

decorator

@symfonic_tool -- unified tool decorator (Roadmap Item 6).

The decorator wraps a Python callable into an object the existing :class:~symfonic.core.tools.registry.ToolRegistry already accepts -- a LangChain :class:~langchain_core.tools.StructuredTool -- and attaches symfonic-specific metadata (scope / routing_mode / visibility) on a private :pyattr:_symfonic_metadata attribute.

Design choices

  • Schema derivation is delegated to LangChain. StructuredTool.from_function(parse_docstring=True) already handles every edge case the roadmap calls out (primitives, Pydantic BaseModel parameters, Optional/Union, Annotated[T, "desc"], no-docstring fallback). Re-implementing schema synthesis would be a reliability regression for zero ergonomic gain.

  • Async-gen functions are preserved verbatim. StructuredTool.from_function(coroutine=fn) stores fn on the resulting tool's coroutine slot without wrapping it, so inspect.isasyncgenfunction(tool.coroutine) remains True and Phase 3's :func:~symfonic.core.tools.streaming.wrap_async_gen_tool continues to detect generator tools at preset-wiring time.

  • No flag required (GA). The decorator is always importable and its output always registers. ToolRegistry.register lifts the attached metadata into its own fields with no gate. Vanilla LangChain tools are unaffected.

Availability

Shipped experimental in 7.1.x behind experimental_tool_decorator; graduated to GA thereafter. The config flag survives as a deprecated no-op for backward compatibility and is ignored. Import the decorator from symfonic.core (from symfonic.core import symfonic_tool) or symfonic.core.tools.

SymfonicToolMetadata dataclass

SymfonicToolMetadata(
    scope: ScopeLiteral = "public",
    routing_mode: RoutingMode = "observe",
    visibility: Visibility = "visible",
)

Symfonic-specific tool metadata.

Attached to the decorated tool as _symfonic_metadata. The registry reads this attribute, raises SymfonicAgentError if the experimental flag is off, and otherwise lifts the metadata into its own :class:~symfonic.core.tools.registry.ToolRegistration fields (visibility="hidden" -> visible_to_agents=False).

Future Symfonic versions may add fields here; consumers should treat the object as read-only and prefer :func:get_symfonic_metadata over poking the attribute directly.

get_symfonic_metadata

get_symfonic_metadata(
    obj: Any,
) -> SymfonicToolMetadata | None

Return the :class:SymfonicToolMetadata attached to obj, or None.

Source code in src/symfonic/core/tools/decorator.py
def get_symfonic_metadata(obj: Any) -> SymfonicToolMetadata | None:
    """Return the :class:`SymfonicToolMetadata` attached to ``obj``, or None."""
    meta = getattr(obj, _METADATA_ATTR, None)
    return meta if isinstance(meta, SymfonicToolMetadata) else None

is_symfonic_tool

is_symfonic_tool(obj: Any) -> bool

Return True if obj was produced by :func:symfonic_tool.

Source code in src/symfonic/core/tools/decorator.py
def is_symfonic_tool(obj: Any) -> bool:
    """Return ``True`` if ``obj`` was produced by :func:`symfonic_tool`."""
    return isinstance(getattr(obj, _METADATA_ATTR, None), SymfonicToolMetadata)

symfonic_tool

symfonic_tool(
    *,
    scope: ScopeLiteral = "public",
    routing_mode: RoutingMode = "observe",
    visibility: Visibility = "visible",
    name: str | None = None,
    description: str | None = None,
) -> Callable[[Callable[..., Any]], StructuredTool]

Decorate a Python callable into a symfonic-compatible tool.

The resulting object is a :class:~langchain_core.tools.StructuredTool accepted by :meth:~symfonic.core.tools.registry.ToolRegistry.register without any further plumbing.

Parameters

scope: Logical visibility scope. "public" (default), "internal", or "admin". Symfonic does not yet enforce scope at the invocation tier; the field is captured today so policy code can consume it without a future signature change. routing_mode: Per-tool override for the framework's dynamic tool-routing verdict. Defaults to "observe"; "enforce" causes the router to actually narrow the manifest, "off" opts out of routing entirely. visibility: "visible" (default) keeps the tool in the agent-visible manifest; "hidden" flips the registry's visible_to_agents field to False so the tool is bound to the LangGraph runtime but never offered to the LLM in the manifest. Useful for system-level tools (telemetry emitters, feature-flag readers) that should be callable by orchestration code but never selected by the model. name: Override the tool name. Defaults to fn.__name__. description: Override the description. Defaults to the function's docstring.

Returns

Callable A decorator that, applied to a callable, returns a :class:StructuredTool with the symfonic metadata attached.

Notes
  • The decorated callable's async / sync / async-generator semantics are preserved on the underlying tool's func / coroutine slots. inspect.isasyncgenfunction continues to return True for generator tools so Phase 3's :func:~symfonic.core.tools.streaming.wrap_async_gen_tool works unchanged.
  • Registration requires no flag (GA). The resulting tool registers through ToolRegistry.register like any LangChain tool; its scope / routing_mode / visibility metadata is lifted into the ToolRegistration automatically.
Source code in src/symfonic/core/tools/decorator.py
def symfonic_tool(
    *,
    scope: ScopeLiteral = "public",
    routing_mode: RoutingMode = "observe",
    visibility: Visibility = "visible",
    name: str | None = None,
    description: str | None = None,
) -> Callable[[Callable[..., Any]], StructuredTool]:
    """Decorate a Python callable into a symfonic-compatible tool.

    The resulting object is a :class:`~langchain_core.tools.StructuredTool`
    accepted by :meth:`~symfonic.core.tools.registry.ToolRegistry.register`
    without any further plumbing.

    Parameters
    ----------
    scope:
        Logical visibility scope.  ``"public"`` (default), ``"internal"``,
        or ``"admin"``.  Symfonic does not yet enforce ``scope`` at the
        invocation tier; the field is captured today so policy code can
        consume it without a future signature change.
    routing_mode:
        Per-tool override for the framework's dynamic tool-routing
        verdict.  Defaults to ``"observe"``; ``"enforce"`` causes the
        router to actually narrow the manifest, ``"off"`` opts out of
        routing entirely.
    visibility:
        ``"visible"`` (default) keeps the tool in the agent-visible
        manifest; ``"hidden"`` flips the registry's
        ``visible_to_agents`` field to ``False`` so the tool is bound
        to the LangGraph runtime but never offered to the LLM in the
        manifest.  Useful for system-level tools (telemetry emitters,
        feature-flag readers) that should be callable by orchestration
        code but never selected by the model.
    name:
        Override the tool name.  Defaults to ``fn.__name__``.
    description:
        Override the description.  Defaults to the function's docstring.

    Returns
    -------
    Callable
        A decorator that, applied to a callable, returns a
        :class:`StructuredTool` with the symfonic metadata attached.

    Notes
    -----
    * The decorated callable's async / sync / async-generator
      semantics are preserved on the underlying tool's ``func`` /
      ``coroutine`` slots.  ``inspect.isasyncgenfunction`` continues
      to return ``True`` for generator tools so Phase 3's
      :func:`~symfonic.core.tools.streaming.wrap_async_gen_tool` works
      unchanged.
    * Registration requires no flag (GA). The resulting tool registers
      through ``ToolRegistry.register`` like any LangChain tool; its
      ``scope`` / ``routing_mode`` / ``visibility`` metadata is lifted
      into the ``ToolRegistration`` automatically.
    """

    metadata = SymfonicToolMetadata(
        scope=scope,
        routing_mode=routing_mode,
        visibility=visibility,
    )

    def decorator(fn: Callable[..., Any]) -> StructuredTool:
        if not callable(fn):
            raise TypeError(
                "@symfonic_tool can only decorate callables, got "
                f"{type(fn).__name__!r}"
            )

        tool_name = name or fn.__name__
        tool_desc = description if description is not None else (
            inspect.getdoc(fn) or f"Symfonic tool '{tool_name}'."
        )

        # parse_docstring=True so ``Args:`` blocks surface as field
        # descriptions; error_on_invalid_docstring=False keeps the
        # decorator forgiving for callers who do not follow Google-style
        # docstrings.
        builder_kwargs: dict[str, Any] = {
            "name": tool_name,
            "description": tool_desc,
            "parse_docstring": True,
            "error_on_invalid_docstring": False,
        }

        if inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn):
            # StructuredTool stores async impls on .coroutine.  Pass
            # ``func=None`` so the builder does not try to use the
            # async function as a sync one.
            built = StructuredTool.from_function(
                func=None,
                coroutine=fn,
                **builder_kwargs,
            )
        else:
            built = StructuredTool.from_function(func=fn, **builder_kwargs)

        # Attach metadata.  StructuredTool is a Pydantic model with
        # extra="forbid" on its model_config, so we use object.__setattr__
        # to bypass the validation and stash the attribute on the
        # instance (mirrors how LangChain itself attaches private state).
        object.__setattr__(built, _METADATA_ATTR, metadata)

        return built

    return decorator