-
Notifications
You must be signed in to change notification settings - Fork 27
test: add focused unit tests for orchestrator budget guardrails #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Flamki
merged 2 commits into
Flamki:master
from
Fury03:test/issue-19-budget-guardrail-tests
Jun 8, 2026
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: Tests | ||
|
|
||
| on: | ||
| push: | ||
| branches: [ "**" ] | ||
| pull_request: | ||
| branches: [ "**" ] | ||
|
|
||
| jobs: | ||
| unit: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 20 | ||
| cache: npm | ||
|
|
||
| - name: Install dependencies | ||
| run: npm ci | ||
|
|
||
| - name: Run unit tests | ||
| run: npm test | ||
|
|
||
| # Fast, dependency-free guardrail suite — also runs on its own so a | ||
| # failure here is unambiguous even if install/other suites have issues. | ||
| - name: Budget guardrail tests | ||
| run: npm run test:budget | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| /** | ||
| * Budget guardrails & payment-outcome accounting for the orchestrator. | ||
| * | ||
| * This module is intentionally dependency-free (no SDK, network, or config | ||
| * imports) so the guardrail logic can be unit-tested deterministically in | ||
| * isolation — mirroring the `settlement-header.js` pattern. `orchestrator.js` | ||
| * imports these helpers so the tests guard the *real* code path rather than a | ||
| * re-implementation. | ||
| * | ||
| * All monetary values are USDC. Costs are derived from `agent.price` (a string | ||
| * in the registry), matching the orchestrator's historical behavior. | ||
| */ | ||
|
|
||
| /** | ||
| * Resolve an agent's per-call cost as a number. | ||
| * Returns NaN for malformed prices (preserving `parseFloat` semantics). | ||
| * @param {{ price?: string }} agent | ||
| * @returns {number} | ||
| */ | ||
| export function agentCost(agent) { | ||
| return parseFloat(agent?.price); | ||
| } | ||
|
|
||
| /** | ||
| * Remaining budget headroom (may be negative if already overspent). | ||
| * @param {number} budget | ||
| * @param {number} totalSpent | ||
| * @returns {number} | ||
| */ | ||
| export function remainingBudget(budget, totalSpent) { | ||
| return budget - totalSpent; | ||
| } | ||
|
|
||
| /** | ||
| * Format a USDC amount for display/reporting (4 decimal places). | ||
| * @param {number} value | ||
| * @returns {string} | ||
| */ | ||
| export function formatAmount(value) { | ||
| return Number(value).toFixed(4); | ||
| } | ||
|
|
||
| /** | ||
| * Core guardrail predicate: would running this step push spend over budget? | ||
| * Strict greater-than means a step whose cost exactly consumes the remaining | ||
| * budget is still allowed to run. | ||
| * @param {number} totalSpent | ||
| * @param {number} cost | ||
| * @param {number} budget | ||
| * @returns {boolean} | ||
| */ | ||
| export function exceedsBudget(totalSpent, cost, budget) { | ||
| return totalSpent + cost > budget; | ||
| } | ||
|
Comment on lines
+52
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Treat non-finite values as over-budget to prevent guardrail bypass. If Proposed fix export function exceedsBudget(totalSpent, cost, budget) {
- return totalSpent + cost > budget;
+ if (!Number.isFinite(totalSpent) || !Number.isFinite(cost) || !Number.isFinite(budget)) {
+ return true;
+ }
+ return totalSpent + cost > budget;
}🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Build the skipped-step record pushed onto `results` when a step is denied | ||
| * by the budget guardrail. | ||
| * @param {{ id: string, price: string }} agent | ||
| * @param {number} budget | ||
| * @param {number} totalSpent | ||
| * @returns {{ agentId: string, skipped: true, reason: string }} | ||
| */ | ||
| export function buildSkipResult(agent, budget, totalSpent) { | ||
| return { | ||
| agentId: agent.id, | ||
| skipped: true, | ||
| reason: `Budget limit (${formatAmount(remainingBudget(budget, totalSpent))} USDC remaining, need ${agent.price})`, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Build the `budget_limit` broadcast event payload (without a timestamp; the | ||
| * caller is responsible for stamping it so this stays deterministic). | ||
| * @param {{ name: string, price: string }} agent | ||
| * @param {number} budget | ||
| * @param {number} totalSpent | ||
| * @returns {{ type: 'budget_limit', agent: string, cost: string, remaining: string }} | ||
| */ | ||
| export function buildBudgetLimitEvent(agent, budget, totalSpent) { | ||
| return { | ||
| type: 'budget_limit', | ||
| agent: agent.name, | ||
| cost: agent.price, | ||
| remaining: formatAmount(remainingBudget(budget, totalSpent)), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Classify a single step's payment outcome into an accounting bucket. | ||
| * Anything that is not a confirmed x402 or direct-XLM payment counts as unpaid. | ||
| * @param {string|undefined|null} paidVia | ||
| * @returns {'x402' | 'stellar-xlm' | 'unpaid'} | ||
| */ | ||
| export function paymentBucket(paidVia) { | ||
| if (paidVia === 'x402') return 'x402'; | ||
| if (paidVia === 'stellar-xlm-direct') return 'stellar-xlm'; | ||
| return 'unpaid'; | ||
| } | ||
|
|
||
| /** | ||
| * Tally a list of `paidVia` values into the three counters the orchestrator | ||
| * reports. Empty input yields all zeros. | ||
| * @param {Array<string|undefined|null>} paidViaList | ||
| * @returns {{ x402PaymentCount: number, xlmFallbackCount: number, unpaidCount: number }} | ||
| */ | ||
| export function tallyPaymentOutcomes(paidViaList) { | ||
| const counts = { x402PaymentCount: 0, xlmFallbackCount: 0, unpaidCount: 0 }; | ||
| for (const paidVia of paidViaList || []) { | ||
| const bucket = paymentBucket(paidVia); | ||
| if (bucket === 'x402') counts.x402PaymentCount += 1; | ||
| else if (bucket === 'stellar-xlm') counts.xlmFallbackCount += 1; | ||
| else counts.unpaidCount += 1; | ||
| } | ||
| return counts; | ||
| } | ||
|
|
||
| /** | ||
| * Summarize the dominant payment protocol used across a run. | ||
| * @param {number} x402Count | ||
| * @param {number} xlmFallbackCount | ||
| * @returns {'x402' | 'stellar-xlm' | 'mixed' | 'none'} | ||
| */ | ||
| export function paymentProtocolSummary(x402Count, xlmFallbackCount) { | ||
| if (x402Count > 0 && xlmFallbackCount === 0) return 'x402'; | ||
| if (x402Count === 0 && xlmFallbackCount > 0) return 'stellar-xlm'; | ||
| if (x402Count > 0 && xlmFallbackCount > 0) return 'mixed'; | ||
| return 'none'; | ||
| } | ||
|
|
||
| /** | ||
| * Whether the budget has been fully consumed (used for the final summary). | ||
| * @param {number} totalSpent | ||
| * @param {number} budget | ||
| * @returns {boolean} | ||
| */ | ||
| export function isBudgetExhausted(totalSpent, budget) { | ||
| return totalSpent >= budget; | ||
| } | ||
|
|
||
| /** | ||
| * Count steps that actually ran (everything not explicitly skipped). | ||
| * NOTE: this intentionally counts "agent not found" error entries as used, | ||
| * preserving the orchestrator's existing behavior — see the test that pins it. | ||
| * @param {Array<{ skipped?: boolean }>} results | ||
| * @returns {number} | ||
| */ | ||
| export function countUsed(results) { | ||
| return results.filter((r) => !r.skipped).length; | ||
| } | ||
|
|
||
| /** | ||
| * Count steps skipped by the budget guardrail. | ||
| * @param {Array<{ skipped?: boolean }>} results | ||
| * @returns {number} | ||
| */ | ||
| export function countSkipped(results) { | ||
| return results.filter((r) => r.skipped).length; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Harden GitHub Actions usage by pinning SHAs and disabling credential persistence.
Line 14 and Line 17 use tag refs instead of commit SHAs, and Line 14 doesn’t disable persisted credentials. This weakens CI supply-chain and token hygiene.
Suggested fix
🧰 Tools
🪛 zizmor (1.25.2)
[warning] 13-14: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[error] 14-14: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
[error] 17-17: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 Prompt for AI Agents