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
60 changes: 60 additions & 0 deletions packages/core/src/__tests__/agent-run-event-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { decodeAgentRunEvent } from '../agent-run.js';
import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '../agent-run.js';

function ledgerRecord(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
type: 'run_started',
id: 'event-1',
runId: 'run-1',
sessionId: 'session-1',
turnId: 'turn-1',
ts: 1,
...overrides,
};
}

test('AgentRun reads an event type this build does not write', () => {
// The ledger outlives the build that wrote it, so a reader meets types whose writer was retired
// and types that have not shipped yet. Rejecting either one bricks startup on real data (#1942).
const decoded = decodeAgentRunEvent(
ledgerRecord({ type: 'written_by_another_version', data: { inputTokens: 7 } }),
);

assert.equal(decoded.type, 'written_by_another_version');
assert.equal(decoded.data?.inputTokens, 7);
});

test('AgentRun still rejects a record whose envelope is damaged', () => {
// Tolerating an unknown `type` is not tolerating an unreadable record: everything around `type`
// stays exact, so real corruption is still caught rather than carried forward as a live event.
for (const record of [
ledgerRecord({ type: '' }),
ledgerRecord({ type: ' ' }),
ledgerRecord({ type: 42 }),
ledgerRecord({ unexpectedField: 'present' }),
ledgerRecord({ ts: 'not-a-number' }),
ledgerRecord({ id: undefined }),
]) {
assert.throws(() => decodeAgentRunEvent(record), /Invalid AgentRun event schema/);
}
});

test('AgentRun closes its write contract against a type this build does not emit', () => {
// The assertion here is the compiler: `@ts-expect-error` itself fails to build if the call ever
// type-checks again, which is what widening `appendEvent` back to `AgentRunEvent` would do. The
// call is only declared, never made, so the contract is checked without a store.
const retired: AgentRunEvent = ledgerRecord({
type: 'written_by_another_version',
}) as unknown as AgentRunEvent;
const appendRetired = (store: AgentRunStore) =>
// @ts-expect-error A ledger is read with any type but appended to only with an emitted one.
store.appendEvent('session-1', 'run-1', retired);
assert.equal(typeof appendRetired, 'function');

const emitted: EmittedAgentRunEvent = { ...retired, type: 'run_started' };
const appendEmitted = (store: AgentRunStore) => store.appendEvent('session-1', 'run-1', emitted);
assert.equal(typeof appendEmitted, 'function');
});
31 changes: 26 additions & 5 deletions packages/core/src/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,6 @@ export const AGENT_RUN_EVENT_TYPES = [
'sandbox_denial_detected',
'provider_request_captured',
'provider_request_attempt_recorded',
// No current writer; shipped ledgers still carry it and strict reads must not reject them (#1942).
'usage_recorded',
'model_call_attempt_recorded',
'history_compact_checkpoint_recorded',
'active_full_compact_block_recorded',
Expand All @@ -376,8 +374,14 @@ export const AGENT_RUN_EVENT_TYPES = [

export type AgentRunEventType = (typeof AGENT_RUN_EVENT_TYPES)[number];

/**
* A decoded ledger record. The ledger is append-only and outlives any single build, so `type` is
* an open string: a reader must accept a type another version wrote, whether that version retired
* the writer or has not shipped yet (#1942). The envelope around `type` is still validated, so
* this tolerance does not extend to a record that gained or lost a field.
*/
export interface AgentRunEvent {
type: AgentRunEventType;
type: string;
id: string;
runId: string;
sessionId: string;
Expand All @@ -387,6 +391,22 @@ export interface AgentRunEvent {
data?: Record<string, unknown>;
}

/**
* What this build may append. `AGENT_RUN_EVENT_TYPES` is the emitted catalogue, not the readable
* one, so it stays free to shrink when a writer retires while a misspelled or retired type fails
* to compile at the append that would persist it.
*/
export interface EmittedAgentRunEvent extends AgentRunEvent {
type: AgentRunEventType;
}

const EMITTED_AGENT_RUN_EVENT_TYPES: ReadonlySet<string> = new Set(AGENT_RUN_EVENT_TYPES);

/** Whether this build emits `type`, and so knows what its record means. */
export function isEmittedAgentRunEventType(type: string): type is AgentRunEventType {
return EMITTED_AGENT_RUN_EVENT_TYPES.has(type);
}

const AGENT_RUN_HEADER_SHAPE = defineObjectShape<AgentRunHeader>()(
[
'runId',
Expand Down Expand Up @@ -532,7 +552,8 @@ export function decodeAgentRunEvent(value: unknown): AgentRunEvent {
if (
!isRecord(value) ||
!hasExactShape(value, AGENT_RUN_EVENT_SHAPE) ||
!(AGENT_RUN_EVENT_TYPES as readonly unknown[]).includes(value.type) ||
typeof value.type !== 'string' ||
value.type.trim().length === 0 ||
typeof value.id !== 'string' ||
typeof value.runId !== 'string' ||
typeof value.sessionId !== 'string' ||
Expand Down Expand Up @@ -563,7 +584,7 @@ export interface AgentRunStore {
appendEvent(
sessionId: string,
runId: string,
event: AgentRunEvent,
event: EmittedAgentRunEvent,
options?: { durable?: boolean },
): Promise<void>;
readEvents(sessionId: string, runId: string): Promise<AgentRunEvent[]>;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,12 +404,14 @@ export type {
AgentRunInputSummary,
AgentRunStatus,
AgentRunStore,
EmittedAgentRunEvent,
RootExecutionDescriptor,
} from './agent-run.js';
export {
AGENT_RUN_STATUSES,
decodeAgentRunEvent,
decodeAgentRunHeader,
isEmittedAgentRunEventType,
isSessionInlineRun,
} from './agent-run.js';

Expand Down
14 changes: 7 additions & 7 deletions packages/headless/src/__tests__/provider-request-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { AgentRunEvent, AgentRunHeader } from '@maka/core';
import type { AgentRunHeader, EmittedAgentRunEvent } from '@maka/core';
import type { InvocationResult } from '@maka/runtime';
import { acquireOperationalStateDatabase } from '@maka/storage';

Expand Down Expand Up @@ -262,8 +262,8 @@ test('exports a torn AgentRun tail as incomplete provider-request evidence', asy
const storage = await openHeadlessStorageForWrite(storageRoot);
const runStore = storage.executionStores.agentRunStore;
await runStore.createRun(header);
await runStore.appendEvent(identity.sessionId, identity.runId, capture as AgentRunEvent);
await runStore.appendEvent(identity.sessionId, identity.runId, attempt as AgentRunEvent);
await runStore.appendEvent(identity.sessionId, identity.runId, capture as EmittedAgentRunEvent);
await runStore.appendEvent(identity.sessionId, identity.runId, attempt as EmittedAgentRunEvent);
corruptAgentRunEvent(storageRoot, identity.sessionId, identity.runId, 1);

const traceEventsPath = await writeHarborTaskRunTrace({
Expand Down Expand Up @@ -336,7 +336,7 @@ test('also diagnoses missing provider evidence when the only run event is corrup
const exportedEvents = (await readFile(traceEventsPath, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line) as AgentRunEvent);
.map((line) => JSON.parse(line) as EmittedAgentRunEvent);

assert.deepEqual(
exportedEvents.map((event) => event.type),
Expand Down Expand Up @@ -415,12 +415,12 @@ test('exports missing provider-request evidence for every continuation invocatio
await runStore.appendEvent(
completeIdentity.sessionId,
completeIdentity.runId,
capture as AgentRunEvent,
capture as EmittedAgentRunEvent,
);
await runStore.appendEvent(
completeIdentity.sessionId,
completeIdentity.runId,
attempt as AgentRunEvent,
attempt as EmittedAgentRunEvent,
);

const traceEventsPath = await writeHarborTaskRunTrace({
Expand Down Expand Up @@ -489,7 +489,7 @@ test('preserves existing run events when exporting a missing-evidence diagnostic
const exportedEvents = (await readFile(traceEventsPath, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line) as AgentRunEvent);
.map((line) => JSON.parse(line) as EmittedAgentRunEvent);

assert.deepEqual(
exportedEvents.map((event) => event.type),
Expand Down
9 changes: 7 additions & 2 deletions packages/headless/src/__tests__/task-run-inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import type { AgentRunEvent, AgentRunHeader, RuntimeEvent } from '@maka/core';
import type {
AgentRunEventType,
AgentRunHeader,
EmittedAgentRunEvent,
RuntimeEvent,
} from '@maka/core';
import { buildHistoryCompactCheckpoint } from '@maka/runtime';
import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage';
import type { HeavyTaskSemanticSelfCheckState, TaskEvent } from '../task-contracts.js';
Expand Down Expand Up @@ -385,7 +390,7 @@ function runHeader(overrides: Partial<AgentRunHeader> = {}): AgentRunHeader {
};
}

function runEvent(id: string, type: AgentRunEvent['type']): AgentRunEvent {
function runEvent(id: string, type: AgentRunEventType): EmittedAgentRunEvent {
return { id, type, runId: RUN_ID, sessionId: SESSION_ID, turnId: TURN_ID, ts: 10 };
}

Expand Down
5 changes: 3 additions & 2 deletions packages/headless/src/harbor-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AgentRunEvent,
AgentRunHeader,
BackendKind,
EmittedAgentRunEvent,
PricingConfig,
ProviderType,
RuntimeEvent,
Expand Down Expand Up @@ -694,7 +695,7 @@ export async function writeHarborTaskRunTrace(input: {
turnId: header.turnId,
ts: header.updatedAt,
message: header.traceWriteError,
} satisfies AgentRunEvent,
} satisfies EmittedAgentRunEvent,
]
: events;
if (header.backendKind !== 'ai-sdk' || evidenceEvents.some(isProviderRequestTraceEvidence)) {
Expand All @@ -714,7 +715,7 @@ export async function writeHarborTaskRunTrace(input: {
reason: 'missing_provider_request_evidence',
invocationId: invocation.invocationId,
},
} satisfies AgentRunEvent,
} satisfies EmittedAgentRunEvent,
];
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import type { AgentRunEvent, AgentRunHeader } from '@maka/core';
import type { AgentRunHeader, EmittedAgentRunEvent } from '@maka/core';
import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores';
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
import {
Expand Down Expand Up @@ -141,7 +141,7 @@ describe('HostExecutionInspectCoordinator', () => {
await withCoordinator(async ({ stores, coordinator }) => {
const session = await stores.sessionStore.create(sessionInput('Exact evidence budget'));
await stores.agentRunStore.createRun(runHeader(session.id, 'exact-run', 1));
const baseEvent: AgentRunEvent = {
const baseEvent: EmittedAgentRunEvent = {
type: 'run_started',
id: 'exact-event',
sessionId: session.id,
Expand All @@ -152,7 +152,7 @@ describe('HostExecutionInspectCoordinator', () => {
};
const baseBytes = Buffer.byteLength(JSON.stringify(baseEvent), 'utf8');
assert.ok(baseBytes < EXECUTION_INSPECT_EVIDENCE_MAX_BYTES);
const event: AgentRunEvent = {
const event = {
...baseEvent,
data: {
payload: 'x'.repeat(EXECUTION_INSPECT_EVIDENCE_MAX_BYTES - baseBytes),
Expand Down
11 changes: 8 additions & 3 deletions packages/runtime/src/__tests__/context-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core';
import type {
AgentRunEvent,
AgentRunHeader,
AgentRunStore,
EmittedAgentRunEvent,
} from '@maka/core';
import { createSqliteAgentRunStore } from '@maka/storage';
import { readLatestContextDiagnostics } from '../context-diagnostics.js';

Expand Down Expand Up @@ -217,7 +222,7 @@ function attemptEvent(
inputTokens: number | undefined,
contextWindow: number,
segments: Array<Record<string, unknown>> = [],
): AgentRunEvent {
): EmittedAgentRunEvent {
const turnId = `turn-${runId}`;
return {
type: 'provider_request_attempt_recorded',
Expand Down Expand Up @@ -255,7 +260,7 @@ function checkpointEvent(
eventCount: number,
turnCount: number,
estimatedTokens: number,
): AgentRunEvent {
): EmittedAgentRunEvent {
return {
type: 'history_compact_checkpoint_recorded',
id: `checkpoint-${ts}`,
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime/src/__tests__/conversation-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { test } from 'node:test';
import type {
AgentRunHeader,
AgentRunStore,
EmittedAgentRunEvent,
RuntimeEvent,
RuntimeEventStore,
StoredMessage,
Expand Down Expand Up @@ -970,6 +971,24 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi
turnId: 'turn-1',
ts: 3,
});
// A record left by a build whose writer this one no longer has. The cast is the point: the
// write contract forbids producing this type, and only another version could have put it in
// the source ledger. The rewriters cannot check an unknown payload for source-owned ids, so
// the copy must drop it rather than carry those ids into the target (#1942).
await runStore.appendEvent('session-source', 'run-source', {
type: 'written_by_another_version',
id: 'foreign-source',
runId: 'run-source',
sessionId: 'session-source',
turnId: 'turn-1',
ts: 3.5,
data: { runtimeEventId: 'event-source-1' },
} as unknown as EmittedAgentRunEvent);
assert.ok(
(await runStore.readEvents('session-source', 'run-source')).some(
(event) => event.type === 'written_by_another_version',
),
);
const source = await new RuntimeReadModel({
runStore,
runtimeEventStore,
Expand Down
10 changes: 8 additions & 2 deletions packages/runtime/src/__tests__/execution-inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import type { AgentRunEvent, AgentRunHeader, RuntimeEvent } from '@maka/core';
import type {
AgentRunEvent,
AgentRunEventType,
AgentRunHeader,
EmittedAgentRunEvent,
RuntimeEvent,
} from '@maka/core';
import { createSessionStore } from '@maka/storage';
import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage';
import {
Expand Down Expand Up @@ -156,7 +162,7 @@ function runHeader(sessionId: string): AgentRunHeader {
};
}

function runEvent(sessionId: string, type: AgentRunEvent['type']): AgentRunEvent {
function runEvent(sessionId: string, type: AgentRunEventType): EmittedAgentRunEvent {
return { id: `op-${type}`, type, runId: RUN_ID, sessionId, turnId: TURN_ID, ts: TS + 1 };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path';
import { describe, it } from 'node:test';
import type {
AgentRunEvent,
EmittedAgentRunEvent,
AgentRunHeader,
CreateSessionInput,
SessionHeader,
Expand Down Expand Up @@ -222,7 +223,7 @@ function runHeader(sessionId: string): AgentRunHeader {
};
}

function runEvent(sessionId: string): AgentRunEvent {
function runEvent(sessionId: string): EmittedAgentRunEvent {
return {
type: 'run_started',
id: 'run-1-run_started-11',
Expand Down
Loading
Loading