Circe PR #9: restart fixes, product gaps remain
Durable context is real. Eight implementation gaps are addressed in the corrective branch. The product is not yet complete.
466 tests and 147 earlier review checks pass. No live model run was made. The original PR needs these corrections; neither PR has been merged by this task.
+1451 −174 baseline 5dc176f fix 927c9ea24 September 2026 · no numeric rating · no new external runtime dependencies
Architecture and complexity
PR #9 addresses the previous audit’s largest structural gap: semantic context now survives restart alongside kernel state, and a continuing host rebuilds discourse instead of starting every call empty. PR #8 is already in its base, and all 147 earlier independent review checks still pass. This review did not duplicate those earlier fixes.
The remaining defects are integration ownership problems. The host must own observation identity, the decision evidence preceding execution, and serialization of calls that share a clock and discourse. Leaving these responsibilities to individual callers would recreate the same alternate-entry-point drift seen in the earlier PR stack.
The third accepted-turn log is justified because a semantic abstention creates no kernel command to deduplicate. It records the first interpretation before consequences. The kernel still owns authorization, effects and reconciliation; the context store still owns evidence. The files are ordered append boundaries, not an atomic multi-file transaction. This distinction is documented and the pre-dispatch cut is tested.
The corrective change adds no external runtime dependency and does not rewrite the kernel. Most additions are regression tests, the live HTTP mapping, and a small turn snapshot codec. That is proportionate to the failures found. I would keep these boundaries and resist adding a general orchestration framework. The append-only JSONL adapters still read whole logs and lack compaction, so long-lived scale remains unproven.
Review scope and method
- Reviewed actual PR #9 head 5dc176f80246d2ee672a166e9a8f5026c9aece5e on top of merged PR #8.
- Used three requested GPT-6 Luna agents for bounded host, persistence and transport work; the primary agent reviewed their edits and completed follow-up validation after agents reached their usage limit.
- Kept the original checkout clean; fixes are in an isolated worktree and a separate PR stacked on #9. Neither PR was merged.
- Baseline: 438 passing tests. Corrective head: 466 tests, typecheck and package-boundary checks pass. The five fixture cases and continuing restart demo pass.
- Re-ran a 40-case selection against the original PR: 17 expected failures and 23 passes. Three failures exercise missing control/client APIs; counts are not distinct defects.
- No live model call was made. Remaining product gaps are listed separately from fixed implementation findings.
Observation identity → serialized host → authority observations → immutable frame → bounded perception → first interpretation recorded → decision traces persisted → accepted-turn compiler commands → kernel authorization / journal → executor Retry / restart → pinned interpretation → identical command IDs → kernel deduplication / reconciliation
What still prevents a complete product claim
No TYPESAFE_API_KEY or TYPESAFE_MODEL is configured. The live command exits before making a call. Configure them in /home/abstergo/.config/circe/typesafe.env outside Git to enable the next verification. Do not paste the secret into the task. The HTTP mapping was checked against TypeSafe’s official OpenAPI document, but credentials and a real response are still needed.
Typed confirmation, revision, cancellation, resumption and reconciliation are available. The current request-oriented demo does not supply a general natural-language control or query-response workflow. The five synthetic live cases exercise a narrow slice. They are not a representative interpretation benchmark, and thresholds are not calibrated against observed outcomes.
The context adapter can persist annotations, assertions and phrase bindings; the host persists authority observations, frames, traces and interpreted turns. Applications still need to decide which accepted semantic results become durable beliefs or bindings. Persisted decision traces alone do not establish that the system learns reusable semantic knowledge.
One process owns each store path. Multi-process locking, migrations beyond current format checks, retention and compaction are not implemented. No performance claim for unbounded histories is supported by this review.
Transport reference: TypeSafe official OpenAPI. Previous review: merged implementation audit and PR #8 fixes.
Verification evidence
Repository tests: 337 evals, 41 journal, 15 context-file, 9 file-log, 24 Jev and 40 inspector. The external 147-check suite overlaps with retained regressions and is reported separately. A 40-case baseline comparison gives 17 failures and 23 passes, including expected failures for missing APIs. The baseline already passes the torn UTF-8 tail and evaluation-directory controls.
Review JSONReview textFull correction diff466 testsBaseline failures147 prior checksTypecheckFixture evaluationRestart demo
Changed files
Findings and corrections
P1 F78. Reuse the first interpretation when an observation is retried code addressedapps/inspector/src/host.ts:323 · logic · correctness
PR #9 invokes perception again on every runTurn delivery. Stable kernel command IDs cannot protect an observation that originally abstained, and they do not pin the number or meaning of a later interpretation. Retrying after a restart can ask a nondeterministic model to turn an earlier abstention into new work.
The host now appends the first interpreted turn before trace writes or dispatch. Reopening uses that snapshot, checks the input fingerprint, and redelivers exactly its compiled commands. This also covers a failure immediately before dispatch. The log preserves workflow Maps explicitly; recovery checks provenance and recompiles the saved semantic turn before accepting its commands.
Evidence. host.test.ts covers completed replay, abstention, conflicting input and the pre-dispatch cut; turn-store.test.ts covers Maps, detached reads, conflicts and corrupt records.
Implementation diff
diff --git a/apps/inspector/src/host.ts b/apps/inspector/src/host.ts
index fa2e83f..65136a8 100644
--- a/apps/inspector/src/host.ts
+++ b/apps/inspector/src/host.ts
@@ -5,9 +5,11 @@ import {
type CapabilityDescriptor,
type CapabilityExecutor,
type EntityRef,
+ type EffectId,
type Intent,
type InteractionJournal,
type InteractionState,
+ type KernelCommand,
type Observation,
type Referent,
type Transaction,
@@ -18,6 +20,7 @@ import {
ingestWorldView,
type CapabilitySemantics,
type ContextFrame,
+ type DecisionTrace,
type FrameInteraction,
type FrameLiveObligation,
type FramePendingIntent,
@@ -35,6 +38,11 @@ import {
type WorkflowDefinition,
type WorkflowRun,
} from "@circe/perception";
+import {
+ createInMemoryAcceptedTurnStore,
+ type AcceptedTurnStore,
+ type AcceptedTurnRecord,
+} from "./turn-store";
/**
* Continuing interaction host.
@@ -70,6 +78,8 @@ export interface ContinuingInteractionConfig {
/** durable or in-memory; the host never closes what it does not own */
context: SemanticContextStoreContract;
journal: InteractionJournal;
+ /** optional durable pin for the first interpretation of each observation */
+ turnStore?: AcceptedTurnStore;
capabilities: CapabilitySemantics[];
workflow: WorkflowDefinition;
backend: PerceptionBackend;
@@ -105,6 +115,10 @@ export interface ContinuingTurnReport {
export interface ContinuingInteractionHost {
readonly context: SemanticContextStoreContract;
runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport>;
+ /** Drive a typed kernel command (confirm/cancel/resume/revise/propose). */
+ command(command: KernelCommand, now: string): Promise<TurnResult>;
+ /** Resolve an UNKNOWN dispatch from its recorded receipt. */
+ reconcile(effectId: EffectId, now: string): Promise<TurnResult>;
/**
* A non-language kernel input turn (UI focus/selection, world events).
* Language never goes through here; the perception path is `runTurn`.
@@ -215,6 +229,15 @@ export function createContinuingInteractionHost(
config: ContinuingInteractionConfig,
): ContinuingInteractionHost {
let clockNow: string | undefined;
+ const turnStore = config.turnStore ?? createInMemoryAcceptedTurnStore();
+ // A continuing interaction has one discourse timeline. Serialize ingestion
+ // and language turns so each frame is grounded in the previous turn's state.
+ let turnQueue: Promise<void> = Promise.resolve();
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
+ const result = turnQueue.then(work);
+ turnQueue = result.then(() => undefined, () => undefined);
+ return result;
+ }
const runtime = createRuntime({
ports: {
authorityFor: (ref) => {
@@ -256,51 +279,117 @@ export function createContinuingInteractionHost(
context: config.context,
state: () => runtime.state(),
- async ingest(observation: Observation): Promise<TurnResult> {
- clockNow = observation.at;
- return runtime.ingest(observation);
+ ingest(observation: Observation): Promise<TurnResult> {
+ const stableObservation = structuredClone(observation);
+ return enqueue(async () => {
+ clockNow = stableObservation.at;
+ return runtime.ingest(stableObservation);
+ });
},
- async runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> {
- clockNow = input.now;
- const state = runtime.state();
- const scopeId = state.scopeStack[state.scopeStack.length - 1];
- const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : [];
- const refs = input.refs ?? scopeRefs;
- await observe(refs);
-
- const { frame } = await assembleFrame(config.context, {
- interactionId: config.interactionId,
- observationId: input.observationId,
- utterance: { text: input.utterance, source: "text" },
- interaction: frameInteractionFor(runtime.state(), refs, descriptors()),
- capabilities: config.capabilities,
- now: input.now,
+ command(command: KernelCommand, now: string): Promise<TurnResult> {
+ const stableCommand = structuredClone(command);
+ return enqueue(async () => {
+ clockNow = now;
+ return runtime.command(stableCommand);
});
+ },
- const run = await runPerceptionWorkflow({
- backend: config.backend,
- frame,
- workflow: config.workflow,
- policy: config.policy,
- now: input.now,
- runId: `host:${config.interactionId}:${input.observationId}`,
+ reconcile(effectId: EffectId, now: string): Promise<TurnResult> {
+ return enqueue(async () => {
+ clockNow = now;
+ return runtime.reconcile(effectId);
});
+ },
- const turn = semanticTurnFromRun(run, config.propositions(run, frame));
- const compiled = compileSemanticTurn(turn, {
- frame,
- commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`,
- });
+ runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> {
+ const stableInput = structuredClone(input);
+ return enqueue(async () => {
+ const input = stableInput;
+ const key = `${config.interactionId}:${input.observationId}`;
+ const inputFingerprint = JSON.stringify({
+ observationId: input.observationId,
+ utterance: input.utterance,
+ now: input.now,
+ refs: input.refs === undefined
+ ? "<active-scope>"
+ : input.refs.map((ref) => ({
+ namespace: ref.namespace,
+ type: ref.type,
+ id: ref.id,
+ authority: ref.authority,
+ })),
+ });
+ const pinned = turnStore.get(key);
+ if (pinned && pinned.inputFingerprint !== inputFingerprint) {
+ throw new Error(`observation identity ${input.observationId} was reused with different turn input`);
+ }
- config.beforeCommands?.(frame);
+ clockNow = input.now;
+ let frame: ContextFrame;
+ let run: WorkflowRun;
+ let turn: SemanticTurn;
+ let compiled: CompiledTurn;
+ if (pinned) {
+ ({ frame, run, turn, compiled } = pinned);
+ } else {
+ const state = runtime.state();
+ const scopeId = state.scopeStack[state.scopeStack.length - 1];
+ const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : [];
+ const refs = input.refs ?? scopeRefs;
+ await observe(refs);
- const results: TurnResult[] = [];
- for (const command of compiled.commands) {
- results.push(await runtime.command(command));
- }
+ ({ frame } = await assembleFrame(config.context, {
+ interactionId: config.interactionId,
+ observationId: input.observationId,
+ utterance: { text: input.utterance, source: "text" },
+ interaction: frameInteractionFor(runtime.state(), refs, descriptors()),
+ capabilities: config.capabilities,
+ now: input.now,
+ }));
+
+ run = await runPerceptionWorkflow({
+ backend: config.backend,
+ frame,
+ workflow: config.workflow,
+ policy: config.policy,
+ now: input.now,
+ runId: `host:${config.interactionId}:${frame.id}`,
+ });
+
+ turn = semanticTurnFromRun(run, config.propositions(run, frame));
+ compiled = compileSemanticTurn(turn, {
+ frame,
+ commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`,
+ });
+ const record: AcceptedTurnRecord = { key, inputFingerprint, frame, run, turn, compiled };
+ // Pin the first interpretation before trace writes or any command dispatch.
+ turnStore.record(record);
+ }
- return { frame, run, turn, compiled, results, state: runtime.state() };
+ persistDecisionTraces(config.context, run.traces);
+ config.beforeCommands?.(frame);
+
+ const results: TurnResult[] = [];
+ for (const command of compiled.commands) {
+ results.push(await runtime.command(command));
+ }
+
+ return { frame, run, turn, compiled, results, state: runtime.state() };
+ });
},
};
}
+
+function persistDecisionTraces(store: SemanticContextStoreContract, traces: DecisionTrace[]): void {
+ for (const trace of traces) {
+ const existing = store.decision(trace.id);
+ if (existing) {
+ if (JSON.stringify(existing) !== JSON.stringify(trace)) {
+ throw new Error(`decision identity ${trace.id} was reused with different provenance`);
+ }
+ continue;
+ }
+ store.recordDecision(trace);
+ }
+}
Prompt for AI Agents
P1 F79. Serialize calls that share the interaction clock and discourse code addressedapps/inspector/src/host.ts:236 · logic · correctness
The original host lets simultaneous runTurn and ingest calls operate on one runtime and mutable clock. A later call can change the time while an earlier perception call is suspended. Both turns can assemble against discourse that has not incorporated the other operation.
A single per-host promise queue now orders turns, observations, commands and reconciliation. Inputs are cloned before joining the queue, so caller mutation during the wait cannot change the operation. A concurrency regression observes overlapping backend work on PR #9 and a maximum of one active turn after the fix.
Evidence. host.test.ts checks simultaneous calls, pre-enqueue snapshots and persisted provenance.
Implementation diff
diff --git a/apps/inspector/src/host.ts b/apps/inspector/src/host.ts
index fa2e83f..65136a8 100644
--- a/apps/inspector/src/host.ts
+++ b/apps/inspector/src/host.ts
@@ -5,9 +5,11 @@ import {
type CapabilityDescriptor,
type CapabilityExecutor,
type EntityRef,
+ type EffectId,
type Intent,
type InteractionJournal,
type InteractionState,
+ type KernelCommand,
type Observation,
type Referent,
type Transaction,
@@ -18,6 +20,7 @@ import {
ingestWorldView,
type CapabilitySemantics,
type ContextFrame,
+ type DecisionTrace,
type FrameInteraction,
type FrameLiveObligation,
type FramePendingIntent,
@@ -35,6 +38,11 @@ import {
type WorkflowDefinition,
type WorkflowRun,
} from "@circe/perception";
+import {
+ createInMemoryAcceptedTurnStore,
+ type AcceptedTurnStore,
+ type AcceptedTurnRecord,
+} from "./turn-store";
/**
* Continuing interaction host.
@@ -70,6 +78,8 @@ export interface ContinuingInteractionConfig {
/** durable or in-memory; the host never closes what it does not own */
context: SemanticContextStoreContract;
journal: InteractionJournal;
+ /** optional durable pin for the first interpretation of each observation */
+ turnStore?: AcceptedTurnStore;
capabilities: CapabilitySemantics[];
workflow: WorkflowDefinition;
backend: PerceptionBackend;
@@ -105,6 +115,10 @@ export interface ContinuingTurnReport {
export interface ContinuingInteractionHost {
readonly context: SemanticContextStoreContract;
runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport>;
+ /** Drive a typed kernel command (confirm/cancel/resume/revise/propose). */
+ command(command: KernelCommand, now: string): Promise<TurnResult>;
+ /** Resolve an UNKNOWN dispatch from its recorded receipt. */
+ reconcile(effectId: EffectId, now: string): Promise<TurnResult>;
/**
* A non-language kernel input turn (UI focus/selection, world events).
* Language never goes through here; the perception path is `runTurn`.
@@ -215,6 +229,15 @@ export function createContinuingInteractionHost(
config: ContinuingInteractionConfig,
): ContinuingInteractionHost {
let clockNow: string | undefined;
+ const turnStore = config.turnStore ?? createInMemoryAcceptedTurnStore();
+ // A continuing interaction has one discourse timeline. Serialize ingestion
+ // and language turns so each frame is grounded in the previous turn's state.
+ let turnQueue: Promise<void> = Promise.resolve();
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
+ const result = turnQueue.then(work);
+ turnQueue = result.then(() => undefined, () => undefined);
+ return result;
+ }
const runtime = createRuntime({
ports: {
authorityFor: (ref) => {
@@ -256,51 +279,117 @@ export function createContinuingInteractionHost(
context: config.context,
state: () => runtime.state(),
- async ingest(observation: Observation): Promise<TurnResult> {
- clockNow = observation.at;
- return runtime.ingest(observation);
+ ingest(observation: Observation): Promise<TurnResult> {
+ const stableObservation = structuredClone(observation);
+ return enqueue(async () => {
+ clockNow = stableObservation.at;
+ return runtime.ingest(stableObservation);
+ });
},
- async runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> {
- clockNow = input.now;
- const state = runtime.state();
- const scopeId = state.scopeStack[state.scopeStack.length - 1];
- const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : [];
- const refs = input.refs ?? scopeRefs;
- await observe(refs);
-
- const { frame } = await assembleFrame(config.context, {
- interactionId: config.interactionId,
- observationId: input.observationId,
- utterance: { text: input.utterance, source: "text" },
- interaction: frameInteractionFor(runtime.state(), refs, descriptors()),
- capabilities: config.capabilities,
- now: input.now,
+ command(command: KernelCommand, now: string): Promise<TurnResult> {
+ const stableCommand = structuredClone(command);
+ return enqueue(async () => {
+ clockNow = now;
+ return runtime.command(stableCommand);
});
+ },
- const run = await runPerceptionWorkflow({
- backend: config.backend,
- frame,
- workflow: config.workflow,
- policy: config.policy,
- now: input.now,
- runId: `host:${config.interactionId}:${input.observationId}`,
+ reconcile(effectId: EffectId, now: string): Promise<TurnResult> {
+ return enqueue(async () => {
+ clockNow = now;
+ return runtime.reconcile(effectId);
});
+ },
- const turn = semanticTurnFromRun(run, config.propositions(run, frame));
- const compiled = compileSemanticTurn(turn, {
- frame,
- commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`,
- });
+ runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> {
+ const stableInput = structuredClone(input);
+ return enqueue(async () => {
+ const input = stableInput;
+ const key = `${config.interactionId}:${input.observationId}`;
+ const inputFingerprint = JSON.stringify({
+ observationId: input.observationId,
+ utterance: input.utterance,
+ now: input.now,
+ refs: input.refs === undefined
+ ? "<active-scope>"
+ : input.refs.map((ref) => ({
+ namespace: ref.namespace,
+ type: ref.type,
+ id: ref.id,
+ authority: ref.authority,
+ })),
+ });
+ const pinned = turnStore.get(key);
+ if (pinned && pinned.inputFingerprint !== inputFingerprint) {
+ throw new Error(`observation identity ${input.observationId} was reused with different turn input`);
+ }
- config.beforeCommands?.(frame);
+ clockNow = input.now;
+ let frame: ContextFrame;
+ let run: WorkflowRun;
+ let turn: SemanticTurn;
+ let compiled: CompiledTurn;
+ if (pinned) {
+ ({ frame, run, turn, compiled } = pinned);
+ } else {
+ const state = runtime.state();
+ const scopeId = state.scopeStack[state.scopeStack.length - 1];
+ const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : [];
+ const refs = input.refs ?? scopeRefs;
+ await observe(refs);
- const results: TurnResult[] = [];
- for (const command of compiled.commands) {
- results.push(await runtime.command(command));
- }
+ ({ frame } = await assembleFrame(config.context, {
+ interactionId: config.interactionId,
+ observationId: input.observationId,
+ utterance: { text: input.utterance, source: "text" },
+ interaction: frameInteractionFor(runtime.state(), refs, descriptors()),
+ capabilities: config.capabilities,
+ now: input.now,
+ }));
+
+ run = await runPerceptionWorkflow({
+ backend: config.backend,
+ frame,
+ workflow: config.workflow,
+ policy: config.policy,
+ now: input.now,
+ runId: `host:${config.interactionId}:${frame.id}`,
+ });
+
+ turn = semanticTurnFromRun(run, config.propositions(run, frame));
+ compiled = compileSemanticTurn(turn, {
+ frame,
+ commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`,
+ });
+ const record: AcceptedTurnRecord = { key, inputFingerprint, frame, run, turn, compiled };
+ // Pin the first interpretation before trace writes or any command dispatch.
+ turnStore.record(record);
+ }
- return { frame, run, turn, compiled, results, state: runtime.state() };
+ persistDecisionTraces(config.context, run.traces);
+ config.beforeCommands?.(frame);
+
+ const results: TurnResult[] = [];
+ for (const command of compiled.commands) {
+ results.push(await runtime.command(command));
+ }
+
+ return { frame, run, turn, compiled, results, state: runtime.state() };
+ });
},
};
}
+
+function persistDecisionTraces(store: SemanticContextStoreContract, traces: DecisionTrace[]): void {
+ for (const trace of traces) {
+ const existing = store.decision(trace.id);
+ if (existing) {
+ if (JSON.stringify(existing) !== JSON.stringify(trace)) {
+ throw new Error(`decision identity ${trace.id} was reused with different provenance`);
+ }
+ continue;
+ }
+ store.recordDecision(trace);
+ }
+}
Prompt for AI Agents
P1 F80. Expose recovery and confirmation through the continuing host code addressedapps/inspector/src/host.ts:121 · logic · correctness
The file host can reconstruct a confirmation gate or UNKNOWN effect, but its public API only offers ingestion and request-oriented semantic turns. The underlying runtime is private. Application callers cannot use the kernel’s typed confirm or reconcile methods through this host.
The host now exposes command and reconcile, using the same queue and clock as other operations. Retained tests restart before confirmation and after a lost invocation response; confirmation executes once and reconciliation resolves the recorded application without invoking again. This supplies typed controls. It does not implement an English confirmation/correction workflow.
Evidence. host.test.ts exercises both missing APIs through real file-backed restart; both fail as absent methods on the original PR.
Implementation diff
diff --git a/apps/inspector/src/host.ts b/apps/inspector/src/host.ts
index fa2e83f..65136a8 100644
--- a/apps/inspector/src/host.ts
+++ b/apps/inspector/src/host.ts
@@ -5,9 +5,11 @@ import {
type CapabilityDescriptor,
type CapabilityExecutor,
type EntityRef,
+ type EffectId,
type Intent,
type InteractionJournal,
type InteractionState,
+ type KernelCommand,
type Observation,
type Referent,
type Transaction,
@@ -18,6 +20,7 @@ import {
ingestWorldView,
type CapabilitySemantics,
type ContextFrame,
+ type DecisionTrace,
type FrameInteraction,
type FrameLiveObligation,
type FramePendingIntent,
@@ -35,6 +38,11 @@ import {
type WorkflowDefinition,
type WorkflowRun,
} from "@circe/perception";
+import {
+ createInMemoryAcceptedTurnStore,
+ type AcceptedTurnStore,
+ type AcceptedTurnRecord,
+} from "./turn-store";
/**
* Continuing interaction host.
@@ -70,6 +78,8 @@ export interface ContinuingInteractionConfig {
/** durable or in-memory; the host never closes what it does not own */
context: SemanticContextStoreContract;
journal: InteractionJournal;
+ /** optional durable pin for the first interpretation of each observation */
+ turnStore?: AcceptedTurnStore;
capabilities: CapabilitySemantics[];
workflow: WorkflowDefinition;
backend: PerceptionBackend;
@@ -105,6 +115,10 @@ export interface ContinuingTurnReport {
export interface ContinuingInteractionHost {
readonly context: SemanticContextStoreContract;
runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport>;
+ /** Drive a typed kernel command (confirm/cancel/resume/revise/propose). */
+ command(command: KernelCommand, now: string): Promise<TurnResult>;
+ /** Resolve an UNKNOWN dispatch from its recorded receipt. */
+ reconcile(effectId: EffectId, now: string): Promise<TurnResult>;
/**
* A non-language kernel input turn (UI focus/selection, world events).
* Language never goes through here; the perception path is `runTurn`.
@@ -215,6 +229,15 @@ export function createContinuingInteractionHost(
config: ContinuingInteractionConfig,
): ContinuingInteractionHost {
let clockNow: string | undefined;
+ const turnStore = config.turnStore ?? createInMemoryAcceptedTurnStore();
+ // A continuing interaction has one discourse timeline. Serialize ingestion
+ // and language turns so each frame is grounded in the previous turn's state.
+ let turnQueue: Promise<void> = Promise.resolve();
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
+ const result = turnQueue.then(work);
+ turnQueue = result.then(() => undefined, () => undefined);
+ return result;
+ }
const runtime = createRuntime({
ports: {
authorityFor: (ref) => {
@@ -256,51 +279,117 @@ export function createContinuingInteractionHost(
context: config.context,
state: () => runtime.state(),
- async ingest(observation: Observation): Promise<TurnResult> {
- clockNow = observation.at;
- return runtime.ingest(observation);
+ ingest(observation: Observation): Promise<TurnResult> {
+ const stableObservation = structuredClone(observation);
+ return enqueue(async () => {
+ clockNow = stableObservation.at;
+ return runtime.ingest(stableObservation);
+ });
},
- async runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> {
- clockNow = input.now;
- const state = runtime.state();
- const scopeId = state.scopeStack[state.scopeStack.length - 1];
- const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : [];
- const refs = input.refs ?? scopeRefs;
- await observe(refs);
-
- const { frame } = await assembleFrame(config.context, {
- interactionId: config.interactionId,
- observationId: input.observationId,
- utterance: { text: input.utterance, source: "text" },
- interaction: frameInteractionFor(runtime.state(), refs, descriptors()),
- capabilities: config.capabilities,
- now: input.now,
+ command(command: KernelCommand, now: string): Promise<TurnResult> {
+ const stableCommand = structuredClone(command);
+ return enqueue(async () => {
+ clockNow = now;
+ return runtime.command(stableCommand);
});
+ },
- const run = await runPerceptionWorkflow({
- backend: config.backend,
- frame,
- workflow: config.workflow,
- policy: config.policy,
- now: input.now,
- runId: `host:${config.interactionId}:${input.observationId}`,
+ reconcile(effectId: EffectId, now: string): Promise<TurnResult> {
+ return enqueue(async () => {
+ clockNow = now;
+ return runtime.reconcile(effectId);
});
+ },
- const turn = semanticTurnFromRun(run, config.propositions(run, frame));
- const compiled = compileSemanticTurn(turn, {
- frame,
- commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`,
- });
+ runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> {
+ const stableInput = structuredClone(input);
+ return enqueue(async () => {
+ const input = stableInput;
+ const key = `${config.interactionId}:${input.observationId}`;
+ const inputFingerprint = JSON.stringify({
+ observationId: input.observationId,
+ utterance: input.utterance,
+ now: input.now,
+ refs: input.refs === undefined
+ ? "<active-scope>"
+ : input.refs.map((ref) => ({
+ namespace: ref.namespace,
+ type: ref.type,
+ id: ref.id,
+ authority: ref.authority,
+ })),
+ });
+ const pinned = turnStore.get(key);
+ if (pinned && pinned.inputFingerprint !== inputFingerprint) {
+ throw new Error(`observation identity ${input.observationId} was reused with different turn input`);
+ }
- config.beforeCommands?.(frame);
+ clockNow = input.now;
+ let frame: ContextFrame;
+ let run: WorkflowRun;
+ let turn: SemanticTurn;
+ let compiled: CompiledTurn;
+ if (pinned) {
+ ({ frame, run, turn, compiled } = pinned);
+ } else {
+ const state = runtime.state();
+ const scopeId = state.scopeStack[state.scopeStack.length - 1];
+ const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : [];
+ const refs = input.refs ?? scopeRefs;
+ await observe(refs);
- const results: TurnResult[] = [];
- for (const command of compiled.commands) {
- results.push(await runtime.command(command));
- }
+ ({ frame } = await assembleFrame(config.context, {
+ interactionId: config.interactionId,
+ observationId: input.observationId,
+ utterance: { text: input.utterance, source: "text" },
+ interaction: frameInteractionFor(runtime.state(), refs, descriptors()),
+ capabilities: config.capabilities,
+ now: input.now,
+ }));
+
+ run = await runPerceptionWorkflow({
+ backend: config.backend,
+ frame,
+ workflow: config.workflow,
+ policy: config.policy,
+ now: input.now,
+ runId: `host:${config.interactionId}:${frame.id}`,
+ });
+
+ turn = semanticTurnFromRun(run, config.propositions(run, frame));
+ compiled = compileSemanticTurn(turn, {
+ frame,
+ commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`,
+ });
+ const record: AcceptedTurnRecord = { key, inputFingerprint, frame, run, turn, compiled };
+ // Pin the first interpretation before trace writes or any command dispatch.
+ turnStore.record(record);
+ }
- return { frame, run, turn, compiled, results, state: runtime.state() };
+ persistDecisionTraces(config.context, run.traces);
+ config.beforeCommands?.(frame);
+
+ const results: TurnResult[] = [];
+ for (const command of compiled.commands) {
+ results.push(await runtime.command(command));
+ }
+
+ return { frame, run, turn, compiled, results, state: runtime.state() };
+ });
},
};
}
+
+function persistDecisionTraces(store: SemanticContextStoreContract, traces: DecisionTrace[]): void {
+ for (const trace of traces) {
+ const existing = store.decision(trace.id);
+ if (existing) {
+ if (JSON.stringify(existing) !== JSON.stringify(trace)) {
+ throw new Error(`decision identity ${trace.id} was reused with different provenance`);
+ }
+ continue;
+ }
+ store.recordDecision(trace);
+ }
+}
Prompt for AI Agents
P1 F81. Apply the same canonical context values before and after restart code addressedpackages/context-file/src/index.ts:132 · logic · data-integrity
PR #9 validates and appends context operations, then applies their original input objects in memory. Replay applies the decoder’s normalized shape instead. An entity with an extra runtime property therefore has different content before and after reopening. Probability decoding also drops an own __proto__ key, and the writer accepts sparse or symbol-keyed input that JSON cannot faithfully preserve.
The writer now decodes the encoded operation before appending and applies that canonical value immediately. Numeric records preserve own keys; unrepresentable values and a second format header are rejected. These changes reuse the existing codec rather than introduce a second persistence model.
Evidence. context-file/index.test.ts has four failing comparisons on PR #9, covering canonical parity, probability ownership, invalid input and repeated headers.
Implementation diff
diff --git a/packages/context-file/src/index.ts b/packages/context-file/src/index.ts
index 9d85a33..2277b30 100644
--- a/packages/context-file/src/index.ts
+++ b/packages/context-file/src/index.ts
@@ -7,6 +7,7 @@ import {
CONTEXT_LOG_FORMAT,
assertPlainData,
decodeContextLogOp,
+ encodeContextLogOp,
type ContextLogOp,
} from "./codec";
@@ -113,6 +114,9 @@ export function createFileContextStore(
throw new Error(`semantic store ${path} does not start with a format record`);
}
for (const [index, op] of existing.entries()) {
+ if (index > 0 && op.op === "format") {
+ throw new Error(`semantic store ${path}: unexpected format record at operation ${index + 1}`);
+ }
try {
applyContextLogOp(inner, op);
} catch (error) {
@@ -125,41 +129,46 @@ export function createFileContextStore(
}
/** Validate representability, then make the operation durable, then apply. */
- const record = (op: ContextLogOp): void => {
+ const record = <T extends ContextLogOp>(op: T): T => {
assertPlainData(op, "operation");
- log.append(op);
+ // Replay applies the decoder's normalized shape. Apply that same shape
+ // now, so unknown runtime fields and omitted optional values cannot make
+ // live state differ from a reopened store.
+ const canonical = decodeContextLogOp(JSON.parse(encodeContextLogOp(op))) as T;
+ log.append(canonical);
+ return canonical;
};
return {
version: () => inner.version(),
putEntity(entity) {
- record({ op: "put-entity", record: entity });
- inner.putEntity(entity);
+ const op = record({ op: "put-entity", record: entity });
+ inner.putEntity(op.record);
},
recordObservation(input) {
- record({ op: "record-observation", input });
- return inner.recordObservation(input);
+ const op = record({ op: "record-observation", input });
+ return inner.recordObservation(op.input);
},
addRelation(input) {
- record({ op: "add-relation", input });
- return inner.addRelation(input);
+ const op = record({ op: "add-relation", input });
+ return inner.addRelation(op.input);
},
recordEvent(input) {
- record({ op: "record-event", input });
- return inner.recordEvent(input);
+ const op = record({ op: "record-event", input });
+ return inner.recordEvent(op.input);
},
recordAssertion(input) {
- record({ op: "record-assertion", input });
- return inner.recordAssertion(input);
+ const op = record({ op: "record-assertion", input });
+ return inner.recordAssertion(op.input);
},
addAnnotation(input) {
- record({ op: "add-annotation", input });
- return inner.addAnnotation(input);
+ const op = record({ op: "add-annotation", input });
+ return inner.addAnnotation(op.input);
},
addBinding(input) {
- record({ op: "add-binding", input });
- return inner.addBinding(input);
+ const op = record({ op: "add-binding", input });
+ return inner.addBinding(op.input);
},
recordDecision(trace) {
// Pre-check so a rejected duplicate never becomes a durable record that
@@ -169,8 +178,8 @@ export function createFileContextStore(
`duplicate decision id ${trace.id}: decision provenance is never overwritten`,
);
}
- record({ op: "record-decision", trace });
- return inner.recordDecision(trace);
+ const op = record({ op: "record-decision", trace });
+ return inner.recordDecision(op.trace);
},
recordFrame(frame) {
const existingFrame = inner.frame(frame.id);
@@ -180,27 +189,28 @@ export function createFileContextStore(
`duplicate frame id ${frame.id} with different content: frames are immutable`,
);
}
- record({ op: "record-frame", frame });
- inner.recordFrame(frame);
+ const op = record({ op: "record-frame", frame });
+ inner.recordFrame(op.frame);
},
upsertSalience(input) {
- record({ op: "upsert-salience", record: input });
- inner.upsertSalience(input);
+ const op = record({ op: "upsert-salience", record: input });
+ inner.upsertSalience(op.record);
},
invalidate(ids, at) {
- record({ op: "invalidate", ids, at });
- return inner.invalidate(ids, at);
+ const op = record({ op: "invalidate", ids, at });
+ return inner.invalidate(op.ids, op.at);
},
invalidateDependents(ids, at) {
- record({ op: "invalidate-dependents", ids, at });
- return inner.invalidateDependents(ids, at);
+ const op = record({ op: "invalidate-dependents", ids, at });
+ return inner.invalidateDependents(op.ids, op.at);
},
currentGeneration(ref) {
// Minting a generation is a state transition: record it exactly once so
// replay reproduces the same generation ids and dependency links.
const existingGeneration = inner.peekGeneration(ref);
if (existingGeneration === undefined) {
- record({ op: "ensure-generation", ref });
+ const op = record({ op: "ensure-generation", ref });
+ return inner.currentGeneration(op.ref);
}
return inner.currentGeneration(ref);
},
Prompt for AI Agents
P2 F82. Reject invalid UTF-8 in completed log records code addressedpackages/file-log/src/index.ts:81 · logic · data-integrity
The original file reader decodes bytes with replacement semantics. Invalid UTF-8 inside a completed JSON string can become the replacement character while the record remains syntactically valid. Evidence has then changed without a corruption error.
The reader now uses fatal UTF-8 decoding and physical byte offsets. Completed corrupt records fail; an unterminated torn fragment is repaired at the last valid append boundary. The completed-record test fails on PR #9. The torn-fragment regression also passes on the original code and is a preservation control, not evidence of a second defect.
Evidence. file-log/index.test.ts tests completed UTF-8 corruption and torn-tail repair; all 41 journal regressions remain green.
Implementation diff
diff --git a/packages/file-log/src/index.ts b/packages/file-log/src/index.ts
index 594d04d..c55cbcb 100644
--- a/packages/file-log/src/index.ts
+++ b/packages/file-log/src/index.ts
@@ -11,6 +11,8 @@ import {
} from "node:fs";
import { dirname } from "node:path";
+const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
+
/**
* Generic append-only JSONL file log.
*
@@ -76,22 +78,45 @@ function parseLog<T>(path: string, decode: (value: unknown) => T, label: string)
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`${label} path ${path} is not a regular file`);
}
- const text = readFileSync(path, "utf8");
- const endsWithNewline = text.endsWith("\n");
- const totalBytes = Buffer.byteLength(text, "utf8");
- const lines = text.split("\n");
+ const bytes = readFileSync(path);
+ const endsWithNewline = bytes.length > 0 && bytes[bytes.length - 1] === 0x0a;
+ const totalBytes = bytes.length;
+ const lines: Buffer[] = [];
+ let lineStart = 0;
+ for (let index = 0; index < bytes.length; index += 1) {
+ if (bytes[index] === 0x0a) {
+ lines.push(bytes.subarray(lineStart, index));
+ lineStart = index + 1;
+ }
+ }
+ lines.push(bytes.subarray(lineStart));
const lastIndex = lines.length - 1;
let lastNonEmpty = -1;
- lines.forEach((line, index) => {
- if (line.trim()) lastNonEmpty = index;
+ const decodedLines: Array<string | undefined> = lines.map((line, index) => {
+ try {
+ const decoded = strictUtf8.decode(line);
+ if (decoded.trim()) lastNonEmpty = index;
+ return decoded;
+ } catch (error) {
+ if (index === lastIndex && !endsWithNewline) {
+ lastNonEmpty = index;
+ return undefined;
+ }
+ throw new Error(`corrupt ${label} line ${index + 1} in ${path}: invalid UTF-8 (${String(error)})`);
+ }
});
const values: T[] = [];
let offset = 0;
let lastRecordHadNewline = false;
- for (const [index, raw] of lines.entries()) {
+ for (const [index, rawBytes] of lines.entries()) {
const isLastSegment = index === lastIndex;
const hasNewline = !(isLastSegment && !endsWithNewline);
+ const raw = decodedLines[index];
+ if (raw === undefined) {
+ // An incomplete UTF-8 sequence at the tail is a torn append.
+ return { values, repair: { offset, needsSeparator: false } };
+ }
const trimmed = raw.trim();
if (!trimmed) {
// only the final segment produced by a trailing newline may be empty
@@ -101,7 +126,7 @@ function parseLog<T>(path: string, decode: (value: unknown) => T, label: string)
try {
const parsed: unknown = JSON.parse(trimmed);
values.push(decode(parsed));
- offset += Buffer.byteLength(raw, "utf8") + (hasNewline ? 1 : 0);
+ offset += rawBytes.length + (hasNewline ? 1 : 0);
lastRecordHadNewline = hasNewline;
} catch (error) {
// Only a torn JSON fragment at the final line is a crash artifact;
Prompt for AI Agents
P1 F83. Evaluate applied outcomes rather than effect IDs code addressedapps/inspector/src/heldout.ts:315 · logic · correctness
The original evaluation can pass after an executor returns EXECUTION_ERROR because its command and effect-count checks still match. Its fixture client also searches ahead for a convenient matching question set, masking a wrong call sequence. It has no active injected-client option for running the same cases against a real provider.
The corrected evaluation requires successful effects and actual expected target invocation, checks no mutation on abstention, consumes the next fixture strictly, accepts an injected client, and counts actual calls instead of cached run metadata. Each evaluation gets fresh files. Failures remain visible as case checks. The unchanged fixture suite remains a pipeline test rather than semantic accuracy evidence.
Evidence. heldout-review.test.ts covers a failed executor, transcript skipping, injection, redelivery call counts and independent evaluation directories.
Implementation diff
diff --git a/apps/inspector/src/heldout.ts b/apps/inspector/src/heldout.ts
index 1f9d717..3a97d0c 100644
--- a/apps/inspector/src/heldout.ts
+++ b/apps/inspector/src/heldout.ts
@@ -1,10 +1,11 @@
-import { mkdtempSync, rmSync } from "node:fs";
+import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CapabilityDescriptor, EntityRef } from "@circe/core";
import type { CapabilitySemantics, ContextFrame } from "@circe/context";
import {
ACCEPTANCE_POLICY_V1,
+ choiceOf,
type AcceptancePolicy,
type DecisionSpec,
type PerceptionStatus,
@@ -25,15 +26,15 @@ import { acceptedChoice, refForChoice } from "./scenario";
/**
* Held-out interaction evaluation.
*
- * Cases are recorded conversations with a reference model: each backend call
- * is answered from a transcript matched by the exact decision ids asked, and
- * the harness scores what the whole product did with that answer — retrieval,
+ * In fixture mode each backend call is answered from a supplied script matched
+ * by the exact decision ids asked. The evaluation checks retrieval,
* frame assembly, policy, accepted turns, compiled commands, kernel effects
* and restart recovery.
*
* This measures the pipeline, not language understanding. The same
* `JevDecisionClient` seam accepts a live transport; replacing the recorded
- * client with a live one is the only change a model accuracy study needs. No
+ * client with a live one permits actual model runs. A representative corpus and
+ * outcome calibration are separate work. No
* number in this harness is a claim about model quality.
*/
@@ -80,12 +81,13 @@ export interface HeldOutCase {
refs: EntityRef[];
descriptor: CapabilityDescriptor;
capability: CapabilitySemantics;
+ actSpec?: DecisionSpec;
referentSpec: DecisionSpec;
operationSpec: DecisionSpec;
policy?: AcceptancePolicy;
turns: HeldOutTurn[];
/**
- * Recorded reference-model answers, consumed in call order. Each entry must
+ * Hand-written fixture answers, consumed in call order. Each entry must
* match the exact decision ids of the call it answers.
*/
scripts: ScriptedTurn[];
@@ -130,22 +132,21 @@ export interface HeldOutEvaluation {
* order. A live host would replace this with a transport-backed client.
*/
export function createSequencedReferenceClient(scripts: ScriptedTurn[]): JevDecisionClient {
+ const transcript = structuredClone(scripts);
let cursor = 0;
return {
id: "held-out-reference",
async complete(request: JevDecisionRequest): Promise<unknown> {
const asked = request.decisions.map((decision) => decision.id).sort();
- for (let index = cursor; index < scripts.length; index += 1) {
- const script = scripts[index]!;
- const wanted = [...script.decisions].sort();
- if (wanted.length === asked.length && wanted.every((id, position) => id === asked[position])) {
- cursor = index + 1;
- return {
- status: script.status ?? "RESOLVED",
- answers: structuredClone(script.answers),
- latencyMs: script.latencyMs ?? 0,
- };
- }
+ const script = transcript[cursor];
+ const wanted = script ? [...script.decisions].sort() : [];
+ if (script && wanted.length === asked.length && wanted.every((id, position) => id === asked[position])) {
+ cursor += 1;
+ return {
+ status: script.status ?? "RESOLVED",
+ answers: structuredClone(script.answers),
+ latencyMs: script.latencyMs ?? 0,
+ };
}
throw new Error(
`no held-out scripted answer for decisions [${asked.join(", ")}] at call ${cursor + 1}`,
@@ -162,8 +163,10 @@ export function createSequencedReferenceClient(scripts: ScriptedTurn[]): JevDeci
export function selectAndActPropositions(
referentSpec: DecisionSpec,
operationSpec: DecisionSpec,
+ actSpec?: DecisionSpec,
): (run: WorkflowRun, frame: ContextFrame) => SemanticProposition[] {
return (run, frame) => {
+ if (actSpec && acceptedChoice(run, actSpec.id) !== "request") return [];
const target = acceptedChoice(run, referentSpec.id);
const capability = acceptedChoice(run, operationSpec.id);
if (!target || target === "none" || !capability || capability === "none") return [];
@@ -179,15 +182,24 @@ export function selectAndActPropositions(
refs: [ref],
polarity: "include",
capability,
- decisions: [referentTrace.id, operationTrace.id],
+ decisions: [referentTrace.id, operationTrace.id,
+ ...(actSpec ? run.traces.filter((trace) => trace.specId === actSpec.id).map((trace) => trace.id) : [])],
},
];
};
}
+export interface HeldOutEvaluationOptions {
+ /** Parent for preserved per-case run directories; every run gets a fresh directory. */
+ directory?: string;
+ /** Omit for fixture playback; supply a real client for a live model evaluation. */
+ clientForCase?: (scenario: HeldOutCase) => JevDecisionClient;
+ model?: string;
+}
+
export async function runHeldOutEvaluation(
cases: HeldOutCase[],
- options: { directory?: string } = {},
+ options: HeldOutEvaluationOptions = {},
): Promise<HeldOutEvaluation> {
const reports: HeldOutCaseReport[] = [];
const metrics: HeldOutMetrics = {
@@ -204,23 +216,30 @@ export async function runHeldOutEvaluation(
for (const scenario of cases) {
metrics.cases += 1;
const ownedDirectory = options.directory === undefined;
- const directory =
- options.directory ?? mkdtempSync(join(tmpdir(), `circe-heldout-${scenario.id}-`));
+ if (options.directory !== undefined) mkdirSync(options.directory, { recursive: true });
+ const directory = mkdtempSync(join(options.directory ?? tmpdir(), "circe-heldout-"));
const paths = {
- journalPath: join(directory, `${scenario.id}.journal.jsonl`),
- contextPath: join(directory, `${scenario.id}.context.jsonl`),
+ journalPath: join(directory, "journal.jsonl"),
+ contextPath: join(directory, "context.jsonl"),
};
const world = createDemoWorld(scenario.entities, scenario.descriptor);
- const client = createSequencedReferenceClient(scenario.scripts);
- const backend = createJevBackend({ client, model: "held-out-reference" });
+ const client = options.clientForCase?.(scenario) ?? createSequencedReferenceClient(scenario.scripts);
+ const backend = createJevBackend({
+ client: { id: client.id, async complete(request) {
+ metrics.backendCalls += 1;
+ return client.complete(request);
+ } },
+ model: options.model ?? "held-out-reference",
+ });
const workflow: WorkflowDefinition = {
id: `${scenario.id}.v1`,
stages: [
- { id: "referent", specs: () => [scenario.referentSpec] },
+ ...(scenario.actSpec ? [{ id: "act", specs: () => [scenario.actSpec!] }] : []),
+ { id: "referent", specs: (state) => !scenario.actSpec || choiceOf(state, scenario.actSpec.id) === "request" ? [scenario.referentSpec] : [] },
{ id: "operation", specs: () => [scenario.operationSpec] },
],
};
- const propositions = selectAndActPropositions(scenario.referentSpec, scenario.operationSpec);
+ const propositions = selectAndActPropositions(scenario.referentSpec, scenario.operationSpec, scenario.actSpec);
const config = {
interactionId: `interaction:${scenario.id}`,
capabilities: [scenario.capability],
@@ -232,11 +251,12 @@ export async function runHeldOutEvaluation(
executors: world.executors,
};
- let host = createFileContinuingInteractionHost({ ...config, ...paths });
+ let host: ReturnType<typeof createFileContinuingInteractionHost> | undefined;
const checks: HeldOutCheck[] = [];
const commands: HeldOutCaseReport["commands"] = [];
const effectIds: string[] = [];
let turnIndex = 0;
+ const delivered = new Set<string>();
const check = (name: string, ok: boolean, detail?: string): void => {
checks.push({ turn: turnIndex, name, ok, ...(detail !== undefined ? { detail } : {}) });
@@ -246,6 +266,7 @@ export async function runHeldOutEvaluation(
};
try {
+ host = createFileContinuingInteractionHost({ ...config, ...paths });
for (const turn of scenario.turns) {
turnIndex += 1;
metrics.turns += 1;
@@ -260,13 +281,13 @@ export async function runHeldOutEvaluation(
expectEqual("ui scope", result.scope.map((ref) => ref.id), turn.expect.scope);
}
} else {
+ const invokedBefore = world.invoked.length;
const report = await host.runTurn({
observationId: turn.observationId,
utterance: turn.utterance,
now: turn.now,
...(turn.refs !== undefined ? { refs: turn.refs } : {}),
});
- metrics.backendCalls += report.run.calls;
metrics.acceptedDecisions += report.run.state.accepted.size;
const turnCommands = report.compiled.commands.map((command) => ({
capability: command.capability,
@@ -288,8 +309,15 @@ export async function runHeldOutEvaluation(
}
if (expect.command === null) {
expectEqual("no compiled command", turnCommands.length, 0);
+ expectEqual("no applied mutation", world.invoked.length, invokedBefore);
} else if (expect.command !== undefined) {
expectEqual("compiled command", turnCommands, [expect.command]);
+ check("execution succeeded", report.results.length === turnCommands.length &&
+ report.results.every((result) => !result.errors?.length && result.effects.length > 0 &&
+ result.effects.every((effect) => effect.status === "SUCCEEDED")),
+ "Every expected command must produce a successful effect, not merely an effect id.");
+ expectEqual("applied targets", world.invoked.slice(invokedBefore).map((ref) => ref.id),
+ delivered.has(turn.observationId) ? [] : expect.command.targets);
}
if (expect.effectCount !== undefined) {
expectEqual("cumulative effects", effectIds.length, expect.effectCount);
@@ -308,6 +336,7 @@ export async function runHeldOutEvaluation(
`expected at least ${expect.recentReferentsAtLeast}, got ${report.frame.interaction.recentReferents.length}`,
);
}
+ delivered.add(turn.observationId);
if (turnCommands.length === 0) {
// A run that failed validation is not an abstention; it produced
// no binding because the answer was unusable, not because the
@@ -321,9 +350,12 @@ export async function runHeldOutEvaluation(
host = createFileContinuingInteractionHost({ ...config, ...paths });
}
}
+ } catch (error) {
+ check("case completed", false, error instanceof Error ? error.message : "Case threw an unknown error");
} finally {
- host.close();
- if (ownedDirectory) rmSync(directory, { recursive: true, force: true });
+ try { host?.close(); }
+ catch (error) { check("stores closed", false, error instanceof Error ? error.message : "Close failed"); }
+ finally { if (ownedDirectory) rmSync(directory, { recursive: true, force: true }); }
}
const failed = checks.filter((entry) => !entry.ok);
Prompt for AI Agents
P1 F84. Provide a runnable live bounded-decision transport code addressedpackages/perception-jev/src/typesafe-client.ts:68 · logic · correctness
PR #9 explicitly leaves the live transport to a future host. The injected interface and hand-written answers therefore do not demonstrate the original product requirement of language passing through real semantic decisions into safe execution.
The corrective branch adds a native-fetch TypeSafe System One client and eval:live entry point. It maps bounded questions, validates answer keys and distributions, handles HTTP errors and timeouts, and preserves returned model metadata. Its live corpus includes a request/query decision so a query cannot become a mutation through referent selection alone.
This closes the missing code path only. Mock HTTP tests and mocked live-workflow controls pass, but no API key or account-supported model is configured. No real-model call or accuracy result exists. That remains an external validation blocker and is not represented as completed product behavior.
Evidence. Seven additional Jev transport tests, two mocked live-workflow controls, and an explicit no-credentials exit check. Official contract: https://api.typesafe.ai/openapi.json.
Implementation diff
diff --git a/packages/perception-jev/src/typesafe-client.ts b/packages/perception-jev/src/typesafe-client.ts
new file mode 100644
index 0000000..f5aab18
--- /dev/null
+++ b/packages/perception-jev/src/typesafe-client.ts
@@ -0,0 +1,126 @@
+import type { DecisionSpec } from "@circe/perception";
+import type { JevDecisionClient, JevDecisionRequest } from "./backend";
+
+const DEFAULT_BASE_URL = "https://api.typesafe.ai";
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+export interface TypeSafeJevClientOptions {
+ apiKey?: string;
+ apiKeyEnv?: string;
+ baseUrl?: string;
+ timeoutMs?: number;
+ fetch?: typeof globalThis.fetch;
+}
+
+type TypeSafeQuestion =
+ | { type: "noul"; instructions: string; criteria?: { true?: string; false?: string } }
+ | { type: "choice"; instructions: string; criteria: Record<string, string> }
+ | { type: "score"; instructions: string; criteria: string[] };
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function questionFor(spec: DecisionSpec): TypeSafeQuestion {
+ switch (spec.kind) {
+ case "binary":
+ return { type: "noul", instructions: spec.question, ...(spec.criteria ? { criteria: spec.criteria } : {}) };
+ case "choice":
+ return {
+ type: "choice",
+ instructions: spec.question,
+ criteria: Object.fromEntries(spec.options.map(({ id, description }) => [id, description ?? id])),
+ };
+ case "score":
+ return { type: "score", instructions: spec.question, criteria: [...spec.levels] };
+ }
+}
+
+function answerFor(spec: DecisionSpec, raw: unknown): Record<string, unknown> {
+ if (!isRecord(raw)) throw new Error(`TypeSafe returned a malformed answer for decision ${spec.id}`);
+ if (spec.kind === "binary" && raw.type === "noul" && typeof raw.noul === "number") {
+ return { id: spec.id, kind: "binary", probability: raw.noul };
+ }
+ if (spec.kind === "choice" && raw.type === "choice" && typeof raw.choice === "string" && isRecord(raw.probabilities) && typeof raw.confidence === "number") {
+ return { id: spec.id, kind: "choice", choice: raw.choice, probabilities: raw.probabilities, confidence: raw.confidence };
+ }
+ if (spec.kind === "score" && raw.type === "score" && typeof raw.score === "number" && isRecord(raw.probabilities) && typeof raw.confidence === "number") {
+ const providerProbabilities = raw.probabilities;
+ const expectedLevels = spec.levels.map((_, index) => String(index));
+ const unexpectedLevel = Object.keys(providerProbabilities).find((level) => !expectedLevels.includes(level));
+ if (unexpectedLevel !== undefined) {
+ throw new Error(`TypeSafe returned unexpected score probability level ${unexpectedLevel} for decision ${spec.id}`);
+ }
+ const probabilities = expectedLevels.map((level) => providerProbabilities[level]);
+ if (probabilities.some((probability) => typeof probability !== "number")) {
+ throw new Error(`TypeSafe omitted a score probability level for decision ${spec.id}`);
+ }
+ return { id: spec.id, kind: "score", score: raw.score, probabilities, confidence: raw.confidence };
+ }
+ throw new Error(`TypeSafe answer type did not match decision ${spec.id}`);
+}
+
+/**
+ * Native-fetch adapter for TypeSafe's documented System One HTTP API.
+ * Credentials are read only when complete() runs; callers may supply an
+ * explicit key for managed hosts and tests.
+ */
+export function createTypeSafeJevClient(options: TypeSafeJevClientOptions = {}): JevDecisionClient {
+ const fetchImpl = options.fetch ?? globalThis.fetch;
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
+ const keyName = options.apiKeyEnv ?? "TYPESAFE_API_KEY";
+ return {
+ id: "typesafe-jev",
+ async complete(request: JevDecisionRequest): Promise<unknown> {
+ const apiKey = options.apiKey ?? process.env[keyName];
+ if (!apiKey) throw new Error(`TypeSafe API key is required (set ${keyName})`);
+ const questions = Object.fromEntries(request.decisions.map((spec) => [spec.id, questionFor(spec)]));
+ if (Object.keys(questions).length !== request.decisions.length) {
+ throw new Error("TypeSafe request contains duplicate decision ids");
+ }
+ const startedAt = Date.now();
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+ try {
+ const response = await fetchImpl(`${baseUrl}/v1/systemone`, {
+ method: "POST",
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
+ body: JSON.stringify({
+ model: request.model,
+ state: {
+ utterance: request.context.utterance,
+ interaction: request.context.interaction,
+ candidates: request.context.candidates,
+ capabilities: request.context.capabilities,
+ coverage: request.coverage,
+ },
+ questions,
+ }),
+ signal: controller.signal,
+ });
+ if (!response.ok) throw new Error(`TypeSafe request failed with HTTP ${response.status}`);
+ const payload: unknown = await response.json();
+ if (!isRecord(payload) || !isRecord(payload.answers)) throw new Error("TypeSafe returned a malformed response");
+ const providerAnswers = payload.answers;
+ const askedIds = new Set(request.decisions.map((spec) => spec.id));
+ const unexpectedId = Object.keys(providerAnswers).find((id) => !askedIds.has(id));
+ if (unexpectedId !== undefined) throw new Error(`TypeSafe returned unasked answer for decision ${unexpectedId}`);
+ const answers = request.decisions.map((spec) => {
+ if (!Object.hasOwn(providerAnswers, spec.id)) throw new Error(`TypeSafe omitted answer for decision ${spec.id}`);
+ return answerFor(spec, providerAnswers[spec.id]);
+ });
+ return {
+ status: "RESOLVED",
+ answers,
+ ...(typeof payload.model === "string" ? { model: payload.model } : {}),
+ latencyMs: Date.now() - startedAt,
+ };
+ } catch (error) {
+ if (controller.signal.aborted) throw new Error("TypeSafe request timed out");
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+ },
+ };
+}
Prompt for AI Agents
Review handoff prompts
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In apps/inspector/src/host.ts around line 370, verify persist the decisions that justified commands against the baseline 5dc176f and corrective commit 927c9ea. PR #9 records frames and observations, but runTurn never writes run.traces to its semantic store. The durable context adapter can store decisions, yet the real host omits them. A successful operation therefore survives restart without its original decision evidence in the context store. The corrected host persists the actual decision traces before entering the command loop and permits only identical provenance on a retry. Two existing host tests gained assertions that fail on the original PR. Restart and first-turn execution retain the same trace identities. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. host.test.ts checks decision persistence on initial execution and after reopening.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In apps/inspector/src/host.ts around line 323, verify reuse the first interpretation when an observation is retried against the baseline 5dc176f and corrective commit 927c9ea. PR #9 invokes perception again on every runTurn delivery. Stable kernel command IDs cannot protect an observation that originally abstained, and they do not pin the number or meaning of a later interpretation. Retrying after a restart can ask a nondeterministic model to turn an earlier abstention into new work. The host now appends the first interpreted turn before trace writes or dispatch. Reopening uses that snapshot, checks the input fingerprint, and redelivers exactly its compiled commands. This also covers a failure immediately before dispatch. The log preserves workflow Maps explicitly; recovery checks provenance and recompiles the saved semantic turn before accepting its commands. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. host.test.ts covers completed replay, abstention, conflicting input and the pre-dispatch cut; turn-store.test.ts covers Maps, detached reads, conflicts and corrupt records.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In apps/inspector/src/host.ts around line 236, verify serialize calls that share the interaction clock and discourse against the baseline 5dc176f and corrective commit 927c9ea. The original host lets simultaneous runTurn and ingest calls operate on one runtime and mutable clock. A later call can change the time while an earlier perception call is suspended. Both turns can assemble against discourse that has not incorporated the other operation. A single per-host promise queue now orders turns, observations, commands and reconciliation. Inputs are cloned before joining the queue, so caller mutation during the wait cannot change the operation. A concurrency regression observes overlapping backend work on PR #9 and a maximum of one active turn after the fix. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. host.test.ts checks simultaneous calls, pre-enqueue snapshots and persisted provenance.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In apps/inspector/src/host.ts around line 121, verify expose recovery and confirmation through the continuing host against the baseline 5dc176f and corrective commit 927c9ea. The file host can reconstruct a confirmation gate or UNKNOWN effect, but its public API only offers ingestion and request-oriented semantic turns. The underlying runtime is private. Application callers cannot use the kernel’s typed confirm or reconcile methods through this host. The host now exposes command and reconcile, using the same queue and clock as other operations. Retained tests restart before confirmation and after a lost invocation response; confirmation executes once and reconciliation resolves the recorded application without invoking again. This supplies typed controls. It does not implement an English confirmation/correction workflow. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. host.test.ts exercises both missing APIs through real file-backed restart; both fail as absent methods on the original PR.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In packages/context-file/src/index.ts around line 132, verify apply the same canonical context values before and after restart against the baseline 5dc176f and corrective commit 927c9ea. PR #9 validates and appends context operations, then applies their original input objects in memory. Replay applies the decoder’s normalized shape instead. An entity with an extra runtime property therefore has different content before and after reopening. Probability decoding also drops an own __proto__ key, and the writer accepts sparse or symbol-keyed input that JSON cannot faithfully preserve. The writer now decodes the encoded operation before appending and applies that canonical value immediately. Numeric records preserve own keys; unrepresentable values and a second format header are rejected. These changes reuse the existing codec rather than introduce a second persistence model. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. context-file/index.test.ts has four failing comparisons on PR #9, covering canonical parity, probability ownership, invalid input and repeated headers.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In packages/file-log/src/index.ts around line 81, verify reject invalid utf-8 in completed log records against the baseline 5dc176f and corrective commit 927c9ea. The original file reader decodes bytes with replacement semantics. Invalid UTF-8 inside a completed JSON string can become the replacement character while the record remains syntactically valid. Evidence has then changed without a corruption error. The reader now uses fatal UTF-8 decoding and physical byte offsets. Completed corrupt records fail; an unterminated torn fragment is repaired at the last valid append boundary. The completed-record test fails on PR #9. The torn-fragment regression also passes on the original code and is a preservation control, not evidence of a second defect. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. file-log/index.test.ts tests completed UTF-8 corruption and torn-tail repair; all 41 journal regressions remain green.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In apps/inspector/src/heldout.ts around line 315, verify evaluate applied outcomes rather than effect ids against the baseline 5dc176f and corrective commit 927c9ea. The original evaluation can pass after an executor returns EXECUTION_ERROR because its command and effect-count checks still match. Its fixture client also searches ahead for a convenient matching question set, masking a wrong call sequence. It has no active injected-client option for running the same cases against a real provider. The corrected evaluation requires successful effects and actual expected target invocation, checks no mutation on abstention, consumes the next fixture strictly, accepts an injected client, and counts actual calls instead of cached run metadata. Each evaluation gets fresh files. Failures remain visible as case checks. The unchanged fixture suite remains a pipeline test rather than semantic accuracy evidence. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. heldout-review.test.ts covers a failed executor, transcript skipping, injection, redelivery call counts and independent evaluation directories.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In packages/perception-jev/src/typesafe-client.ts around line 68, verify provide a runnable live bounded-decision transport against the baseline 5dc176f and corrective commit 927c9ea. PR #9 explicitly leaves the live transport to a future host. The injected interface and hand-written answers therefore do not demonstrate the original product requirement of language passing through real semantic decisions into safe execution. The corrective branch adds a native-fetch TypeSafe System One client and eval:live entry point. It maps bounded questions, validates answer keys and distributions, handles HTTP errors and timeouts, and preserves returned model metadata. Its live corpus includes a request/query decision so a query cannot become a mutation through referent selection alone. Retain the passing controls and confirm the relevant regression fails for the intended reason on the baseline. Seven additional Jev transport tests, two mocked live-workflow controls, and an explicit no-credentials exit check. Official contract: https://api.typesafe.ai/openapi.json.
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. Review the corrective PR https://github.com/Absterrg0/circe/pull/10 at 927c9ea409b3c6c6133ca5e3dc00af2b64d5c97f against PR #9 baseline 5dc176f80246d2ee672a166e9a8f5026c9aece5e. Read /home/abstergo/Documents/reviews/Circe-pr9-continuing-abbyview.json and /home/abstergo/Documents/reviews/Circe-pr9-continuing-abbyview.html. The recorded implementation findings are addressed; verify them rather than rebuilding abstractions. Product work remains: live model evaluation requires TYPESAFE_API_KEY and TYPESAFE_MODEL configured outside Git, then representative semantic/control workflows and outcome-based calibration. Never treat mocked answers or RED-contract tests as semantic accuracy. Do not merge without user authorization.
/abbyview Circe pr9-continuing. Incremental review from 927c9ea409b3c6c6133ca5e3dc00af2b64d5c97f. Read /home/abstergo/Documents/reviews/Circe-pr9-continuing-abbyview.json; preserve its baseline and finding bodies, append supported new findings and validate current upstream. Give no numeric scores. Publish at https://circe-pr9-continuing.plan.abstergo.fyi/.
P1 F77. Persist the decisions that justified commands code addressedapps/inspector/src/host.ts:370 · logic · data-integrity
PR #9 records frames and observations, but runTurn never writes run.traces to its semantic store. The durable context adapter can store decisions, yet the real host omits them. A successful operation therefore survives restart without its original decision evidence in the context store.
The corrected host persists the actual decision traces before entering the command loop and permits only identical provenance on a retry. Two existing host tests gained assertions that fail on the original PR. Restart and first-turn execution retain the same trace identities.
Evidence. host.test.ts checks decision persistence on initial execution and after reopening.
Implementation diff
diff --git a/apps/inspector/src/host.ts b/apps/inspector/src/host.ts index fa2e83f..65136a8 100644 --- a/apps/inspector/src/host.ts +++ b/apps/inspector/src/host.ts @@ -5,9 +5,11 @@ import { type CapabilityDescriptor, type CapabilityExecutor, type EntityRef, + type EffectId, type Intent, type InteractionJournal, type InteractionState, + type KernelCommand, type Observation, type Referent, type Transaction, @@ -18,6 +20,7 @@ import { ingestWorldView, type CapabilitySemantics, type ContextFrame, + type DecisionTrace, type FrameInteraction, type FrameLiveObligation, type FramePendingIntent, @@ -35,6 +38,11 @@ import { type WorkflowDefinition, type WorkflowRun, } from "@circe/perception"; +import { + createInMemoryAcceptedTurnStore, + type AcceptedTurnStore, + type AcceptedTurnRecord, +} from "./turn-store"; /** * Continuing interaction host. @@ -70,6 +78,8 @@ export interface ContinuingInteractionConfig { /** durable or in-memory; the host never closes what it does not own */ context: SemanticContextStoreContract; journal: InteractionJournal; + /** optional durable pin for the first interpretation of each observation */ + turnStore?: AcceptedTurnStore; capabilities: CapabilitySemantics[]; workflow: WorkflowDefinition; backend: PerceptionBackend; @@ -105,6 +115,10 @@ export interface ContinuingTurnReport { export interface ContinuingInteractionHost { readonly context: SemanticContextStoreContract; runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport>; + /** Drive a typed kernel command (confirm/cancel/resume/revise/propose). */ + command(command: KernelCommand, now: string): Promise<TurnResult>; + /** Resolve an UNKNOWN dispatch from its recorded receipt. */ + reconcile(effectId: EffectId, now: string): Promise<TurnResult>; /** * A non-language kernel input turn (UI focus/selection, world events). * Language never goes through here; the perception path is `runTurn`. @@ -215,6 +229,15 @@ export function createContinuingInteractionHost( config: ContinuingInteractionConfig, ): ContinuingInteractionHost { let clockNow: string | undefined; + const turnStore = config.turnStore ?? createInMemoryAcceptedTurnStore(); + // A continuing interaction has one discourse timeline. Serialize ingestion + // and language turns so each frame is grounded in the previous turn's state. + let turnQueue: Promise<void> = Promise.resolve(); + function enqueue<T>(work: () => Promise<T>): Promise<T> { + const result = turnQueue.then(work); + turnQueue = result.then(() => undefined, () => undefined); + return result; + } const runtime = createRuntime({ ports: { authorityFor: (ref) => { @@ -256,51 +279,117 @@ export function createContinuingInteractionHost( context: config.context, state: () => runtime.state(), - async ingest(observation: Observation): Promise<TurnResult> { - clockNow = observation.at; - return runtime.ingest(observation); + ingest(observation: Observation): Promise<TurnResult> { + const stableObservation = structuredClone(observation); + return enqueue(async () => { + clockNow = stableObservation.at; + return runtime.ingest(stableObservation); + }); }, - async runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> { - clockNow = input.now; - const state = runtime.state(); - const scopeId = state.scopeStack[state.scopeStack.length - 1]; - const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : []; - const refs = input.refs ?? scopeRefs; - await observe(refs); - - const { frame } = await assembleFrame(config.context, { - interactionId: config.interactionId, - observationId: input.observationId, - utterance: { text: input.utterance, source: "text" }, - interaction: frameInteractionFor(runtime.state(), refs, descriptors()), - capabilities: config.capabilities, - now: input.now, + command(command: KernelCommand, now: string): Promise<TurnResult> { + const stableCommand = structuredClone(command); + return enqueue(async () => { + clockNow = now; + return runtime.command(stableCommand); }); + }, - const run = await runPerceptionWorkflow({ - backend: config.backend, - frame, - workflow: config.workflow, - policy: config.policy, - now: input.now, - runId: `host:${config.interactionId}:${input.observationId}`, + reconcile(effectId: EffectId, now: string): Promise<TurnResult> { + return enqueue(async () => { + clockNow = now; + return runtime.reconcile(effectId); }); + }, - const turn = semanticTurnFromRun(run, config.propositions(run, frame)); - const compiled = compileSemanticTurn(turn, { - frame, - commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`, - }); + runTurn(input: ContinuingTurnInput): Promise<ContinuingTurnReport> { + const stableInput = structuredClone(input); + return enqueue(async () => { + const input = stableInput; + const key = `${config.interactionId}:${input.observationId}`; + const inputFingerprint = JSON.stringify({ + observationId: input.observationId, + utterance: input.utterance, + now: input.now, + refs: input.refs === undefined + ? "<active-scope>" + : input.refs.map((ref) => ({ + namespace: ref.namespace, + type: ref.type, + id: ref.id, + authority: ref.authority, + })), + }); + const pinned = turnStore.get(key); + if (pinned && pinned.inputFingerprint !== inputFingerprint) { + throw new Error(`observation identity ${input.observationId} was reused with different turn input`); + } - config.beforeCommands?.(frame); + clockNow = input.now; + let frame: ContextFrame; + let run: WorkflowRun; + let turn: SemanticTurn; + let compiled: CompiledTurn; + if (pinned) { + ({ frame, run, turn, compiled } = pinned); + } else { + const state = runtime.state(); + const scopeId = state.scopeStack[state.scopeStack.length - 1]; + const scopeRefs = scopeId ? (state.scopes.get(scopeId)?.refs ?? []) : []; + const refs = input.refs ?? scopeRefs; + await observe(refs); - const results: TurnResult[] = []; - for (const command of compiled.commands) { - results.push(await runtime.command(command)); - } + ({ frame } = await assembleFrame(config.context, { + interactionId: config.interactionId, + observationId: input.observationId, + utterance: { text: input.utterance, source: "text" }, + interaction: frameInteractionFor(runtime.state(), refs, descriptors()), + capabilities: config.capabilities, + now: input.now, + })); + + run = await runPerceptionWorkflow({ + backend: config.backend, + frame, + workflow: config.workflow, + policy: config.policy, + now: input.now, + runId: `host:${config.interactionId}:${frame.id}`, + }); + + turn = semanticTurnFromRun(run, config.propositions(run, frame)); + compiled = compileSemanticTurn(turn, { + frame, + commandIdFor: (_proposition, index) => `${config.interactionId}:${input.observationId}:${index}`, + }); + const record: AcceptedTurnRecord = { key, inputFingerprint, frame, run, turn, compiled }; + // Pin the first interpretation before trace writes or any command dispatch. + turnStore.record(record); + } - return { frame, run, turn, compiled, results, state: runtime.state() }; + persistDecisionTraces(config.context, run.traces); + config.beforeCommands?.(frame); + + const results: TurnResult[] = []; + for (const command of compiled.commands) { + results.push(await runtime.command(command)); + } + + return { frame, run, turn, compiled, results, state: runtime.state() }; + }); }, }; } + +function persistDecisionTraces(store: SemanticContextStoreContract, traces: DecisionTrace[]): void { + for (const trace of traces) { + const existing = store.decision(trace.id); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(trace)) { + throw new Error(`decision identity ${trace.id} was reused with different provenance`); + } + continue; + } + store.recordDecision(trace); + } +}Prompt for AI Agents