Skip to content

Implement cost.budget + BUDGET_EXHAUSTED #20

@nficano

Description

@nficano

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

Steps:

  1. Add CostBudget lease capability with max, unit, consumed fields (use bcmath or Brick\Math for precision)
  2. Runtime increments counter on cost-tagged metric events
  3. On exceeding budget, emit BUDGET_EXHAUSTED error
  4. Implement subset enforcement (child ≤ parent)
  5. Add samples/cost_budget/ demo
  6. Tests

Reference: spec §9.6.


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

Implementation prompt

Largely landed; "verify + harden + extend" pass. PSR-4 root: Arcp\ -> src/. Tests: Arcp\Tests\ -> tests/. Spec ref: spec/docs/draft-arcp-1.1.md §9.6, §12 errors. Precision is handled via an internal int scaled by 1_000_000 (see CostBudget::SCALE); do not add bcmath/Brick\Math.

Files to touch

  • src/Runtime/CostBudget.php — existing. Verify and audit edge cases.
  • src/Errors/BudgetExhaustedException.php — existing. Verify only.
  • src/Errors/ErrorCode.php — confirm BudgetExhausted = 'BUDGET_EXHAUSTED' case.
  • src/Runtime/Job.php — confirm ?CostBudget $budget is a constructor arg.
  • src/Runtime/JobManager.php start() — confirm ?CostBudget $budget = null parameter is forwarded to Job.
  • src/Internal/Runtime/ToolInvocationHandler.php — confirm CostBudget::fromInvocationArguments($msg->arguments) is called and the result is passed to $this->runtime->jobs->start(). Also confirm that BudgetExhaustedException thrown from inside the tool fiber is caught and emitted as a tool.error (via ErrorPayload::fromException) with details: {currency, remaining}.
  • src/Runtime/JobContext.php emitMetric() — confirm the cost-tagged metric path calls $job->budget?->consume($name, $value, $unit) and propagates BudgetExhaustedException out of the fiber so the tool handler unwinds.
  • src/Internal/Client/ErrorMapper.php — confirm BudgetExhausted is mapped to BudgetExhaustedException with details.currency and details.remaining.
  • samples/cost_budget/main.php — existing. Verify runnable.
  • tests/Unit/Runtime/V11FeaturesTest.php — already covers consume/exhaust/subset. Extend with edge cases below.
  • tests/Unit/Runtime/CostBudgetTest.php (new) — focused unit.
  • tests/Integration/CostBudgetTest.php (new) — integration.

Public API additions

Namespace Arcp\Runtime:

final class CostBudget {
    public static function fromPatterns(array $patterns): self;          // ['USD:1.00', 'tokens:1000']
    public static function fromInvocationArguments(array $arguments): ?self; // reads `cost.budget` or `lease.cost.budget`
    public function consume(string $metricName, int|float $value, string $unit): ?string; // returns formatted remaining; throws BudgetExhaustedException
    /** @return array<string, string> */
    public function remaining(): array;
    public function containsSubset(self $child): bool;
}

Namespace Arcp\Errors:

final class BudgetExhaustedException extends ARCPException {
    public function __construct(
        string $currency,
        int|float|string $remaining,
        string $message = '',
        ?\Throwable $previous = null,
    );
    public function code(): ErrorCode;   // ErrorCode::BudgetExhausted
}

Step-by-step changes

  1. src/Runtime/CostBudget.php:
    • Confirm parse() accepts pattern currency:amount where amount is \d+(\.\d+)? and currency matches [A-Za-z][A-Za-z0-9_-]*.
    • Confirm consume() only acts on metric names starting with cost. (other than cost.budget.remaining). Audit: the current check $this->remaining[$unit] <= 0 throws AFTER subtracting; ensure the wire remaining reported in the exception matches the spec (negative-clipped to 0 is acceptable but document).
    • Confirm containsSubset() compares scaled ints, currency by currency.
  2. src/Runtime/JobContext.php emitMetric(): confirm budget consumption happens BEFORE the MetricEvent is emitted, so an exhausted budget aborts the tool before the metric escapes. The current implementation injects dims['budget_remaining'] — keep that.
  3. src/Internal/Runtime/ToolInvocationHandler.php: confirm BudgetExhaustedException (an ARCPException) is caught by the existing catch (ARCPException $e) arm and $this->failJob(..., ErrorPayload::fromException($e)) produces a wire error with code: 'BUDGET_EXHAUSTED' and details: {currency, remaining}.
  4. src/Errors/ErrorPayload.php (read to confirm): make sure ErrorPayload::fromException($e) reads $e->code()->value and $e->details() so the wire payload has correct shape.
  5. src/Errors/BudgetExhaustedException.php: confirm details is ['currency' => ..., 'remaining' => ...] and retryable === false.
  6. src/Internal/Client/ErrorMapper.php: confirm budgetExhausted() reconstructs the exception with currency and remaining from $err->details.
  7. samples/cost_budget/main.php: open, confirm it (a) attaches arguments: ['cost.budget' => ['USD:0.10']] on invokeTool, (b) emits metrics until exhaustion, (c) shows the client catching BudgetExhaustedException. Run with php samples/cost_budget/main.php.
  8. tests/Unit/Runtime/CostBudgetTest.php (new): add cases not in V11FeaturesTest.php:
    • testConsumeIgnoresNonCostMetrics() (e.g. latency.ms).
    • testConsumeIgnoresCostBudgetRemaining() (the sentinel name).
    • testMultipleCurrenciesIndependent()USD:1.00, tokens:1000; consume tokens does not touch USD.
    • testFractionalPrecision() — 0.000001 increments and never underflows below zero by 1.
    • testParseRejectsBadPattern()'USD' or 'USD:abc' throws InvalidArgumentException.
    • testSubsetMixedCurrencies() — parent has USD only; child requests tokens -> containsSubset false.
  9. tests/Integration/CostBudgetTest.php (new):
    • Register a tool whose handler calls $ctx->emitMetric('cost.search', 0.05, 'USD') in a loop. Pass arguments: ['cost.budget' => ['USD:0.10']] to invokeTool. Assert third emit causes the job to fail with wire BUDGET_EXHAUSTED; client receives typed BudgetExhaustedException with details.currency === 'USD'.
    • Add a test that emitted MetricEvents before exhaustion include dims.budget_remaining populated.
  10. docs/guides/leases.md (already exists): append a short subsection "Cost budgets (v1.1 §9.6)" describing the pattern format and the tool.invoke arguments['cost.budget'] shape.

Tests to add

  • tests/Unit/Runtime/CostBudgetTest.php (cases above).
  • tests/Integration/CostBudgetTest.php:
    • testJobFailsWithBudgetExhausted()
    • testClientReceivesTypedBudgetExhaustedException()
    • testMetricEventsCarryBudgetRemainingDim()

Verification commands

cd /Users/nficano/code/arpc/php-sdk
composer test -- --filter CostBudget
composer test -- --filter V11Features
composer stan
composer psalm
php samples/cost_budget/main.php

Acceptance

  • [task] CostBudget parses currency:amount patterns from tool.invoke arguments['cost.budget'] and from arguments['lease']['cost.budget'].
  • [task] consume() ignores non-cost.* metrics and the cost.budget.remaining sentinel.
  • [task] BudgetExhaustedException thrown when a currency counter reaches zero or below.
  • [task] JobContext::emitMetric() injects budget_remaining into the metric event's dims.
  • [task] Wire error has code: 'BUDGET_EXHAUSTED', details: {currency, remaining}, retryable: false.
  • [task] ErrorMapper reconstructs typed BudgetExhaustedException on the client side.
  • [task] containsSubset() enforces child ≤ parent per currency; child with an unknown currency is rejected.
  • [task] samples/cost_budget/main.php runs and demonstrates exhaustion.
  • [task] Unit + integration tests added; docs/guides/leases.md updated.
  • [task] composer stan && composer psalm && composer test all pass.

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