ADR-0052: Supported validation and testing surfaces¶
- Status: Accepted
- Kind: Retrospective
- Area: utilities
- Date: 2026-07-09
- Relations: none
Context¶
Two auxiliary packages sit outside LionAGI's primary session and operation surfaces but are nevertheless public integration contracts: lionagi.work and lionagi.testing. Their names can invite broader assumptions than the code supports, so the shipped boundary and failure behavior must be explicit.
P1 — Structured inputs need typed, reusable validation without a scheduler. A caller needs to declare fields, apply defaults, coerce common scalar/container types, run ordered rules, and receive all validation errors. None of that requires task dispatch, worker assignment, or persistence. FieldSpec, WorkForm, Rule, and RuleSet provide the form contract (lionagi/work/form.py, lionagi/work/rules.py).
P2 — li agent --form must fail before a model call. Treating a form as loose prompt context would allow misspelled or undeclared values to bypass validation. The CLI therefore owns a stricter closed-schema preflight boundary than direct fill_form() callers: it validates the file shape, declared fields, values, and form status before it constructs the eventual model invocation (lionagi/cli/agent.py).
P3 — Lifecycle names must not imply a shipped execution engine. WorkForm exposes submitted and completed states, but no production submitter, dispatcher, worker registry, or persistence projection consumes them. The states exist in the public model; they do not prove a task engine exists.
P4 — Downstream tests need deterministic coverage of the real Branch/iModel boundary. A separate fake Branch protocol would miss request construction, streaming, structured response parsing, tool-call formatting, endpoint matching, and copy behavior. TestBranch constructs a normal Branch and iModel around a registered ScriptedEndpoint, which serves typed canned responses and records calls without an external request (lionagi/testing/_branch.py, lionagi/testing/_endpoint.py).
P5 — The testing namespace participates in production endpoint discovery. The service registry imports lionagi.testing._endpoint so provider="scripted" is selected through the normal endpoint registry. lionagi.testing.__all__ also publishes scripted infrastructure, response models, legacy mock helpers, async helpers, and data loaders. It is not an unversioned repository-local helper directory (lionagi/testing/__init__.py, lionagi/service/connections/registry.py).
| Concern | Decision |
|---|---|
| Form data and lifecycle | D1: Keep FieldSpec and WorkForm as Element-based typed forms whose transitions are explicit but whose submitted/completed states have no execution semantics. |
| Filling and declarative rules | D2: Fill by declaration/default, coerce declared values, run every enabled rule in insertion order, and return a new validated/error form with collected failures. |
| CLI preflight | D3: Treat li agent --form as a closed, fail-before-model validation gate and render only validated values into the prompt preamble. |
| Public testing API and script matching | D4: Support the names in lionagi.testing.__all__, with typed script/response shapes, positional or conditional matching, and call inspection. |
| Scripted endpoint integration | D5: Register provider="scripted" through normal endpoint discovery and serve non-streaming/streaming/error entries without external I/O. |
| Numeric test and validation bounds | D6: Preserve the regex input cap, endpoint queue/concurrency defaults, scripted delay units, and async-helper budgets as visible defaults. |
This ADR deliberately does not decide:
- task scheduling, dispatch, worker execution, queues, or work persistence; no such engine exists in
lionagi.work; - a production business workflow for
submittedorcompleted; they are currently only model transitions; - model/provider behavior outside the scripted endpoint;
- pytest's own stability or fixture-discovery rules; this ADR records the shipped plugin surface;
- safety for untrusted regular-expression patterns. The rule layer bounds input length but still uses Python's backtracking
reengine with caller-supplied patterns; - performance guarantees implied by test timeout defaults. They are helper defaults, not service objectives.
Decision¶
D1 — Work forms are typed value containers, not work execution¶
FieldSpec and WorkForm both inherit Element, so they carry the normal Element identity/metadata contract in addition to the fields below.
The model contract (lionagi/work/form.py) is:
FieldType = Literal["str", "int", "float", "bool", "list", "dict"]
FormStatus = Literal[
"draft", "filled", "validated", "error", "submitted", "completed"
]
class FieldSpec(Element):
name: str
type: FieldType = "str"
required: bool = True
default: Any = None
description: str = ""
def coerce(self, value: Any) -> Any: ...
class WorkForm(Element):
title: str = ""
fields: dict[str, FieldSpec] = Field(default_factory=dict)
values: dict[str, Any] = Field(default_factory=dict)
status: FormStatus = "draft"
validation_errors: list[str] = Field(default_factory=list)
@property
def form_id(self) -> str: ...
def get(self, name: str, default: Any = None) -> Any: ...
def field_names(self) -> list[str]: ...
def is_complete(self) -> bool: ...
def transition_to(self, new_status: FormStatus) -> WorkForm: ...
Both Pydantic models allow arbitrary types, use enum values, populate by field name, and forbid extra model fields.
The declared transition graph is:
stateDiagram-v2
draft --> filled
filled --> validated
filled --> error
validated --> submitted
validated --> error
error --> draft
submitted --> completed
submitted --> error
completed --> [*] Exact semantics:
- A field name must match
[A-Za-z_][A-Za-z0-9_]*. Invalid names fail Pydantic construction with the validator'sValueError. - A non-
Nonedefault must already match the declared Python type, except anintdefault is accepted for afloatfield. Defaults are validated when theFieldSpecis constructed; they are not lazily coerced later. coerce(None)returnsNone. Existing instances of the target type pass through.int -> floatwidens; case-insensitivetrue/1/yesandfalse/0/nostrings become booleans; numeric strings becomeintorfloat. Other mismatches raiseTypeErrorcontaining field, expected type, actual type, and value.form_idis the string representation of the inherited Element UUID.get()is a mapping lookup with a default.field_names()preserves dictionary insertion order.is_complete()returns true forvalidatedandcompleted, but notsubmitted. This is a readiness convenience, not evidence that external work completed.transition_to()validates only the transition table and returnsmodel_copy()with the new status. Invalid, repeated, or outgoing-from-completed transitions raiseValueErrorand list the allowed destinations.- The models are immutable by convention, not frozen. Functional helpers return copies, but direct assignment remains possible under the underlying Element/model configuration.
- No production code in this surface submits a form, marks it completed, dispatches it, persists its lifecycle, or resumes it after restart. Restart behavior belongs to whichever caller stores a serialized form.
Why this way: forms and lifecycle values are useful for validation independently of execution. Retaining the explicit states preserves the public model while this ADR refuses to infer components that are absent from source.
D2 — Filling, coercion, and rules collect failures on new forms¶
The functional API never mutates the source form:
def fill_form(
form: WorkForm,
values: dict[str, Any],
*,
ruleset: RuleSet | None = None,
) -> WorkForm: ...
def validate_form(
form: WorkForm,
*,
ruleset: RuleSet | None = None,
) -> WorkForm: ...
The declarative rule contract (lionagi/work/rules.py) is:
CheckKind = Literal["required", "type", "range", "pattern", "custom"]
class Rule(BaseModel):
rule_id: str
field: str
check: CheckKind
params: dict[str, Any] = Field(default_factory=dict)
message: str | None = None
enabled: bool = True
def apply(self, form: WorkForm) -> str | None: ...
class RuleSet:
def add(self, rule: Rule) -> RuleSet: ...
def remove(self, rule_id: str) -> bool: ...
def get(self, rule_id: str) -> Rule | None: ...
def rules(self) -> list[Rule]: ...
def apply_all(self, form: WorkForm) -> list[str]: ...
Exact fill/validation semantics:
fill_form()iterates declared fields first. A supplied value wins; otherwise a non-Nonefield default is copied; a required field with no value/default stays absent for validation to report.- It then propagates every supplied undeclared key unchanged. This direct API is an open-value container; only the CLI seam in D3 closes it.
- The helper creates a
filledcopy with prior errors cleared, then immediately callsvalidate_form(). It returnsvalidatedorerror, not an observable filled-only result. fill_form()andvalidate_form()do not calltransition_to()and do not require the input form to be in a particular status. Their output status is derived from the current validation pass.- A required field is missing when
values.get(name)isNone; an explicitNonetherefore fails required validation. Non-Nonevalues are coerced with the field contract from D1. - A ruleset sees a copy containing successfully coerced values. Field errors do not short-circuit rules. All rule errors append after field errors.
- Output
valuespreserves undeclared inputs and any declared values that failed coercion; successful declared coercions replace their original values. Outputvalidation_errorsis the complete collected list for that pass.
Exact rule semantics:
- Disabled rules return
None.RuleSet.apply_all()visits every rule in insertion order and collects every non-Noneerror; it never short-circuits. requiredfails only onNone.typeskipsNone, supports the six FieldType names, and acceptsintwherefloatis expected. An unknown type name returns a rule error rather than raising.rangeskipsNone, rejects non-numeric values, then applies inclusiveminandmaxbounds when present.patternskipsNone, rejects non-strings, applies the D6 input cap, compiles with integerflags, returns a rule error for invalid regex, and usesre.searchrather than a full match.customrequiresparams["callable"]. Exceptions raised by the callable are converted to an error string; false usesmessage, thenparams["error"], then the generated fallback.RuleSet.add()returns itself for chaining and raisesValueErroron duplicaterule_id.remove()returns whether it removed a rule.get()returnsNoneon a miss.rules()returns a shallow list copy.
Why this way: callers receive one immutable-by-convention result containing coerced values and all failures, which is better for preflight reporting than exception-at-first-error. Direct open-value behavior is retained as shipped but must not be confused with the stricter CLI contract.
D3 — li agent --form is a closed, fail-before-model gate¶
The CLI accepts a YAML or JSON mapping with exactly these top-level fields:
title: optional string
fields:
field_name:
type: str | int | float | bool | list | dict
required: true
default: null
description: ""
values:
field_name: value
The implementation contract (lionagi/cli/agent.py) is:
_FORM_SPEC_ALLOWED_KEYS = frozenset({"title", "fields", "values"})
def _load_form_spec(path: str) -> dict: ...
def _build_work_form(spec: dict, spec_path: str) -> WorkForm: ...
def _form_to_context_block(form: WorkForm) -> str: ...
Exact semantics:
- The path must exist and be a regular file. Missing paths raise
FileNotFoundError; non-files raiseValueError. - Loading tries
yaml.safe_load()first (YAML also accepts JSON), then falls back tojson.loads()only if YAML loading raises. The resulting top level must be a mapping. - Unknown top-level keys are rejected. Present
fieldsandvaluesmust each be mappings. Values with absent/empty fields are rejected because they would be unvalidated context. - When fields exist, every value key must have a declaration. Each declaration must be a mapping and is constructed as
FieldSpec(name=<mapping key>, **declaration); invalid field specifications are wrapped in a path/field-specificValueError. - A
WorkFormis created andfill_form()runs when there are fields or values. Thus declared required fields are validated even when the values mapping is empty. A completely empty form staysdraftand contributes no values. - File/build errors and
status == "error"are logged and return CLI status 1. This happens beforerun_async()constructs the model invocation. - A non-error form with values is rendered in insertion order as:
[Work Form: <title>]
<key>: <Python repr(value)>
followed by a blank line and the user's prompt. Only this validated form reaches the model call. - The CLI does not currently accept or construct a RuleSet; its gate uses FieldSpec required/type/default behavior. Declarative rules remain a direct Python API.
sequenceDiagram
participant CLI as li agent
participant Work as lionagi.work
participant Model as Branch/model call
CLI->>CLI: load YAML/JSON and enforce closed schema
CLI->>Work: construct fields; fill and validate values
alt invalid spec or form
Work-->>CLI: error(s)
CLI--x Model: no invocation
else accepted form
Work-->>CLI: validated values (or empty draft)
CLI->>Model: invoke with optional validated preamble
end Why this way: --form promises validation, so forwarding undeclared values would violate the option's purpose. The CLI closes the direct API's open-value behavior at the external input boundary.
D4 — lionagi.testing is a supported typed scripting surface¶
Every name in lionagi.testing.__all__ is part of the supported downstream surface. The groups are scripted infrastructure/environment helpers, response entry types, legacy mock builders, async/validation/data helpers, and test-data loaders (lionagi/testing/__init__.py). Pytest fixtures are supplied separately by registering lionagi.testing.pytest_plugin; the plugin itself exports no ordinary names.
The script contract (lionagi/testing/_script.py) is:
class ScriptModel(BaseModel):
version: int = 1
mode: str = Field(default="auto", pattern="^(auto|positional|when_only)$")
responses: list[ResponseEntry] = Field(default_factory=list)
@classmethod
def coerce(cls, source: Any) -> ScriptModel: ...
@classmethod
def from_yaml(cls, path: str | Path) -> ScriptModel: ...
@classmethod
def from_json(cls, path: str | Path) -> ScriptModel: ...
@classmethod
def from_responses(
cls,
responses: list[dict[str, Any] | ResponseEntry],
**kwargs: Any,
) -> ScriptModel: ...
def next(self, payload: dict[str, Any], call_index: int) -> tuple[ResponseEntry, str]: ...
def reset(self) -> None: ...
Script models forbid extra top-level fields. Runtime _cursor and _served_by_when are private and absent from serialization.
The response payload shapes (lionagi/testing/_types.py) are:
class WhenMatcher(BaseModel):
prompt_contains: str | None = None
prompt_regex: str | None = None
has_tool: str | None = None
after_calls: int | None = None
call_index: int | None = None
class TextResponse:
type: Literal["text"] = "text"
content: str
when: WhenMatcher | None = None
class ToolCallResponse:
type: Literal["tool_call"] = "tool_call"
name: str
arguments: dict[str, Any] = Field(default_factory=dict)
id: str | None = None
when: WhenMatcher | None = None
class StructuredResponse:
type: Literal["structured"] = "structured"
data: dict[str, Any]
when: WhenMatcher | None = None
class StreamResponse:
type: Literal["stream"] = "stream"
chunks: list[StreamChunkSpec]
when: WhenMatcher | None = None
class ErrorResponse:
type: Literal["error"] = "error"
kind: Literal["rate_limit", "timeout", "server_error", "bad_request", "value_error"] = "value_error"
message: str = "scripted error"
delay_ms: int = 0
when: WhenMatcher | None = None
Response models and matchers forbid extra fields. StreamChunkSpec.type is one of system, thinking, text, tool_use, tool_result, result, or error, with optional content/tool fields, is_error=False, is_delta=False, and empty metadata.
Exact loading and matching semantics:
ScriptModel.coerce()accepts a script model (deep-copied), YAML/JSON path, raw response list, or script mapping. Other types raiseTypeError. Unknown response discriminators raiseValueError; non-mapping entries raiseTypeError.- In
automode, unserved non-emptywhenentries are scanned in source order before positional fallback. A conditional entry is served at most once. Positional fallback skips conditional entries and advances its private cursor. - In
positionalmode, conditional matching is disabled and conditional entries are skipped by the cursor. Inwhen_onlymode, absence of a match raisesScriptExhaustedErrorwithout positional fallback. call_indexandafter_callsare gating conditions. Current content-predicate evaluation then uses the first configured predicate in this fixed order:prompt_contains,prompt_regex,has_tool; returning success for an earlier one does not evaluate later configured predicates. Despite the matcher docstring's “AND-composed” wording, multiple content predicates are not currently ANDed.- Prompt containment is case-insensitive; prompt regex uses
re.search; tool matching compares the exact tool name. A matcher containing onlycall_indexorafter_callscan match. - Exhaustion errors include the call index, available positional count or no-match condition, and the last user message when extractable.
reset()clears both cursor and served-conditional state.
The Branch factory contract (lionagi/testing/_branch.py) is:
def scripted_imodel(
script: Any,
*,
model: str = "scripted-test",
**imodel_kwargs: Any,
) -> iModel: ...
class TestBranch:
@staticmethod
def from_script(script, *, model="scripted-test", name="TestBranch",
user="tester", tools=None, system=None, **branch_kwargs) -> Branch: ...
@staticmethod
def from_responses(responses: list[dict[str, Any]], **kwargs) -> Branch: ...
@staticmethod
def from_text(text: str | list[str], **kwargs) -> Branch: ...
@staticmethod
def from_yaml(path: str | Path, **kwargs) -> Branch: ...
@staticmethod
def from_json(path: str | Path, **kwargs) -> Branch: ...
@staticmethod
def scripted(branch: Branch) -> ScriptedEndpoint: ...
@staticmethod
def calls(branch: Branch) -> list[RecordedCall]: ...
@staticmethod
def attach_script(branch: Branch, script: Any) -> None: ...
The returned normal Branch uses the same scripted iModel for chat and parse. Asking TestBranch.scripted() about a non-scripted Branch raises TypeError, which protects shared tests from accidental external-provider use.
Why this way: typed scripts fail early on malformed fixtures, while normal Branch and iModel construction exercises the production request and response boundaries. A public export list makes the compatibility obligation explicit.
D5 — The scripted endpoint uses normal discovery and no external request¶
The provider registers through the standard decorator (lionagi/testing/_endpoint.py):
@register_endpoint(
provider="scripted",
endpoint="chat/completions",
aliases=["chat", "query_cli", "cli"],
endpoint_type=EndpointType.AGENTIC,
base_url="internal",
auth_type="bearer",
)
class ScriptedEndpoint(AgenticEndpoint):
is_cli: ClassVar[bool] = True
DEFAULT_QUEUE_CAPACITY: ClassVar[int] = 10
DEFAULT_CONCURRENCY_LIMIT: ClassVar[int] = 3
def __init__(self, config: Any = None, **kwargs: Any) -> None: ...
def attach_script(self, source: Any) -> None: ...
def clear_calls(self) -> None: ...
async def _call(self, payload: dict[str, Any], headers: dict[str, Any],
**kwargs: Any) -> dict[str, Any]: ...
async def stream(self, request: Any, extra_headers: dict | None = None,
**kwargs: Any) -> AsyncGenerator[StreamChunk]: ...
Service discovery imports the leaf module lionagi.testing._endpoint with the other provider modules. Import failures are ignored consistently with optional providers; successful import installs the standard registry metadata. scripted_imodel() selects it through provider="scripted", endpoint="chat".
Exact endpoint semantics:
script=is removed before base endpoint configuration so it cannot leak into a request payload. If absent,LIONAGI_TEST_SCRIPTis consulted. A dummy API key andrequires_tokens=Falsesatisfy common endpoint machinery without token counting or external authentication.- No script creates an empty
ScriptModel, logs a warning when no script/env value was provided, and causes the first call to raiseScriptExhaustedError. - Non-stream
TextResponsebecomes an OpenAI-chat-compatible response with assistant text,finish_reason="stop", and zero token usage.StructuredResponse.datais JSON encoded into the assistant content so ordinary Branch parsing handles it. ToolCallResponsebecomes one function tool call; a missing id receives a generatedcall_...id and arguments are JSON encoded. A scriptStreamResponseserved through the non-stream path concatenates only text chunks.- Streaming a
StreamResponseyields each mappedStreamChunkin order. Streaming a text entry yields a text delta and then a done result. Streaming tool/structured entries yields one result chunk containing the encoded normal response. - A scripted error optionally sleeps for
delay_ms / 1000, records the call, then maps rate limit/server/bad request to HTTP-like 429/500/400ClientResponseError, timeout toasyncio.TimeoutError, and other errors toValueError. The streaming error path first yields an error chunk, then raises the mapped exception. - Every served call appends a
RecordedCallcontaining a shallow payload/header copy, response type/value, streamed flag, and matching reason.clear_calls()does not reset the script cursor;attach_script()replaces and resets the script. - Copying endpoint runtime state deep-copies and resets the script cursor for the clone and shallow-copies prior call records. Future calls on original and clone do not consume each other's positional entries.
- All responses are constructed in process. The endpoint does not invoke an external HTTP service;
aiohttpis used only to construct realistic error objects.
Why this way: normal endpoint registration exercises the same discovery, payload, Branch, parse, stream, and copy paths as other providers. In-process serving keeps the test deterministic and observable without defining a second fake-Branch interface.
D6 — Validation and test defaults are explicit, not performance promises¶
The shipped numerical defaults are:
| Surface | Default | Behavior and rationale |
|---|---|---|
| Pattern rule input | 4,096 characters | Longer strings return a validation error before re.search. This bounds the input dimension of backtracking but does not make a pathological trusted pattern safe. The exact 4,096 choice has no further recorded rationale. |
| Scripted endpoint queue capacity | 10 | Explicitly mirrors AgenticEndpoint; no rationale for exactly 10 is recorded in the scripted endpoint. |
| Scripted endpoint concurrency limit | 3 | Explicitly mirrors AgenticEndpoint; no rationale for exactly 3 is recorded in the scripted endpoint. |
ErrorResponse.delay_ms | 0 ms | No artificial delay unless the script supplies one; any supplied integer is divided by 1,000 before asyncio.sleep. No maximum is enforced here. |
AsyncTestHelpers.assert_eventually | 5.0 s timeout, 0.1 s interval | Polls until success, then raises AssertionError at timeout. Values are inherited testing conveniences with no recorded calibration. |
collect_async_results | 100 items, 10.0 s | Stops at the item limit or soft timeout and returns collected results. It does not raise solely because the timeout elapsed. |
run_with_timeout | 5.0 s | Uses a hard fail_after deadline and raises on expiry. |
wait_for_all | 10.0 s | Hard deadline; cancels unfinished tasks and re-raises timeout. |
| Async cleanup grace | 0.01 s | Allows task cleanup before counting remaining tasks; no rationale for exactly 0.01 s is recorded. |
Changing these defaults affects validation acceptance or test timing/concurrency. They are public defaults but not service-level latency or throughput guarantees.
Consequences¶
- CLI callers receive structured input validation without introducing another scheduler or persistence model. Invalid form input cannot reach a model call through the
--formpath. - Direct Python callers retain an open-value form container and ordered declarative rules. Callers requiring a closed schema must enforce it themselves or use the CLI gate.
submittedandcompletedremain valid public statuses, but maintainers must not document them as a worker lifecycle until production consumers exist.- Downstream projects can test normal Branch, iModel, streaming, structured response, tool-call, error, and endpoint-copy paths deterministically without external I/O.
- Treating
lionagi.testing.__all__as supported increases compatibility obligations. Moving a symbol, changing a response discriminator, or altering script matching requires a deprecation/migration decision. - Provider discovery has an intentional dependency on one leaf testing module. Reorganizing that module can break
provider="scripted"even when pytest helpers are unused. - The matcher docstring and multiple-content-predicate behavior currently disagree. Tests relying on more than one content predicate must account for the implemented priority until the delta is resolved.
- Reversing D3 changes an external safety boundary; reversing D5 would require either special-case provider construction or a parallel fake runtime. Both are higher cost than changing an internal helper.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Decide whether submitted and completed are reserved compatibility states or remove them from the current WorkForm contract; document the selected lifecycle, test every retained transition, and avoid implying dispatch semantics without an execution engine. | S | (filled at issue-open time) |
| 2 | Publish a compatibility and deprecation policy for every name in lionagi.testing.__all__, and add a registry contract test proving that provider="scripted" remains discoverable and network-free without requiring pytest fixture imports. | S | (filled at issue-open time) |
| 3 | Make the undeclared-value policy explicit for direct fill_form() callers: either reject undeclared keys consistently with li agent --form or document and test the direct API as an open-value container. | S | (filled at issue-open time) |
| 4 | Align WhenMatcher documentation and implementation: either require all configured predicates to match or document the shipped prompt_contains → prompt_regex → has_tool priority; add a regression test with multiple content predicates. | S | (filled at issue-open time) |
Alternatives considered¶
Describe lionagi.work as a task engine¶
The lifecycle names and “work” package name could support that framing, and future dispatch components might reuse the models. It lost because no submitter, worker registry, dispatcher, queue, retry engine, or persistence projection exists. An ADR must record shipped behavior rather than infer architecture from names.
Use plain dictionaries for form validation¶
Dictionary schemas would reduce model classes and Element coupling. They would also move field-name/default/type validation into every caller and lose the typed lifecycle and reusable rule API. The current Pydantic/Element models provide one source of truth for Python and CLI construction.
Reject undeclared values in fill_form() itself¶
A closed direct API would match the CLI and prevent accidental extra context. It lost as the retrospective decision because current code deliberately propagates extras and downstream callers may rely on it. The delta requires an explicit compatibility choice rather than silently changing behavior in documentation.
Stop rule evaluation on the first failure¶
Fail-fast validation is simpler and may avoid expensive later checks. It lost because the form is a preflight/reporting surface: callers benefit from correcting all field and rule errors in one pass. Custom rule exceptions are already converted into data, supporting collection rather than unwinding.
Use unrestricted regex validation¶
Removing the 4,096-character cap would avoid rejecting legitimate long values. It lost because Python's backtracking engine can consume disproportionate time on large inputs. The current cap bounds one dimension while honestly leaving trusted-pattern selection to the caller.
Make lionagi.testing internal-only¶
This would allow aggressive refactors and reduce compatibility commitments. It lost because the package declares a deliberate export list, provides a documented pytest plugin, and is imported by normal endpoint discovery. Existing downstream use is a supported interface, not an incidental repository test helper.
Build a separate fake Branch protocol¶
A minimal fake would be faster to construct and easier to control. It lost because it would bypass real iModel/endpoint matching, payload creation, parse handling, streaming, action formatting, and copy behavior—the paths this surface exists to test.
Move ScriptedEndpoint immediately into the service package¶
The production registry dependency would then no longer point into a testing namespace. It lost for the current retrospective contract because public imports, environment helpers, pytest fixtures, and endpoint construction all reference the existing location. Moving it requires a compatibility/deprecation plan, not an unannounced file relocation.
Match script conditions with unconstrained callbacks¶
Python callbacks could express arbitrary request predicates and eliminate the fixed WhenMatcher fields. They would make YAML/JSON scripts non-portable, complicate serialization, and permit test behavior that cannot be inspected from fixture data. The declarative matcher remains intentionally small.
Notes¶
Primary source anchors for this retrospective record are lionagi/work/__init__.py, lionagi/work/form.py, lionagi/work/rules.py, lionagi/cli/agent.py, all public implementation modules under lionagi/testing/, and lionagi/service/connections/registry.py for the production registration exception.