Guides
Guardrails & Policies
Policies are rules the Niitaka SDK evaluates in real time as your agent runs — cost limits, step caps, retries, and fallbacks — without touching your agent code.
Where to configure policies
Dashboard
Create and edit policies in the Policies page. Changes take effect on the next session — no redeploy needed.
YAML file
Define policies in a local file and pass policy_file= to configure(). File policies take priority over DB policies for matching agents.
Policy types
cost_limitEmit a warning or abort when accumulated session cost crosses a threshold. Each entry is one threshold — use two entries (warn + abort) for a soft and hard limit.
| Field | Description |
|---|---|
condition.cost_exceeded | Dollar amount (USD). Triggers when total session cost exceeds this value. |
action.type | "warn" emits a signal and lets the session continue. "abort" raises AgentExecutionStopped before the next LLM call. |
step_limitAbort or warn when the number of completed LLM + tool calls in a session reaches a cap. Catches runaway loops before they drain budget.
| Field | Description |
|---|---|
condition.steps_exceeded | Integer. Triggers when step count reaches or exceeds this value. |
action.type | "warn" emits a signal and continues. "abort" halts before the next call. |
retryAutomatically retry failed LLM calls with configurable backoff. Scope to specific error types so unrecoverable errors (auth, bad request) propagate immediately.
| Field | Description |
|---|---|
condition.on_error | Always true — condition is met whenever any LLM/tool call raises an error. |
action.max_retries | Maximum number of retry attempts before giving up. |
action.backoff_seconds | Base delay in seconds between retries. |
action.backoff | "exponential" (default) doubles delay each attempt. "linear" waits backoff_seconds each time. |
action.on_errors | Optional list of error class names to retry on. Omit to retry on any error. |
fallbackSwitch to an alternate model after all retry attempts are exhausted. Pairs with retry to form the full resilience stack.
| Field | Description |
|---|---|
condition.on_error | Always true — condition is met on any error after retries are exhausted. |
action.fallback_model | Model ID to switch to (e.g. "gpt-4o-mini"). |
action.on_errors | Optional list of error class names that trigger the fallback. Omit to fall back on any error. |
Full YAML example
A complete policy file with all four policy types configured for one agent. Each cost_limit or step_limit threshold is its own entry — create two entries (warn + abort) to get both a signal and a hard stop:
# policies.yaml — load with niitaka.configure(policy_file="policies.yaml")
version: "1"
policies:
# Warn at $0.30
- agent_id: my-agent
type: cost_limit
priority: 5
condition:
cost_exceeded: 0.30
action:
type: warn
# Hard abort at $0.50
- agent_id: my-agent
type: cost_limit
priority: 10
condition:
cost_exceeded: 0.50
action:
type: abort
# Cap the number of LLM + tool call steps
- agent_id: my-agent
type: step_limit
priority: 10
condition:
steps_exceeded: 20
action:
type: abort
# Retry transient errors with exponential backoff
- agent_id: my-agent
type: retry
priority: 8
condition:
on_error: true
action:
max_retries: 3
backoff_seconds: 2
backoff: exponential
on_errors:
- RateLimitError
- APITimeoutError
- InternalServerError
# Fall back to a cheaper model after retries are exhausted
- agent_id: my-agent
type: fallback
priority: 7
condition:
on_error: true
action:
fallback_model: gpt-4o-mini
on_errors:
- RateLimitError
- APITimeoutErrorimport os
import niitaka
niitaka.configure(
api_key=os.getenv("NIITAKA_API_KEY"),
policy_file="policies.yaml", # file policies take priority over DB policies
)Accessing guardrail values in code
The SDK enforces guardrails automatically — you don't need to check thresholds manually. If you need to read the active policy values from your agent (e.g. for logging or adaptive behaviour), use get_runtime_config():
import niitaka
# The SDK enforces guardrails automatically — no manual checks needed.
# To inspect active policy values from your agent code:
with niitaka.start_session(agent_id="my-agent"):
result = niitaka.get_runtime_config()
cfg = result.get("config", {})
# Guardrail thresholds from the active shipped version / experiment variant:
guardrails = cfg.get("guardrails", {})
cost_limit = guardrails.get("cost_limit_usd") # e.g. 0.50 (None if not set)
max_steps = guardrails.get("max_steps") # e.g. 20 (None if not set)
# Use for observability / logging — enforcement is handled by the SDK.
print(f"Active cost limit: {cost_limit}, step limit: {max_steps}")How guardrails fire
Cost and step guardrails are pre-call gates. Before each LLM call the SDK checks accumulated totals from previous calls against the configured limits. If a threshold is crossed:
- warn — emits a guardrail signal in the session timeline and lets the call proceed.
- abort — raises
AgentExecutionStoppedbefore the next call starts. The current call always completes and is logged — limits prevent the next call from running, not the one that just finished.
Scoping guardrails across multiple calls
By default, cost/step accounting resets every time you open a new start_session() — exactly right for one session per logical run of your agent. If your agent makes several calls for one task (an agentic tool-calling loop, or several calls proxied through a separate service) and you want a guardrail to see the cumulative total across all of them, open an explicit niitaka.policy_scope() block. It spans multiple calls — even across separate sessions — independent of session lifetime, so a fresh session per call no longer means a fresh guardrail budget per call:
import niitaka
# A tool-calling loop makes several LLM calls for one logical task.
# Without policy_scope(), each start_session() resets cost/step accounting to
# zero, so cost_limit can never see the loop's cumulative spend — only ever
# one call at a time.
with niitaka.policy_scope(agent_id="my-agent", cost_limit_usd=0.50, max_steps=10):
for step in range(10):
with niitaka.start_session(agent_id="my-agent", goal=f"step {step}"):
run_one_step()
# cost_limit/step_limit fire against the SCOPE's running total across all
# ten sessions above, not any single one of them.Cross-process guardrails
policy_scope() carries guardrail state across calls within one process. When those calls cross a process boundary — an upstream service proxying to a downstream one — two more pieces close the gap:
- Visibility —
attach_session(session_id)redirects where the downstream service's auto-instrumented calls log, so they land as typed events under the caller's real session instead of creating an unrelated top-level session per call.attach_session(None)is a true no-op, safe to call with an optional/absent header value.starting_sequence=keeps event ordering correct on a long-running process that attaches to many sessions over its lifetime — without it, events can sort out of true call order relative to whichever session is currently attached. - Accounting — attaching to a session only affects where events log, not guardrail enforcement.
get_active_scope_state()snapshots the active scope's cumulative cost/steps to hand downstream;policy_scope(imported_state=...)seeds a new scope from that snapshot instead of zero;import_scope_state()merges a returned post-call state back into the caller's own scope. - Explicit gating —
check_guardrails()runs the same cost_limit/step_limit check instrument_litellm() does automatically, for a call it can't see (e.g. one proxied to another process over HTTP) that should still be gated by the active scope.
Deliberately not concurrency-safe: two downstream calls dispatched in parallel from the same upstream scope each see a stale snapshot of the other. Correct for sequential calls within one turn — a concurrency-safe version needs one atomically-consulted source of truth, which this doesn't attempt to be.
# policy_scope() carries guardrail state across calls WITHIN one process.
# When the calls cross a process boundary (an upstream service proxying to a
# downstream one), two more pieces close that gap:
# Upstream — hand this scope's state to the downstream service:
with niitaka.policy_scope(agent_id="my-agent", cost_limit_usd=0.50):
state = niitaka.get_active_scope_state() # export the current cumulative total
result = call_downstream_service(session_id=session.session_id, scope_state=state)
niitaka.import_scope_state(result["scope_state"]) # merge downstream's post-call total back in
# Downstream — attach to the caller's session for visibility, import their
# scope state for accounting, and gate the call against the imported total:
with niitaka.attach_session(received_session_id, starting_sequence=received_sequence):
with niitaka.policy_scope(imported_state=received_scope_state):
niitaka.check_guardrails() # raises AgentExecutionStopped if already over limit
call_llm(...)
return {"scope_state": niitaka.get_active_scope_state()}Policy evaluation order
Higher priority numbers are evaluated first. When multiple policies of the same type match, the highest-priority action wins. A common pattern: abort at priority 10, warn at priority 5 — if both thresholds are crossed, the abort fires first, as intended. All policy changes are versioned and logged in the Policy Audit Log in the dashboard.