ADR-0012: Branch action execution and event lifecycle¶
- Status: Accepted
- Kind: Retrospective
- Area: actions-tools
- Date: 2026-07-09
- Relations: extends ADR-0011
Context¶
The descriptor and registry in ADR-0011 answer which function a branch can expose. Execution crosses more boundaries: argument normalization, governance, hook emission, callable invocation, event persistence, observer emission, and conversation history. Five concrete problems define the shipped transaction.
P1 — A failed callable must leave an inspectable execution record. Tool bodies, preprocessors, and postprocessors can fail after request validation. FunctionCalling therefore inherits the generic Event lifecycle and records terminal status, response, duration, and error. Ordinary exceptions become FAILED data; cancellation-class BaseException values become CANCELLED and continue propagating (lionagi/protocols/action/function_calling.py; lionagi/protocols/generic/event.py).
P2 — A model-facing call is a conversation transaction, not only a function call. The normal Branch.act() path authorizes the proposed call, emits lifecycle hooks, invokes through the branch registry, logs and emits the event, and writes a linked ActionRequest/ActionResponse pair. Structured operate() and the LNDL action bridge route generated requests through this operation, so these model-facing paths receive the same transaction (lionagi/operations/act/act.py; lionagi/operations/operate/operate.py; lionagi/operations/lndl_middle/lndl_middle.py).
P3 — Captured execution failure and branch response construction disagree. ActionManager.invoke() returns a FunctionCalling after Event.invoke() even when its status is FAILED. The branch transaction does not inspect that status. It emits TOOL_POST, persists the failed event, and writes func_call.response—normally None—as the attempted action output. MessageManager.a_add_message() removes None-valued keyword arguments before dispatch, so this call re-adds the existing ActionRequest rather than creating an ActionResponse. Conversation history therefore cannot distinguish a failed tool body from a successful tool returning None: both leave an unresponded request, even though the event record can distinguish them (lionagi/protocols/action/manager.py; lionagi/operations/act/act.py; lionagi/protocols/messages/manager.py; lionagi/protocols/messages/action_response.py).
P4 — Governance and intrinsic tool policy observe different call shapes. The session observer authorizes a raw ToolInvocation before request-model normalization. Agent permission hooks are attached later as Tool.preprocessor callables and see the normalized dictionary. When user preprocessors exist, security preprocessors run before and after them. These are separate seams rather than one authoritative phase model (lionagi/session/observer.py; lionagi/agent/factory.py; lionagi/agent/permissions.py).
P5 — Batch and Python callable behavior introduce concurrency edges. The branch operation can run request transactions concurrently or sequentially. Inside each FunctionCalling, coroutine functions are awaited and declared synchronous functions run inline on the current event-loop thread. Awaitability is classified from the callable declaration, not the returned value (lionagi/operations/act/act.py; lionagi/ln/_async_call.py; lionagi/ln/concurrency/utils.py).
| Concern | Decision |
|---|---|
| Invocation record | D1: FunctionCalling executes preprocessor → callable → postprocessor inside the generic failure-capturing Event lifecycle. |
| Conversation transaction | D2: Branch.act() delegates each request to _act(), which orders authorization, hooks, invocation, event handling, and linked messages. |
| Outcome projection | D3: branch responses currently project FunctionCalling.response without interpreting terminal event status. |
| Batch behavior | D4: action batches use explicit concurrent or sequential strategy, preserving one _act() transaction per input. |
| Policy boundary | D5: session authorization, tool processors, and direct low-level invocation remain distinct public seams. |
| Sync/async behavior | D6: coroutine declarations are awaited; synchronous declarations and processors execute inline. |
This ADR does not decide:
- Tool schema generation, registry identity, or MCP discovery; ADR-0011 owns those contracts.
- Construction and clone scope of built-in providers; ADR-0013 owns that lifecycle.
- The complete session observer or hook vocabulary. This ADR records only behavior on the action execution path.
- Retry policy for arbitrary tool side effects. The default action batch does not retry; callers can supply
AlcallParams, but idempotency remains tool-specific.
Decision¶
D1 — FunctionCalling is a failure-capturing execution event¶
The invocation type and its generic execution state are:
# lionagi/protocols/action/function_calling.py
class FunctionCalling(Event):
func_tool: Tool = Field(..., exclude=True)
arguments: dict[str, Any] | BaseModel
@property
def function(self): ...
async def _invoke(self) -> Any: ...
def to_dict(self, *args, **kw) -> dict[str, Any]: ...
# lionagi/protocols/generic/event.py
class EventStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
CANCELLED = "cancelled"
ABORTED = "aborted"
class Execution:
__slots__ = ("status", "duration", "response", "error", "retryable")
def __init__(
self,
duration: float | None | UnsetType = Unset,
response: Any = None,
status: EventStatus = EventStatus.PENDING,
error: str | BaseException | None = None,
retryable: bool | None | UnsetType = Unset,
) -> None: ...
class Event(Element):
execution: Execution = Field(default_factory=Execution)
streaming: bool = Field(False, exclude=True)
async def invoke(self) -> None: ...
The successful callable pipeline is:
validated arguments
→ optional preprocessor(arguments, **preprocessor_kwargs)
→ func_callable(**arguments)
→ optional postprocessor(response, **postprocessor_kwargs)
→ execution.response
FunctionCalling.to_dict() adds function and normalized arguments to the generic event serialization. The live func_tool remains excluded.
Exact semantics
- Construction: an input
BaseModelis dumped withexclude_unset=True. If the tool hasrequest_options, that model is then instantiated and dumped the same way. Strict or minimum-key validation runs before an event reachesinvoke()(ADR-0011 D2); construction errors are not captured in this event. - First invocation: an event in
PENDINGchanges toPROCESSING, samples a UTC timestamp for duration measurement, and enters_invoke(). The start sample is not a persisted execution field;created_atremains the event-construction timestamp. - Preprocessor: its return value replaces
self.arguments. No second request-model validation follows the transform. - Callable: the current arguments are expanded as keyword arguments.
- Postprocessor: its return value becomes the eventual response.
- Coroutine declaration: a callable or processor for which
is_coro_func(...)is true is awaited. - Synchronous declaration: a callable or processor classified as sync is called directly. If it returns an awaitable, that awaitable is treated as the ordinary response and is not awaited.
- Successful completion: the final result is stored in
execution.response, status becomesCOMPLETED, and duration is always recorded in seconds. - Ordinary exception: any
Exceptionraised by the preprocessor, callable, or postprocessor sets status toFAILED, adds the exception toexecution.error, leaves the pre-existing response value unchanged (normallyNone), records duration, and is not re-raised. - Cancellation-class failure: a
BaseExceptionoutsideExceptionis added to the error, status becomesCANCELLED, duration is recorded, and the exception is re-raised. - Repeated invocation:
Event.invoke()returns immediately whenever status is notPENDING. A completed, failed, processing, or cancelledFunctionCallingis not executed again. - Terminal notification: setting
COMPLETED,FAILED,CANCELLED,ABORTED, orSKIPPEDsignals the lazily createdcompletion_event. - Error accumulation cap:
Execution.add_error()retains at most 100 members once the error becomes anExceptionGroup. The cap bounds serialized error growth; the value is inherited from the generic event layer and has no action-specific recorded rationale. One ordinaryFunctionCalling.invoke()normally contributes one error. - Serialization: exceptions become
{ "error": <type>, "message": <text> }; unserializable responses fall back through recursive conversion and finally the string"<unserializable>".
Why this way
Failure as event data lets callers inspect and persist a complete attempt without using exceptions as the only control channel. It also allows independent concurrent calls to finish. Cancellation remains exceptional because swallowing task cancellation would break structured concurrency. The design requires every caller above Event to inspect status; D3 records where the branch layer currently fails to do so.
D2 — _act() is the normal branch conversation transaction¶
The public and internal signatures are:
# lionagi/session/branch.py
async def Branch.act(
self,
action_request: list | ActionRequest | BaseModel | dict,
*,
strategy: Literal["concurrent", "sequential"] = "concurrent",
verbose_action: bool = False,
suppress_errors: bool = True,
call_params: AlcallParams = None,
) -> list[ActionResponse]: ...
# lionagi/operations/act/act.py
async def _act(
branch: Branch,
action_request: BaseModel | dict | ActionRequest,
suppress_errors: bool = False,
verbose_action: bool = False,
): ...
The accepted proposal passed to session governance is:
# lionagi/session/control.py
@dataclass(frozen=True, slots=True)
class ToolInvocation:
function: str
arguments: dict = field(default_factory=dict)
branch_id: str | None = None
The immediate return value is not the persisted message type:
# lionagi/operations/fields.py
class ActionResponseModel(HashableModel):
function: str = Field(default_factory=str)
arguments: dict[str, Any] = Field(default_factory=dict)
output: Any = None
The persisted message pair uses these content shapes:
# lionagi/protocols/messages/action_request.py
@dataclass(slots=True)
class ActionRequestContent:
function: str = ""
arguments: dict[str, Any] = field(default_factory=dict)
action_response_id: str | None = None
# lionagi/protocols/messages/action_response.py
@dataclass(slots=True)
class ActionResponseContent:
function: str = ""
arguments: dict[str, Any] = field(default_factory=dict)
output: Any = None
action_request_id: str | None = None
error: str | None = None
For an allowed call that returns a FunctionCalling, the transaction order is:
normalize envelope
→ branch.authorize(ToolInvocation from raw arguments)
→ TOOL_PRE
→ ActionManager.invoke()
→ TOOL_POST
→ branch.emit_and_log(FunctionCalling)
→ ensure ActionRequest message
→ add linked ActionResponse only when response is not None
→ return ActionResponseModel
The message-manager discriminator responsible for the None edge is:
# lionagi/protocols/messages/manager.py
async def MessageManager.a_add_message(self, **kwargs):
# None-valued entries are removed before create_message().
_msg = self.create_message(**{k: v for k, v in kwargs.items() if v is not None})
...
def MessageManager.create_message(
*,
action_output: Any = None,
action_request: ActionRequest | None = None,
action_response: ActionResponse | Any = None,
...,
):
if action_request and action_output is None and action_response is None:
... # select ActionRequest
elif action_response is not None or action_output is not None:
... # select ActionResponse
Exact semantics
- Accepted envelopes: an
ActionRequest; aBaseModelwhose class declares at leastfunctionandarguments; or a dictionary containing both keys. Any other shape raisesValueErrorbefore authorization. - Raw gate arguments: if the envelope's
argumentsvalue is not already a dictionary, governance receives{}. Pydantic request-model normalization has not yet run. - Standalone branch:
Branch.authorize()returnsTruewhen no session observer is bound. - Bound observer: no installed gate allows. A falsy gate result or an exception from the gate denies and adds a
GateDeniedsignal to the observer flow. - Denied call: no tool hook, function event, or tool callable runs.
_act()ensures anActionRequest, adds an action response whose output is{"error": "denied by governance gate", "function": <name>}, and returns anActionResponseModel. Denial is a value regardless ofsuppress_errors. - Correlation and summaries: an allowed call gets a random UUID string
call_id.TOOL_PREreceivestool_name,call_id, and the first 200 characters ofstr(raw_arguments). When verbose logging is enabled, its argument preview is capped at 50 characters. Both are inherited observability budgets; no recorded rationale explains the exact lengths. - Blocking pre-hook:
_act()callsHookBus.emit(TOOL_PRE, ...); the bus routes that point throughblocking_emit(), runs handlers sequentially, and propagates ordinary handler exceptions. Because pre-emission sits outside_act()'s invocationtry, such an exception bypassesTOOL_ERROR,suppress_errors, event logging, and action history.StopHookstops remaining pre-handlers but does not prevent the tool invocation itself. - Returned event:
TOOL_POSTreceives the samecall_id,tool_name, the first 200 characters ofstr(func_call.response), and the recorded duration. Non-pre hook handler exceptions are logged and isolated byHookBus. - Event handling:
branch.emit_and_log()logs the complete event and emits it to the session observer. The observer stores the event before applying routes and subscriptions; a gate denial at this emission stage suppresses dispatch but not storage. - Observer failure after invocation:
emit_and_log()is outside_act()'s invocationtry, and it usesBranch.emit(), not the failure-isolating_safe_emit(). An observer route, predicate, or subscriber exception can therefore propagate after the event is durably logged but before request/response history is created. - Message linking for non-
Noneoutput: a dictionary or generic model becomes anActionRequestwhose recipient is the tool descriptor id. The response message copies function and arguments from that request and stores its id asaction_request_id; message-manager linking writes the reciprocal response id. Falsy outputs other thanNone(0,"",[],{}) still create and link anActionResponsebecause the discriminator usesis not None. - Message linking for
Noneoutput:a_add_message()filters out theaction_outputentry, selects the ActionRequest construction path, and re-includes the same request at its existing progression position. NoActionResponseis created andActionRequest.action_response_idremainsNone. - Message callback failure:
a_add_message()mutates the message pile before firing itson_message_addedcallbacks. It runs all callbacks, then re-raises one failure or an exception group._act()does not roll back the already-inserted request/response, so the caller can receive an exception after history changed. - Successful return: the immediate
ActionResponseModelcontains the request's function and arguments andfunc_call.responseas output, includingNone; its existence does not imply that anActionResponsemessage was persisted. - Cancellation: cancellation re-raised by
FunctionCalling.invoke()is not caught by_act()'sexcept Exception; it propagates beforeTOOL_POST, event logging, and history creation.
Why this way
The transaction gives model-visible calls a consistent audit and history path while keeping denial readable by the next reasoning turn. The hook positions support guards and observability without moving callable-specific transforms out of Tool. The order also exposes two weaknesses: raw authorization and normalized processors do not see the same payload, and a status-blind projection can contradict the event truth.
D3 — Branch response projection is status-blind¶
There are two failure channels after authorization.
| Failure channel | Example | Does manager raise? | Hook path | History output |
|---|---|---|---|---|
| Pre-invocation exception | unknown function, request-model validation, strict key mismatch | Yes | TOOL_ERROR | structured error dictionary when suppressed; no history when re-raised |
| Captured event failure | preprocessor, callable, or postprocessor raises ordinary Exception | No | TOOL_POST | no ActionResponse when func_call.response remains None; request remains unresponded |
Exact semantics
- Manager exception:
_act()emitsTOOL_ERRORwithcall_id,tool_name, the exception object, andduration=None, then logs a dictionary containing error, function, arguments, and branch id. - Suppressed manager exception: with
suppress_errors=True,_act()ensures request and response history. Persisted output containserror,function, andarguments; the immediate output containserrorand a formattedmessage. The original exception does not propagate. - Unsuppressed manager exception: with
suppress_errors=False, the exception is re-raised after hook emission and logging; no request/response messages are added by that path. - Captured callable failure:
ActionManager.invoke()returns the failed event._act()does not branch onfunc_call.statusor readfunc_call.execution.error. It followsTOOL_POST, emits/logs the event, and passes the response value to the message manager. When the value isNone, no response message is constructed. - Successful
None: the same immediate output (None) and history state (an unrespondedActionRequest, with noActionResponse) is produced for a completed callable that intentionally returnsNone. - Unused message error field:
ActionResponseContent.errorand itssuccessproperty can distinguish failure, but_act()populates errors insideoutputonly on the raised-and-suppressed path and never maps a failed event into that field.
Why this way
The mismatch arose because the low-level event deliberately captures ordinary exceptions, while the branch code was organized around exceptions escaping manager invocation. Both pieces are internally coherent but their composition drops the status discriminant. The existing ActionResponseContent.error field provides a correction path without changing successful output values; the shipped behavior remains recorded here until that delta lands.
D4 — Batches preserve one transaction per call under two strategies¶
Batch configuration is a frozen parameter object:
# lionagi/operations/types.py
@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
prepare_act_kw() supplies AlcallParams(output_dropna=True) when callers do not pass call_params (lionagi/operations/_defaults.py).
Exact semantics
- Input normalization: both strategies wrap a single request in a one-element list.
- Empty input: an empty list returns an empty list. Concurrent mode creates no task; sequential mode performs no iteration. No hooks, events, or messages are produced.
- Concurrent: the configured
AlcallParamsapplies_act()to every request. The genericalcallimplementation stores results by input index, so the returned list preserves input order rather than completion order. - Default concurrent budget: the default action parameters set no retry, timeout, throttle, or
max_concurrentvalue. All input calls may start concurrently, and each call is attempted once.output_dropna=Trueis the only action-specific override; it removes null-like results from the collected output. No recorded rationale explains an unbounded default beyond inheritance from the generic call helper. - Caller-supplied controls:
AlcallParamscan add retry attempts, timeout, concurrency cap, throttle, and exception-return behavior. Applying retries to side-effecting_act()calls can duplicate tool effects and history; the action layer does not add an idempotency key or rollback. - Concurrent raised failure: with
return_exceptions=False, an unsuppressed exception from one_act()task is re-raised (a single grouped exception is unwrapped) and the task group cancels outstanding siblings.suppress_errors=Trueconverts the ordinary manager-exception path to values, but cancellation-class failures still propagate.return_exceptions=Trueretains raised failures in their input slots. - Sequential:
_act()is awaited in list order and every result is appended. A raised unsuppressed exception stops the loop; an error returned because suppression is enabled does not. - Invalid strategy: any value other than
concurrentorsequentialraisesConfigurationError. - Per-call lifecycle: authorization, hook ids, event records, and message pairs are independent for every item under either strategy.
Why this way
The strategy switch gives callers ordering when effects depend on prior branch state and parallelism when calls are independent. Reusing AlcallParams makes operational controls available without a second batch framework. It also means callers, not the action layer, own the safety of retries and high concurrency.
D5 — Governance, processors, and low-level access are separate seams¶
The session gate accepts ToolInvocation before the manager constructs a FunctionCalling. Agent permission policy is converted to a tool preprocessor:
# lionagi/agent/permissions.py
@dataclass
class PermissionPolicy:
mode: str = "allow_all"
allow: dict[str, list[str]] = field(default_factory=dict)
deny: dict[str, list[str]] = field(default_factory=dict)
escalate: dict[str, list[str]] = field(default_factory=dict)
on_escalate: Callable | None = None
def check(self, tool_name: str, action: str, args: dict) -> PermissionDecision: ...
def to_pre_hook(self) -> Callable: ...
Factory hook composition is:
security_pre:* and security_pre:<tool>
→ pre:* and pre:<tool>
→ security hooks again, but only when user pre-hooks exist
→ callable
→ post hooks
Exact semantics
- Session authorization payload: function, raw dictionary arguments (or
{}for a non-dictionary), and branch id. - Request-model validation: runs only after authorization, when the manager builds
FunctionCalling. - Tool processor payload: the Pydantic-normalized argument dictionary. A processor may replace it with another dictionary before invocation.
- Security revalidation:
_chain_pre_hooks()appends the same security hooks after user hooks only when at least one user hook exists. With no user pre-hook, security runs once. - Permission errors: a permission preprocessor raises
PermissionError; because it occurs insideEvent.invoke(), it becomes a capturedFAILEDevent rather than the session gate's denial-shaped value. D3 then projects it as a post-callNoneoutput. - Public low-level manager:
branch.actsreturns the manager. A caller can invoke it directly, receiving request-model validation and event capture but bypassing session authorization,HookBus, branch logging/emission, and messages. - Public callable:
Tool.func_callablecan be called directly, bypassing manager validation, tool processors, event lifecycle, governance, hooks, logging, and history. - Normal model paths:
operate()and LNDL call the branch action operation, so their model-generated requests use the governed transaction.
Why this way
The seams were added for different scopes: session gates govern a conversation, processors adapt or protect one tool, and manager/callable access supports testing and low-level composition. Their coexistence is useful but not substitutable. A future authoritative executor must name bypass APIs explicitly and present every policy phase with one normalized call context.
D6 — Sync execution is inline and awaitability follows declarations¶
FunctionCalling._invoke() calls is_coro_func() separately for the preprocessor, tool callable, and postprocessor.
Exact semantics
- An
async deffunction is awaited on the current event loop. - A declared synchronous function is executed directly on the event-loop thread; it is not sent through
run_sync()by the generic action executor. - A sync pre- or postprocessor has the same inline behavior.
- A callable object or decorator whose declaration is classified as sync but whose return value is awaitable produces that awaitable as data; the executor does not call
inspect.isawaitable()on results. - Blocking sync CPU or I/O stalls other calls sharing the event loop, including a nominally concurrent action batch.
- Built-in tools that know they perform blocking work explicitly use
run_sync()inside their async wrappers; this is a provider implementation convention, not enforcement byFunctionCalling.
Why this way
Direct sync support makes ordinary Python functions easy to register and avoids imposing thread handoff on tiny pure functions. The absence of an explicit blocking policy leaves latency and decorator edge cases to individual tool authors, which is retained as a delta rather than presented as an ideal contract.
Consequences¶
- Ordinary callable failures survive as queryable event records and do not inherently abort sibling calls in a concurrent batch.
- Session denial is model-visible and occurs before hook and callable side effects.
- The normal branch transaction provides separate integration points for pre-guards, post/error observation, durable logs, reactive observer handlers, and conversation messages.
- Captured tool failures are truthful in the event but absent from action-response history when the response remains
None; a successfulNoneand a failed call are conversation-equivalent unresponded requests today. - A blocking
TOOL_PREexception bypasses suppression and history, while a permission preprocessor exception is captured and then misprojected by D3. Policy location changes user-visible semantics. - An observer exception after invocation can leave a durable function event without the linked action response in conversation history. Recovery and debugging must compare both records rather than assuming they advance atomically.
- Direct manager and callable entry points are useful escape hatches but are not governed equivalents of
Branch.act(). - Concurrent batches have no default cap or retry; caller-supplied retries can repeat non-idempotent effects, while an unsuppressed failure can cancel outstanding siblings.
- Reversing D1 from captured failures to raised failures would change manager and batch control flow. Reversing D2 requires migrating every model-facing operation that relies on action history and hooks.
- Contributors must inspect
FunctionCalling.statuswhenever invoking through the manager and must explicitly offload blocking work inside async tool adapters.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Make the branch action transaction inspect FunctionCalling.status, emit TOOL_ERROR for captured failures, persist an error-bearing ActionResponse, and reserve TOOL_POST for completed calls; acceptance must define and persist a linked response for successful None and distinguish it from failure in sequential and concurrent regression tests. | M | #2014 |
| 2 | Publish one authoritative branch action executor and reduce ActionManager to registry and resolution responsibilities; acceptance requires Branch, operate, iterative reasoning, and LNDL paths to use the executor, any no-history invocation to be explicitly named, and observer/message-callback failures to have a tested policy that cannot silently split event logging from action history. | M | (filled at issue-open time) |
| 3 | Define one normalized call context and explicit authorization, intrinsic-policy, agent-policy, transform, and revalidation phases; acceptance requires every phase to receive the same normalized arguments and security revalidation not to depend on whether a user preprocessor exists. | M | (filled at issue-open time) |
| 4 | Define and enforce a sync-tool execution policy; acceptance requires blocking sync work either to be offloaded by the executor or to require an explicit inline opt-in, with returned awaitables handled consistently. | S | (filled at issue-open time) |
Alternatives considered¶
A. Let callable exceptions propagate instead of recording failed events¶
This would make failure impossible to overlook and align with conventional Python call semantics. It lost because event status, duration, and error are durable execution data, and independent action batches benefit from a returned attempt record. Cancellation still propagates because it belongs to task control rather than business failure.
B. Return only values from ActionManager.invoke()¶
This would simplify callers and hide the event abstraction below the manager. It lost because callers need status, duration, and error for logging and reactive observation; the branch transaction persists the full FunctionCalling, not only its response.
C. Treat any returned FunctionCalling as success¶
This is the organic shape currently shipped: exceptions escaping the manager enter the error path, while a returned object enters the post path. It bought simple exception-led control flow. It loses the event's status discriminant, creating the successful-None ambiguity. Delta 1 retains the status-aware alternative as the required correction.
D. Raise session denial as PermissionError¶
This would make authorization failure match ordinary Python access-control behavior and stop the current operation immediately. It lost because reasoning loops need to see the denial as a tool result and adapt, and denial is expected policy output rather than an executor malfunction.
E. Put all policy in the session gate¶
One gate would give authorization a single location and avoid processor-dependent failure semantics. It lost because intrinsic tool policies and transforms travel with a tool even outside one session, while session governance needs branch and conversation scope. The unresolved requirement is a shared normalized call context, not deletion of either scope.
F. Put all policy in tool preprocessors¶
This would keep tools self-contained and make direct manager invocation policy-aware. It lost because preprocessors run after request construction and cannot represent the session observer's pre-invocation audit/denial contract. Direct callable access would still bypass them.
G. Execute every sync callable in a worker thread¶
This would protect the event loop from blocking I/O and CPU work. It lost in the shipped generic executor because it imposes thread handoff and thread-safety constraints on small pure functions and bound objects. Built-ins offload known blocking operations manually; Delta 4 requires an explicit policy instead of relying on convention.
H. Detect and await any returned awaitable¶
This would make decorated sync wrappers and callable objects behave like coroutine functions. It lost because the implementation uses declaration classification uniformly for processors and callables. The current behavior is simple but sharp; changing it requires tests for nested awaitables and postprocessor ordering.
I. Make a batch transactional¶
An all-or-nothing batch could roll back earlier tool effects when one call fails. It lost because arbitrary tools cross files, processes, networks, and conversation state with no shared transaction manager. Sequential ordering and per-call results are honest; atomic multi-step behavior belongs inside a purpose-built tool.
Notes¶
The existing ActionResponseContent.error field can carry a structured failure without changing successful output values. Whether branch execution returns failures as values or raises them is separable from whether history represents the failure truthfully.