Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,74 @@
# @helix-agent/core changelog

## 2.8.1 - 2026-05-28

**The repair path had not been exercised against real Circle until 2.8.1's
pre-publish testing.** (The 2.8.0 bench measured outcomes but did not route
failures through `wrap()`'s repair loop; the canary used `wrap()` but never
errored.) That first real exercise revealed two engine defects — the
amount-corruption (L1) and the silent-underpay policy (L2) — and prompted a
detection-vs-repair audit that reclassified prior "measured" q-values as
**detection-only**. We fixed the defects and corrected the record rather than
ship around them.

### Fixed
- **Engine no longer corrupts non-scalar amounts (L1).** `auto-detect` /
`applyOverrides` (and `split_transaction`) treated any payload `amount` as a
scalar number it could halve. Circle's API uses `amount: string[]`, so a
repair that touched the amount silently broke the array shape and zeroed the
value — turning a recoverable error into a self-inflicted "invalid parameter".
Amount mutation is now type-guarded: arrays, objects, and ambiguous strings
are never scalar-mutated; numeric strings round-trip to preserve shape.
- **`insufficient-funds` defaults to `hold_and_notify`, not `reduce_request` (L2).**
Auto-halving a fixed obligation (payroll, invoice) silently underpays — a
contractor owed 10 must not receive 5. The correct response to "not enough
money" is to stop and alert the operator. `reduce_request` now fires only when
the caller explicitly opts in via `allowPartial: true` (meaningful for
best-effort transfers such as gas top-ups), and even then the L1 guard applies.

### Changed — q-value reclassification (honesty)
A detection-vs-repair audit of every non-prior capsule found that most
"validated" q-values were validated for **detection** (we correctly classify the
failure) but **not for repair** (the strategy actually completing the original
call). Net result: **zero capsules are engine-repair-validated; one
(`stale_quote`) has validated efficacy and it is advisory.** Reclassified:
- `circle-insufficient-funds` and generic `payment-insufficient`: marked
**non-repairable** (`nonRepairable: true`) — Helix cannot create funds; the
halt is correct, there is no repair to score. q set to a neutral 0.50.
- `wallets-api-rate-limit` (was 0.76): demoted to **0.50, detection-only**. The
bench's 76% was first-attempt API acceptance, not repair recovery; the
`serialize_and_backoff` strategy does not actually serialize and recovered ~0%
of rate-limited calls under real concurrency (see KNOWN_ISSUES KI-1, PR #4).
- `stale_quote` (0.96): retained, but reclassified as **advisory (`observe`)** —
the 96% E2E (Exp D, 932 tx) is agent-side workflow reordering, not an in-engine
repair.
- All other generic priors (0.68–0.88) normalized to **0.50** — they were
unvalidated priors with no supporting artifact.
- Circle adapter `successProbability` values aligned to `seed-genes.ts` so the
prior is not fragmented across two sources.

We would rather ship this honestly than carry confident-looking numbers we
cannot defend.

### Added
- `WrapOptions.allowPartial` — opt-in for partial-payment strategies.
- `WrapOptions.freezeArgs` — when true, no strategy may auto-mutate call args;
`parameterModifier` becomes the only path that can change them.
- `GeneCapsule.nonRepairable` — marks halt-not-repair capsules; for these,
qValue is a neutral prior, not a repair-success probability.
- `KNOWN_ISSUES.md` — tracks KI-1 (serialize_and_backoff no-op) and KI-2
(immune-stat inflation: halts currently count toward immuneHits/savedRevenue;
do not cite those figures externally until fixed).

### Migration from 2.8.0
- **Auto-detect no longer mutates non-scalar amounts.** Defensive; no correct
caller depended on the broken behavior, but if you relied on the engine
halving an array/string amount, it now leaves it intact.
- **`insufficient-funds` now halts by default.** Callers that want the old
reduce-and-retry behavior must set `allowPartial: true`.
- **q-values changed** per the audit above — seeded priors are re-seeded on next
load; learned q-values from real traffic are unaffected.

## 2.8.0 - 2026-05-26

Three Circle-focused PRs landed since 2.7.3: a full Circle platform
Expand Down
47 changes: 47 additions & 0 deletions packages/core/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Known Issues — @helix-agent/core

Tracked honesty-debt and known-broken behavior. Surfaced by the 2.8.1 audit.

---

## KI-1 — `serialize_and_backoff` does not serialize (no-op)

- **Tracking:** https://github.com/usehelix/helix/issues/9
- **Status:** open · **Severity:** high (repair-flawed) · **Target:** PR #4
- **Surfaced by:** 2.8.1 audit; `scripts/circle-bench/results/v1-serialize-and-backoff-anomaly.md`

The `serialize_and_backoff` strategy (Circle `wallets-api-rate-limit`) sets
`_helix_serialize` / `_helix_concurrency` override flags that **no caller ever
reads**. It then sleeps once (~2s) and retries — **in parallel** — so under real
concurrency the retries land in the same rate-limit window and all 429 again.
Bench 2.1 (5×20-concurrent Arc Testnet) showed the repair recovered ~0% of
rate-limited calls; the measured "76%" was ambient first-attempt API acceptance.

- **Consequence:** `wallets-api-rate-limit` was demoted to q=0.50 (detection-only)
in 2.8.1 and flagged KNOWN-BROKEN inline (`seed-genes.ts`, `platforms/circle/strategies.ts`).
- **Do NOT** re-validate or re-raise this capsule's q-value without first
implementing real serialization (per-wallet semaphore, or a wrap-layer
concurrency gate that consumes `_helix_concurrency`). The v2 `chunk_concurrent`
path (`scripts/circle-bench/`) is the intended replacement.

---

## KI-2 — Immune-stat inflation: halts count as "immune successes"

- **Tracking:** https://github.com/usehelix/helix/issues/10
- **Status:** open · **Severity:** high (honesty-debt) · **Target:** 2.8.2 / 2.9.0
- **Surfaced by:** 2.8.1 audit (`pcec.ts` immune branch)

`PcecEngine.repair()` increments `stats.immuneHits` and `stats.savedRevenue`, and
records `immune: true` in the audit log, for **any** existing gene with
`qValue > 0.3` — **including `hold_and_notify` / `nonRepairable` capsules that do
not complete the original call.** A capsule that merely *halts* (e.g.
insufficient-funds) therefore counts toward "immune success" and "saved revenue".

- **Consequence:** every published `immuneHits` / `savedRevenue` figure is
**inflated by halts** and cannot be cleanly read as "repairs that completed the
original intent."
- **Until fixed, do NOT cite `immuneHits` or `savedRevenue` in external
material.** Distinguish repair-completions from correct-halts in the stats:
e.g. exclude `nonRepairable` genes (and `observe`-mode capsules) from
`immuneHits`/`savedRevenue`, or split into `repairHits` vs `haltHits`.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@helix-agent/core",
"version": "2.8.0",
"version": "2.8.1",
"description": "Agent payment intelligence — predict costs, optimize execution, fix failures. Powered by VialOS Runtime.",
"type": "module",
"main": "dist/index.js",
Expand Down
59 changes: 53 additions & 6 deletions packages/core/src/engine/auto-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,60 @@
* Removes the need for users to write parameterModifier.
*/

export type AmountShape = 'number' | 'numeric-string' | 'string' | 'array' | 'object' | 'undefined';

export interface DetectedSignature {
type: 'viem-tx' | 'fetch' | 'generic-payment' | 'unknown';
paramIndex: number;
/**
* Shape of the payment amount field, when detectable. Determines whether a
* strategy may safely apply scalar arithmetic (halve, split) to it. Only
* 'number' and 'numeric-string' are safe; arrays/objects/ambiguous strings
* are NOT (e.g. Circle's `amount: string[]`). Optional for backward-compat
* with callers that construct DetectedSignature directly; detectSignature
* always populates it.
*/
amountShape?: AmountShape;
}

/**
* Classify a payment amount's shape. Only 'number' / 'numeric-string' (and
* bigint, treated as 'number') are safe for scalar math. Arrays (Circle's
* `amount: string[]`), objects ({ value, currency }), and non-numeric strings
* MUST NOT be scalar-mutated — doing so corrupts the shape the SDK requires
* and turns a recoverable error into a self-inflicted invalid-param failure.
*/
export function amountShapeOf(v: unknown): AmountShape {
if (v === undefined || v === null) return 'undefined';
if (typeof v === 'number' || typeof v === 'bigint') return 'number';
if (typeof v === 'string') {
const t = v.trim();
return t !== '' && Number.isFinite(Number(t)) ? 'numeric-string' : 'string';
}
if (Array.isArray(v)) return 'array';
if (typeof v === 'object') return 'object';
return 'string';
}

export function detectSignature(args: unknown[]): DetectedSignature {
if (!args || args.length === 0) return { type: 'unknown', paramIndex: -1 };
if (!args || args.length === 0) return { type: 'unknown', paramIndex: -1, amountShape: 'undefined' };
const first = args[0] as Record<string, unknown>;

// Viem transaction: has 'to' + at least one other tx field
if (typeof first === 'object' && first !== null && 'to' in first) {
const txFields = ['to', 'value', 'nonce', 'gas', 'gasPrice', 'maxFeePerGas', 'maxPriorityFeePerGas', 'data', 'chainId'];
if (txFields.filter(f => f in first).length >= 2) return { type: 'viem-tx', paramIndex: 0 };
if (txFields.filter(f => f in first).length >= 2) return { type: 'viem-tx', paramIndex: 0, amountShape: amountShapeOf(first.value) };
}

// Fetch-like: first arg is URL string
if (typeof first === 'string' && (first as string).startsWith('http')) return { type: 'fetch', paramIndex: 0 };
if (typeof first === 'string' && (first as string).startsWith('http')) return { type: 'fetch', paramIndex: 0, amountShape: 'undefined' };

// Generic payment object
if (typeof first === 'object' && first !== null && ('amount' in first || 'value' in first)) return { type: 'generic-payment', paramIndex: 0 };
if (typeof first === 'object' && first !== null && ('amount' in first || 'value' in first)) {
return { type: 'generic-payment', paramIndex: 0, amountShape: amountShapeOf(first.amount ?? first.value) };
}

return { type: 'unknown', paramIndex: -1 };
return { type: 'unknown', paramIndex: -1, amountShape: 'undefined' };
}

export function applyOverrides(args: unknown[], overrides: Record<string, unknown>, strategy: string, sig: DetectedSignature): unknown[] | null {
Expand Down Expand Up @@ -82,9 +114,24 @@ export function applyOverrides(args: unknown[], overrides: Record<string, unknow

if (sig.type === 'generic-payment') {
const p = { ...(newArgs[0] as Record<string, unknown>) };
const shape = sig.amountShape ?? amountShapeOf(p.amount);

// L1 guard: the generic-payment path only understands SCALAR numeric
// amounts. If the amount is an array (e.g. Circle's `amount: string[]`),
// an object, or an ambiguous non-numeric string, REFUSE to mutate it —
// scaling or replacing it would corrupt the shape the SDK requires and
// self-inflict an invalid-param failure. Returning null makes wrap()
// retry with the original args / surface the real error unchanged.
if (shape === 'array' || shape === 'object' || shape === 'string') {
return null;
}

if (strategy === 'reduce_request') {
if (overrides.amount !== undefined) p.amount = overrides.amount;
else if (p.amount) p.amount = (p.amount as number) * 0.5;
else if (shape === 'number') p.amount = (p.amount as number) * 0.5;
// numeric-string: halve but round-trip back to a string so the shape
// the SDK received (a scalar string) is preserved.
else if (shape === 'numeric-string') p.amount = String(Number(p.amount) * 0.5);
} else {
for (const [k, v] of Object.entries(overrides)) { if (k in p) p[k] = v; }
}
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/engine/pcec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,10 @@ export class PcecEngine {
};
}

private constructCandidates(failure: FailureClassification): RepairCandidate[] {
private constructCandidates(failure: FailureClassification, context?: RepairContext): RepairCandidate[] {
const candidates: RepairCandidate[] = [];
for (const adapter of this.adapters) {
candidates.push(...adapter.construct(failure));
candidates.push(...adapter.construct(failure, context));
}
return candidates.map((c) => ({
...c,
Expand Down Expand Up @@ -476,7 +476,7 @@ export class PcecEngine {
}

// ── CONSTRUCT ──
let candidates = this.constructCandidates(failure);
let candidates = this.constructCandidates(failure, context as RepairContext | undefined);
if (registryCandidate) candidates.unshift(registryCandidate);

// ── LLM CONSTRUCT FALLBACK (when no adapter has strategies) ──
Expand Down
Loading
Loading