Skip to content

ADR-0013: Built-in tool provider and Branch binding

  • Status: Accepted
  • Kind: Retrospective
  • Area: actions-tools
  • Date: 2026-07-09
  • Relations: extends ADR-0011

Context

ADR-0011 defines the executable Tool descriptor and branch registry. Built-in tools still need a construction boundary: some adapters are reusable objects with no branch dependency, while others close over a branch, exchange, history, or mutable per-branch state. Five concrete problems shaped the shipped provider layer.

P1 — Maintained built-ins need a richer input contract than raw signature inspection. The file reader/editor, shell, search, diagnostics, navigation, and syntax-search adapters pair Pydantic request/response models with async handlers. Their request models become provider schemas and their handlers enforce workspace containment, timeouts, caps, and typed failure values (lionagi/tools/file/reader.py; lionagi/tools/file/editor.py; lionagi/tools/code/).

P2 — The common marker describes only the stateless case. LionTool requires a single synchronous to_tool() -> Tool. Branch.register_tools() recognizes that marker, instantiates marker classes without arguments, calls to_tool(), and registers the result. The interface has no construction context, multi-tool output, lifecycle, or clone contract (lionagi/tools/base.py; lionagi/session/branch.py).

P3 — Three providers are factories despite inheriting from the marker. ContextTool needs branch messages and progression. LionMessenger needs a branch, exchange, and roster. CodingToolkit builds multiple tools around branch history and mutable file state. Each exposes bind(...) and deliberately raises from to_tool(); generic branch registration therefore cannot consume the declared hierarchy uniformly (lionagi/tools/context/context.py; lionagi/tools/communication/messenger.py; lionagi/tools/coding.py).

P4 — The coding toolkit adds stateful value but duplicates basic adapters. Its read-before-edit guard, context-aware invalidation, nudge integration, sandbox session, and subagent construction are branch-scoped. At the same time it implements reader, editor, shell, search, diagnostics, navigation, and syntax search again, using shared request models and selected helpers but returning independently assembled dictionaries. Defaults and failure shapes can therefore drift from standalone adapters (lionagi/tools/coding.py).

P5 — Branch cloning copies descriptors, not provider intent. Branch.clone() creates a new manager and passes it the original registry's Tool objects. The descriptors and callable closures are reused without a scope or rebinding check. A tool created by ContextTool.bind(), LionMessenger.bind(), or CodingToolkit.bind() can remain closed over the source branch after registration on the clone (lionagi/session/branch.py).

Concern Decision
Provider marker D1: LionTool is a minimal one-tool adapter marker, and generic branch registration calls to_tool().
Stateless built-ins D2: maintained standalone adapters cache one Pydantic-backed Tool and return typed result dictionaries.
Context-bound factories D3: context and messenger providers expose explicit bind(...) methods and reject generic to_tool().
Stateful toolkit D4: CodingToolkit.bind(branch) returns a configurable list of branch-bound tools with local state and parallel basic implementations.
Clone behavior D5: branch cloning copies registered Tool descriptors by reference and performs no provider rebinding.

This ADR does not decide:

  • Function-schema derivation, registry duplicate handling, or remote MCP normalization; ADR-0011 owns those contracts.
  • Authorization, event status, hooks, and action-message ordering; ADR-0012 owns the execution transaction.
  • The internal implementation of sandbox or subagent orchestration. This ADR records only that those optional tool factories are part of the coding provider's output.
  • Resource and prompt graph behavior. Their current co-location with LionTool is recorded because it affects module cohesion, not because this ADR governs them.

Decision

D1 — LionTool is a minimal single-tool adapter marker

The complete provider interface is:

# lionagi/tools/base.py
class LionTool(ABC):
    is_lion_system_tool: bool = True
    system_tool_name: str

    @abstractmethod
    def to_tool(self) -> Tool:
        pass

The same module also defines graph-resource types unrelated to registration:

lionagi/tools/base.py
├── LionTool
├── ResourceCategory
├── ResourceMeta(BaseModel)
├── Resource(Node)
└── Prompt(Resource)

Branch registration is:

# lionagi/session/branch.py
def _register_tool(
    self,
    tools: FuncTool | LionTool,
    update: bool = False,
): ...

def register_tools(
    self,
    tools: FuncTool | list[FuncTool] | LionTool,
    update: bool = False,
): ...

Exact semantics

  • A class object that is a LionTool subclass is instantiated with no arguments.
  • A LionTool instance is converted by calling to_tool() synchronously.
  • The result is handed to ActionManager.register_tool(), so it must be one registry-acceptable Tool, raw callable, or one-entry MCP dictionary.
  • A top-level list is iterated and each element follows the same path. The marker contract itself cannot return multiple tools; if to_tool() returns a list, manager registration rejects it.
  • update is forwarded to each registry insertion.
  • A provider whose constructor requires context cannot be passed as a class to generic registration; zero-argument instantiation fails before to_tool().
  • A branch-bound provider instance reaches to_tool() and receives that provider's deliberate NotImplementedError.
  • is_lion_system_tool and system_tool_name are conventions used by built-ins; the abstract base enforces neither value nor uniqueness.

Why this way

The marker made the common stateless case concise: a provider object can own setup and cache while the branch receives an ordinary Tool. It deliberately avoided a larger factory framework. Once providers required branch context or multiple outputs, the single-method shape stopped satisfying substitutability but remained the inherited base.

D2 — Standalone built-ins cache one Pydantic-backed tool

The stateless provider modules are:

lionagi/tools/
├── _subprocess.py
├── file/
│   ├── reader.py       ReaderTool
│   └── editor.py       EditorTool
└── code/
    ├── bash.py         BashTool
    ├── search.py       SearchTool
    ├── check.py        CodeCheckTool
    ├── nav.py          NavTool
    └── ast_search.py   AstSearchTool

Every class owns _tool, initializes it to None, and constructs the descriptor only on the first to_tool() call. Subsequent calls return the same Tool object. Each wrapper is async, reconstructs its request model from **kwargs, awaits handle_request(), and returns response.model_dump().

The provider-visible request contracts are:

# reader.py
class ReaderRequest(BaseModel):
    action: ReaderAction                   # read | open | list_dir
    path: str
    offset: int | None = None
    limit: int | None = None
    recursive: bool | None = None
    file_types: list[str] | None = None

class ReaderResponse(BaseModel):
    success: bool
    content: str | None = None
    error: str | None = None

# editor.py
class EditorRequest(BaseModel):
    action: EditorAction                   # write | edit
    file_path: str
    content: str | None = None
    old_string: str | None = None
    new_string: str | None = None
    replace_all: bool = False

class EditorResponse(BaseModel):
    success: bool
    content: str | None = None
    error: str | None = None

# bash.py
class BashRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")
    command: str
    timeout: int | None = None              # milliseconds
    cwd: str | None = None

class BashResponse(BaseModel):
    stdout: str = ""
    stderr: str = ""
    return_code: int
    timed_out: bool = False
# search.py
class SearchRequest(BaseModel):
    action: SearchAction                    # grep | find
    pattern: str
    path: str | None = "."
    include: str | None = None
    max_results: int | None = 50

class SearchResponse(BaseModel):
    success: bool
    content: str | None = None
    count: int = 0
    error: str | None = None

# check.py
class CodeCheckRequest(BaseModel):
    paths: list[str]
    tool: Literal["ruff"] = "ruff"
    max_diagnostics: int = Field(50, ge=1, le=500)

class CodeCheckResponse(BaseModel):
    status: Literal["ok", "diagnostics", "unavailable", "error"]
    diagnostics: list[CodeDiagnostic] = Field(default_factory=list)
    summary: str = ""
    tool: str = "ruff"

# nav.py
class NavRequest(BaseModel):
    action: str                             # outline | find_definition | find_references
    path: str
    symbol: str | None = None

class NavResponse(BaseModel):
    success: bool
    items: list[NavItem] = Field(default_factory=list)
    error: str | None = None
# ast_search.py
class AstSearchRequest(BaseModel):
    pattern: str
    path: str = "."
    lang: Literal[
        "python", "rust", "typescript", "javascript", "go", "c", "cpp"
    ] = "python"
    max_results: int = Field(50, ge=1, le=500)

class AstSearchResponse(BaseModel):
    status: Literal["ok", "matches", "unavailable", "error"]
    matches: list[AstSearchMatch] = Field(default_factory=list)
    summary: str = ""
    total: int = 0

The response item shapes used by diagnostics and navigation are:

class CodeDiagnostic(BaseModel):
    file: str
    line: int
    col: int
    end_line: int | None = None
    end_col: int | None = None
    severity: Literal["error", "warning", "info"] = "warning"
    code: str = ""
    message: str
    source: str = "ruff"
    fixable: bool = False

class NavItem(BaseModel):
    kind: str
    name: str
    line: int
    col: int
    signature: str | None = None

class AstSearchMatch(BaseModel):
    file: str
    line: int
    col: int
    text: str

Exact shared semantics

  • Reader, editor, diagnostics, navigation, and syntax-search constructors resolve workspace_root or Path.cwd() once to an absolute path. Search freezes an explicitly supplied root once; workspace_root=None leaves standalone search uncontained and resolves paths against the current process.
  • The shared path resolver rejects a direct symlink, paths escaping the workspace, and protected final names such as .env, .netrc, and private-key filenames (lionagi/libs/path_safety.py).
  • Expected operational failures are returned inside typed response objects rather than raised. Pydantic request validation can still raise before the handler returns.
  • Known blocking file, subprocess, and AST work is sent through run_sync() by the async handlers, avoiding the generic inline-sync behavior in ADR-0012 D6.

Reader semantics and budgets

  • read rejects missing/non-files, symlinks, and files whose first 8192 bytes contain a NUL. The prefix bounds binary sniffing before the full read; the exact 8192-byte value is inherited with no recorded tuning rationale. It decodes UTF-8 with replacement and returns <one-based-line-number>\t<text> rows.
  • Negative offsets clamp to zero. Missing, zero, or negative limit becomes 2000 lines. The 2000-line window is inherited; no source comment records why that exact value was selected.
  • list_dir uses recursive and extension filters, truncating the file list to 1,000. The cap bounds returned context; no recorded rationale explains the exact 1,000 value.
  • open accepts local .pdf, .pptx, .docx, .html, and .htm files up to 50 MiB. The size protects document conversion from an unbounded input; the exact 50 MiB choice has no recorded rationale.
  • Remote open accepts only https URLs whose host is explicitly configured and still resolves to a public, SSRF-safe address. The coding toolkit supplies an empty host set, so its bound reader rejects all remote URLs.
  • Converted text is cached by the original path and read through the same numbered-line window. Cache expiry is hard-coded at 300 seconds (documented as five minutes). ReaderTool(cache_ttl=...) stores the caller value, but _read_cached() and _evict_expired() read the module constant, so the constructor value does not alter expiry.
  • Missing docling returns an actionable success=False response; conversion exceptions are returned as errors.

Editor semantics

  • write requires non-None content, creates parent directories, and overwrites the target. The standalone adapter's instruction to read first is advisory; no standalone read-state check exists.
  • edit requires both strings, rejects a missing file, and requires an exact match. Zero matches returns whitespace/line-prefix hints. Multiple matches fail unless replace_all=True.
  • The edit write opens the already resolved file with O_NOFOLLOW when the platform supports it. Success returns a bounded nearby snippet; the 40-character context on each side and 200-character fallback are inherited presentation values with no recorded rationale.

Shell and search semantics and budgets

  • Bash uses shlex.split(), shell=False, and rejects ;, &&, ||, pipes, redirects, newlines, backticks, and $() before process launch.
  • Bash timeout defaults to 30,000 ms when omitted and clamps supplied values into 1–300,000 ms; an explicit zero or negative value therefore becomes 1 ms. The default targets ordinary commands and the five-minute maximum bounds long-running calls. No source note records evidence for the exact values.
  • The subprocess starts a new process group and terminates that group on timeout. It drains both streams and stores at most 100,000 bytes per stream while continuing to drain to avoid child-process deadlock. After a timeout, each drain thread gets a one-second join window. The output cap protects model context; the exact byte and join values are inherited without recorded tuning evidence.
  • Grep and find each use fixed argv with shell=False and a fixed 30-second timeout. Grep exit codes 0 and 1 are success (1 is an empty match); every other grep code is an error. Find reports a nonzero code as an error only when stderr is non-empty; a nonzero code with empty stderr falls through to a successful result.
  • Standalone search defaults to 50 results for both actions because the model field and handler both use 50. Its field description says find defaults to 100, but that value is not the standalone runtime behavior.
  • SearchRequest.max_results has no numeric constraint. None or zero becomes 50; a negative value remains truthy and is passed to Python's lines[:max_results] slice, dropping that many trailing rows rather than raising a validation error.

Diagnostics and navigation semantics and budgets

  • Code check supports only the ruff binary. Missing ruff is unavailable, findings are diagnostics, and no findings are ok. Ruff gets 30 seconds. An exit code of 2 or greater becomes error when stdout is empty; non-empty stdout is still parsed and classified by its contents. Malformed non-empty JSON becomes error.
  • Diagnostics are sliced to max_diagnostics, default 50 and validated within 1–500. The bound limits response noise; no source record justifies the exact ceiling.
  • An unexpected Ruff or ast-grep exit includes at most 300 characters of stderr in its summary. This is an inherited diagnostic-presentation cap with no recorded rationale.
  • Navigation uses the Python standard-library AST for one contained file. Omitting the symbol argument fails the two symbol actions; a supplied but absent symbol returns a successful empty item list. Syntax/read errors become success=False.
  • outline reports class, function, and method nodes. find_definition matches classes, sync/async functions, and simple name assignments/annotated assignments; it is not an import or attribute resolver. find_references reports every matching ast.Name regardless of load/store context and every ast.Attribute whose final attribute matches, so its results are syntactic occurrences rather than semantic references.
  • AST search locates sg or ast-grep, returns unavailable if absent, uses a 30-second subprocess timeout, and accepts 1–500 results with default 50. It accepts JSON arrays or NDJSON and converts zero-based tool lines to one-based response lines.

Why this way

Pydantic-backed adapters make model schema and runtime validation share a source, while typed response dictionaries keep expected operational failure visible to a reasoning loop. Per-provider caching avoids rebuilding descriptors. The repeated wrapper pattern is straightforward, but it leaves operational constants distributed across modules and does not by itself solve branch-bound construction.

D3 — Context and messenger are explicit branch-bound factories

The context factory contract is:

# lionagi/tools/context/context.py
class ContextAction(str, Enum):
    status = "status"
    get_messages = "get_messages"
    evict = "evict"
    evict_action_results = "evict_action_results"
    restore = "restore"
    compact = "compact"

class ContextRequest(BaseModel):
    action: ContextAction
    start: int | None = None
    end: int | None = None
    keep_last: int | None = None
    summary: str | None = None
    mode: str | None = None
    scope: str | None = None
    auto: bool = False

class ContextTool(LionTool):
    def bind(self, branch: Branch) -> Tool: ...
    def to_tool(self) -> Tool: ...  # raises NotImplementedError

bind() captures branch.msgs and returns one tool whose callable mutates or reports the branch's active progression.

Context exact semantics

  • The active progression is copied lazily into branch.metadata["current_progression"] on the first mutating action; the full message pile and progression remain intact.
  • status reports active/total/evicted counts, role counts, and an estimated token sum.
  • get_messages defaults to active scope. Full scope marks every preview active or evicted. Previews retain at most 120 characters; the cap is a presentation budget with no recorded rationale for the exact value.
  • evict clamps its start to at least index 1, preserving the system message, and removes [start:end) from the active progression only.
  • evict_action_results keeps the most recent five action responses by default. Five is a context-retention heuristic stated in the request schema; no measurement is recorded.
  • restore indexes the full record and reinserts messages in chronological order.
  • get_messages and restore clamp ranges and return successful empty results when the clamped range contains nothing. evict and compact instead return success=False when their clamped start is not before the end. evict_action_results treats zero or negative keep_last as keeping none.
  • compact defaults to tool_io, collapsing only action request/response messages; mode="all" collapses the whole selected span except index 0. It inserts one assistant summary message and excludes originals only from the active view.
  • mode and scope are unconstrained strings: only the exact "tool_io" value selects tool-only compaction, so any other mode string follows the all-message branch; only exact scope="all" selects the full record, and every other scope uses the active view.
  • Automatic compaction concatenates at most 6,000 source characters before one direct model call and returns a normal failure dictionary if the call fails or yields no text. The cap bounds the auxiliary prompt; its exact value has no recorded rationale.

The messenger factory contract is:

# lionagi/tools/communication/messenger.py
class MessengerRequest(BaseModel):
    action: MessengerAction                 # send | done | finished | wakeup
    to: str | list[str] | None = None
    content: str | None = None

class LionMessenger(LionTool):
    def __init__(self, exchange: Exchange): ...
    def on(self, event: str, callback): ...
    def bind(
        self,
        branch: Branch,
        roster: dict[str, UUID],
        sender_name: str | None = None,
        channel: str = "team",
    ) -> Tool: ...
    def to_tool(self) -> Tool: ...           # raises NotImplementedError

Messenger exact semantics

  • The bound callable captures the branch id as sender, the roster, channel, exchange, and callback dispatcher. Default sender display is the first eight characters of the branch id; eight is an inherited display convention with no recorded collision policy.
  • send requires recipients and content, sends once per known roster name, includes each returned message in the branch pile if absent, and returns semicolon-joined text. Unknown recipients are per-target text results rather than exceptions.
  • done and finished fire optional synchronous callbacks and return status text.
  • wakeup uses only the first item when to is a list, sends content prefixed with [WAKEUP], tracks the message, and fires the callback.
  • Unknown actions and missing fields are returned as strings beginning with an error or unknown-action message; there is no typed messenger response model.
  • Exceptions from Exchange.send() or a registered synchronous callback are not converted to those strings. They leave the messenger callable and, on the normal branch path, become a failed FunctionCalling event under ADR-0012 D1.
  • ContextTool.to_tool() and LionMessenger.to_tool() always raise with instructions to call bind(...).

Why this way

Explicit binding makes captured context visible at construction and keeps ordinary Tool execution unaware of branch internals. It also allowed context and communication features to ship without changing the LionTool base. The cost is that instances claim an abstract method they intentionally cannot fulfill, and generic registration fails at runtime rather than expressing required context in the type.

D4 — CodingToolkit.bind() returns a branch-scoped tool set

The toolkit's selection and construction contract is:

# lionagi/tools/coding.py
ALL_CODING_TOOLS = (
    "reader", "editor", "bash", "search", "code_check",
    "code_nav", "ast_search", "context", "sandbox", "subagent",
)

DEFAULT_CODING_TOOLS = (
    "reader", "editor", "bash", "search", "code_check",
    "code_nav", "ast_search", "context",
)

class CodingToolkit(LionTool):
    def __init__(
        self,
        notify: bool = True,
        notify_threshold: float = 0.7,
        notify_max_tokens: int = 200_000,
        workspace_root: str | Path | None = None,
        tools: Sequence[str] | None = None,
        nudge_engine: NudgeEngine | None = None,
        nudge_rules: Sequence[NudgeRule] | None = None,
    ): ...

    def security_pre(self, tool_name: str, handler: Callable) -> CodingToolkit: ...
    def pre(self, tool_name: str, handler: Callable) -> CodingToolkit: ...
    def post(self, tool_name: str, handler: Callable) -> CodingToolkit: ...
    def on_error(self, tool_name: str, handler: Callable) -> CodingToolkit: ...
    def bind(self, branch: Branch) -> list[Tool]: ...
    def to_tool(self) -> Tool: ...            # raises NotImplementedError

Sandbox and subagent add their own request models:

class SandboxRequest(BaseModel):
    action: SandboxAction                    # create | diff | commit | merge | discard
    message: str | None = None

class SubagentRequest(BaseModel):
    instruction: str
    permissions: str = "read_only"
    max_turns: int = 20
    cwd: str | None = None

bind() constructs local functions, pairs each with its canonical request model, attaches tool-specific preprocessors/postprocessors, filters by enabled_tools, and returns a list in ALL_CODING_TOOLS order.

Agent construction treats this provider as a special case rather than sending it through generic LionTool.to_tool() registration: _register_coding_tools() constructs the toolkit, installs configured hooks, calls toolkit.bind(branch), and registers the returned list (lionagi/agent/factory.py).

Exact branch-scoped semantics

  • Selection: omitted tools selects the eight defaults. Unknown names raise ValueError during toolkit construction. Sandbox and subagent are opt-in.
  • Workspace: the root is resolved once. The bound functions close over that root and the supplied branch.
  • File-state population: a successful filesystem text read records resolved path and modification time in file_state and marks the path in read_tracked. Image reads and reads served from the converted-document cache return before that metadata is added. A successful write or edit also refreshes file_state, but removes the path from read_tracked.
  • Mutation guard: an existing-file write and an edit require a file_state entry and compare the current mtime with the stored value. A new-file write needs no entry. Once a toolkit write/edit succeeds, its refreshed entry authorizes a later mutation without another read. An external mtime change forces a re-read; failure to stat() during the guard returns no error and allows the mutation attempt to continue.
  • Context coupling: after successful eviction or compaction, invalidation examines only paths still in read_tracked and drops those whose reader response is no longer active. Mutation-derived entries have been removed from read_tracked, so they remain in file_state even when the original read evidence leaves active context.
  • Tool hooks: security/user preprocessors and postprocessors use the same composition helpers as agent factory registration. Registered error hooks are stored on the toolkit but are not attached to the returned Tool values in bind().
  • Nudges: when notify=True, bind() uses a caller-supplied nudge_engine unchanged or constructs NudgeEngine(branch, rules=...), stores it as _bound_nudge_engine, and adds a wildcard post-hook that can add a system field to dictionary results. Evaluation failure logs a warning and preserves the original result.
  • Repeated binding: the notify hook is appended to the toolkit's persistent _post_hooks map inside every bind() call. Reusing one CodingToolkit instance for another branch therefore attaches both the new hook and prior bind hooks to the new descriptors; those older closures retain their earlier engine and file-state maps.
  • Declared nudge numbers: notify_threshold=0.7 and notify_max_tokens=200_000 are stored on the toolkit but not read by bind() or passed to the constructed nudge engine in this module. They are inherited inactive settings here; no runtime budget or rationale can be claimed from them.
  • Sandbox state: one mutable session slot is captured per bind. A second create is rejected until merge or discard. A successful merge clears the slot. A discard that returns clears it regardless of the returned success flag; an exception from sandbox_discard() propagates before the clear and leaves the slot populated.
  • Subagent budget: max_turns defaults to 20 and clamps to 1–50 before becoming max_extensions. Successful text is truncated to 5,000 characters in the parent result. The request text identifies 20/50 as reasoning bounds, and the response cap bounds parent-context growth; no measurement for the exact values is recorded.

Parallel implementation semantics

  • The toolkit imports the standalone request models and selected blocking helpers, so provider schemas share field names and constraints.
  • The bound reader additionally returns images as base64 data URLs; the standalone reader treats NUL-bearing binary content as unsupported. Toolkit document opening allows no remote hosts.
  • Toolkit editor enforces a file_state prerequisite for an existing-file write and an edit; the first mutation normally requires a successful text read, while successful mutations refresh the state for later calls. Standalone editor only instructs the caller to read first.
  • Toolkit search defaults to 50 results for grep and 100 for find when the field is omitted or null. Standalone search uses 50 for both. Toolkit responses use total_matches/total_found and shown; standalone uses count.
  • Toolkit search shares the unconstrained SearchRequest: zero selects its per-action default, while a negative limit reaches slicing and can produce a negative shown count. Its grep handler treats only return code 2 as failure and does not inspect the shared helper's timed_out flag, whereas standalone grep rejects every code outside {0, 1} and handles timeout explicitly.
  • Toolkit code check calls the shared Ruff helper but does not pass the standalone workspace cwd. Toolkit navigation and AST search call the same low-level helpers and assemble their own dictionaries.
  • Toolkit bash implements the same operator rejection and output cap through shared subprocess code, then renames returncode to return_code. It computes the timeout as timeout or 30000 before clamping, so an explicit zero means 30 seconds rather than the standalone adapter's 1 ms; negative values still clamp to 1 ms and values above five minutes clamp to 300,000 ms.
  • Expected failures remain dictionaries, but not every dictionary has the corresponding standalone response model's complete fields or wording.

Why this way

Binding once provides a natural home for read state, active-context checks, optional nudge state, and sandbox lifecycle. Reusing request models retains provider-schema compatibility. Reimplementing the handlers made stateful behavior easy to add around each operation, but it created two semantic sources of truth. The accepted delta is to compose canonical standalone operations and layer only branch state and policy.

D5 — Branch cloning copies registered descriptors without rebinding

The clone path is:

# lionagi/session/branch.py
def clone(self, sender: ID.Ref = None) -> Branch:
    tools = (
        list(self._action_manager.registry.values())
        if self._action_manager.registry
        else None
    )
    branch_clone = Branch(
        system=<cloned system>,
        user=self.user,
        messages=[msg.clone() for msg in self.msgs.messages],
        tools=tools,
        chat_model=<copied or shared model>,
        parse_model=<copied or shared model>,
        metadata={"clone_from": self},
    )
    ...
    return branch_clone

Exact semantics

  • The clone constructs a new ActionManager and registry dictionary.
  • The values passed into that manager are the original Tool objects. Registration of a Tool stores it as-is, so source and clone registries point to identical descriptor objects and callable objects.
  • Stateless cached descriptors are consequently shared too; mutation of processors or schema through either reference is visible to both branches.
  • A callable returned by ContextTool.bind(source) retains the source branch, message manager, and metadata in its closure after clone registration.
  • A messenger callable retains the source branch id and source message pile used by its tracking closure.
  • Every CodingToolkit.bind(source) function retains the source branch, source progression, source message manager, local file-state dictionaries, nudge engine, and optional sandbox slot.
  • The clone operation has no provider identity, scope enum, rebind callback, or cloneability check, so it cannot distinguish these descriptors from reusable stateless tools.
  • Message objects themselves are cloned and recipients are rewritten; this does not affect the callable closures already copied.

Why this way

Copying registry values preserves the visible tool set and is sufficient for stateless functions. It also avoids requiring every third-party descriptor to implement cloning. Once providers captured branch state, descriptor reuse became semantically unsafe. The current code has no metadata from which clone could infer a repair, so rebinding must be an explicit provider contract or cloning must reject non-cloneable bindings.

Consequences

  • Maintained standalone tools are easy to construct, test, cache, and register through one generic path.
  • Pydantic request models give standalone and toolkit variants a shared provider input vocabulary.
  • Expected file and subprocess failures are model-visible typed values rather than exceptions, and known blocking operations are offloaded deliberately.
  • LionTool is not substitutable across its current subclasses: three implementations deliberately raise from the only abstract method.
  • Operational caps are dispersed across adapters; several exact values are inherited without recorded evidence, and one reader cache setting is exposed but inactive.
  • CodingToolkit's parallel handlers already diverge in search defaults, response keys, subprocess exit/timeout classification, explicit-zero timeout behavior, image behavior, and read-before-edit enforcement.
  • CodingToolkit's context invalidation applies only while state remains tagged as read-derived; a successful mutation converts it to mutation-derived state that can outlive the active reader response.
  • Rebinding the same notifying CodingToolkit instance accumulates branch-scoped nudge closures, so provider instances are single-bind in practice even though the interface does not enforce that lifecycle.
  • A cloned registry has independent name membership but shared descriptor identity. For bound closures, calls through the clone can mutate, message from, or inspect the source branch.
  • Reversing D1 requires a provider result that can represent one or many tools plus explicit construction context. Reversing D5 requires retaining provider provenance at registration time; a bare closure cannot be reliably rebound after the fact.
  • Contributors must not assume a LionTool instance is generically registerable or a registered Tool is safe to copy across branch scope.

Current-vs-ideal delta

# Delta Size Issue
1 Replace the partial LionTool.to_tool() contract with a provider build contract that returns one or more tools from an explicit context; acceptance requires stateless and branch-bound built-ins to use the same construction path without raising from the advertised interface and to declare whether provider instances are reusable, single-bind, or cloneable. M (filled at issue-open time)
2 Make branch cloning rebuild branch-bound providers for the clone or reject non-cloneable bindings; acceptance requires context, coding, and messenger callables registered on a clone to reference only the clone's state. S (filled at issue-open time)
3 Refactor CodingToolkit to compose canonical standalone operations and add only branch-scoped state and policy; acceptance requires response-schema and failure-semantic parity tests for every operation exposed in both modes, plus a tested file-state lifecycle that either requires active read evidence for each mutation or explicitly defines when a prior mutation substitutes for a read. M (filled at issue-open time)
4 Move resource and prompt graph types out of the tool-provider base module; acceptance requires the tool base module to contain only registration and construction abstractions with unchanged public compatibility aliases. S (filled at issue-open time)

Alternatives considered

A. Keep LionTool.to_tool() as the universal provider interface

This is sufficient for cached stateless adapters and keeps branch registration tiny. It lost as a universal abstraction because context and messenger need inputs and coding returns multiple descriptors. Their NotImplementedError implementations are direct evidence that the method cannot express the shipped provider set.

B. Remove LionTool and use ordinary factories

Plain functions returning Tool | list[Tool] would remove the broken inheritance and make branch-bound inputs explicit without a new framework. It remains viable and is the seed recorded in the original draft. It loses if third-party providers need a stable, typed construction and clone contract discoverable by generic branch code; that product requirement decides factory functions versus a provider protocol.

C. Introduce a context-aware provider protocol

A protocol such as build(context) -> list[Tool] could support stateless and bound providers uniformly and carry clone scope. It was not the organic implementation because the original built-ins each returned one tool and no branch context was needed. Delta 1 retains this design direction without selecting its final public type prematurely.

D. Put branch context directly on Tool

Adding a branch field or generic context object to every descriptor would let one to_tool() path construct bound behavior. It lost because most tools are reusable and the callable already captures only what it needs; putting session state on the core descriptor would couple ADR-0011's portable declaration to Branch.

E. Reuse standalone tools unchanged inside CodingToolkit

This would guarantee response and default parity and remove duplicated subprocess/file logic. It did not satisfy the initial stateful needs by itself: read-before-edit requires observing successful reads, context eviction must invalidate that state, and nudges need post-call information. The retained design is composition plus explicit state/policy wrappers, not simple list concatenation.

F. Keep the parallel coding implementations

This bought direct access to branch-local state in each closure and allowed operation- specific response additions. It lost as the long-term shape because standalone and bound semantics already differ in defaults, fields, and supported content. Every bug fix must be reasoned about twice.

G. Deep-copy tool descriptors during branch clone

Deep-copying would separate mutable schema and processor fields. It lost as a fix for branch binding because copying a Python closure does not rewrite its captured branch, message manager, exchange sender id, or nudge engine. The result would look independent while retaining the same scope bug.

H. Drop all tools when cloning

This would avoid stale closures and force callers to register a safe set explicitly. It lost because branch splitting expects visible capabilities to carry forward and existing tests assert cloned stateless tools remain registered. A scope-aware provider contract can preserve safe tools and rebuild or reject unsafe ones.

I. Mark bound tools non-cloneable and reject cloning

This is the smallest safe correction: registration records scope and clone fails loudly when a provider offers no rebinding function. It gives up transparent branch splitting for context/coding/messenger users. Delta 2 keeps it as the fallback when a provider cannot build against the clone.

J. Keep resource and prompt graph types in tools.base

Co-location reduces module count and preserves old imports. It lost on cohesion: those types do not implement registration or callable construction, so the module name no longer identifies one responsibility. Delta 4 preserves compatibility aliases while moving their implementation boundary.

Notes

Removing LionTool in favor of ordinary factories remains a viable alternative to a provider protocol. The deciding constraint is whether third-party providers need a stable, typed construction interface for branch context, multiple-tool output, and clone scope.