Skip to content

Implement cost.budget capability + BUDGET_EXHAUSTED error #25

@nficano

Description

@nficano

ARCP v1.1 §9.6 introduces cost.budget as a runtime-enforced lease capability with counters. Not implemented.

Steps:

  1. Add CostBudget capability type (max, unit, consumed) to lib/src/main/kotlin/dev/arcp/lease/
  2. Implement runtime counter in ARCPRuntime — increments on metric events tagged as cost
  3. When consumed >= max, raise BUDGET_EXHAUSTED error (see related error-code issue)
  4. Subset semantics: child budget must be ≤ parent
  5. Add samples/CostBudget/ mirroring typescript-sdk/examples/cost-budget/
  6. Add tests

Reference: spec §9.6, §13.5.


Audit reference: ARCP SDK Audit v1.1 (2026-05-19)

Implementation prompt

Files to touch

  • lib/src/main/kotlin/dev/arcp/lease/CostBudget.kt (new) — CostBudget value type, BudgetAmount parser, currency math.
  • lib/src/main/kotlin/dev/arcp/lease/BudgetCounter.kt (new) — mutable counter that decrements on cost metric events.
  • lib/src/main/kotlin/dev/arcp/lease/BudgetRegistry.kt (new) — JobId → BudgetCounter map with lifecycle hooks for job acceptance and termination.
  • lib/src/main/kotlin/dev/arcp/lease/LeaseSubset.kt (new) — subsumes(parent: List<CostBudget>, child: List<CostBudget>): Boolean per §9.4 / §10.
  • lib/src/main/kotlin/dev/arcp/error/ErrorCode.kt — add BUDGET_EXHAUSTED (depends on Add BUDGET_EXHAUSTED, AGENT_VERSION_NOT_AVAILABLE error codes #26).
  • lib/src/main/kotlin/dev/arcp/error/ARCPException.kt — add ARCPException.BudgetExhausted subclass.
  • lib/src/main/kotlin/dev/arcp/runtime/ARCPRuntime.kt — wire BudgetRegistry into job submission and into the metric ingestion path; surface BUDGET_EXHAUSTED as Nack or tool.error when a counter reaches zero.
  • lib/src/main/kotlin/dev/arcp/messages/Telemetry.kt — add StandardMetrics.COST_BUDGET_REMAINING constant ("cost.budget.remaining"); keep COST_USD etc untouched.
  • lib/src/test/kotlin/dev/arcp/lease/CostBudgetTest.kt (new).
  • lib/src/test/kotlin/dev/arcp/lease/BudgetCounterTest.kt (new).
  • lib/src/test/kotlin/dev/arcp/lease/LeaseSubsetTest.kt (new).
  • lib/src/test/kotlin/dev/arcp/error/ErrorCodeTest.kt — extend coverage for BUDGET_EXHAUSTED.
  • samples/src/main/kotlin/com/arcp/samples/costbudget/Main.kt (new).
  • samples/build.gradle.kts — register runCostBudget.
  • tests/src/test/kotlin/dev/arcp/tests/CostBudgetTest.kt (new).

Confirm with ls /Users/nficano/code/arpc/kotlin-sdk/lib/src/main/kotlin/dev/arcp/ (no lease/ package exists yet — create it).

Public API additions (package dev.arcp.lease)

/** Currency identifier (RFC §9.6). Opaque to the protocol; common values: USD, EUR, credits. */
@JvmInline
@Serializable
public value class Currency(public val code: String) {
    init { require(code.isNotBlank()) { "currency must not be blank" } }
}

/** A single budget pattern: `currency:decimal` (RFC §9.6). */
@Serializable(with = BudgetAmountSerializer::class)
public data class BudgetAmount(val currency: Currency, val value: BigDecimal) {
    public fun render(): String = "${currency.code}:${value.toPlainString()}"

    public companion object {
        public fun parse(s: String): BudgetAmount   // throws IllegalArgumentException
    }
}

/** Per-currency counter (RFC §9.6). Multiple currencies are tracked independently. */
public data class CostBudget(val budgets: List<BudgetAmount>) {
    init {
        require(budgets.groupBy { it.currency }.all { it.value.size == 1 }) {
            "duplicate currency in cost.budget"
        }
    }
    public fun byCurrency(c: Currency): BudgetAmount? = budgets.firstOrNull { it.currency == c }
}

/** Mutable per-job counter. */
public class BudgetCounter(initial: CostBudget) {
    public fun consume(amount: BudgetAmount): Outcome
    public fun remaining(currency: Currency): BigDecimal?
    public fun isExhausted(): Boolean
    public sealed interface Outcome {
        public data object Ok : Outcome
        public data class Exhausted(public val currency: Currency) : Outcome
    }
}

public class BudgetRegistry {
    public fun register(jobId: JobId, budget: CostBudget)
    public fun consume(jobId: JobId, amount: BudgetAmount): BudgetCounter.Outcome
    public fun terminate(jobId: JobId)
    public fun remaining(jobId: JobId): CostBudget?
}

public object LeaseSubset {
    public fun subsumes(parent: CostBudget, child: CostBudget): Boolean
}

Step-by-step changes

  1. lease/CostBudget.kt (new) — define Currency, BudgetAmount, CostBudget. Use java.math.BigDecimal for the decimal value. Hand-write BudgetAmountSerializer : KSerializer<BudgetAmount> that encodes/decodes the wire string "USD:5.00" (mirror the ErrorCodeSerializer pattern in error/ErrorCode.kt).
  2. lease/BudgetCounter.kt (new) — mutable counter:
    • Internal ConcurrentHashMap<Currency, BigDecimal> initialised from the CostBudget.
    • consume(amount): require amount.value >= 0 (negative values rejected, no decrement, per §9.6); subtract from the matching currency; if the post-decrement value is ≤ BigDecimal.ZERO, return Outcome.Exhausted(currency). Otherwise Outcome.Ok.
    • isExhausted(): true iff any currency's remaining ≤ 0.
  3. lease/BudgetRegistry.kt (new) — ConcurrentHashMap<JobId, BudgetCounter> plus the four methods above. terminate(jobId) removes the counter so jobs are not leaked across submissions.
  4. lease/LeaseSubset.kt (new) — implement §9.4 / §10 rule: for every currency in child, the child amount must be the parent's remaining amount (caller passes the remaining as a CostBudget). Return false if a currency in child is absent from parent (children cannot introduce currencies the parent did not authorise).
  5. error/ErrorCode.kt — add BUDGET_EXHAUSTED("BUDGET_EXHAUSTED", false) (also see Add BUDGET_EXHAUSTED, AGENT_VERSION_NOT_AVAILABLE error codes #26).
  6. error/ARCPException.kt — add public class BudgetExhausted(public val currency: Currency, public val jobId: JobId, message: String = "budget exhausted for ${currency.code} on $jobId") : ARCPException(message) { override val code = ErrorCode.BUDGET_EXHAUSTED }.
  7. runtime/ARCPRuntime.kt
    • Add private val budgets: BudgetRegistry = BudgetRegistry() constructor param (default).
    • When handling JobSubmit (see Implement agent versioning name@version #23), parse lease_request["cost.budget"] into a CostBudget and call budgets.register(jobId, costBudget).
    • Extend handleEnvelope to recognise incoming Metric envelopes whose name starts with "cost." (excluding "cost.budget.remaining") and whose unit matches a budgeted Currency. For each such metric, call budgets.consume(...). On Outcome.Exhausted, reply with a Nack (or JobFailed, see §9.6) carrying ErrorCode.BUDGET_EXHAUSTED.
    • After every material decrement, optionally emit a Metric(name = "cost.budget.remaining", value = …, unit = currency.code) envelope per §9.6.
    • On terminal job state, call budgets.terminate(jobId).
  8. messages/Telemetry.kt — append public const val COST_BUDGET_REMAINING: String = "cost.budget.remaining" to StandardMetrics.
  9. samples/src/main/kotlin/com/arcp/samples/costbudget/Main.kt (new) — emit three cost.inference metrics that sum to slightly more than the configured USD:1.00 budget; assert the third one triggers BUDGET_EXHAUSTED and the runtime emits a final cost.budget.remaining metric of ≤ 0. Print outcome.
  10. samples/build.gradle.kts — register "runCostBudget" to "com.arcp.samples.costbudget.MainKt".
  11. tests/src/test/kotlin/dev/arcp/tests/CostBudgetTest.kt (new) — end-to-end via HarnessFixture exercising budget enforcement at the client/runtime boundary.

Tests to add

  • lib/src/test/kotlin/dev/arcp/lease/CostBudgetTest.kt
    • "parses USD:5.00 round-trip via wire string" — BudgetAmount.parse("USD:5.00").render() == "USD:5.00".
    • "rejects missing colon" — BudgetAmount.parse("USD5.00") throws.
    • "rejects duplicate currency in CostBudget" — CostBudget(listOf(parse("USD:1"), parse("USD:2"))) throws.
    • "serializes through arcpJson as a JSON string" — assert encoded form is "\"USD:5.00\"".
  • lib/src/test/kotlin/dev/arcp/lease/BudgetCounterTest.kt
    • "decrements remaining on consume" — start USD:1.00, consume USD:0.30, remaining = 0.70.
    • "returns Exhausted at zero" — start USD:0.10, consume USD:0.10Outcome.Exhausted(USD).
    • "rejects negative amount" — consume USD:-0.10 throws IllegalArgumentException.
    • "tracks multiple currencies independently" — consume USD does not affect EUR counter.
  • lib/src/test/kotlin/dev/arcp/lease/LeaseSubsetTest.kt
    • "child within parent passes" — parent USD:5, child USD:2 → true.
    • "child exceeding parent fails" — parent USD:5, child USD:10 → false.
    • "child introducing new currency fails" — parent USD:5, child EUR:1 → false.
  • lib/src/test/kotlin/dev/arcp/error/ErrorCodeTest.kt — extend the entries loop coverage to include BUDGET_EXHAUSTED and assert retryableByDefault == false.
  • tests/src/test/kotlin/dev/arcp/tests/CostBudgetTest.kt — drive a job with USD:0.10 budget; emit two cost.inference metrics summing past the budget; assert the runtime returns BUDGET_EXHAUSTED and terminate(jobId) cleared the counter.

Verification commands

Run from /Users/nficano/code/arpc/kotlin-sdk:

./gradlew :lib:test --tests "dev.arcp.lease.*"
./gradlew :lib:test --tests "dev.arcp.error.ErrorCodeTest"
./gradlew :tests:test --tests "dev.arcp.tests.CostBudgetTest"
./gradlew :samples:runCostBudget
./gradlew check

Acceptance

  • Package dev.arcp.lease exists with Currency, BudgetAmount, CostBudget, BudgetCounter, BudgetRegistry, LeaseSubset.
  • BudgetAmount parses and renders the currency:decimal wire form per §9.6.
  • BudgetCounter.consume(...) rejects negative values and returns Exhausted at zero.
  • LeaseSubset.subsumes(...) enforces per §9.4 / §10 — children may not exceed parent or add new currencies.
  • ErrorCode.BUDGET_EXHAUSTED exists with retryableByDefault = false and round-trips (depends on Add BUDGET_EXHAUSTED, AGENT_VERSION_NOT_AVAILABLE error codes #26).
  • ARCPRuntime decrements counters on incoming cost metrics and replies with BUDGET_EXHAUSTED when a counter reaches zero.
  • StandardMetrics.COST_BUDGET_REMAINING constant is exposed.
  • samples/costbudget/Main.kt runs via ./gradlew :samples:runCostBudget.
  • ./gradlew check green.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature implementationv1.1ARCP v1.1 feature work

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions