ADR-0011: Function tool descriptor and Branch registry¶
- Status: Accepted
- Kind: Retrospective
- Area: actions-tools
- Date: 2026-07-09
- Relations: none
Context¶
LionAGI presents Python callables to model providers as function schemas and later executes provider-selected calls locally. Four concrete problems shaped the shipped tool layer.
P1 — A provider descriptor and a Python executable have different persistence properties. A provider needs a JSON-compatible function name, description, and parameter schema. Local execution needs a live callable plus optional pre- and postprocessors. Python callables and closures cannot be reconstructed safely from a serialized record, so treating a tool as a restorable data object would promise more than the runtime can deliver (lionagi/protocols/action/tool.py; lionagi/protocols/generic/element.py).
P2 — Python signatures, provider schemas, and invocation validation are three distinct contracts. A Pydantic request model supplies the practical typed path used by built-ins: its JSON Schema is advertised and its constructor normalizes arguments. Without one, function_to_schema() recognizes only a small primitive annotation map and advertises every signature parameter as required, including parameters with Python defaults. At invocation, non-strict validation requires only signature parameters that have no defaults, while strict validation compares the argument-key set with the schema's required set. The three views therefore diverge for some raw callables (lionagi/libs/schema/function_to_schema.py; lionagi/protocols/action/function_calling.py).
P3 — Tool visibility is conversation-local. Different branches can expose different subsets of the same reusable Python callables. A branch consequently needs a name-indexed registry that owns its provider schema list, resolves an action request, and constructs the invocation event without making tools process-global (lionagi/protocols/action/manager.py; lionagi/session/branch.py).
P4 — Remote MCP tools must look ordinary after discovery, but their transport is not ordinary. The registry currently accepts a one-entry MCP configuration, discovers remote schemas, builds a local async proxy, remembers transport policy, and reaches a process-global client pool. Direct pool use is fail-closed for command and URL transports. The two explicit config-loading helpers instead install a per-load policy with both transport classes allowed when the caller omits a policy. Discovered tools use the remote tool's unqualified name in the branch registry, so remote servers can collide with each other or with local tools (lionagi/protocols/action/manager.py; lionagi/service/connections/mcp_wrapper.py).
| Concern | Decision |
|---|---|
| Executable descriptor | D1: Tool combines a serializable provider schema with excluded live callables and is intentionally not deserializable. |
| Input contract | D2: Pydantic request_options is the typed path; raw signature derivation and strict/non-strict validation retain their shipped, non-equivalent semantics. |
| Branch visibility | D3: each Branch owns an ActionManager keyed by provider-visible function name. |
| Remote normalization | D4: the action registry normalizes MCP discoveries into ordinary Tool values while using the service-owned process-global connection pool. |
This ADR does not decide:
- Invocation, authorization, hooks, event status, or history ordering; those are the execution transaction recorded in ADR-0012.
- How built-in providers obtain branch context or behave when a branch is cloned; that construction boundary is recorded in ADR-0013.
- Provider-specific outer request envelopes. This ADR fixes the portable function object stored in
tool_schema, not each model service's surrounding payload. - Session-level governance policy. The registry resolves names; the normal branch execution path decides whether a resolved call is authorized.
Decision¶
D1 — Tool is a live callable descriptor, not a restorable executable¶
Tool is a Pydantic Element. Its shipped field contract is:
# lionagi/protocols/action/tool.py
class Tool(Element):
func_callable: Callable[..., Any] = Field(..., exclude=True)
mcp_config: dict[str, dict[str, Any]] | None = None
tool_schema: dict[str, Any] | None = None
request_options: type | None = None
preprocessor: Callable[[Any], Any] | None = Field(None, exclude=True)
preprocessor_kwargs: dict[str, Any] = Field(default_factory=dict, exclude=True)
postprocessor: Callable[[Any], Any] | None = Field(None, exclude=True)
postprocessor_kwargs: dict[str, Any] = Field(default_factory=dict, exclude=True)
strict_func_call: bool = False
@property
def function(self) -> str: ...
@property
def required_fields(self) -> set[str]: ...
@property
def minimum_acceptable_fields(self) -> set[str]: ...
@classmethod
def from_dict(cls, data: dict[str, Any]): ...
def to_dict(
self,
mode: Literal["python", "json", "db"] = "python",
**kw,
) -> dict[str, Any]: ...
The inherited Element fields are id: UUID, created_at: float, and metadata: dict; Element forbids unknown fields and permits arbitrary Python types (lionagi/protocols/generic/element.py). Tool.to_dict() delegates to that element serialization and adds a derived top-level function string. The callable, preprocessor, postprocessor, and both processor-kwargs dictionaries are excluded by their fields. tool_schema, mcp_config, request_options, and strict_func_call remain model fields; the live execution object is not recreated from them.
The public reference aliases retain the accepted input vocabulary:
FuncTool: TypeAlias = Tool | Callable[..., Any] | dict
FuncToolRef: TypeAlias = FuncTool | str
ToolRef: TypeAlias = FuncToolRef | list[FuncToolRef] | bool
Exact semantics
- Local construction: with no
mcp_config, the before-validator requires a callable with a usable name. A non-callable is rejected during model validation. - MCP construction:
mcp_configandfunc_callableare mutually exclusive.mcp_configmust be a dictionary with exactly one entry. Its key becomes the proxy callable's name; its value is passed tocreate_mcp_tool(). - Missing schema: when
tool_schema is None, the after-validator callsfunction_to_schema(func_callable, request_options=request_options)exactly once at construction. - Supplied schema: a caller-provided schema is retained without a second schema normalization pass. Later properties assume the OpenAI-style
tool_schema["function"]shape. - Function identity:
Tool.functionistool_schema["function"]["name"]; registry identity follows the schema name, not necessarilyfunc_callable.__name__when a custom schema is supplied. - Serialization:
to_dict()retains descriptor data and addsfunction; excluded callables and processors do not appear. Serialization is a record of the declaration, not an executable snapshot.mcp_configis not redacted, so configured environment or transport values remain in the record and must be handled as configuration-sensitive data. - Deserialization:
Tool.from_dict(...)always raisesNotImplementedError. Arbitrary data cannot recreate the callable or its closure. - Mutation:
Toolis not frozen. Factory code can attach processors after construction, and a registry holds the same object by reference.
Why this way
One object keeps the provider declaration adjacent to the exact callable it describes, which makes local registration and schema lookup small. Excluding live objects avoids pretending closures are portable. The cost is that serialized tool data is diagnostic or presentational only; restoring executable behavior always requires code-driven construction.
D2 — Pydantic request models are the typed contract; raw derivation is limited¶
The schema adapter is Python-native and emits an OpenAI-format function object:
# lionagi/libs/schema/function_to_schema.py
class FunctionSchema(SchemaModel):
name: str
description: str | None = None
parameters: dict[str, Any] | None = Field(
None,
validation_alias="request_options",
)
strict: bool | None = None
def function_to_schema(
f_,
style: Literal["google", "rest"] = "google",
*,
request_options: dict[str, Any] | None = None,
strict: bool = None,
func_description: str = None,
parametert_description: dict[str, str] = None,
return_obj: bool = False,
) -> dict: ...
For the request-model path, the FunctionSchema.parameters validator converts a Pydantic model type with model_json_schema(). Built-in tools pass model classes such as ReaderRequest and BashRequest, even though the helper's annotation says dict[str, Any] | None; runtime validation accepts the model type.
The resulting provider payload has this shape:
{
"type": "function",
"function": {
"name": "callable_name",
"description": "docstring-derived or supplied text",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
}
When strict is truthy, function_to_schema() also adds function.strict. That provider-schema flag is distinct from Tool.strict_func_call, which controls LionAGI's local key-set check.
Invocation normalization occurs when a FunctionCalling is constructed:
# lionagi/protocols/action/function_calling.py
class FunctionCalling(Event):
func_tool: Tool = Field(..., exclude=True)
arguments: dict[str, Any] | BaseModel
# before validation: BaseModel -> model_dump(exclude_unset=True)
# after validation:
# request_options(**arguments) -> model_dump(exclude_unset=True)
# strict=True -> keys(arguments) == tool.required_fields
# strict=False -> minimum_acceptable_fields <= keys(arguments)
Exact semantics
- Pydantic path:
request_options(**arguments)performs the request model's coercion, constraints, defaults, and extra-field policy. The normalized arguments are dumped withexclude_unset=True, so a model default not supplied by the caller is not automatically forwarded to the Python function. - Empty request-options value:
function_to_schema()testsrequest_optionsby truthiness.Noneand an empty dictionary both select raw signature derivation; a Pydantic model type selects the typed path. - Raw schema types: without
request_options, onlystr,int,float,list,tuple,bool, anddicthave an explicit Python-to-JSON map. Bothintandfloatmap to JSON Schemanumber. An unannotated parameter defaults tostring; other annotations contribute their__name__as the schema type. - Raw required set: every signature parameter is appended to provider
required, whether or not the Python signature gives it a default. Positional-only,*args, and**kwargsare not represented specially. - Non-strict local check: the minimum set is computed with
inspect.signature()from parameters having no default, then parameters literally namedkw,kwargs, orargsare removed. Empty arguments are accepted when all ordinary parameters have Python defaults. Extra keys pass this set check and can fail later when expanded asfunc_callable(**arguments). - Signature-inspection failure: raw schema construction propagates an
inspect.signature()failure unless the caller suppliestool_schema. During later non-strict validation,minimum_acceptable_fieldscatches inspection failure and returns an empty set, so the key-presence check accepts any argument dictionary and leaves failure to callable invocation. - Strict local check: the normalized argument keys must equal the schema's
requiredset exactly. Missing required keys and supplied optional keys both fail. A schema without arequiredkey can raiseKeyErrorthroughTool.required_fields. - Validation failure: request-model construction or either key-set check raises before the callable starts. The higher execution layer decides whether that exception becomes a response or propagates (ADR-0012).
- Callable invocation: all arguments are eventually expanded as keyword arguments. Positional-only parameters and
*argstherefore require an explicit adapter even if schema generation succeeds. A raw**kwargscallable can execute in non-strict mode, but derivation advertises one requiredkwargsstring property rather than the open keyword object the callable accepts; provider-correct use therefore needs an explicit schema or request model.
Why this way
Pydantic provides one artifact for provider schema, coercion, and field constraints, so it is the reliable path for maintained tools. Raw derivation keeps simple keyword callables convenient. The shipped raw behavior was retained because tests and existing provider payloads assert that even defaulted parameters are advertised as required; a correction is a compatibility change, not a formatting cleanup.
D3 — ActionManager is the branch-local name registry and resolver¶
The manager's public contract is:
# lionagi/protocols/action/manager.py
class ActionManager(Manager):
def __init__(self, *args: FuncTool, **kwargs) -> None: ...
def __contains__(self, tool: FuncToolRef) -> bool: ...
def register_tool(self, tool: FuncTool, update: bool = False) -> None: ...
def register_tools(
self,
tools: list[FuncTool] | FuncTool,
update: bool = False,
) -> None: ...
def match_tool(
self,
action_request: ActionRequest | BaseModel | dict,
) -> FunctionCalling: ...
async def invoke(
self,
func_call: BaseModel | ActionRequest,
) -> FunctionCalling: ...
@property
def schema_list(self) -> list[dict[str, Any]]: ...
def get_tool_schema(
self,
tools: ToolRef = False,
auto_register: bool = True,
update: bool = False,
) -> dict: ...
Its concrete store is registry: dict[str, Tool]. Every Branch constructs a new empty manager, registers its constructor-supplied tools into that manager, and exposes the same object as branch.acts; branch.tools exposes the registry dictionary (lionagi/session/branch.py).
Exact semantics
- Constructor input: positional arguments and keyword values are flattened with
to_list(..., dropna=True, flatten=True)and registered withupdate=True. - Callable registration: a raw callable becomes
Tool(func_callable=callable). - Descriptor registration: a
Toolis stored as-is undertool.function. - MCP dictionary registration: a dictionary becomes
Tool(mcp_config=dict)and must therefore contain exactly one entry. - Unsupported registration: any other value raises
TypeError. - Duplicate local name: for a
Tool, callable, or string membership check, an existing name raisesValueErrorunlessupdate=True; with update, the registry entry is replaced. - Dictionary duplicate edge:
ActionManager.__contains__has no dictionary arm. A second one-entry MCP dictionary with the same derived name therefore bypasses the pre-conversion duplicate check and overwrites the entry even whenupdate=False. - Request matching: dictionaries must contain
functionandargumentskeys.ActionRequestand other Pydantic models are read through attributes. Unsupported envelope types raiseTypeError; an absent registry name raisesValueError. - Resolution result: a successful match returns an uninvoked
FunctionCalling(func_tool=tool, arguments=args). - Manager invocation:
invoke()matches, awaitsFunctionCalling.invoke(), and returns the event regardless of its terminal status. It does not raise an ordinary callable exception captured byEvent.invoke(). - Schema selection:
get_tool_schema(True)returns{"tools": schema_list};Falsereturns the empty list[]. A specific registered name or descriptor returns{"tools": schema}. A list returns{"tools": [schema, ...]}. A one-item list or tuple is collapsed first. - Empty selection:
get_tool_schema([])is distinct fromFalseand returns{"tools": []}. Constructing an empty manager likewise produces an empty registry and emptyschema_list. - Auto-registration: requesting the schema for an unregistered raw callable registers it when
auto_register=True; otherwise it raises. A dictionary supplied to schema selection is returned directly and is not registered. - Schema references:
schema_listand specific lookups return the dictionaries held by eachTool, not defensive copies. Mutating a returned schema mutates what that manager will advertise on later calls. - Ordering:
schema_listfollows dictionary insertion order. Replacing an existing key retains Python dictionary key position. - Branch locality: registering or replacing a tool affects that manager only. The underlying
Toolobject can still be shared by reference across managers.
Why this way
The registry keeps branch-visible capability selection separate from callable definition. Function name is the provider's lookup key, so the same key naturally resolves the model request and provider schema. The design deliberately keeps ActionManager small, but MCP loading in D4 stretches it beyond pure registry duties.
D4 — MCP discoveries are normalized into ordinary tools in the registry layer¶
The MCP-facing manager signatures are:
async def ActionManager.register_mcp_server(
self,
server_config: dict[str, Any],
tool_names: list[str] | None = None,
request_options: dict[str, type] | None = None,
update: bool = False,
security: MCPSecurityConfig | None = None,
) -> list[str]: ...
async def ActionManager.load_mcp_config(
self,
config_path: str,
server_names: list[str] | None = None,
update: bool = False,
mcp_security: MCPSecurityConfig | None = None,
) -> dict[str, list[str]]: ...
async def load_mcp_tools(
config_path: str | None = None,
server_names: list[str] | None = None,
request_options_map: dict[str, dict[str, type]] | None = None,
update: bool = False,
mcp_security: MCPSecurityConfig | None = None,
) -> list[Tool]: ...
The service-layer security dataclass and pool state are:
# lionagi/service/connections/mcp_wrapper.py
@dataclass(frozen=True)
class MCPSecurityConfig:
allow_commands: bool = False
command_allowlist: frozenset[str] | None = None
allow_urls: bool = False
url_allowlist: frozenset[str] | None = None
env_denylist_patterns: frozenset[str] = <sensitive-name defaults>
filter_sensitive_env: bool = True
max_connections_per_server: int = 5
class MCPConnectionPool:
_clients: dict[str, Any] = {}
_configs: dict[str, dict] = {}
_security: MCPSecurityConfig | None = None
_server_security: dict[str, MCPSecurityConfig] = {}
@classmethod
async def get_client(
cls,
server_config: dict[str, Any],
security: MCPSecurityConfig | None = None,
) -> Any: ...
@classmethod
async def cleanup(cls): ...
Auto-discovery projects the remote schema into the same descriptor form as a local tool:
{
"type": "function",
"function": {
"name": "remote_tool_name",
"description": "remote description or null",
"parameters": { "...": "remote inputSchema copied verbatim" }
}
}
Exact semantics
- Explicit tool names: the manager creates a proxy without calling
list_tools()._original_tool_nameis placed in the config. If no request model or schema is supplied, schema generation sees the proxy's**kwargssignature. This loop has no per-tool exception isolation: the first construction or duplicate-registration error aborts the method and later explicit names are not attempted. - Request-model lookup: before either explicit registration or discovery, the manager mutates each
request_optionskey that does not start with"<server_name>_"by adding that prefix. It later looks up the model by the actualtool_name/tool.name, without applying the same prefix. An ordinary unqualified remote name therefore misses an unqualified caller mapping after it is renamed. A mapping attaches only when the remote name already equals the retained or rewritten key; the manager performs no alias reconciliation. - Discovery: with no
tool_names, the manager obtains a pooled client, awaitsclient.list_tools(), and creates oneToolper returned item. A dictionaryinputSchemais copied verbatim intofunction.parameters. Client acquisition orlist_tools()failure occurs before the per-tool loop and propagates fromregister_mcp_server(). - Discovery degradation: an exception while reading one remote schema logs a warning and falls back to proxy signature derivation. An absent,
None, or non-dictionaryinputSchemafalls back without that warning. Failure to construct or register one tool logs a warning and continues discovering siblings. - Names: discovered descriptors are registered under
tool.name, unqualified by server._original_tool_namepreserves the remote call target but does not prevent a registry collision. - Proxy call: the proxy removes underscore-prefixed metadata before acquiring the client, then calls
client.call_tool(actual_tool_name, kwargs). - Proxy response: a single text content item is unwrapped to its text; other
result.contentvalues are returned as content; a one-item list containing a text dictionary is also unwrapped; all other results pass through. - Direct pool trust: command and URL transports are denied by default. Commands require
allow_commands=True; an allowlist, when present, accepts bare command names only. URLs requireallow_urls=True, anhttpsorwssscheme, and an optional host allowlist. - Loader trust:
load_mcp_config()and top-levelload_mcp_tools()replace an omitted policy withMCPSecurityConfig(allow_commands=True, allow_urls=True). That per-load policy is threaded to registration without mutating the process-global default. A transportPermissionErroris logged and re-raised; other server failures become an empty registered-name list or a warning. - Loader input failure: config-file existence, JSON shape, and parsing errors occur before the per-server recovery loop and propagate. Top-level
load_mcp_tools()also raisesValueErrorwhen neitherserver_namesnor a config path supplies a server set. - Policy reuse: an explicit policy is remembered under a content-derived server key, allowing the proxy's later
get_client()call to recover the same authorization. - Pool identity: named configs use
server:<name>and therefore reuse a connected client by server name. Inline configs use the command plus the Python dictionary's object identity, so reuse occurs only when the same dictionary object is passed again; the MCP proxy creates a fresh metadata-stripped dictionary for each call and has no stable inline reuse key. A disconnected cached client is dropped, andcleanup()attempts every client exit before clearing the map. - Environment: command transport starts with the parent environment plus configured values, removes variables whose names contain sensitive patterns by default, and adds quiet logging defaults unless debug mode is active.
- Declared connection cap:
max_connections_per_serverdefaults to5, but the current pool stores one client per cache key and never reads that field. The value is inherited configuration with no enforced budget or recorded rationale in this path.
Why this way
Normalizing remote calls to Tool lets every downstream resolver and provider-schema path ignore transport. Lazy imports keep the foundational action modules importable without FastMCP installed. The tradeoff is architectural: the protocol registry knows about service configuration, transport trust, discovery, and global pool lifecycle, and unqualified names make collision handling a branch-registration concern.
Consequences¶
- Local and remote callables share one provider descriptor and one branch resolver.
- Excluding executable objects does not make a serialized descriptor public-safe:
mcp_configremains visible and may carry transport or environment configuration. - Branches can expose different tool sets while reusing descriptor objects and callables.
- Pydantic-backed tools get provider schema, normalization, constraints, and keyword payloads from one request model.
- Raw callable registration is concise, but provider-required keys can disagree with Python defaults and non-strict runtime validation.
- Custom schemas that omit
requiredremain usable until strict validation asksTool.required_fields; that access can fail withKeyError. - Registry lookup is deterministic by function name, but MCP dictionary registration has a duplicate-check hole and remote unqualified names can collide.
- MCP per-tool request-model selection is key-fragile: the manager can rename a caller's mapping key and then miss it, silently falling back to the proxy's raw
**kwargsschema rather than the intended typed model. - Reversing D1 requires a new code-address or factory identity contract; serialized callables cannot be made restorable by changing
from_dict()alone. - Reversing D3 requires changing the provider-selection boundary because Branch,
operate(), and model request construction consume manager-local schema sets. - Contributors adding a maintained tool must decide whether it is a raw convenience callable or publish a Pydantic request model; only the latter aligns the advertised and normalized contracts reliably.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Version and narrow raw-callable schema derivation so Python defaults remain optional, positional-only and *args signatures require an explicit adapter, open **kwargs callables require an explicit schema, schemas without required are accepted, and tests prove provider schema and runtime validation agree. | M | (filled at issue-open time) |
| 2 | Move MCP configuration, discovery, namespacing, and pool lifecycle into a service-owned factory that returns ready Tool descriptors; acceptance requires protocols.action to have no service-layer import, remote identities to be collision-free, and per-tool request models to resolve by that canonical identity without key mutation or silent fallback. | M | (filled at issue-open time) |
| 3 | Require the MCP-loading caller to make an explicit transport-trust decision; acceptance requires omitted policy to preserve the wrapper's fail-closed command and URL defaults and an explicit trusted-config mode to be observable. | S | (filled at issue-open time) |
Alternatives considered¶
A. Serialize and restore arbitrary callables¶
This would have made Tool a conventional persisted Element and allowed branch state to reconstruct tools from data alone. It lost because closures, bound methods, imported code versions, and security-sensitive execution context have no stable data-only representation. The shipped from_dict() rejection makes the limitation explicit.
B. Split declaration and executable into unrelated objects¶
A pure FunctionSchema registry plus a separate callable map would make the persistence boundary cleaner. It lost for the local-first implementation because every registration, replacement, and invocation would need to keep two name-indexed stores synchronized. Tool instead keeps the schema adjacent to the callable while excluding live fields from serialization.
C. Require Pydantic request models for every tool¶
This would eliminate the raw schema/default mismatch and give every tool a rich validation contract. It lost because registration of small ordinary callables is a core convenience and existing tests rely on it. The system adopted Pydantic as the maintained built-in path without removing the raw adapter.
D. Treat Python signature inspection as the only contract¶
This would avoid request-model duplication and derive provider schema and validation from a callable alone. It lost because Python annotations do not express the same constraints, descriptions, discriminated choices, and JSON Schema details as Pydantic models, and positional or variadic signatures do not map cleanly to provider keyword calls.
E. Use a process-global tool registry¶
A global catalog would reduce per-branch setup and avoid copying registry entries. It lost because model-visible capability selection is branch-specific; a shared global map would make isolation and per-branch replacement indirect policy rather than explicit state.
F. Qualify every MCP name as server.tool¶
Qualification would prevent remote/local collisions and preserve source identity in the registry. It lost in the shipped path because discovery copied server-provided schemas and names directly into the existing function interface. The collision cost is now recorded as a delta rather than hidden.
G. Keep all MCP construction in the connection service¶
A service factory returning ready Tool descriptors would preserve the dependency direction and centralize transport policy and pool lifecycle. It was not the organic shape: MCP support was added at the registry's existing normalization point, using lazy imports to soften the dependency. Delta 2 retains the service-factory design for a future correction.
H. Preserve fail-closed defaults in explicit config loaders¶
This would make an omitted policy deny every command and URL just as direct pool use does. It lost because selecting and loading a config file was treated as an implicit trust act. That convenience creates an important semantic split, so Delta 3 requires a named and observable trust choice rather than leaving the implication unstated.
Notes¶
Correcting the all-parameters-required raw schema is a compatibility change because tests and provider payloads preserve that behavior. It cannot be shipped as an unversioned cleanup.