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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,16 @@ repair or transient-retry charge per agent. All agents observe the
same frozen pre-tick world; valid decisions resolve together while an individual
provider failure is retained as that agent's final lost tick. Start is
deliberately disabled when the server has no key. This development API has no
authentication or monetary cost controls and is not suitable for an unauthenticated
public deployment.
authentication or provider-account balance enforcement. Its experiment-scoped
attempt and credit-admission limits are operator safeguards, not an upstream
billing guarantee, so it is not suitable for unauthenticated public deployment.

State is held only in the Game API process. The API captures one active safe
experiment with bounded complete tick groups while the browser snapshot remains
bounded without splitting a tick. Schema-v10 exports add tick number, resolution
position, virtual time, interval, and lost-tick attribution; schema-v9 archive
imports remain supported. Model and reasoning-profile assignments may be changed
bounded without splitting a tick. Schema-v11 exports add an independent safe,
bounded provider-attempt ledger to schema-v10 tick attribution, including work
that did not produce a committed turn; schema-v9/v10 archive imports remain
supported. Model and reasoning-profile assignments may be changed
between ticks; behavior locks after tick one. A saved slug absent from the
current compatible catalog is preserved and blocks execution until explicitly
replaced. Every provider decision is one plain-text response containing a
Expand All @@ -86,6 +88,9 @@ World Setup also configures server-owned provider-attempt and conservative
credit-admission limits. The per-attempt credit reservation bounds admission
exposure using exact decimal accounting; it is not an upstream provider-account
spending cap or billing guarantee.
Provider-attempt records contain only bounded sanitized attribution, usage, and
failure fields; prompts, raw responses, credentials, and private reasoning are
excluded.

## Opt-in real-provider smoke

Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ Persistent short- and long-term objectives, compact memories, plan revision, sum
Scheduled turns, snapshots, replay, retries, idempotency, durable budget/attempt
ledgers, failure recovery, and operation without the World Lab browser being
open. The current process-local attempt and credit-admission ceilings are an
operator safety boundary, not runtime persistence or provider-account balance
enforcement.
operator safety boundary, and their schema-v11 safe ledger can be exported to
the analysis archive even when no turn committed. This is not active runtime
persistence, restart recovery, or provider-account balance enforcement.

Player development begins only after these agent milestones demonstrate compelling behavior.
2 changes: 1 addition & 1 deletion apps/game-api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,7 @@ describe('game API simulation boundary', () => {
const { document } = experimentExportResponseSchema.parse(
await response.json(),
);
expect(document.schemaVersion).toBe(10);
expect(document.schemaVersion).toBe(11);
const lostTick = document.turns.find(
({ outcome }) => outcome === 'lost-tick',
);
Expand Down
56 changes: 56 additions & 0 deletions apps/game-api/src/attempt-accounting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,60 @@ describe('AttemptAccounting', () => {
expect(accounting.snapshot().attemptsStarted).toBe(0);
},
);

it('retains independent finalized and in-flight records with honest bounds', () => {
const accounting = new AttemptAccounting(null, null, '0.0100', 2);
const details = (turn: number) => ({
agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e' as never,
intendedTurnNumber: turn,
intendedTickNumber: 1,
kind: 'initial' as const,
startedAt: `2026-08-13T12:00:0${turn}.000Z`,
modelId: 'deterministic-script' as never,
reasoningProfile: 'provider-default' as const,
});
const first = accounting.startAdditional(details(1))!;
accounting.finalize(first, {
outcome: 'completed',
completedAt: '2026-08-13T12:00:02.000Z',
provider: {
provider: 'scripted-test',
model: 'deterministic-script' as never,
latencyMs: 1,
costCredits: 0.00000001,
},
});
accounting.startAdditional(details(2));
accounting.startAdditional(details(3));
expect(accounting.ledger()).toMatchObject([
{ intendedTurnNumber: 2, outcome: 'in-flight', reservedCredits: '0.01' },
{ intendedTurnNumber: 3, outcome: 'in-flight', reservedCredits: '0.01' },
]);
expect(accounting.retention()).toEqual({
limit: 2,
totalStartedAttempts: 3,
retainedAttempts: 2,
droppedRecords: 1,
complete: false,
requestedRangeExtendsBeyondRetention: false,
});
});

it('does not mutate accounting when safe start-record validation fails', () => {
const accounting = new AttemptAccounting(2);
expect(accounting.reserve(1)).toBe(true);
const before = accounting.snapshot();
expect(() =>
accounting.startReserved({
agentId: 'not-a-uuid' as never,
intendedTurnNumber: 1,
kind: 'initial',
startedAt: '2026-08-13T12:00:00.000Z',
modelId: 'deterministic-script' as never,
reasoningProfile: 'provider-default',
}),
).toThrow();
expect(accounting.snapshot()).toEqual(before);
expect(accounting.ledger()).toEqual([]);
});
});
109 changes: 102 additions & 7 deletions apps/game-api/src/attempt-accounting.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import type { ProviderMetadata } from '@hexzero/shared';
import {
providerAttemptRecordSchema,
type AgentId,
type ModelAttempt,
type ModelId,
type ProviderAttemptRecord,
type ProviderFailure,
type ProviderMetadata,
type ReasoningProfile,
} from '@hexzero/shared';

export type AttemptExhaustionReason =
| 'provider-attempt-limit'
Expand All @@ -24,6 +33,23 @@ export interface AttemptAccountingSnapshot {
exhaustionReason: AttemptExhaustionReason | null;
}

export interface AttemptStart {
agentId: AgentId;
intendedTurnNumber: number;
intendedTickNumber?: number;
kind: ModelAttempt['kind'];
startedAt: string;
modelId: ModelId;
reasoningProfile: ReasoningProfile;
}

export interface AttemptCompletion {
outcome: 'completed' | 'provider-error' | 'cancelled' | 'timeout';
completedAt: string;
provider?: ProviderMetadata;
failure?: ProviderFailure;
}

interface Decimal {
integer: bigint;
scale: number;
Expand Down Expand Up @@ -98,6 +124,9 @@ export class AttemptAccounting {
#unknownCost = 0;
#nextPermitId = 1;
readonly #inFlight = new Set<number>();
readonly #records = new Map<number, ProviderAttemptRecord>();
#retainedRecords: ProviderAttemptRecord[] = [];
#droppedRecords = 0;
#exhaustionReason: AttemptExhaustionReason | null = null;
readonly limit: number | null;
readonly creditLimit: string | null;
Expand All @@ -107,6 +136,7 @@ export class AttemptAccounting {
limit: number | null,
creditLimit: string | null = null,
reservationCreditsPerAttempt = '0.01',
readonly ledgerLimit = 10_000,
) {
this.limit = limit;
this.creditLimit = creditLimit === null ? null : canonical(creditLimit);
Expand Down Expand Up @@ -142,31 +172,71 @@ export class AttemptAccounting {
return true;
}

startReserved(): number | null {
startReserved(details?: AttemptStart): number | null {
if (this.#reserved < 1) return null;
const permitId = this.#nextPermitId;
const record = details
? providerAttemptRecordSchema.parse({
id: crypto.randomUUID(),
...details,
outcome: 'in-flight',
reservedCredits: this.reservationCreditsPerAttempt,
})
: undefined;
this.#reserved -= 1;
this.#started += 1;
this.#committedExposure = add(
this.#committedExposure,
this.reservationCreditsPerAttempt,
);
const permitId = this.#nextPermitId++;
this.#nextPermitId += 1;
this.#inFlight.add(permitId);
if (record) {
this.#records.set(permitId, record);
this.#retain(record);
}
return permitId;
}

startAdditional(): number | null {
startAdditional(details?: AttemptStart): number | null {
if (!this.reserve(1)) return null;
return this.startReserved();
return this.startReserved(details);
}

releaseReservations(): void {
this.#reserved = 0;
}

finalize(permitId: number, metadata?: ProviderMetadata): void {
if (!this.#inFlight.delete(permitId)) return;
finalize(
permitId: number,
completion?: ProviderMetadata | AttemptCompletion,
): void {
if (!this.#inFlight.has(permitId)) return;
const detailed = completion && 'outcome' in completion ? completion : null;
const metadata =
detailed?.provider ??
(completion && !('outcome' in completion) ? completion : undefined);
const started = this.#records.get(permitId);
let finalizedRecord: ProviderAttemptRecord | undefined;
if (started && detailed) {
finalizedRecord = providerAttemptRecordSchema.parse({
...started,
...detailed,
actualCostCredits:
metadata?.costCredits === undefined
? undefined
: canonical(String(metadata.costCredits)),
});
}
this.#inFlight.delete(permitId);
this.#finalized += 1;
if (started && finalizedRecord) {
this.#records.delete(permitId);
const index = this.#retainedRecords.findIndex(
({ id }) => id === started.id,
);
if (index >= 0) this.#retainedRecords[index] = finalizedRecord;
}
if (metadata?.costCredits === undefined) {
this.#unknownCost += 1;
return;
Expand All @@ -189,6 +259,31 @@ export class AttemptAccounting {
}
}

#retain(record: ProviderAttemptRecord): void {
this.#retainedRecords.push(record);
while (this.#retainedRecords.length > this.ledgerLimit) {
const removed = this.#retainedRecords.shift()!;
for (const [permit, candidate] of this.#records)
if (candidate.id === removed.id) this.#records.delete(permit);
this.#droppedRecords += 1;
}
}

ledger(): readonly ProviderAttemptRecord[] {
return structuredClone(this.#retainedRecords);
}

retention() {
return {
limit: this.ledgerLimit,
totalStartedAttempts: this.#started,
retainedAttempts: this.#retainedRecords.length,
droppedRecords: this.#droppedRecords,
complete: this.#droppedRecords === 0,
requestedRangeExtendsBeyondRetention: false,
};
}

#reservedCredits(): string {
return multiply(this.reservationCreditsPerAttempt, this.#reserved);
}
Expand Down
Loading
Loading