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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agentborne

Agentborne is the working title for an agent-first GPS containment game. Eight persistent, named model-backed agents move, infect, capture contested territory, communicate, and conduct formal alliance diplomacy in one inference in a real Toledo H3 world while the World Lab exposes every safe decision record.
Agentborne is the working title for an agent-first geographic experiment. A configurable roster of model-backed agents moves, infects, captures contested territory, communicates, and conducts formal alliance diplomacy while the World Lab exposes every safe decision record.

## Workspace

Expand Down Expand Up @@ -44,7 +44,7 @@ The command reads only `OPENROUTER_API_KEY` from the repository-root `.env`; its

## Development map source

The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders a deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution. Public deployments remain responsible for confirming provider usage requirements for their traffic.
The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders the same deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. World Setup previews and applies resolution 8–11 scenarios with 1–32 agents, radius at most 40, and at most 5,000 actual generated cells. Schema-v9 exports preserve the authoritative scenario and `durable-influence-v1` attribution. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution.

Alliance leadership, merging, custom metadata, combat systems, relationship scores, group chat, persistent memory, player mechanics, persistence, and autonomous scheduling remain deferred.

Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Eighth focused slice: add versioned objective/personality/strategy prompt layers

Ninth focused slice: consolidate World Lab controls into one responsive command navbar, add browser-session absolute run targets through turn 1,000, make export generation explicitly artifact-based, unify model-option presentation, synchronize effective alliance colors across activity surfaces, and adopt a tokenless dark basemap plus semantic dark chrome. Simulation rules, model decisions, retry behavior, telemetry meaning, and export contents remain unchanged.

Tenth focused slice: add versioned configurable World Lab scenarios, actual H3 geometry preview, deterministic 1–32-agent rosters and separated spawns, current-scenario reset, a replaceable location-search boundary, `durable-influence-v1` attribution, and schema-v9 exports.

Temporary experiment hardening adds explicitly opted-in browser-lifetime unattended recovery with one to three one-call retries and one attributed skip. It does not replace the future server-owned scheduler or simultaneous-tick recovery policy.

## PR 5 — Goals and memory

Persistent short- and long-term objectives, compact memories, plan revision, summaries, and longer simulation runs.
Expand Down
179 changes: 176 additions & 3 deletions apps/game-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,36 @@ import {
verifyModelRequestSchema,
verifyModelResponseSchema,
worldSnapshotSchema,
worldSetupRequestSchema,
worldSetupPreviewResponseSchema,
applyWorldSetupResponseSchema,
generatedAgentRequestSchema,
generatedAgentResponseSchema,
locationSearchRequestSchema,
locationSearchResponseSchema,
defaultWorldSetupResponseSchema,
type ModelVerification,
} from '@agentborne/shared';
import { createDevelopmentWorld } from '@agentborne/world-engine';
import {
createDevelopmentWorld,
generateDeterministicRoster,
} from '@agentborne/world-engine';
import {
SimulationConflictError,
SimulationService,
SimulationTurnCancelledError,
SimulationValidationError,
} from './simulation-service';
import { ExperimentExportValidationError } from './experiment-export';
import { NominatimGeocoder, type Geocoder } from './geocoder';

export { healthResponseSchema };

export interface AppOptions {
service?: SimulationService;
provider?: AgentProvider;
catalog?: Pick<OpenRouterModelCatalog, 'getCatalog'>;
geocoder?: Geocoder;
}

export function providerFromEnvironment(): AgentProvider {
Expand All @@ -73,10 +86,12 @@ export function createApp(options: AppOptions = {}) {
options.catalog ??
new OpenRouterModelCatalog({ apiKey: process.env.OPENROUTER_API_KEY });
const modelVerifications = new Map<string, ModelVerification>();
const geocoder = options.geocoder ?? new NominatimGeocoder();
const turnMutations = new Map<string, Promise<unknown>>();
const mutationPromise = <T>(
context: Context,
operation: 'turn' | 'retry',
operation:
'turn' | 'retry' | 'unattended-retry' | 'unattended-skip' | 'setup',
execute: () => Promise<T>,
): Promise<T> => {
const supplied =
Expand Down Expand Up @@ -104,7 +119,7 @@ export function createApp(options: AppOptions = {}) {
? origin
: null,
allowMethods: ['GET', 'POST'],
allowHeaders: ['Content-Type'],
allowHeaders: ['Content-Type', 'X-Agentborne-Mutation-Id'],
}),
);

Expand All @@ -125,6 +140,113 @@ export function createApp(options: AppOptions = {}) {
context.json(simulationSnapshotSchema.parse(service.getSnapshot())),
);

app.post('/api/simulation/experiment/setup/preview', async (context) => {
return context.json(
worldSetupPreviewResponseSchema.parse(
service.previewWorldSetup(
await context.req.json().catch(() => undefined),
),
),
);
});

app.get('/api/simulation/experiment/setup/default', (context) =>
context.json(
defaultWorldSetupResponseSchema.parse({
request: service.getDefaultWorldSetup(),
}),
),
);

app.post('/api/simulation/experiment/setup', async (context) => {
const request = worldSetupRequestSchema.safeParse(
await context.req.json().catch(() => undefined),
);
if (!request.success)
return context.json(
apiErrorSchema.parse({
error: {
code: 'invalid_request',
message: 'The scenario setup request is invalid.',
},
}),
400,
);
try {
const snapshot = await mutationPromise(context, 'setup', async () =>
service.applyWorldSetup(request.data),
);
return context.json(applyWorldSetupResponseSchema.parse({ snapshot }));
} catch (error) {
if (error instanceof SimulationConflictError)
return context.json(
apiErrorSchema.parse({
error: { code: 'setup_conflict', message: error.message },
}),
409,
);
if (error instanceof SimulationValidationError)
return context.json(
apiErrorSchema.parse({
error: { code: 'invalid_request', message: error.message },
}),
400,
);
throw error;
}
});

app.post(
'/api/simulation/experiment/setup/roster/generate',
async (context) => {
const request = generatedAgentRequestSchema.safeParse(
await context.req.json().catch(() => undefined),
);
if (!request.success)
return context.json(
apiErrorSchema.parse({
error: {
code: 'invalid_request',
message: 'A valid roster count and seed are required.',
},
}),
400,
);
return context.json(
generatedAgentResponseSchema.parse({
roster: generateDeterministicRoster(
request.data.count,
request.data.seed,
),
}),
);
},
);

app.post(
'/api/simulation/experiment/setup/location-search',
async (context) => {
const request = locationSearchRequestSchema.safeParse(
await context.req.json().catch(() => undefined),
);
if (!request.success)
return context.json(
apiErrorSchema.parse({
error: {
code: 'invalid_request',
message: 'Enter a location query between 2 and 120 characters.',
},
}),
400,
);
return context.json(
locationSearchResponseSchema.parse(
await geocoder.search(request.data.query),
),
);
},
);

app.get('/api/simulation/models', async (context) => {
const response = modelCatalogResponseSchema.parse(
await catalog.getCatalog(false),
Expand Down Expand Up @@ -427,6 +549,57 @@ export function createApp(options: AppOptions = {}) {
app.post('/api/simulation/turn/skip', (context) =>
respondToManualTurn(context, 'skip'),
);
app.post('/api/simulation/turn/unattended-retry', async (context) => {
try {
const turn = await mutationPromise(context, 'unattended-retry', () =>
service.retryFailedTurn('unattended-retry'),
);
return context.json(
singleTurnResponseSchema.parse({
snapshot: service.getSnapshot(),
turn,
}),
);
} catch (error) {
if (error instanceof SimulationTurnCancelledError)
return context.json(
cancelledTurnResponseSchema.parse({
snapshot: service.getSnapshot(),
cancelled: true,
}),
);
if (error instanceof SimulationConflictError)
return context.json(
apiErrorSchema.parse({
error: { code: 'turn_conflict', message: error.message },
}),
409,
);
throw error;
}
});
app.post('/api/simulation/turn/unattended-skip', async (context) => {
try {
const turn = await mutationPromise(context, 'unattended-skip', async () =>
service.skipFailedTurn('unattended'),
);
return context.json(
singleTurnResponseSchema.parse({
snapshot: service.getSnapshot(),
turn,
}),
);
} catch (error) {
if (error instanceof SimulationConflictError)
return context.json(
apiErrorSchema.parse({
error: { code: 'turn_conflict', message: error.message },
}),
409,
);
throw error;
}
});

app.post('/api/simulation/reset', (context) => {
try {
Expand Down
40 changes: 36 additions & 4 deletions apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type ProviderMetadata,
type WorldSnapshot,
type BehaviorConfiguration,
type AppliedScenario,
} from '@agentborne/shared';

export interface ExperimentSource {
Expand All @@ -41,6 +42,7 @@ export interface ExperimentSource {
currentWorld: WorldSnapshot;
modelConfiguration: ExperimentModelConfiguration;
behaviorConfiguration: BehaviorConfiguration;
scenario: AppliedScenario;
}

export class ExperimentExportValidationError extends Error {
Expand Down Expand Up @@ -156,6 +158,11 @@ interface MutableMetrics {
automaticRepairAttempts: number;
automaticTransportRetries: number;
manualRetryAttempts: number;
unattendedRetryAttempts: number;
manualSkips: number;
unattendedSkips: number;
recoveredByUnattendedRetry: number;
skippedAfterUnattendedRecovery: number;
retriedTurns: number;
recoveredAutomatically: number;
recoveredManually: number;
Expand Down Expand Up @@ -232,6 +239,11 @@ function mutableMetrics(): MutableMetrics {
automaticRepairAttempts: 0,
automaticTransportRetries: 0,
manualRetryAttempts: 0,
unattendedRetryAttempts: 0,
manualSkips: 0,
unattendedSkips: 0,
recoveredByUnattendedRetry: 0,
skippedAfterUnattendedRecovery: 0,
retriedTurns: 0,
recoveredAutomatically: 0,
recoveredManually: 0,
Expand Down Expand Up @@ -302,8 +314,13 @@ function addToMutable(
): void {
metrics.turns += 1;
if (turn.outcome === 'provider-error') metrics.providerErrors += 1;
else if (turn.outcome === 'operator-skipped') metrics.operatorSkipped += 1;
else metrics[turn.outcome] += 1;
else if (turn.outcome === 'operator-skipped') {
metrics.operatorSkipped += 1;
if (turn.skipKind === 'unattended') {
metrics.unattendedSkips += 1;
metrics.skippedAfterUnattendedRecovery += 1;
} else metrics.manualSkips += 1;
} else metrics[turn.outcome] += 1;
metrics.visited.add(turn.observation.currentCell.cell);
if (
turn.outcome !== 'provider-error' &&
Expand Down Expand Up @@ -410,8 +427,14 @@ function addToMutable(
metrics.manualRetryAttempts += attempts.filter(
({ kind }) => kind === 'manual-retry',
).length;
metrics.unattendedRetryAttempts += attempts.filter(
({ kind }) => kind === 'unattended-retry',
).length;
const retried = attempts.some(({ kind }) => kind !== 'initial');
const manuallyRetried = attempts.some(({ kind }) => kind === 'manual-retry');
const unattendedRetried = attempts.some(
({ kind }) => kind === 'unattended-retry',
);
if (retried) metrics.retriedTurns += 1;
if (
retried &&
Expand All @@ -425,7 +448,10 @@ function addToMutable(
turn.outcome !== 'operator-skipped'
) {
if (manuallyRetried) metrics.recoveredManually += 1;
else metrics.recoveredAutomatically += 1;
else if (unattendedRetried) {
metrics.recoveredAutomatically += 1;
metrics.recoveredByUnattendedRetry += 1;
} else metrics.recoveredAutomatically += 1;
}
for (const { provider } of attempts) {
if (provider) {
Expand Down Expand Up @@ -488,6 +514,11 @@ function finalizeMutable(metrics: MutableMetrics) {
automaticRepairAttempts: metrics.automaticRepairAttempts,
automaticTransportRetries: metrics.automaticTransportRetries,
manualRetryAttempts: metrics.manualRetryAttempts,
unattendedRetryAttempts: metrics.unattendedRetryAttempts,
manualSkips: metrics.manualSkips,
unattendedSkips: metrics.unattendedSkips,
recoveredByUnattendedRetry: metrics.recoveredByUnattendedRetry,
skippedAfterUnattendedRecovery: metrics.skippedAfterUnattendedRecovery,
retriedTurns: metrics.retriedTurns,
recoveredAutomatically: metrics.recoveredAutomatically,
recoveredManually: metrics.recoveredManually,
Expand Down Expand Up @@ -597,14 +628,15 @@ export function createExperimentExport(
},
);
const document: ExperimentExportDocument = {
schemaVersion: 8,
schemaVersion: 9,
generatedAt,
experiment: {
id: source.id,
startedAt: source.startedAt,
providerMode: source.providerMode,
modelConfiguration: structuredClone(source.modelConfiguration),
behaviorConfiguration: structuredClone(source.behaviorConfiguration),
scenario: structuredClone(source.scenario),
...(request.level === 'full-safe'
? { initialAgents: structuredClone([...source.initialAgents]) }
: {}),
Expand Down
Loading
Loading