ADR-0022: Composed branch operation pipeline¶
- Status: Accepted
- Kind: Retrospective
- Area: operations
- Date: 2026-07-09
- Relations: extends ADR-0021
Context¶
Branch.operate() is the composed operation entry point: one call can select an API or CLI adapter, ask for generated structured fields, expose actions to a model, validate the returned value, execute accepted action requests, and attach action results. Four problems force the current coordinator shape.
P1 — A broad public call must become a small typed internal request. The facade accepts an Instruct or loose instruction/guidance/context values, model overrides, response types, field specifications, action controls, persistence controls, and an optional Middle. Passing that bag through every stage would make precedence and defaults implicit. prepare_operate_kw() resolves it into ChatParam or RunParam, optional ParseParam, optional ActionParam, and coordinator flags (lionagi/session/branch.py; lionagi/operations/operate/operate.py).
P2 — The model request schema and the returned result schema are not identical. When reasoning, additional fields, or actions are enabled, the request may include reason, action_required, and action_requests. action_responses cannot be requested from the model because it is produced by the runtime after tool execution, but it must exist in the final response type. The internal Operative materializes those two related models (lionagi/operations/operate/step.py; lionagi/operations/operate/operative.py).
P3 — Validation policy and action policy must have a defined order. A raw adapter result can be text, a mapping, or a Pydantic model. The caller can request return-value, return-None, or raise behavior on a model mismatch. Action requests are safe to inspect only after the adapter has returned and the outer validation policy has run. The shipped shortcut skip_validation=True returns even earlier and therefore also bypasses the action phase.
P4 — Tool execution must not fork by transport. API-native and CLI-native model responses can both request tools, but authorization, hook emission, event logging, message recording, error conversion, and execution strategy belong to act(). If each adapter executed tools directly, those governance paths could diverge.
The public contract has accumulated three known seams. A caller-supplied operative is accepted by Branch.operate() and prepare_operate_kw() but intentionally replaced with None. Structure exists in ChatParam and ParseParam and is used inside parsing, but no dedicated structure argument exists on Branch.operate() or prepare_operate_kw(). Finally, the name skip_validation describes only one of the two phases it disables.
| Concern | Decision |
|---|---|
| Public argument normalization | D1: the preparer produces typed chat/run, parse, and action parameter objects with fixed precedence. |
| Request/response shape | D2: one internal Operative generates a request model without action responses and a response model with them. |
| Adapter and validation order | D3: one Middle invocation precedes outer model-policy handling; raw short-circuit returns before all post-processing. |
| Action execution and enrichment | D4: explicit action enablement plus structured requests dispatch through act() and augment the original result. |
This ADR deliberately does not decide:
- The persistence and lifecycle behavior of the canonical
Middleimplementations; ADR-0021 owns the API/CLI adapter contracts. - Multi-round LNDL behavior; ADR-0024 records that specialized adapter.
- Action-manager registration, tool-schema generation internals, or the governance policy itself; this ADR decides only that the common action path is used.
- Dependency ordering between multiple
operate()calls; ADR-0023 owns graph execution. - A new public
Structureor caller-ownedOperativecontract. Their current non-selection and replacement behavior are recorded as retrospective deltas, not silently promoted to a target.
Decision¶
D1 — prepare_operate_kw() normalizes the public call into typed parameters¶
The implementation contract is:
def prepare_operate_kw(
branch: Branch,
*,
instruct: Instruct = None,
instruction: Instruction | JsonValue = None,
guidance: JsonValue = None,
context: JsonValue = None,
sender: SenderRecipient = None,
recipient: SenderRecipient = None,
progression: Progression = None,
chat_model: iModel = None,
invoke_actions: bool = True,
tool_schemas: list[dict] = None,
images: list = None,
image_detail: Literal["low", "high", "auto"] = None,
parse_model: iModel = None,
skip_validation: bool = False,
handle_validation: HandleValidation = "return_value",
tools: ToolRef = None,
operative: Operative = None,
response_format: type[BaseModel] = None,
actions: bool = False,
reason: bool = False,
call_params: AlcallParams = None,
action_strategy: Literal["sequential", "concurrent"] = "concurrent",
verbose_action: bool = False,
field_models: list[FieldModel | Spec] = None,
include_token_usage_to_model: bool = False,
clear_messages: bool = False,
stream_persist: bool = False,
persist_dir: str | None = None,
snapshot_dir: str | None = None,
middle: Middle | None = None,
**kwargs,
) -> dict: ...
The instruction model normalized by the preparer has these Pydantic fields:
class Instruct(HashableModel):
instruction: str | None = None
guidance: JsonValue | None = None
context: JsonValue | None = None
reason: bool | None = None
actions: bool | None = None
action_strategy: Literal["sequential", "concurrent"] | None = None
The returned mapping has one fixed shape:
{
"instruction": instruct.instruction,
"chat_param": ChatParam(...) | RunParam(...),
"parse_param": ParseParam(...) | None,
"action_param": ActionParam(...) | None,
"handle_validation": "raise" | "return_value" | "return_none",
"invoke_actions": bool,
"skip_validation": bool,
"clear_messages": bool,
"operative": None,
"middle": Middle | None,
"field_models": list[FieldModel | Spec] | None,
"reason": bool | None,
}
Exact normalization semantics:
chat_modeldefaults tobranch.chat_model;parse_modeldefaults to that selected chat model, not independently tobranch.parse_model.- A mapping passed as
instructis validated intoInstruct. When noinstructis supplied, one is built from the loose instruction, guidance, and context arguments. - Explicit
reason=Truesetsinstruct.reason = True. Explicitactions=Truesetsinstruct.actions = Trueand copies a truthyaction_strategyinto the instruction model. RunParamis selected when the selected model is CLI-backed,stream_persistis true, or eitherpersist_dirorsnapshot_diris notNone. Otherwise the preparer buildsChatParam.RunParam.stream_persistis copied even when false.persist_dirandsnapshot_diroverride their dataclass defaults only when explicitly non-None.response_formatis copied into the chat/run parameter. AParseParamis created only when a response format exists and validation is not skipped.ActionParamis created only wheninvoke_actionsis true and either the normalized instruction or the looseactionsflag enables actions. The implementation expression isaction_strategy or instruct.action_strategy or "concurrent", and it always usessuppress_errors=True.- At the public
Branch.operate()boundary,action_strategyitself defaults to the truthy value"concurrent"and is always forwarded. Consequently anInstruct(actions=True, action_strategy="sequential")does not select sequential execution when the caller omits the loose keyword: the facade default wins. The intended-looking instruction fallback is reachable only through a direct internal preparer call that supplies a falsey strategy. The public API cannot currently distinguish “keyword omitted” from “explicitly concurrent.” - Public
tool_schemasare copied into the initial chat/run parameter, but D2 replaces the effective schemas from the selected branch tools whenever anActionParamexists. request_model,operative_model, andimodelin**kwargsare rejected as removed names with guidance toresponse_format=orchat_model=. Remaining**kwargsbecome model invocation options.- A caller-supplied
operativeis discarded by setting the returned value toNone. This is deliberate in the current code so D2 has one construction site, but it makes the accepted public argument ineffective. field_modelsentries are converted later fromFieldModelorSpec; any other entry raisesTypeError. Entries whose resultingSpec.nameis empty do not enter the generated field map.
The structured-parse default created by make_parse_param() uses:
AlcallParams(
retry_initial_delay=1,
retry_backoff=1.85,
retry_attempts=3,
max_concurrent=1,
throttle_period=1,
)
These inherited values serialize parsing attempts, allow three retries, and space retry work. The operations code records no measurement or other rationale for the exact 1, 1.85, 3, and 1 values; they are centralized in lionagi/operations/_defaults.py so callers can replace the parameter object where exposed.
Why this way. A preparer gives every downstream stage one small vocabulary and one precedence rule. Selecting RunParam during preparation makes adapter choice structural rather than a late collection of Boolean checks. The current ignored operative argument is retained only as shipped truth; it is not evidence that two schema owners are desirable.
D2 — One generated Operative separates model-request and runtime-response fields¶
The coordinator signature and schema factories are:
async def operate(
branch: Branch,
instruction: JsonValue | Instruction,
chat_param: ChatParam,
action_param: ActionParam | None = None,
parse_param: ParseParam | None = None,
handle_validation: HandleValidation = "return_value",
invoke_actions: bool = True,
skip_validation: bool = False,
clear_messages: bool = False,
reason: bool = False,
field_models: list[FieldModel | Spec] | None = None,
operative: Operative | None = None,
middle: Middle | None = None,
) -> BaseModel | dict | str | None: ...
class Step:
@staticmethod
def request_operative(
*,
name: str | None = None,
operative_name: str | None = None,
adapter: Literal["pydantic"] = "pydantic",
reason: bool = False,
actions: bool = False,
fields: dict[str, Spec] | None = None,
field_models: list | None = None,
max_retries: int = 3,
auto_retry_parse: bool = True,
base_type: type[BaseModel] | None = None,
**kwargs,
) -> Operative: ...
@staticmethod
def respond_operative(
operative: Operative,
additional_fields: dict[str, Spec] | None = None,
) -> Operative: ...
When actions are requested, the generated semantic fields are:
class ActionRequestModel(HashableModel):
function: str | None = None
arguments: dict[str, Any] | None = None
class ActionResponseModel(HashableModel):
function: str = Field(default_factory=str)
arguments: dict[str, Any] = Field(default_factory=dict)
output: Any = None
# Generated nullable/listable fields
action_required: bool | None
action_requests: list[ActionRequestModel] | None
action_responses: list[ActionResponseModel] | None
Exact schema semantics:
- The coordinator recognizes a caller model class when
chat_param.response_formatis aBaseModelsubclass, or uses the type of aBaseModelinstance. A dictionary response format is passed to adapters but does not becomemodel_classfor outer instance checking. - An internal
Operativeis constructed when at least one of these is present: a base model class, action enablement, named field specs, or reasoning. reason=Trueadds the nullableReasonfield.actions=Trueat construction addsaction_required,action_requests, andaction_responses. Named field specs are added by name.Step.request_operative()setsrequest_exclude={"action_responses"}when actions are present.Step.respond_operative()then materializes the response model from all specs. Therefore the provider request cannot fabricate runtime action results, while the returned model can hold them.- Generated model names are based on the explicit name, the base type name, or
"Operative", withRequestandResponsesuffixes. The only shipped adapter is"pydantic". - The generated response type replaces
_cctx.response_formatand_pctx.response_format, so both provider rendering and parsing target the same superset model. - When an
ActionParamexists, the coordinator callsbranch.acts.get_tool_schema()for the selectedtools(or all tools when the selector is absent), unwraps{"tools": [...]}to the list expected by an instruction, and replaces the effective chat/run tool schemas. Operative.max_retriesdefaults to 3, butoperate()itself never loops over theMiddle; retry behavior belongs to parsing or the selected adapter. The exact default is inherited and has no recorded operations-level measurement rationale.
Why this way. Request and response are related but not identical protocols. One Operative keeps their shared base and generated fields aligned, while request_exclude expresses the one runtime-only field. Building a completely separate result model would duplicate the base schema and risk drift; requesting action_responses from the model would erase the boundary between proposed calls and observed tool results.
D3 — The coordinator invokes one adapter, then applies one outer validation policy¶
The execution order is normative as-built behavior:
prepared params
│
├─ publish selected tool schemas
├─ build request/response Operative when needed
├─ select or accept one Middle
v
await middle(branch, instruction, chat_param, parse_param,
clear_messages, skip_validation=skip_validation)
│
├─ skip_validation=True ───────────────> return raw result
├─ requested model mismatch ───────────> handle_validation policy
├─ invoke_actions=False ───────────────> return validated/current result
└─ otherwise ──────────────────────────> D4 action inspection
Exact adapter-selection and validation semantics:
- A caller-supplied
middlewins without additional wrapping. - Without one,
RunParamor a CLI-backedbranch.chat_modelselectsrun_and_collect; otherwise the coordinator selectscommunicate. - The selected
Middleis awaited exactly once byoperate(). An adapter may internally stream or loop under ADR-0021, but the coordinator does not retry it. clear_messagesandskip_validationare passed to the adapter positionally/by keyword as shown in theMiddleprotocol. Message persistence remains adapter-owned.skip_validation=Truereturns immediately after the adapter. No outer model instance check,invoke_actionscheck, action-request inspection, tool execution, provider writeback, or result enrichment occurs.- When a caller model class exists and the returned value is not an instance of that class,
handle_validation="return_value"returns it unchanged,"return_none"returnsNone, and"raise"raises aValueErrornaming the expected model and including at most the first 200 characters ofrepr(result). - The model mismatch branch returns immediately for
return_valueandreturn_none. It does not continue into actions even if the returned mapping happens to containaction_requests. - With no caller model class, a generated action/reason/field model can still be returned and inspected. The outer type policy is specifically relative to the caller's requested base model.
invoke_actions=Falsereturns after validation and before request inspection. This flag is a second action gate independent of whether anActionParamwas prepared.
Why this way. The adapter owns transport and inner parsing; the coordinator owns the caller's outer contract. Applying the mismatch policy before tools prevents side effects from a value the caller has declared invalid. The raw shortcut is intentionally the earliest return in shipped code, although its broad scope is a naming/documentation debt captured below.
D4 — Structured action requests execute through act() and augment the result¶
The action parameter and dispatcher contracts are:
@dataclass(slots=True, frozen=True, init=False)
class ActionParam(MorphParam):
action_call_params: AlcallParams = None
tools: ToolRef = None
strategy: Literal["concurrent", "sequential"] = "concurrent"
suppress_errors: bool = True
verbose_action: bool = False
async def act(
branch: Branch,
action_request: list | ActionRequest | BaseModel | dict,
action_param: ActionParam,
) -> list[ActionResponse]: ...
The accepted action request payload is structurally:
{
"function": "registered_tool_name",
"arguments": {"named_argument": "value"}
}
A list of those mappings, an ActionRequest, or a Pydantic model exposing both function and arguments is accepted. Any other shape raises ValueError before invocation.
Exact action and enrichment semantics:
- The coordinator reads
action_requestsfrom any returnedBaseModelattribute or mapping key. Plain text and other values produce no requests. - Execution requires an
ActionParamandrequests is not None. An absent field does nothing; an empty list dispatches but yields no responses and therefore no enrichment. - All requests go through
act()."concurrent"uses the configuredAlcallParamscallable;"sequential"awaits requests in input order; any other strategy raisesConfigurationError. - The default action
AlcallParamssetsoutput_dropna=Trueand otherwise inherits library defaults. No operations-level rationale is recorded for additional concurrency or retry numbers because none are fixed here. - Before invoking a tool,
_act()asksbranch.authorize(ToolInvocation(...)). Denial is returned as anActionResponseModelwith an error payload and is recorded in branch messages; it is not raised as a transport exception. - When hooks exist,
_act()emitsTOOL_PRE, thenTOOL_POSTon success orTOOL_ERRORon an exception. Successful calls are emitted/logged and their action request/response messages are recorded. - Tool-hook payloads truncate argument and successful-result summaries to 200 characters; verbose debug logging truncates its argument preview to 50 characters. These are observability bounds and do not truncate the actual invocation or result. The code records no rationale for the exact 200 and 50 values (
lionagi/operations/act/act.py). - With
suppress_errors=True, a tool exception is logged, recorded as an action response, and returned as anActionResponseModelso a later model round can adapt. With false, the original exception is re-raised after the error hook and log path. Noneresponses are removed before enrichment. If none remain, the original result is returned.- When context providers are registered, every non-
Noneaction response model is offered to their writeback hook before enrichment. This includes governance-denial and suppressed tool-error values; the writeback path does not filter by success. - For an internally generated Pydantic result, the coordinator sets
operative.response_model, updatesaction_responses, and returns the updated model. For a mapping, it mutatesresult["action_responses"]. The action phase never replaces the original result with the list of tool responses.
Why this way. act() is the one path that carries authorization, hooks, durable event logging, message state, suppression policy, and strategy. Keeping it outside adapters ensures API, CLI, and custom Middle implementations cannot accidentally create different tool-governance contracts. Augmentation preserves the model's original reasoning and fields alongside observed tool results.
Consequences¶
- API and CLI adapters share one generated schema, validation policy, authorization path, and result-enrichment policy.
- A model sees only the action-request side of the protocol; runtime action responses remain distinguishable and are attached after execution.
- Explicit action enablement is required. Merely returning text that resembles a call, or returning
action_requestswhen noActionParamexists, causes no tool side effect. - An instruction-embedded sequential strategy is currently shadowed by the facade's truthy
"concurrent"default unless the loose keyword is also supplied. Callers must setaction_strategy="sequential"explicitly at the branch boundary. - Validation policy can prevent side effects: a caller-model mismatch returns or raises before action execution. Conversely,
skip_validationis a raw-output mode that disables the entire outer action phase. - The coordinator carries parameter normalization, schema materialization, adapter selection, validation, action orchestration, and enrichment in one module. This centralizes policy but raises the cost of changing its order; every return path must be checked for side effects.
- Contributors adding an adapter need only satisfy
Middle; they must not duplicate outer action execution. Contributors changing generated fields must update both request exclusion and response enrichment semantics. - Reversing D2 is medium-to-high cost because generated provider schemas and returned Pydantic types are coupled. Changing D3 or D4 ordering is behaviorally high-risk because it changes whether a tool executes. Replacing the default adapter selection in D1 is lower cost if ADR-0021 persistence semantics remain intact.
- The existing operation-boundary tests provide high testability (
τ ≈ 0.9for the area), but they do not establish provider-side structured-output reliability or the safety of arbitrary tools.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Deprecate and remove the ignored public operative argument, or define and implement a precedence rule that honors it while preserving generated request and response fields. | S | (filled at issue-open time) |
| 2 | Expose Structure through the chosen public branch APIs and parameter builders, or mark it internal and remove it from public parameter types; add one end-to-end public-path test. | S | (filled at issue-open time) |
| 3 | Split the raw-result shortcut into explicit validation and post-processing controls, or rename and document skip_validation so callers know that it also disables outer action execution. | S | (filled at issue-open time) |
| 4 | Make action-strategy precedence distinguish an omitted facade keyword from explicit "concurrent", then add a public-path test proving that Instruct.action_strategy either governs execution or is deliberately removed. | S | (filled at issue-open time) |
Alternatives considered¶
Separate API and CLI operation coordinators¶
This would allow transport-specific parameter lists and simpler local control flow. It lost because the generated request/response model, mismatch policy, authorization, hooks, and result enrichment are transport-independent. Two coordinators would duplicate the highest-risk ordering rules and could make the same structured request execute tools differently by endpoint family.
Let each Middle execute its own action requests¶
This would keep the full model/tool conversation inside the adapter and could support provider- native tool loops. It lost for the outer operate() phase because adapters would need to reproduce authorization, hooks, logging, message recording, suppression, and enrichment. The LNDL adapter may execute actions inside its bounded language loop, but it still routes those calls through act(); that specialized behavior does not replace the common outer rule.
Ask the model to return action_responses¶
This would permit one response schema and avoid post-execution model updates. It lost because a model cannot authoritatively report a result that only the runtime observes. Including the field in the request would invite fabricated tool outcomes and blur proposed calls with executed effects.
Maintain unrelated request and result models¶
This would give complete freedom to shape each side. It lost because the base caller model, reasoning field, and action-request fields must match on both sides. Independent construction would duplicate those specs and create drift. Operative instead makes the response a materialized superset of the request.
Honor a caller-supplied Operative with implicit precedence¶
This could enable advanced schema customization immediately. It lost in the shipped preparer because no precedence rule answers how caller fields combine with response_format, reason, actions, and field_models, or who owns request_exclude. The code chooses one construction site by discarding the argument; the delta requires an explicit contract before changing that behavior.
Let Instruct.action_strategy win when the loose keyword is omitted¶
This would make a self-contained Instruct govern both action enablement and scheduling. It would also preserve the apparent fallback order in prepare_operate_kw(). It did not ship at the public boundary because Branch.operate(action_strategy="concurrent") always forwards a truthy default, so the preparer cannot observe omission. Correcting it requires an omission sentinel or a single authoritative strategy field; silently changing precedence would alter execution ordering for existing calls.
Treat skip_validation as “skip only the final instance check”¶
This interpretation would preserve parsing or tools while relaxing the caller-model assertion. It lost against the current early return: the flag is passed into the adapter and then checked immediately after it. Changing it would cause tool side effects for calls that are raw-only today, so it requires a named behavioral decision rather than documentation sleight of hand.
Execute actions before outer model mismatch handling¶
This could salvage valid action requests from an otherwise imperfect structured response. It lost because a tool side effect would occur before the caller's declared validation policy was honored. The current order is fail-before-effect for caller-model mismatches.
Return tool responses instead of enriching the original result¶
This would simplify the return type after actions. It lost because it discards the model's original fields and reasoning and changes the response shape depending on whether any tool ran. Enrichment keeps one logical result and makes action outcomes an explicit field.
Notes¶
Primary implementation anchors are lionagi/session/branch.py, lionagi/operations/operate/operate.py, lionagi/operations/operate/step.py, lionagi/operations/operate/operative.py, lionagi/operations/fields.py, lionagi/operations/_defaults.py, lionagi/operations/schema/structure.py, and lionagi/operations/act/act.py. Focused behavioral anchors live in tests/operations/test_operate.py, tests/operations/operate/, and tests/operations/test_act.py.