diff --git a/how-to/integrating-cycles-with-openclaw.md b/how-to/integrating-cycles-with-openclaw.md index 37a42bc..aa9e904 100644 --- a/how-to/integrating-cycles-with-openclaw.md +++ b/how-to/integrating-cycles-with-openclaw.md @@ -1,6 +1,6 @@ # Integrating Cycles with OpenClaw -This guide shows how to add budget enforcement to OpenClaw agents using the [`cycles-openclaw-budget-guard`](https://github.com/runcycles/cycles-openclaw-budget-guard) plugin. The plugin handles the full reserve → commit → release lifecycle automatically, with no custom code required. +This guide shows how to add budget enforcement to OpenClaw agents using the [`cycles-openclaw-budget-guard`](https://github.com/runcycles/cycles-openclaw-budget-guard) plugin. The plugin handles the full reserve → commit → release lifecycle for both model and tool calls automatically, with no custom code required. ## Prerequisites @@ -74,13 +74,13 @@ The plugin hooks into five OpenClaw lifecycle events to enforce budget boundarie | Hook | What happens | |------|-------------| -| `before_model_resolve` | Fetches balance from Cycles. Downgrades the model if budget is low, blocks if exhausted. | -| `before_prompt_build` | Injects a budget-awareness hint into the system prompt. | -| `before_tool_call` | Creates a Cycles reservation for the tool's estimated cost. Blocks the call if denied. | -| `after_tool_call` | Commits the reservation as actual usage. | -| `agent_end` | Releases orphaned reservations and logs a budget summary. | +| `before_model_resolve` | Fetches balance, reserves budget for the model call, downgrades the model if budget is low, blocks if exhausted. Commits immediately since OpenClaw has no `after_model_resolve` hook. | +| `before_prompt_build` | Injects a budget-awareness hint into the system prompt, including forecast projections and pool balances. | +| `before_tool_call` | Checks tool permissions (allowlist/blocklist), applies degradation strategies, creates a Cycles reservation. Optionally retries on denial. | +| `after_tool_call` | Commits the reservation with actual cost (via `costEstimator` callback if configured, otherwise uses the estimate). | +| `agent_end` | Releases orphaned reservations, builds a session summary with cost breakdown and forecasts, fires analytics callbacks/webhooks. | -Each tool call follows the standard Cycles reserve → commit → release protocol. The plugin manages an in-memory map of active reservations so that every `before_tool_call` reservation is properly settled in `after_tool_call` or released at `agent_end`. +Both model and tool calls follow the standard Cycles reserve → commit → release protocol. The plugin manages an in-memory map of active reservations so that every reservation is properly settled or released at `agent_end`. ## Budget levels and model downgrading @@ -89,10 +89,12 @@ The plugin classifies budget into three levels: | Level | Condition | Behavior | |-------|-----------|----------| | **healthy** | `remaining > lowBudgetThreshold` | Pass through — no changes | -| **low** | `exhaustedThreshold < remaining ≤ lowBudgetThreshold` | Swap expensive models for cheaper ones via `modelFallbacks` | +| **low** | `exhaustedThreshold < remaining ≤ lowBudgetThreshold` | Apply low-budget strategies (model downgrade, token limits, tool restrictions) | | **exhausted** | `remaining ≤ exhaustedThreshold` | Block execution (or warn, if `failClosed: false`) | -To configure model downgrading, add a `modelFallbacks` map: +### Chained model fallbacks + +Model fallbacks support both single values and ordered chains. When budget is low, the plugin iterates through candidates and selects the first one whose cost fits within the remaining budget: ```json { @@ -101,8 +103,15 @@ To configure model downgrading, add a `modelFallbacks` map: "cycles-openclaw-budget-guard": { "tenant": "acme", "modelFallbacks": { - "claude-opus-4-20250514": "claude-sonnet-4-20250514", + "claude-opus-4-20250514": ["claude-sonnet-4-20250514", "claude-haiku-4-5-20251001"], "gpt-4o": "gpt-4o-mini" + }, + "modelBaseCosts": { + "claude-opus-4-20250514": 1500000, + "claude-sonnet-4-20250514": 300000, + "claude-haiku-4-5-20251001": 100000, + "gpt-4o": 1000000, + "gpt-4o-mini": 100000 } } } @@ -110,11 +119,11 @@ To configure model downgrading, add a `modelFallbacks` map: } ``` -When the budget drops below `lowBudgetThreshold` (default: 10,000,000 units), any model request matching a key in `modelFallbacks` is transparently swapped to the cheaper alternative. +When the budget drops below `lowBudgetThreshold` (default: 10,000,000 units), any model request matching a key in `modelFallbacks` is transparently swapped to the cheapest affordable alternative. ## Tool cost estimation -Since there is no proxy layer in phase 1, tool costs are estimated upfront. Configure per-tool estimates via `toolBaseCosts`: +Configure per-tool cost estimates via `toolBaseCosts`: ```json { @@ -133,18 +142,305 @@ Since there is no proxy layer in phase 1, tool costs are estimated upfront. Conf } ``` -Any tool not listed defaults to 100,000 units. The plugin reserves this amount before the tool call, then commits the same amount afterward (exact metering is planned for phase 2). +Any tool not listed defaults to 100,000 units. For more accurate cost tracking, provide a `costEstimator` callback programmatically: + +```typescript +{ + costEstimator: (ctx) => { + // ctx: { toolName, estimate, durationMs, result } + if (ctx.toolName === "web_search" && ctx.durationMs) { + return Math.ceil(ctx.durationMs * 100); // cost proportional to duration + } + return undefined; // fall back to estimate + } +} +``` + +The `costEstimator` receives the tool name, original estimate, execution duration, and result. Return a number for actual cost or `undefined` to use the estimate. + +## Tool access control + +Control which tools can be called using allowlists and blocklists with glob-style patterns: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "toolAllowlist": ["web_search", "code_*"], + "toolBlocklist": ["dangerous_*"] + } + } + } +} +``` + +- Blocklist takes precedence over allowlist +- Supports exact names and `*` wildcards (prefix: `code_*`, suffix: `*_tool`, all: `*`) +- Tools blocked by access lists are rejected before any budget reservation is attempted + +## Graceful degradation strategies + +When budget is low, the plugin can apply multiple composable strategies beyond model downgrading: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "lowBudgetStrategies": ["downgrade_model", "reduce_max_tokens", "disable_expensive_tools"], + "maxTokensWhenLow": 1024, + "expensiveToolThreshold": 1000000 + } + } + } +} +``` + +Available strategies: + +| Strategy | Effect | +|----------|--------| +| `downgrade_model` | Use cheaper fallback models from `modelFallbacks` (default) | +| `reduce_max_tokens` | Append token limit guidance to prompt hints | +| `disable_expensive_tools` | Block tools exceeding `expensiveToolThreshold` | +| `limit_remaining_calls` | Cap total tool/model calls via `maxRemainingCallsWhenLow` (default: 10) | + +Strategies are composable — list multiple values to combine them. ## Prompt budget hints When `injectPromptBudgetHint` is enabled (the default), the plugin prepends a compact hint to the system prompt so the model itself is aware of budget constraints: ``` -Budget: 5000000 USD_MICROCENTS remaining. Budget is low — prefer cheaper models and avoid expensive tools. 50% of budget remaining. +Budget: 5000000 USD_MICROCENTS remaining. Budget is low — prefer cheaper models and avoid expensive tools. 50% of budget remaining. Est. ~10 tool calls and ~5 model calls remaining at current rate. Team pool: 50000000 remaining. ``` +The hint includes: +- Current remaining balance and percentage +- Budget level warnings +- Forecast projections based on average call costs so far +- Team pool balance (when `parentBudgetId` is configured) +- Token limit guidance (when `reduce_max_tokens` strategy is active) + This helps models self-regulate — choosing cheaper tools, shorter responses, or skipping optional steps when budget is tight. +## Per-user and per-session scoping + +Scope budgets to individual users or sessions: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "userId": "user-123", + "sessionId": "session-456" + } + } + } +} +``` + +User and session identifiers can also be set dynamically via `ctx.metadata.userId` and `ctx.metadata.sessionId` at runtime — context values override static config. + +These identifiers are threaded into Cycles reservation subjects as dimensions, enabling per-user or per-session budget enforcement. + +## Reservation settings + +Configure reservation behavior per-tool or globally: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "reservationTtlMs": 60000, + "toolReservationTtls": { + "code_execution": 120000 + }, + "overagePolicy": "REJECT", + "toolOveragePolicies": { + "web_search": "ALLOW_IF_AVAILABLE" + } + } + } + } +} +``` + +Overage policies control what happens when a reservation exceeds the remaining budget: +- `REJECT` — deny the reservation (default) +- `ALLOW_IF_AVAILABLE` — allow up to the remaining balance +- `ALLOW_WITH_OVERDRAFT` — allow and create a debt + +## Retry on denied reservations + +Optionally retry tool reservations that are denied, useful when budget is being replenished or released concurrently: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "retryOnDeny": true, + "retryDelayMs": 2000, + "maxRetries": 1 + } + } + } +} +``` + +## Budget transition alerts + +Get notified when the budget level changes (e.g., healthy → low → exhausted): + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "budgetTransitionWebhookUrl": "https://hooks.example.com/budget-alert" + } + } + } +} +``` + +Or set a callback programmatically: + +```typescript +{ + onBudgetTransition: (event) => { + // event: { previousLevel, currentLevel, remaining, timestamp } + console.log(`Budget changed: ${event.previousLevel} → ${event.currentLevel}`); + } +} +``` + +Webhooks are fire-and-forget (best-effort). For guaranteed delivery, use the callback. + +## Session analytics and cost breakdown + +The plugin tracks per-tool and per-model cost breakdowns throughout the session. At `agent_end`, it builds a `SessionSummary` containing: + +- Tenant, budget, user, and session identifiers +- Final remaining/spent/reserved balances +- Total reservations made +- Per-component cost breakdown (e.g., `tool:web_search`, `model:claude-sonnet-4-20250514`) +- Session timing (start/end timestamps) +- Average cost and estimated remaining calls + +The summary is attached to `ctx.metadata["cycles-budget-guard"]` and can also be exported via callback or webhook: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "analyticsWebhookUrl": "https://analytics.example.com/sessions" + } + } + } +} +``` + +Or programmatically: + +```typescript +{ + onSessionEnd: async (summary) => { + await db.insert("agent_sessions", summary); + } +} +``` + +## End-user budget visibility + +Budget status is automatically attached to `ctx.metadata["cycles-budget-guard-status"]` on every hook invocation, making it available to OpenClaw frontends for UI display: + +```json +{ + "level": "low", + "remaining": 5000000, + "allocated": 10000000, + "percentRemaining": 50 +} +``` + +## Multi-currency support + +Override the default currency per-tool or per-model: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "currency": "USD_MICROCENTS", + "modelCurrency": "TOKENS", + "toolCurrencies": { + "web_search": "CREDITS" + } + } + } + } +} +``` + +Each reservation uses the appropriate currency unit. Cost tracking respects the per-component currency. + +## Budget pools + +Surface hierarchical budget information by setting a parent budget ID: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "budgetId": "team-alpha-agent", + "parentBudgetId": "team-alpha" + } + } + } +} +``` + +When `parentBudgetId` is set, the pool balance is included in budget snapshots and prompt hints (e.g., "Team pool: 50000000 remaining."). Reservations target the individual scope — the Cycles server handles hierarchical deduction from the pool. + +## Dry-run mode + +Test the plugin without a live Cycles server: + +```json +{ + "plugins": { + "entries": { + "cycles-openclaw-budget-guard": { + "tenant": "acme", + "cyclesBaseUrl": "http://unused", + "cyclesApiKey": "unused", + "dryRun": true, + "dryRunBudget": 100000000 + } + } + } +} +``` + +In dry-run mode, budget is tracked in-memory using a simulated client. All plugin behavior (classification, reservation, fallbacks, strategies) works identically — only the Cycles server communication is replaced. This is useful for development, testing, and evaluating the plugin before deploying a Cycles server. + ## Fail-open vs fail-closed The plugin distinguishes between two failure modes: @@ -154,11 +450,22 @@ The plugin distinguishes between two failure modes: - `failClosed: false` → logs a warning but allows execution to continue **Cycles server unreachable** — always fail-open: -- If the balance check fails due to a network error, the plugin assumes healthy budget +- If the balance check or reservation fails due to a network error, the plugin assumes healthy budget - This prevents transient infrastructure issues from blocking all agents This matches the Cycles philosophy: budget enforcement should be a guardrail, not a single point of failure. +## Error handling + +The plugin exports two structured error types: + +```typescript +import { BudgetExhaustedError, ToolBudgetDeniedError } from "@runcycles/openclaw-budget-guard"; +``` + +- **`BudgetExhaustedError`** (`code: "BUDGET_EXHAUSTED"`) — thrown when budget is exhausted and `failClosed: true` +- **`ToolBudgetDeniedError`** (`code: "TOOL_BUDGET_DENIED"`) — structured error type for tool denials + ## Verifying the integration Set `logLevel: "debug"` to see the plugin's activity: @@ -179,16 +486,18 @@ Set `logLevel: "debug"` to see the plugin's activity: You should see log lines like: ``` -[cycles-budget-guard] Plugin initialized { tenant: 'acme' } +[cycles-budget-guard] Plugin initialized tenant=acme [cycles-budget-guard] before_model_resolve: model=claude-sonnet-4-20250514 level=healthy [cycles-budget-guard] before_prompt_build: injecting hint (142 chars) [cycles-budget-guard] before_tool_call: tool=web_search callId=abc123 estimate=500000 [cycles-budget-guard] after_tool_call: committed 500000 for tool=web_search -[cycles-budget-guard] Agent session budget summary: { remaining: 9500000, spent: 500000, totalReservationsMade: 1 } +[cycles-budget-guard] Agent session budget summary: remaining=9500000 spent=500000 reservations=1 ``` ## Full configuration reference +### Core settings + | Field | Type | Default | Description | |-------|------|---------|-------------| | `enabled` | boolean | `true` | Master switch | @@ -196,23 +505,105 @@ You should see log lines like: | `cyclesApiKey` | string | `$CYCLES_API_KEY` | Cycles API key | | `tenant` | string | — | Cycles tenant (required) | | `budgetId` | string | — | Optional app-level budget scope | -| `currency` | string | `USD_MICROCENTS` | Budget unit | -| `lowBudgetThreshold` | number | `10000000` | Below this → model downgrade | -| `exhaustedThreshold` | number | `0` | At or below this → block | -| `modelFallbacks` | object | `{}` | Expensive model → cheaper model | -| `toolBaseCosts` | object | `{}` | Tool name → estimated cost | -| `injectPromptBudgetHint` | boolean | `true` | Add budget hint to system prompt | -| `maxPromptHintChars` | number | `200` | Max hint length | -| `defaultModelActionKind` | string | `llm.completion` | Action kind sent to Cycles for model calls | -| `defaultToolActionKindPrefix` | string | `tool.` | Prefix prepended to tool names for the action kind | +| `currency` | string | `USD_MICROCENTS` | Default budget unit | | `failClosed` | boolean | `true` | Block on exhausted budget | | `logLevel` | string | `info` | `debug` / `info` / `warn` / `error` | -## Current limitations (phase 1) +### Budget thresholds + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `lowBudgetThreshold` | number | `10000000` | Below this → low budget mode | +| `exhaustedThreshold` | number | `0` | At or below this → exhausted | + +### Model configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `modelFallbacks` | object | `{}` | Model → fallback model or chain (string or string[]) | +| `modelBaseCosts` | object | `{}` | Model name → estimated cost per call | +| `defaultModelCost` | number | `500000` | Fallback cost when model not in `modelBaseCosts` | +| `defaultModelActionKind` | string | `llm.completion` | Action kind for model reservations | +| `modelCurrency` | string | — | Override currency for model reservations | + +### Tool configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `toolBaseCosts` | object | `{}` | Tool name → estimated cost per call | +| `defaultToolActionKindPrefix` | string | `tool.` | Prefix for tool action kinds | +| `toolAllowlist` | string[] | — | Only these tools are permitted (supports `*` wildcards) | +| `toolBlocklist` | string[] | — | These tools are blocked (supports `*` wildcards) | +| `toolCurrencies` | object | — | Tool name → currency override | +| `toolReservationTtls` | object | — | Tool name → TTL override (ms) | +| `toolOveragePolicies` | object | — | Tool name → overage policy override | +| `costEstimator` | function | — | Custom callback for dynamic cost estimation | + +### Prompt hints + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `injectPromptBudgetHint` | boolean | `true` | Inject budget status into the system prompt | +| `maxPromptHintChars` | number | `200` | Max characters for the budget hint | + +### Reservation settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `reservationTtlMs` | number | `60000` | Default reservation TTL (ms) | +| `overagePolicy` | string | `REJECT` | Default overage policy (`REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT`) | +| `snapshotCacheTtlMs` | number | `5000` | Budget snapshot cache TTL (ms) | + +### Low-budget strategies + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `lowBudgetStrategies` | string[] | `["downgrade_model"]` | Strategies to apply when budget is low | +| `maxTokensWhenLow` | number | `1024` | Token limit when `reduce_max_tokens` is active | +| `expensiveToolThreshold` | number | — | Cost threshold for `disable_expensive_tools` | +| `maxRemainingCallsWhenLow` | number | `10` | Max calls when `limit_remaining_calls` is active | + +### Retry on deny + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `retryOnDeny` | boolean | `false` | Retry tool reservations after denial | +| `retryDelayMs` | number | `2000` | Delay between retries (ms) | +| `maxRetries` | number | `1` | Maximum retry attempts | + +### Dry-run mode + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `dryRun` | boolean | `false` | Use in-memory simulated budget | +| `dryRunBudget` | number | `100000000` | Starting budget for dry-run mode | + +### Per-user/session scoping -- **No per-token LLM enforcement** — the plugin does not intercept or meter actual LLM API calls. Model-level cost tracking requires a gateway or proxy layer (planned for phase 2). -- **No streaming cost tracking** — tool costs are flat estimates via `toolBaseCosts`, not measured from actual usage. -- **No multi-currency support** — a single `currency` unit is used for all reservations. +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `userId` | string | — | User ID for budget scoping (overridable via `ctx.metadata.userId`) | +| `sessionId` | string | — | Session ID for budget scoping (overridable via `ctx.metadata.sessionId`) | + +### Budget transitions + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `onBudgetTransition` | function | — | Callback on budget level changes | +| `budgetTransitionWebhookUrl` | string | — | Webhook URL for level transitions | + +### Session analytics + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `onSessionEnd` | function | — | Callback with session summary at agent end | +| `analyticsWebhookUrl` | string | — | Webhook URL for session summary data | + +### Budget pools + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `parentBudgetId` | string | — | Parent budget ID for pool balance visibility | ## Comparison with manual integration @@ -226,6 +617,9 @@ If you're already using the Cycles TypeScript client directly (see [Programmatic | Release orphans | Your code | `agent_end` hook | | Model downgrade on low budget | Your code | Automatic via `modelFallbacks` | | Prompt budget awareness | Your code | Automatic via `injectPromptBudgetHint` | +| Cost breakdown tracking | Your code | Automatic per-tool/model tracking | +| Session analytics | Your code | Automatic via `onSessionEnd` / webhook | +| Tool access control | Your code | Automatic via `toolAllowlist` / `toolBlocklist` | The plugin is the recommended approach for OpenClaw users — it requires zero custom code and covers the full lifecycle automatically.