<< All versions
Skill v1.0.1
currentAutomated scan100/100railtownai/railtracks/middleware
+3 new
──Details
PublishedOctober 28, 2026 at 10:34 AM
Content Hashsha256:981fa574572d113e...
Git SHA7a9c04fd98aa
Bump Typepatch
──Files
Files (1 file, 9.3 KB)
SKILL.md9.3 KBactive
SKILL.md · 145 lines · 9.3 KB
name: middleware description: Add middleware to a railtracks agent or node (retries, logging, timing, guardrails, context injection). Use when the user wants to wrap, retry, guard, or observe node or LLM calls. argument-hint: "[describe the middleware to implement]" metadata: version: "1.0.1"
Add Middleware to a Railtracks Agent or Node
The user wants to add middleware to a railtracks node/agent: $ARGUMENTS
Quick reference
| Decorator | Signature | Slot | Fires on failure? | |
|---|---|---|---|---|
rt.wrap_node | async fn(call, *args, **kwargs) -> result | middleware= or model_middleware= | Yes — you control the try/except | |
rt.wrap_llm | async fn(llm_call, message_history, schema, tools) -> Response | model_middleware= only | Yes | |
rt.pre_llm | fn(message_history, schema, tools) -> (message_history, schema, tools) (sync or async) | model_middleware= only | N/A (runs before the call) | |
rt.post_llm | fn(response) -> response (sync or async) | model_middleware= only | No — skipped on exception | |
rt.post_node | fn(result) -> result (sync or async) | middleware= or model_middleware= | No — skipped on exception |
- All five accept optional keyword-only
name=and work bare or called:@rt.pre_llm,@rt.post_node(name="print_after"). wrap_node/wrap_llmrequireasync def(TypeErrorotherwise).pre_llm/post_llm/post_nodeaccept sync or async.middleware=wraps the whole node call (an agent's full tool-calling loop, including every tool node it invokes).model_middleware=wraps one raw LLM call inside that loop.- Attach at creation —
rt.agent_node(..., middleware=[...], model_middleware=[...])— or after the fact without mutating the original —rt.couple(node, middleware=[...], model_middleware=[...]). - List order = wrapping order:
middleware=[A, B, C]→AwrapsBwrapsCwraps the node.rt.couple-ing more onto an already-middlewared node adds the new ones innermost; existing middleware stays outermost.
Best practices
1. Exception handling & retries
pre_llm,post_llm,post_nodeonly run on success — an exception skips them entirely. If you need to react to failure (cleanup, translation, fallback), usewrap_node/wrap_llmand put your owntry/exceptaroundawait call(...).- Where you put a retry changes what it re-executes:
model_middleware=retry only re-issues the raw LLM call — safe by default, no side effects beyond the model provider request.middleware=retry on an agent re-runs the entire node, including every tool call that already succeeded in that attempt. Only retry at this slot if the tools involved are safe to re-run (idempotent, or guarded by an idempotency key) — otherwise a transient failure after a non-idempotent tool call (e.g. "send email", "charge card") causes it to fire twice.- Default to retrying in
model_middleware=for provider flakiness (rate limits, timeouts). Reach formiddleware=retry only when you've confirmed the tools are safe to repeat. - Prefer
railtracks.prebuilt.middleware.Retry(max_tries=..., approach=..., retry_on=...)over hand-rolling — it raises once attempts are exhausted rather than swallowing the failure, and lets you scoperetry_onto specific exception types instead of catching everything. - Don't swallow exceptions silently inside a
wrap_node/wrap_llmmiddleware (except Exception: return default) — that hides the failure from callers and from anything else in the chain. If you must return a fallback, do it deliberately (log it, tag it in the result) rather than as an unannounced side effect.
2. Middleware vs. guardrails
rt.input_guard/rt.output_guardare middleware under the hood (BaseGuardrailextendsMiddleware), specialized for one thing: "should this content pass?" The guard function receives anLLMGuardrailEventand returns aGuardrailDecision(allow/block/transform).- Use a guard, not hand-rolled middleware, whenever the logic is a content-policy decision (PII/secrets detection, profanity, jailbreak checks, redaction). You get for free: structured
GuardrailTracerecords (rail_name,phase,action,reason),fail_open=to decide whether a guard's own bug blocks or passes traffic, and consistent block semantics other tooling can rely on. - To keep a domain-specific agent from answering off-topic requests, use the prebuilt
ScopeGuard(scope=..., model=..., sensitivity=...)fromrailtracks.prebuilt.guardrails; a judge model labels the request before the agent's own model runs. - Use plain middleware for everything that isn't a pass/block decision on content: retries, timing, logging, context injection, auth/telemetry.
3. Ordering & composition
- Two different things happen depending on middleware shape — know which one you're ordering:
- Input-transforming middleware (
pre_llm-style: mutatesmessage_history/schema/toolsbefore calling onward) — earlier entries in the list run first and hand their result to later entries. Put transformations before validations:model_middleware=[ContextInjection(), my_guard]so the guard sees the filled-in prompt, not the raw template. - Span-wrapping middleware (retry, timing, logging that brackets a whole call) — the outermost entry sees the aggregate outcome; the innermost is what actually gets re-invoked on each attempt.
middleware=[Retry(3), log_after]→ the log fires once per retry attempt (it's inside the retry).middleware=[log_after, Retry(3)]→ the log fires once, for the final outcome only (it's outside the retry). Pick based on whether you want per-attempt or per-call visibility. - When
rt.couple-ing middleware onto a node that already has some, the new list is innermost — it can't run "before" existing outer middleware like auth checks.
4. Naming & observability
name=setsMiddleware.name, which is recorded with the middleware's events and shown in the beta visualizer (railtracks viz --beta) — the same way guardrails write theirnameinto everyGuardrailTrace.rail_name. Give your middleware a realname=so it's identifiable in traces/logs instead of falling back to the wrapper function's__name__.- Set
name=when the same wrapper function is reused in more than one slot (e.g. one genericretry_nodeattached to several agents) and you need to tell instances apart. - Keep one concern per middleware function. A function that both logs and mutates the prompt makes the ordering question in §3 ambiguous — split it into two so each one has one clear rule for where it belongs in the list.
Patterns
Retry: prebuilt vs. hand-rolled, and where to put it
python
import railtracks as rtfrom railtracks.prebuilt import middlewareAgent = rt.agent_node("Agent",tool_nodes=[send_email], # has a side effect — don't retry the whole nodellm=rt.llm.OpenAILLM(model_name="gpt-6-luna"),model_middleware=[middleware.Retry(max_tries=3)], # retries only the raw LLM call)
Guardrail vs. plain middleware for the same-shaped problem
python
import railtracks as rt# Content-policy decision -> guardrail, not middleware@rt.input_guarddef block_secrets(event: rt.guardrails.LLMGuardrailEvent) -> rt.guardrails.GuardrailDecision:if any("SECRET" in m.content for m in event.messages if isinstance(m.content, str)):return rt.guardrails.GuardrailDecision.block(reason="secret leaked")return rt.guardrails.GuardrailDecision.allow()# Not a policy decision -> plain middleware@rt.pre_llmdef log_prompt(message_history, schema, tools):print(message_history)return message_history, schema, tools
Running a guarded agent
Run it through a Flow like any agent. A block raises GuardrailBlockedError, so catch it where you want to show the user a refusal.
python
import railtracks as rtSupportAgent = rt.agent_node("Support Agent",llm=rt.llm.OpenAILLM("gpt-6-luna"),model_middleware=[block_secrets],)flow = rt.Flow(name="Support", entry_point=SupportAgent)try:result = flow.invoke("user message here")print(result.content)except rt.guardrails.GuardrailBlockedError as e:print(f"Blocked: {e}")
Ordering a retry against a logger to control per-attempt vs. per-call visibility
python
import railtracks as rt@rt.post_node(name="print_after")async def log_result(result):print("Finished:", result)return resultretry = rt.prebuilt.middleware.Retry(max_tries=3)PerAttemptLogging = rt.couple(Agent, middleware=[retry, log_result]) # logs every attemptPerCallLogging = rt.couple(Agent, middleware=[log_result, retry]) # logs only the final outcome
Attaching middleware after the node already exists
python
import railtracks as rtAdjusted = rt.couple(Agent,middleware=[log_result],model_middleware=[log_prompt],)
Rules
- Retry at
model_middleware=by default. Retry atmiddleware=only when every tool the agent calls is safe to run twice, since a retry there re-runs every tool call in the attempt. - Write content allow/block checks as
input_guard/output_guard, so they show up inGuardrailTracewith the rest of your rails. - Write
wrap_node/wrap_llmfunctions asasync def. - Put failure-aware logic in
wrap_llm/wrap_node;post_llm/post_noderun only on success.