All notable changes to rlm-kit. Format loosely follows
Keep a Changelog. Versions track
rlm_kit/__init__.__version__ and pyproject.toml (kept in sync).
The next release (0.2.0) — not yet published to PyPI; the version number is the target, not a release. Harness-engineering layer, plus the first round of hardening surfaced by dogfooding a real downstream consumer.
-
make_json_schema_validator— validate a parsed object against a JSON Schema (draft 2020-12). The generic base for the "validate against an official, vendored, version-pinned upstream JSON schema" pattern: a consumer vendors the schema file + a refresh script (the provider-specific half) and layers its own bespoke checks on top; the kit owns only the validator wiring. Returns the violation messages ("<path>: <reason>", truncated atmax_errorsso a huge invalid doc can't flood the trace) for a parsed dict — parsing stays the consumer's job, so it composes with any extract/parse step.jsonschemais an OPTIONAL dependency (rlm-kit[jsonschema]), imported lazily soimport rlm_kitand the dspy-freetoolspackage stay lean. Consumer-driven: a downstream consumer was hand-rolling structural gates that drift from the real upstream format; this is the reusable half of moving to authoritative schema validation. -
get_sub_lmpromoted to the public surface (rlm_kit.get_sub_lm; lazy re-export, keepsimport rlm_kitdspy-free). Returns the base sub-LMconfigurebuilt — the instance a consumer wraps withintercept_sub_lmbefore passing asRLMTask(sub_lm=...). Consumer-driven: TWO independent consumers were reaching intorlm_kit.runtime.get_sub_lm(a submodule internal) because the kit exposed no public way to get the configured sub-LM; per the "add a named hook, don't reach into a_privatename" rule it is now that hook. Using it (vs reconstructingdspy.LM(cfg.sub_model, …)) keeps a single source of truth so the wrapped model can't drift fromconfigure. -
Public LM-injection seam +
get_configaccessor (configure(cfg, main_lm=…, sub_lm=…),rlm_kit.get_config).configurenow accepts a pre-builtmain_lm/sub_lmand uses it verbatim instead of constructing one from config — adspy.utils.DummyLMin tests, or a cached / custom client in production.get_config(lazy re-export) reads the activeRLMConfigback. Consumer-driven: consumer test suites (and the kit's own) were poking privaterlm_kit.runtime._STATEto inject a fake LM because there was no public path; this closes that reach-in. Backward-compatible (keyword-only, defaultNone); no wire-format change. -
The trace/v1
EVENT_*type constants are now exported (rlm_kit.EVENT_RUN_START,EVENT_MAIN_STEP,EVENT_SUB_CALL,EVENT_TOOL_CALL,EVENT_FINAL,EVENT_RESULT,EVENT_RUN_END). A trace reader matches on these instead of hardcoding wire strings like"result". Additive to__all__; the strings themselves are unchanged and still pinned bytest_contract.py. -
Reusable resolved-IP SSRF guard for the
direct-fetch pattern (rlm_kit.tools.resolved_host_is_safeparse_cidrs).is_safe_urlis only syntactic; the DNS-rebinding re-check (re-resolve each hop, refuse a private/reserved address) was left to each consumer's fetcher — and every consumer re-derived it.resolved_host_is_safe(host, port, *, allow_nets=())now ships that check ONCE, with anallow_netscarve-out (parse_cidrs(["198.18.0.0/16"])) for a host behind a fake-IP proxy / split-DNS VPN (Clash/Mihomo/Surge map every public host into the reserved198.18.0.0/16, which the strict re-check would refuse — starving the model of all fetched source). Emptyallow_nets= unchanged strictness (is_safe_urlstill refuses localhost/metadata regardless). Consumer-driven: surfaced by a downstreamdirectfetcher refusing every host behind such a proxy.
-
max_output_charsis now configurable (RLMConfig.max_output_chars, envRLM_MAX_OUTPUT_CHARS, default10000— dspy's own default, so behaviour is unchanged). dspy.RLM head+tail-truncates each REPL output to this many CHARACTERS before it enters the planner prompt — the planner never sees the omitted middle. Previously the knob was pinned at dspy's default; now it rides the same best-effort passthrough asmax_iterations/max_llm_calls. (Distinct frommax_tokens, which caps the model's own generation.) -
MCP client — connect an external MCP server's tools to an RLM (
rlm_kit.mcp.mcp_tools, optionalrlm-kit[mcp]).with mcp_tools(server) as tools:connects to someone else's MCP server (a local stdio command, or a remote streamable-HTTP URL), discovers its tools, and yields them as ready-to-usedspy.Tools forRLMTask(tools=…); the connection is live for the block and torn down on exit. rlm-kit is a CLIENT only (never a server, bundles none). The crux: the MCP SDK is async but dspy.RLM invokes tools synchronously, so the session runs in a dedicated background thread + event loop and each call bridges viarun_coroutine_threadsafe(...).result(timeout)— dspy's ownTool.from_mcp_toolyields an ASYNC tool forReAct.acall, unusable on the RLM sync path. A hung tool call trips thetimeoutand is cancelled (so it can't wedge the serial session); a start failure still tears the bridge down (no leaked thread/subprocess). Both stdio and streamable-HTTP transports are integration-tested against a real server. Each call records atool_call(trace/v1, no schema change). MCP tools run HOST-SIDE (outside the sandbox; a stdio server is a spawned subprocess) — treat the server as a trusted dependency and its output as a prompt-injection surface.mcp.pylives outside the dspy-freetools/andmcp_toolsis a lazy export, soimport rlm_kitstays dspy/mcp-free. -
The extension contract is now documented AND guarded (
CLAUDE.md,README.md,tests/test_contract.py) — so the next consumer builds on rlm-kit without reverse-engineering it. A README "Building a consumer" section states the 5-step recipe, the promotion rule (generic → kit via the base/wrap split; specific → consumer), and the rollout-vs-reward stage boundary; three matching CLAUDE.md hard invariants pin the contract — the trace is a VERSIONEDrlm-kit/trace/v1wire format (additive-only within v1; removing/renaming/re-typing an event type, envelope key, or established payload field is av2break), the kit produces TRAJECTORIES not reward (every exporter carries areward=hook the downstream trainer fills), and the public surface is__all__(consumers EXTEND via subclass + base/wrap + read-the-trace; if a seam is missing, ADD a public hook — howrecorder_scope/bind_recorder_to_sub_lmwere born — never reach into a_privatename).tests/test_contract.pyfreezes that v1 surface — SCHEMA, the sevenEVENT_*strings, the recorded-event envelope, and theexport_actions/export_sft_turns/export_rlrecord shapes + the public__all__— so a kit change that would silently break a downstream reader (a consumer's report + RL export, a consumer UI's replay) fails HERE in the kit's own suite, not opaquely in the consumer. (+7 tests → 148.) -
Fixed: batched lifeline escalations are now recorded (
trace.recorder_scope+sub_lm.bind_recorder_to_sub_lm, wired inRLMTask.arun; surfaced dogfooding a consumer's UI — a run that usedllm_query_batchedrecorded ZEROsub_calls, solifeline_callsunder-counted). Root cause:dspy.RLM.llm_query_batchedfans the sub-LM across aThreadPoolExecutor, and aContextVaris NOT inherited by executor worker threads (unlike an asyncio task) — so the sub-LM'scurrent_recorder()wasNonethere and the escalation went untraced (a single, same-threadllm_querywas fine).arunnow binds the active recorder to the sub-LM per run; the binding re-establishes the recorder ContextVar inside whatever thread the sub-LM is called from. Per-run, so concurrent runs sharing the base sub-LM don't cross-contaminate; dspy stores+callssub_lmwith no isinstance check, so the duck-typed proxy is a valid drop-in. -
A FAILED run now records its trajectory too (
RLMTask.arun).record_main_trajectoryran only on success, so a run that exhausted the retry budget (e.g. the result never coerced intooutput_model) was written with ZEROmain_steps — blind on the planner side, exactly when you most need to see what it did. Now the last attempt's trajectory is recorded before the error re-raises. No result event is recorded (there is none), so every reader still keys success offRESULTand the run stays correctly "failed" (the SFT keep-filter, complete+valid, still excludes it). Surfaced dogfooding a consumer UI's trajectory drawer — a failed run was unnavigable. -
read_skillrecords a contentpreview(a head of the skill, alongside the existingresult_len), so a trace reader / replay UI can show WHAT was read, not just how long it was — matching how a model-tool call records its output. Inspection-only; the planner still gets the full text. -
Live per-turn
main_steptimestamps (TraceRecorder.begin_main_capture/note_main_step, arecord(ts=…)override, and an auto-installed_MainStepTimerdspy parse callback inRLMTask.arun; surfaced by dogfooding a consumer UI's trajectory view).dspy.RLMonly exposes its REPL trajectory on the FINALPrediction, sorecord_main_trajectorystamped everymain_stepat finalize time (all identical) — a run was "blind mid-trajectory" for per-turn timing while tool_calls were already live-stamped. Now a per-turn parse callback (the only parse carrying bothreasoningandcode) captures each turn's LIVE time;record_main_trajectorymatches it back by reasoning and backfills the eventts, keeping the full{reasoning,code,output}payload,step_id, and file order identical. Provably training-safe: the RL dataset and replay sort bystep_id(neverts) andelapsed_sismax(ts)-min(ts)(main_steps are interior), so only a main_step'stsVALUE improves — now consistent with tool_calls ("when it happened"). Degrades to the oldclock()stamp when no callback is wired (a replay) or the callback context can't be entered. The timer MERGES into dspy's callback list, so it coexists with a consumer's own callbacks (e.g. a consumer's SSE streamer). -
TraceRecorderlive observer (on_event=, surfaced by dogfooding a consumer's UI). An optional callback fired (best-effort, OUTSIDE the lock) for every recorded event as it happens, so a consumer can stream the trajectory live. It is the correct live source fortool_call/sub_call: the RLM's tools run INSIDE the Deno/pyodide sandbox (the planner's REPL invokes them), which bypasses dspy'son_toolcallback entirely — but the recorder sees each one. An observer exception is swallowed so it can never break the source-of-truth trace write. -
Sub-LM-escalation convention (README, surfaced by dogfooding a consumer): escalate to the sub-LM when a model-backed tool WALLS (repeated failures on the SAME gap) instead of circling it — circling a walled tool burns the iteration budget and can hit the cap unfinished; one focused sub-LM question often unblocks it (the sub-LM is the recovery seat). Convention in the consumer's task INSTRUCTIONS, not an API. A consumer can nudge its planner this way (after a few repeated tool declines on a gap), turning a run that would otherwise circle a stuck tool into one that escalates once and converges under the cap.
-
make_model_toolcircuit breaker (max_consecutive_invalid=N, default off; surfaced by dogfooding a consumer — a weak planner ignored the escalation PROMPT and hammered a model-tool dozens of times on an out-of-distribution input, crashing at the iteration cap). A run-scoped breaker: after N consecutive validator declines the next call SHORT-CIRCUITS (no model call,ModelToolResult.circuit_broken=True, emptyraw), capping wasted calls and letting the consumer redirect the root LM (escalate / finalize) instead of letting it thrash. Resets on any validator-ok; an endpoint error does not count. The factory only FLAGS the break — the consumer owns the message + tracing (same base/wrap split). It's the deterministic backstop to the prompt-only sub-LM-escalation convention above. -
Run-config-in-
run_start-meta convention (README corollary to the judgement-only recipe, surfaced by dogfooding a consumer): an OFFLINE, config-free consumer reads only what the trace records, so any per-run config it needs to interpret the run (the value a validator enforced, the budget ahit_iteration_cap-style metric compares against, the model roles) belongs in therun_startmeta — honoring an env override end-to-end (live AND offline labels) instead of a reader guessing a hardcoded default; old traces lacking a key fall back gracefully. A consumer records its canonical author andmax_iterationsthere sorl_exportreads the real per-run values. -
Judgement-only-SUBMIT recipe (README, surfaced by dogfooding a consumer): the companion to grounded completeness, for the producer side of a model-backed tool. When a
make_model_toolis the authoritative producer of an artifact, the root LM'soutput_modelshould carry JUDGEMENT + the producing tool-call's id — never the artifact bytes or a self-reportedvalidflag — and the result is ASSEMBLED on read (re-source the artifact verbatim from the tool-call event, derive validity from the validator) on the live path, re-render, AND the dataset exporters. Stops two failure modes: a root LM re-typing (and mangling) the tool's output, and the SFT SUBMIT turn teaching the policy to re-author the artifact / a self-reportedvalidlying to the keep-filter. Convention, not an API. -
Grounded-completeness recipe (README, surfaced by dogfooding a consumer): documents the agentic-RAG sufficient-context pattern as an RLM convention — hold a retrieved ground-truth in persistent REPL state, diff the generated artifact against it field-by-field, emit itemized gaps, and finalize only when the diff is clean. The fix for CONTENT-correctness defects a format validator can't see (a model self-assessing "complete" from memory ships half-right artifacts). Convention, not an API: it lives in the consumer's task INSTRUCTIONS and needs no new model (the main LM critiques cheaply against its own REPL state). A consumer uses it so the planner stops finalizing a generated artifact whose content only looks right.
-
JSON-literal REPL aliases (
sandbox.py, surfaced by dogfooding a consumer): the deno/pyodide sandbox is now constructed by the kit as a thinPythonInterpretersubclass that pre-bindstrue/false/nulltoTrue/False/Nonein the REPL namespace. A JSON-trained instruct model habitually writes JSON literals inside the Python REPL — e.g.SUBMIT({"valid": true})— which raisedNameError: name 'true' is not definedand made the model thrash on the identical call (one consumer run burned 14/25 REPL turns on exactly this). Same isolation as dspy's own default interpreter;RLMTasknow owns the interpreter's teardown (dspy only tears down one it built itself). A real user variable of the same name still shadows the alias. -
Sub-LM interception hook (
sub_lm.py):intercept_sub_lmwraps adspy.LMso the RLM's sub-LM escalations (via the built-inllm_query/llm_query_batched) are traced assub_callevents, with an optional deterministic validate → post-process pipeline;model_as_toolexposes extra models for LM-decided multi-model routing. (Renamed frommake_middleware_lm— see Changed.) -
Skills-as-tools (
skills.py):load_skills_as_toolssurfaces a Skills directory to the RLM. Defaultdiscovery="list"gives the LMlist_skills/read_skill(discover-then-read).discovery="inject"returnsread_skillonly, and the caller injects the catalog into the prompt itself viarender_skills_manifest(dir)(or reads it structurally withdiscover_skills) — skipping thelist_skillsround-trip when the skill set is small and fixed. -
Unified trajectory recording (
trace.py):TraceRecorderwrites an append-only JSONL stream — main steps (Prediction.trajectory), every sub-LM call, every tool call — keyed byrun_id+step_id. Optional Langfuse mirror. -
Replay + datasets (
replay.py,dataset.py): reconstruct a run using recorded tool outputs;export_sft_turns/export_rlturn traces into training data. -
dataset.export_actions(surfaced by dogfooding a consumer): emits EVERY action — planner step, model-as-tool call, sub-LM escalation — as a first-class,kind-tagged RL record (so a trainer can split generator vs orchestrator data), with the pluggable run reward attached.export_rlstays planner-focused. -
dataset.export_sft_turns(surfaced by dogfooding a consumer): per-root-TURN SFT samples (input = full historySEEDED with the run's initial state from therun_startmeta,output = that turn) — the RLM post-training recipe of arXiv 2512.24601 (App. A: one sample per iteration, mask loss tooutput). The seed is the "first user input" a bare RLM trajectory lacks (the prompt lives in a REPL variable, not a chat turn). -
tools.make_model_tool(promoted from dogfooding a consumer): the generic "model-as-tool + validate" core — chat a secondary model, retry ONLY transient endpoint errors, capture thinking-mode reasoning, run a validator, return aModelToolResult. Like the fetch / web_search bases, it picks no endpoint and templates no messages; the consuming project wraps it with its ownchat_fn+ validator + tool name/messages/tracing. -
sub_callevents now capture the escalation input (sub_lm.py): the the intercepted sub-LM records the prompt the planner sent the sub-LM, not just its output — needed for RL data on escalations. -
trace.record_tool_call(surfaced by dogfooding a consumer): one helper that owns thetool_callemission — active-recorder lookup,None-guard, and the canonical{tool, args, …}payload shape the replay/dataset readers consume. Every tool wrapper (in a consumer:fetch_url,web_search, a model-tool generator, a validator) previously hand-rolled that boilerplate and re-derived the payload shape by hand — so the trace format, the replay/RL source of truth, was copied across each consumer instead of owned in one place. Now used internally bymodel_as_tool, the skills tools, and themake_fetch_tool/make_web_search_toolfactories too; it no-ops without an active recorder, so a tool may call it unconditionally.make_fetch_toolrecords only the outcome (ok+result_len, ornoteon refusal/error) and NOT the fetched body — bulk content lands in a REPL variable, so recording it would only bloat the JSONL (mirrorsread_skill); a fetcher error is caught and returned as text.make_web_search_toolis symmetric: bothok=Falsepaths (empty query, searcher error) return a short reactable string rather than[]or a raised exception, so the planner gets actionable text in its REPL. (trace.py, tools/) -
GEPA harness skeleton (
optimize.py): metric templates now;compile_taskis a documented Phase-2 stub.
- No more "Unclosed connector" warning from litellm (
runtime.py). litellm (dspy's LM backend) defaults to an aiohttp transport whose pooledClientSessionis bound to the per-runasyncio.runloop; when that loop closes, aiohttp logs a noisy "Unclosed connector" through the loop's exception handler.RLMTasknow setslitellm.disable_aiohttp_transport = Truebefore the first LM call, forcing litellm onto httpx so no aiohttp session is created and nothing dangles. Best-effort and idempotent — a litellm-free install just no-ops. - Retry logging no longer floods the terminal with a degenerate LM completion
(
_retry.py). A failed attempt was logged with the caught exception's full string, and dspy'sAdapterParseErrorembeds the ENTIRE raw LM completion in its message — so a root model that degenerates into a repetition loop (never emitting the expected output fields) dumped thousands of lines to stderr.run_with_retrynow formats the logged exception through_short_error: the exception type + head + tail are kept (the adapter name and the expected/actual-fields summary survive), the middle is elided. Normal short errors still log in full; only a pathologically large message is capped. Consumer-driven: surfaced by a downstream studio whose general (non-fine-tuned) root model degenerated on a run.
-
RLMConfig.max_retriesnow defaults to1(was3) — no whole-RLM retry by default (config.py; breaking).run_with_retryre-runs the ENTIRE RLM on any output-coercion failure, so the old default of 3 silently MULTIPLIEDmax_iterations(up to 3× the turns) and re-did every fetch/search/tool call — breaking the budget contract a consumer + its UI rely on, while rarely fixing a PERSISTENT failure (same model + schema → same bad output; the dominant real failure is a TRANSIENT planner-endpoint hiccup, not a coercion bug). Now a run executes the RLM EXACTLY once and fails cleanly if it can't finalize (and, since record-on-failure, still records its trajectory). RaiseRLM_MAX_RETRIESonly when transient infra flakiness genuinely warrants a whole-run retry, knowing the budget cost. -
configure()tolerates a non-owner thread/task (runtime.py, surfaced by dogfooding a consumer's UI).dspy.configureis owner-locked — dspy records the first thread + async task to call it and raises "can only be changed by the thread that initially configured it" on a later call from a different one. A long-lived driver running each task in its own worker thread (a server handles per-request live runs viaasyncio.runin a fresh thread) crashed on the 2nd run. The global LM config set by the firstconfigureis READABLE from every thread, so on a non-owner thread the kit reuses it: swallow ONLY that ownershipRuntimeError(thread or async-task variant) and continue; re-raise anything else. -
Plain model ids with a custom endpoint (
runtime.py). Whenbase_urlis set,configurepins litellm'scustom_llm_provider="openai", so model names can be the bare id the endpoint serves (qwen/qwen3-next) instead of the misleadingopenai/qwen/qwen3-next. dspy.LM runs on litellm, which routes by parsing a provider out of the model string; a bareqwen/...makes it readqwenas the provider and fail ("LLM Provider NOT provided"), so theopenai/prefix was a litellm routing tag — not a vendor claim — that read as if a Qwen model were an OpenAI one. The pin sends the id verbatim tobase_url(matching the bare-name convention the raw-OpenAI-SDK generator already used); a still-prefixedopenai/...name keeps working. With no base_url, write litellm's own prefix as before. -
RLMConfig.max_tokensnow defaults to8192instead ofNone(config.py). WithNonethe kit sent nomax_tokens, so the SERVER applied its own default cap (1000 on the dogfooded NIM/vLLM). A reasoning model emits its chain-of-thought (reasoning_content) BEFORE the answer (content); a turn whose reasoning exceeds that small cap is truncated mid-thought (finish_reason="length",completion_tokens=1000) andcontentcomes back empty → dspy's "The LM returned an empty or null response", failing the run intermittently (only the verbose turns). This is not a vLLM/NIM or guided-decoding bug — it is any reasoning model behind any OpenAI-compatible server that capsmax_tokenslow by default. Shipping a generous default leaves room for reasoning + answer everywhere; setRLM_MAX_TOKENS(orRLMConfig(max_tokens=None)) to defer to the server. (diagnosed by capturing per-callfinish_reason/reasoning_len/completion_tokenson a telnet run; verified: 16 calls, 0 empty / 0 length-truncations at 16384 vs an empty at the 1000 cap.) -
CI/release workflows hardened for the public-repo + PyPI-publish threat model (
.github/workflows/).ci.ymlnow runs least-privilege (permissions: contents: read— it only checks out and tests/lints; specifyingpermissions:drops every unlisted scope tonone, so a compromised action on an untrusted fork PR can't push, tag, or open issues) withconcurrencycancel-in-progress. Both workflows now SHA-pin their third-party actions —astral-sh/setup-uv, and (highest blast radius, it uploads to PyPI)pypa/gh-action-pypi-publish— so a repointed tag can't inject code that runs with the token; GitHub-ownedcheckout/*-artifactstay on major tags.release.yml's OIDC Trusted Publishing (no API token,id-token: writescoped to just the publish job) is untouched. (Ported from the same hardening applied to a downstream consumer, itself borrowed from a public awesome-list repo's CI posture.) -
New
RLMConfig.adapter(RLM_ADAPTER) selects the structured-output adapter; default"json"(schema-guided) (config.py,runtime.py). Modes:"json"(default),"chat","default"."json"drives schema-guided structured output end-to-end._LenientJSONAdaptermakes a structured-output server constraint-decode the planner, so it emits valid output even when the model formats imperfectly — aJSONAdapterthat forces thejson_schemaresponse_format (nolitellm.register_modelpoke — it bypasses dspy'ssupports_response_schemagate directly), removes stock dspy'sjson_objectfallback, AND tolerates a JSON object body emitted without the outer{ }. StockJSONAdapter, when itsjson_schemaattempt raises for ANY reason (incl. a transient upstream 502), falls back to bareresponse_format={"type":"json_object"}and re-calls — which vLLM/NIM reject with a 400 ("'json_object' requires a JSON schema") that masks the real error and burns the retry on a dead-on-arrival format. The lenient adapter instead always sendsjson_schemaand lets a failure propagate (drivingChatAdapter's call path, which for aJSONAdapterinstance raises rather than falling back), so the task-level retry re-tries the format the server accepts; and it brace-wraps an unbraced object body before re-parsing (schema-guided backends intermittently drop the{ }). Works on any structured-output endpoint — OpenAI-proper AND vLLM/NVIDIA-NIM (which reject schema-less json_object but accept json_schema). NewRLMConfig.max_tokens(RLM_MAX_TOKENS) caps per-call generation so verbose guided JSON isn't truncated mid-object."chat"→dspy.ChatAdapter(use_json_adapter_fallback=False): never sendsresponse_format; for an endpoint with NO structured-output support. The fallback is off because dspy's stock ChatAdapter recovers via barejson_object, which vLLM rejects — but that also means a weak model dropping a field has no recovery here, so"chat"is not as portable as it looks (it regressed an OpenAI-proper + mini-model run that"json"/"default"both handle)."default"→ leave dspy's stock adapter (ChatAdapter with the json_object fallback): recovers on OpenAI-proper endpoints, but the fallback is rejected by vLLM/NIM. (Why this exists: dspy's stock ChatAdapter, on a parse error, retries throughJSONAdapterand emitsresponse_format={"type":"json_object"}; vLLM returns 400 "'json_object' requires a JSON schema" — a fronting proxy may mask it as "all channels failed". Surfaced + verified by dogfooding a consumer across a vLLM/NIM planner AND an OpenAI-proper gpt planner.)
-
make_fetch_tool/make_web_search_toolare now SYNC (wereasync def). dspy's interpreter invokes tools with a plain synchronous call andstr()-serialises the result (PythonInterpreter._handle_tool_call, noawaitonforwardoraforward), so an async tool returned an un-awaited coroutine — its body never ran and the model received"<coroutine object …>". The async factories were thus unusable as RLM tools (a silent footgun, and why a consumer hand-rolled sync versions over the primitives). Now sync and directly usable inRLMTask(tools=…);fetcher/searcherinputs are sync too. New CLAUDE.md invariant: tools passed toRLMTaskmust be sync. (tools/fetch.py, tools/search.py) -
Renamed
make_middleware_lm→intercept_sub_lm(andMiddlewareError→SubLMValidationError). The old name hid the function's actual job: it is THE hook to intercept the RLM's sub-LM (dspy.RLM exposes no other —llm_queryjust callssub_lm(prompt)), and its always-on job issub_calltracing, with validate/ post-process as opt-in. Pre-1.0 hard rename, no alias; the sole consumer is updated in lockstep. The module file was renamedmiddleware.py→sub_lm.pyto match. (sub_lm.py) -
sub_callpayload labels its role explicitly. Addedkind:"sub_lm"and renamed themiddlewarefield toname(the wrapper's label), so a reader/dataset sees "this is a sub-LM escalation" without decoding an implementation detail.dataset.py/replay.pyread neither field, so the change is backward-compatible for the readers. (sub_lm.py) -
TraceRecorder.recordis now thread-safe.llm_query_batchedfans sub_lm calls across threads, so a wrapped sub_lm recordssub_calls concurrently; the step-assignment- JSONL write now run under a lock so concurrent records can't race
step_idor interleave lines (the JSONL is the replay/RL source of truth). The Langfuse mirror stays outside the lock. (trace.py)
- JSONL write now run under a lock so concurrent records can't race
-
RLMTask._build_rlmresolves custom output types deterministically. dspy resolves a textual output type (e.g.-> analysis_data: VulnerabilityReport) by walking the call stack and searching each frame's globals/locals for the name. That happens to work when the consumer frame carrying the import is on the stack, but it is an implicit, call-path-dependent coupling: it raisesValueError: Unknown namefor dynamically-built types or when the task is driven from a runner that never imported the type._build_rlmnow bindsoutput_modelexplicitly via dspy'scustom_types=, so resolution no longer depends on the call stack. (dspy silently dropscustom_typeswheninstructions is None— it re-parses the signature without them — so_build_rlmpasses""rather thanNonewhen anoutput_modelis set, keeping the binding for tasks that declared no instructions.) (task.py) -
RLMConfig.from_env()falls back toAI_MODEL_NAME/SUB_AI_MODEL_NAMEso the kit drops into projects already keyed on those vars without re-keying env.RLM_*still wins when set. (config.py) -
configure(observe=True)best-effort-bootstraps the Langfuse client (previously only OpenInference was instrumented), so consumers don't have to callget_client()themselves. Non-fatal if Langfuse is absent. (runtime.py) -
Reasoning models now work as the RLM ROOT (surfaced by dogfooding a consumer — benchmarking a reasoning model as the planner). A reasoning model (qwen3 / deepseek / glm / gpt-oss) served over an OpenAI-compatible API sometimes emits the WHOLE structured turn into the
reasoning_contentchannel and returnscontent(the dict'stext) null; dspy's base_call_postprocessthen raised "The LM returned an empty or null response" and the RLM died on its very first turn with zero REPL steps._LenientJSONAdapter._call_postprocessnow promotesreasoning_contenttotextwhentextis empty, then defers to the normal parse path. Guarded onnot text, so a well-behaved model (answer incontent, thinking inreasoning_content) is untouched and its native thinking stays discarded. This is distinct from the earliermax_tokens-truncation empty-content failure mode (that one truncates mid-thought; this one routes the whole answer to the wrong channel). (runtime.py)
- Initial scaffold:
RLMConfig+configure,RLMTask, the retry/validation engine (_retry.py), the sandbox security guard (sandbox.py), tools (schema validator, SSRF-guarded fetch), examples, and tests.