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_limit

Emit 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.

FieldDescription
condition.cost_exceededDollar 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_limit

Abort or warn when the number of completed LLM + tool calls in a session reaches a cap. Catches runaway loops before they drain budget.

FieldDescription
condition.steps_exceededInteger. Triggers when step count reaches or exceeds this value.
action.type"warn" emits a signal and continues. "abort" halts before the next call.
retry

Automatically retry failed LLM calls with configurable backoff. Scope to specific error types so unrecoverable errors (auth, bad request) propagate immediately.

FieldDescription
condition.on_errorAlways true — condition is met whenever any LLM/tool call raises an error.
action.max_retriesMaximum number of retry attempts before giving up.
action.backoff_secondsBase delay in seconds between retries.
action.backoff"exponential" (default) doubles delay each attempt. "linear" waits backoff_seconds each time.
action.on_errorsOptional list of error class names to retry on. Omit to retry on any error.
fallback

Switch to an alternate model after all retry attempts are exhausted. Pairs with retry to form the full resilience stack.

FieldDescription
condition.on_errorAlways true — condition is met on any error after retries are exhausted.
action.fallback_modelModel ID to switch to (e.g. "gpt-4o-mini").
action.on_errorsOptional 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:

yaml
# 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
        - APITimeoutError
python
import 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():

python
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 AgentExecutionStopped before 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:

python
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.

python
# 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.