Skip to main content
Runtime Controls wrap your functions with composable guardrails so agents can run unattended without spiraling into infinite loops, duplicate writes, or runaway tool-call volume. No API key required — they work standalone around your own code, or combined with Buildfunctions hardware-isolated sandboxes.
Runtime Controls is in beta. The SDK interface may change between releases.

Install

npm install buildfunctions

Why Runtime Controls

When agents run unattended, things go wrong:
  • Infinite loops - the agent retries the same failing call forever
  • Runaway tool-call volume - unchecked tool calls can spike API usage quickly
  • Duplicate side effects - the same write runs twice
  • Unsafe actions - an agent executes destructive operations
  • Cascading failures - one broken dependency can fan out failures
Runtime Controls reduce these failure modes, but you still need to configure the right controls for your workflow. maxToolCalls is a call-count guardrail, not direct provider billing telemetry.

Examples

Wrap Any Async Call (No API Key)

JavaScript
import { RuntimeControls } from 'buildfunctions'

const controls = RuntimeControls.create({
  maxToolCalls: 50,
  timeoutMs: 30_000,
  retry: { maxAttempts: 3, initialDelayMs: 200, backoffFactor: 2 },
  loopBreaker: { warningThreshold: 5, quarantineThreshold: 8, stopThreshold: 12 },
  onEvent: (event) => console.log(`[controls] ${event.type}: ${event.message}`),
})

const guardedFetch = controls.wrap({
  toolName: 'api-call',
  runKey: 'agent-run-1',
  destination: 'https://api.example.com',
  run: async ([payload]) => {
    const res = await fetch('https://api.example.com/data', {
      method: 'POST',
      body: JSON.stringify(payload),
    })
    return res.json()
  },
})

const result = await guardedFetch({ query: 'latest results' })

Use run() Directly

JavaScript
import { RuntimeControls } from 'buildfunctions'

const controls = RuntimeControls.create({ retry: { maxAttempts: 2 } })

const result = await controls.run(
  { toolName: 'simple-tool' },
  async () => 'ok'
)
runtime["signal"] is available in handlers but optional. Use it when your tool call supports cancellation.

Control Layers

Every control is opt-in and composable. Enable only what you need.

Retry with Exponential Backoff

Automatically retries transient failures (503, 429, timeouts) with configurable backoff.
JavaScript
const controls = RuntimeControls.create({
  retry: {
    maxAttempts: 4,
    initialDelayMs: 250,
    maxDelayMs: 10_000,
    backoffFactor: 2,
    jitterRatio: 0.2,
  },
})
Custom retry decisions:
JavaScript
const controls = RuntimeControls.create({
  retry: { maxAttempts: 4 },
  retryClassifier: ({ error, statusCode }) => {
    if (statusCode === 409) {
      return { retryable: true, delayMs: 500, reason: 'conflict_backoff' }
    }
    return { retryable: error.code === 'NETWORK_ERROR' }
  },
})

Tool-Call Budget

Per-run cap on the number of tool calls when maxToolCalls is configured. Budgets are scoped by runKey. This is a tool-call-count budget, not a dollar-based cost meter.
JavaScript
const controls = RuntimeControls.create({
  maxToolCalls: 50,
})

await controls.run({ toolName: 'shell', runKey: 'agent-run-1' }, async () => 'ok')

// Reset budget when starting a new run.
await controls.reset('agent-run-1')

Loop Breaker

Detects repeated no-progress patterns and escalates from warning to quarantine to stop.
JavaScript
const controls = RuntimeControls.create({
  loopBreaker: {
    enabled: true,
    warningThreshold: 5,
    quarantineThreshold: 8,
    stopThreshold: 12,
    quarantineMs: 15_000,
    stopCooldownMs: 120_000,
  },
})

Circuit Breaker

Temporarily blocks calls to a failing dependency. Keyed by (tenant, toolName, destination).
JavaScript
const controls = RuntimeControls.create({
  circuitBreaker: {
    enabled: true,
    windowMs: 30_000,
    minRequests: 20,
    failureRateThreshold: 0.6,
    cooldownMs: 60_000,
  },
})
When failure rate exceeds threshold inside the sliding window, the circuit opens for cooldownMs.

Timeout and Cancellation

Per-call timeout with abort signal propagation.
JavaScript
const controls = RuntimeControls.create({ timeoutMs: 30_000 })

const controller = new AbortController()
await controls.run(
  { toolName: 'shell', signal: controller.signal },
  async ({ signal }) => {
    return runCommand('npm test', { signal })
  }
)

Policy Gates

Deterministic allow / deny / require_approval rules with specificity-based precedence.
JavaScript
const controls = RuntimeControls.create({
  policy: {
    mode: 'enforce',
    rules: [
      {
        id: 'deny-destructive',
        action: 'deny',
        tools: ['repo-admin'],
        actionPrefixes: ['delete'],
        reason: 'Destructive repo operations are blocked',
      },
      {
        id: 'approve-external-writes',
        action: 'require_approval',
        tools: ['ticket-write'],
        destinations: ['*.external.example.com'],
        reason: 'External writes require human approval',
      },
    ],
    approvalHandler: async () => false,
  },
})
Policy precedence (highest wins):
  1. Higher tool specificity (exact name > prefix > wildcard)
  2. Higher destination specificity (exact host > wildcard subdomain > *)
  3. Longer action prefix match
  4. Stricter action (deny > require_approval > allow)
  5. Earlier rule index on exact tie (current behavior)

Idempotency

Prevents duplicate side effects by replaying prior results for calls with the same idempotencyKey.
JavaScript
const controls = RuntimeControls.create({
  idempotency: {
    enabled: true,
    ttlMs: 600_000,
    includeErrors: false,
    namespaceByRunKey: true,
  },
})

await controls.run(
  {
    toolName: 'pr-comment',
    runKey: 'pr-4821',
    idempotencyKey: 'comment:pr-4821:summary',
  },
  async () => postComment('LGTM')
)

await controls.run(
  {
    toolName: 'pr-comment',
    runKey: 'pr-4821',
    idempotencyKey: 'comment:pr-4821:summary',
  },
  async () => postComment('LGTM') // replayed, does not execute again
)

Concurrency Locks

Prevents simultaneous conflicting writes to the same resource.
JavaScript
const controls = RuntimeControls.create({
  concurrency: {
    enabled: true,
    waitMode: 'reject', // or 'wait'
    leaseMs: 30_000,
    waitTimeoutMs: 5_000,
  },
})

await controls.run(
  {
    toolName: 'repo-write',
    resourceKey: 'repo:buildfunctions/web-app',
  },
  async () => gitPush('main')
)

Verifier Hooks

Custom correctness gates that run before execution, after success, and after errors.
JavaScript
const controls = RuntimeControls.create({
  verifiers: {
    beforeCall: ({ toolName, action }) => {
      if (toolName === 'repo-admin' && action?.startsWith('delete')) {
        return { allow: false, reason: 'delete blocked' }
      }
      return { allow: true }
    },
    afterSuccess: ({ result }) => {
      if (!result || typeof result !== 'object') {
        return { allow: false, reason: 'unexpected result shape' }
      }
      return { allow: true }
    },
    afterError: ({ error }) => {
      return { allow: true }
    },
  },
})

Per-Tool and Per-Destination Overrides

Apply stricter or looser controls to specific tools or destinations.
JavaScript
const controls = RuntimeControls.create({
  timeoutMs: 60_000,
  retry: { maxAttempts: 4 },
  overrides: {
    tools: {
      shell: { timeoutMs: 10_000, retry: { maxAttempts: 2 } },
    },
    destinations: {
      '*.internal-api.example.com': { timeoutMs: 5_000 },
    },
  },
})
Tool overrides win when both tool and destination set the same field.

Agent Logic Safety

applyAgentLogicSafety adds pre-execution safety checks without changing RuntimeControls internals.

Injection Guard

Scans tool name, action, destination, and args for injection patterns before execution.
JavaScript
import { RuntimeControls, applyAgentLogicSafety } from 'buildfunctions'

const controls = RuntimeControls.create(
  applyAgentLogicSafety(
    { retry: { maxAttempts: 2 } },
    {
      injectionGuard: {
        enabled: true,
        patterns: [
          /ignore\s+previous\s+instructions/i,
          /\brm\s+-rf\b/i,
          /<script\b/i,
        ],
      },
    }
  )
)
Default patterns (when patterns is omitted):
  • ignore (all|any|previous) instructions
  • system prompt
  • developer message
  • <script
  • rm -rf

Exit Conditions

Enforces maximum steps per run and can block calls after a terminal action.
JavaScript
const controls = RuntimeControls.create(
  applyAgentLogicSafety(
    {},
    {
      exitCondition: {
        enabled: true,
        maxStepsPerRun: 30,
        terminalActions: [{ toolNamePattern: 'agent-control', actionPrefix: 'finish' }],
        blockAfterTerminal: true,
      },
    }
  )
)

Intent Allowlist

Restricts which tool + action combinations are permitted. Anything not explicitly allowed is denied.
JavaScript
const controls = RuntimeControls.create(
  applyAgentLogicSafety(
    {},
    {
      intentAllowlist: {
        enabled: true,
        rules: [
          { toolNamePattern: 'cpu-sandbox', actionPrefixes: ['run_'] },
          { toolNamePattern: 'repo-write', actionPrefixes: ['push_'] },
          { toolNamePattern: 'pr-comment', actionPrefixes: ['post_'] },
        ],
      },
    }
  )
)

Combining All Three

JavaScript
const controls = RuntimeControls.create(
  applyAgentLogicSafety(
    {
      tenantKey: 'my-team-prod',
      maxToolCalls: 100,
      retry: { maxAttempts: 3 },
      concurrency: { enabled: true, waitMode: 'reject', leaseMs: 30_000 },
    },
    {
      injectionGuard: { enabled: true },
      exitCondition: {
        enabled: true,
        maxStepsPerRun: 50,
        terminalActions: [{ toolNamePattern: 'agent-control', actionPrefix: 'finish' }],
        blockAfterTerminal: true,
      },
      intentAllowlist: {
        enabled: true,
        rules: [
          { toolNamePattern: 'cpu-sandbox', actionPrefixes: ['run_'] },
          { toolNamePattern: 'repo-write', actionPrefixes: ['push_'] },
          { toolNamePattern: 'pr-comment', actionPrefixes: ['post_'] },
        ],
      },
    }
  )
)

Observability

Event Callback

JavaScript
const controls = RuntimeControls.create({
  onEvent: (event) => {
    console.log(`[${event.type}] ${event.message}`, event.details)
  },
})

Event Sinks

JavaScript
const controls = RuntimeControls.create({
  eventSinks: [
    async (event) => await writeToDatabase(event),
    async (event) => await sendToMonitoring(event),
  ],
  onEventSinkFailure: ({ failure, sinkIndex }) => {
    console.error(`Event sink ${sinkIndex} failed:`, failure)
  },
})

Event Types

EventMeaning
retryA retry attempt was scheduled
loop_warningRepeated no-progress pattern detected
loop_quarantinePattern quarantined (temporarily blocked)
loop_stopPattern hard-stopped
circuit_openDependency circuit breaker opened
budget_stopRun tool-call budget exceeded
policy_deniedPolicy denied a call
policy_approval_requiredApproval required by policy
policy_approvedApproval granted by handler
policy_dry_runSimulated policy decision (dry-run mode)
verifier_rejectedVerifier rejected call, result, or error
idempotency_replayPrior result replayed
concurrency_waitWaiting for resource lock
concurrency_rejectedLock rejected or wait timed out

State Adapters

By default, state is in-memory. To persist guardrail state across processes, provide adapters.
JavaScript
const stateAdapter = {
  get: async (key) => await store.get(key),
  set: async (key, value) => await store.set(key, value),
  delete: async (key) => await store.delete(key),
  keys: async (prefix) => await store.keys(prefix),
}

const controls = RuntimeControls.create({
  state: {
    budget: stateAdapter,
    circuit: stateAdapter,
    loop: stateAdapter,
    lock: stateAdapter,
    idempotency: stateAdapter,
  },
})
State is namespaced by tenantKey, so teams can scope keys when sharing adapter backends. Unit tests cover adapter contracts; validate your backend and lock semantics with integration tests before production use.

API Methods

RuntimeControls.create(config?)

Creates a new controls instance. All config is optional.

controls.run(context, fn)

Runs fn(runtime) through enabled control layers. Context fields:
  • toolName (required)
  • runKey, destination, action
  • args
  • idempotencyKey, resourceKey
  • timeoutMs (per-call override)
  • signal (caller cancellation)
JavaScript
const result = await controls.run(
  {
    toolName: 'shell',
    runKey: 'agent-run-42',
    destination: 'sandbox.example.com',
    action: 'run_tests',
    args: { command: 'npm test' },
    idempotencyKey: 'test:42:v1',
    resourceKey: 'workspace:/repo',
    signal: controller.signal,
    timeoutMs: 10_000,
  },
  async ({ signal }) => {
    return runShell('npm test', { signal })
  }
)

controls.wrap(params)

Creates a reusable guarded function for repeated tool calls.
JavaScript
const guardedShell = controls.wrap({
  toolName: 'shell',
  runKey: 'agent-run-42',
  destination: 'sandbox.example.com',
  run: async ([command], { signal }) => {
    return runShell(command, { signal })
  },
})

await guardedShell('npm test')
await guardedShell('npm run lint')
Dynamic context via resolver functions:
JavaScript
const guardedShell = controls.wrap({
  toolName: 'shell',
  run: async ([command], { signal }) => runShell(command, { signal }),
  resolveRunKey: (command) => `run-${command}`,
  resolveDestination: () => 'sandbox.example.com',
  resolveAction: (command) => (command.startsWith('rm') ? 'delete' : 'execute'),
  resolveIdempotencyKey: (command) => `shell:${command}`,
  resolveResourceKey: () => 'workspace:/repo',
})

controls.reset(runKey=None)

Resets budget counters for the selected run key.
JavaScript
await controls.reset('agent-run-42')

Full Configuration Reference

ConfigDefaultDescription
tenantKey"default"Namespace for all state keys
timeoutMs60000Per-call timeout (0 disables)
maxToolCallsunsetPer-runKey call budget
retry.maxAttempts4Total attempts including first
retry.initialDelayMs250Backoff base delay
retry.maxDelayMs10000Backoff cap
retry.backoffFactor2Exponential multiplier
retry.jitterRatio0.2Delay jitter (0..1)
retryClassifierunsetCustom retry decision callback
loopBreaker.enabledtrueEnables repeated-pattern detection
loopBreaker.warningThreshold5Emits loop_warning
loopBreaker.quarantineThreshold8Emits loop_quarantine, blocks for quarantineMs
loopBreaker.stopThreshold12Emits loop_stop, blocks for stopCooldownMs
loopBreaker.quarantineMs15000Quarantine duration
loopBreaker.stopCooldownMs120000Stop duration
loopBreaker.maxFingerprints200Max fingerprints retained
circuitBreaker.enabledtrueEnables dependency circuit protection
circuitBreaker.windowMs30000Sliding failure window
circuitBreaker.minRequests20Requests before open decision
circuitBreaker.failureRateThreshold0.6Open when failure rate exceeds this
circuitBreaker.cooldownMs60000Open duration
policy.enabledtrueEnables rule enforcement
policy.mode"enforce""dryRun" emits simulation events only
policy.rules[]Array of allow/deny/require_approval rules
policy.approvalHandlerunsetRequired for require_approval rules
verifiers.beforeCallunsetPre-execution verifier
verifiers.afterSuccessunsetPost-success verifier
verifiers.afterErrorunsetPost-error verifier
idempotency.enabledtrueEnables replay by idempotencyKey
idempotency.ttlMsunsetReplay record expiration
idempotency.includeErrorsfalseReplay final errors when enabled
idempotency.namespaceByRunKeytrueScope replay by run key
concurrency.enabledfalseEnables locking by resourceKey
concurrency.leaseMs30000Lock lease duration
concurrency.waitMode"reject""reject" immediately or "wait"
concurrency.waitTimeoutMs5000Wait timeout
concurrency.pollIntervalMs50Poll interval in wait mode
overrides.toolsunsetPer-tool config overrides
overrides.destinationsunsetPer-destination config overrides
state.*in-memoryState adapters (budget, circuit, loop, lock, idempotency)
onEventunsetSynchronous event callback
eventSinks[]Async event fan-out sinks
onEventSinkFailureunsetSink failure callback

FAQ

Do I need a Buildfunctions API token?

No. Runtime Controls works standalone without any API token.

What should I wrap first?

Start with side-effecting calls:
  1. Repository writes (git push, branch operations)
  2. External ticket or issue creation
  3. Sandbox and test execution
  4. PR or issue comments

What should be denied by default?

Start by denying destructive actions (delete*) and requiring approval for external writes.

Can I use this without agents?

Yes. Runtime Controls wrap functions — API calls, database queries, file operations, shell commands. Validate behavior in your own integration paths.