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
LionToolis 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
LionToolsubclass is instantiated with no arguments. - A
LionToolinstance is converted by callingto_tool()synchronously. - The result is handed to
ActionManager.register_tool(), so it must be one registry-acceptableTool, 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. updateis 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 deliberateNotImplementedError. is_lion_system_toolandsystem_tool_nameare 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=Noneleaves 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
readrejects 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
limitbecomes 2000 lines. The 2000-line window is inherited; no source comment records why that exact value was selected. list_dirusesrecursiveand extension filters, truncating the file list to 1,000. The cap bounds returned context; no recorded rationale explains the exact 1,000 value.openaccepts local.pdf,.pptx,.docx,.html, and.htmfiles up to 50 MiB. The size protects document conversion from an unbounded input; the exact 50 MiB choice has no recorded rationale.- Remote
openaccepts onlyhttpsURLs 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
doclingreturns an actionablesuccess=Falseresponse; conversion exceptions are returned as errors.
Editor semantics
writerequires non-Nonecontent, creates parent directories, and overwrites the target. The standalone adapter's instruction to read first is advisory; no standalone read-state check exists.editrequires both strings, rejects a missing file, and requires an exact match. Zero matches returns whitespace/line-prefix hints. Multiple matches fail unlessreplace_all=True.- The edit write opens the already resolved file with
O_NOFOLLOWwhen 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=Falseand a fixed 30-second timeout. Grep exit codes0and1are success (1is 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_resultshas no numeric constraint.Noneor zero becomes 50; a negative value remains truthy and is passed to Python'slines[: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
ruffbinary. Missingruffisunavailable, findings arediagnostics, and no findings areok. Ruff gets 30 seconds. An exit code of 2 or greater becomeserrorwhen stdout is empty; non-empty stdout is still parsed and classified by its contents. Malformed non-empty JSON becomeserror. - 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
symbolargument fails the two symbol actions; a supplied but absent symbol returns a successful empty item list. Syntax/read errors becomesuccess=False. outlinereports class, function, and method nodes.find_definitionmatches classes, sync/async functions, and simple name assignments/annotated assignments; it is not an import or attribute resolver.find_referencesreports every matchingast.Nameregardless of load/store context and everyast.Attributewhose final attribute matches, so its results are syntactic occurrences rather than semantic references.- AST search locates
sgorast-grep, returnsunavailableif 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. statusreports active/total/evicted counts, role counts, and an estimated token sum.get_messagesdefaults 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.evictclamps its start to at least index 1, preserving the system message, and removes[start:end)from the active progression only.evict_action_resultskeeps the most recent five action responses by default. Five is a context-retention heuristic stated in the request schema; no measurement is recorded.restoreindexes the full record and reinserts messages in chronological order.get_messagesandrestoreclamp ranges and return successful empty results when the clamped range contains nothing.evictandcompactinstead returnsuccess=Falsewhen their clamped start is not before the end.evict_action_resultstreats zero or negativekeep_lastas keeping none.compactdefaults totool_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.modeandscopeare unconstrained strings: only the exact"tool_io"value selects tool-only compaction, so any other mode string follows the all-message branch; only exactscope="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.
sendrequires 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.doneandfinishedfire optional synchronous callbacks and return status text.wakeupuses only the first item whentois 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 failedFunctionCallingevent under ADR-0012 D1. ContextTool.to_tool()andLionMessenger.to_tool()always raise with instructions to callbind(...).
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
toolsselects the eight defaults. Unknown names raiseValueErrorduring 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_stateand marks the path inread_tracked. Image reads and reads served from the converted-document cache return before that metadata is added. A successful write or edit also refreshesfile_state, but removes the path fromread_tracked. - Mutation guard: an existing-file write and an edit require a
file_stateentry 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 tostat()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_trackedand drops those whose reader response is no longer active. Mutation-derived entries have been removed fromread_tracked, so they remain infile_stateeven 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
errorhooks are stored on the toolkit but are not attached to the returnedToolvalues inbind(). - Nudges: when
notify=True,bind()uses a caller-suppliednudge_engineunchanged or constructsNudgeEngine(branch, rules=...), stores it as_bound_nudge_engine, and adds a wildcard post-hook that can add asystemfield 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_hooksmap inside everybind()call. Reusing oneCodingToolkitinstance 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.7andnotify_max_tokens=200_000are stored on the toolkit but not read bybind()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_turnsdefaults to 20 and clamps to 1–50 before becomingmax_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_stateprerequisite 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_foundandshown; standalone usescount. - Toolkit search shares the unconstrained
SearchRequest: zero selects its per-action default, while a negative limit reaches slicing and can produce a negativeshowncount. Its grep handler treats only return code2as failure and does not inspect the shared helper'stimed_outflag, 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
returncodetoreturn_code. It computes the timeout astimeout or 30000before 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
ActionManagerand registry dictionary. - The values passed into that manager are the original
Toolobjects. Registration of aToolstores 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 sourcebranch, 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.
LionToolis 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
CodingToolkitinstance 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
LionToolinstance is generically registerable or a registeredToolis 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.