Skip to content
Merged
35 changes: 31 additions & 4 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,42 @@ No process runs between events: the handler wakes, executes to its next await, p
`close-merge-request` in addition to comments and discussions; the earlier
comment-only gap was closed by the pinned relay-helpers release.

The initial local memory slice supports `recall` and `why` in authored flows,
with no journal step for either read. Script scope is stable across runs of
The local memory slice supports `recall`, `why`, and `learn` in authored
flows. None of them is a journal step: reads journal nothing, and `learn`
appends a record to the root run's `memory-learn` stream. Script scope is stable across runs of
the same flow file and name; reads cannot widen it to another flow. The
existing `memory: { script: true }` header enables an eager reachability
check; direct `.memory` use also triggers it. Aliased access is checked at
call time. The local Node SDK and an existing readable SQLite DB are required
(`AI_HIST_DB` overrides `defaultDbPath()`); JSONL fallback is disabled.
`learn` and `memory: { agent: true }` refuse pending the journal-backed write
and identity-scoped agent follow-ups. CLI-only operation is also deferred.
`learn(finding)` journals first. It stages the finding's compacted
trajectory file (the format `ai-hist sync` ingests) under a temporary name,
appends the finding to the durable root run's `memory-learn` stream, and
only then renames the file into place at
`<db-dir>/flows-memory/<scope-hash>/.trajectories/compacted/<id>.json`, so
nothing is recallable that the journal does not record, and disk failures
happen before anything is journaled. Files live beside the ai-hist DB, not
the flow source, so a flow deployed from a read-only checkout can still
learn. A file is derived only from its commit, which it cites
(`journal: { runId, stream, id }`); if another attempt on the root
committed the same finding first, that commit wins, so every attempt
publishes the same bytes. A resumed body that re-runs `learn` finds the
commit and publishes it without journaling again. Journal errors propagate
unchanged, so a read interruption or lost lease leaves the root resumable.
`learn` without a durable root run refuses (`memory_unjournaled`). `recall`
and `why` read the ai-hist DB plus the scope's learned files, so the next
run sees a finding before any sync; a synced copy is deduplicated in favour
of the DB row (an unsynced `recall` entry has a negative `id`), and a
damaged learned file fails the read (`memory_unreachable`) rather than
being skipped. The id hashes scope and finding, so re-learning a finding
atomically replaces one file. Trajectory files need no lock: each is
staged privately and published by atomic rename, and `learn` never
modifies the ai-hist DB; ordering between writers comes from the root run's
stream. A file takes the DB's mode, and a failed `learn`
(`memory_finding_invalid`, `memory_unwritable`) publishes nothing. A body
must await `learn`: a call whose outcome the body never consumed fails the
run with `unawaited_step`. `memory: { agent: true }` refuses pending the
identity-scoped agent follow-up. CLI-only operation is also deferred.

4. **`{{prev}}` / return-value chaining.** Output flows downward implicitly; naming steps is for reaching back, not bookkeeping.
5. **Headers are optional escalation.** identity, memory, budget, tools appear only when used. The empty header is the common case. [Budget headers and spend](BUDGET.md) specifies parsing, prices, journal attribution, and admission limits.
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/src/authored-flow-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export type AuthoredFlowExecutionErrorCode =
| 'journal_protocol_violation'
| 'missing_completion'
| 'memory_unreachable'
/** `f.memory.learn` was given a finding without non-empty string fields. */
| 'memory_finding_invalid'
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
/** `f.memory.learn` could not persist the finding to script memory. */
| 'memory_unwritable'
Comment thread
khaliqgant marked this conversation as resolved.
Resolved
/** `f.memory.learn` ran without a durable root run to journal the finding in. */
| 'memory_unjournaled'
| 'operation_after_completion'
| 'operation_callback_failed'
| 'step_failed'
Expand Down
19 changes: 18 additions & 1 deletion packages/sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { SlackCall } from './slack-writeback.js';
import { checkMcpHeader, McpPreflightError } from './cli/check-typescript.js';
import { buildMcpProxy, runMcpEffect } from './authored-mcp.js';
import { AuthoredBudget } from './authored-budget.js';
import { assertMemoryReachable, authoredMemory, scriptMemoryScope } from './authored-memory.js';
import { assertMemoryReachable, authoredMemory, journalLearnLedger, MemoryWriteTracker, scriptMemoryScope } from './authored-memory.js';
import { authoredDeterministicRunner, authoredWorkerRunner } from './authored-worker-step.js';
import { isSurfaceFlowCompletionReason, isSurfaceRunCompletionReason } from './authored-step-output.js';
import {
Expand Down Expand Up @@ -285,6 +285,7 @@ export async function executeAuthoredFlow<Input = undefined>(

const journalSteps: AuthoredFlowJournalStep[] = [];
const authoredSteps: AuthoredFlowOperation<unknown>[] = [];
const memoryWrites = new MemoryWriteTracker();
const lifecycle = new AuthoredFlowLifecycle();
const activities = new AuthoredActivities(journal, options.rootRunId);
const edgeOverrides = new Map<string, AuthoredStepEdges>();
Expand Down Expand Up @@ -563,6 +564,8 @@ export async function executeAuthoredFlow<Input = undefined>(
scriptMemoryScope(flowPath, definition.name),
() => assertOperationAllowed('memory', definition.name, requestedCompletion),
definition.header.memory?.script !== false,
memoryWrites,
options.rootRunId === undefined ? undefined : journalLearnLedger(journal, options.rootRunId),
),
run: runOperation,
llm: llmOperation,
Expand Down Expand Up @@ -818,6 +821,10 @@ export async function executeAuthoredFlow<Input = undefined>(
bodyFailure = error;
}
if (bodyFailed) {
// An in-flight learn must land or fail before this attempt gives the root
// back: a re-driven attempt reads the memory-learn stream, and must not
// race a commit this one is still making.
await memoryWrites.settle();
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
try {
worker.stop(bodyFailure);
await stopAuthoredOperations(authoredSteps, bodyFailure);
Expand Down Expand Up @@ -850,6 +857,8 @@ export async function executeAuthoredFlow<Input = undefined>(
'missing_completion',
`flow "${definition.name}" returned without done()`,
);
// Same as a failed body: no learn may outlive the attempt that started it.
await memoryWrites.settle();
try {
worker.stop(missingCompletion);
await stopAuthoredOperations(authoredSteps, missingCompletion);
Expand All @@ -860,6 +869,14 @@ export async function executeAuthoredFlow<Input = undefined>(
throw missingCompletion;
}
try {
// A `learn` whose outcome the body never awaited may have failed, or may
// still be racing done(), so the run cannot claim the finding was kept.
// Let each settle, then fail the run rather than report success over it.
const unawaitedLearns = await memoryWrites.unawaited();
if (unawaitedLearns > 0) {
throw new AuthoredFlowExecutionError('unawaited_step',
`flow "${definition.name}" returned with ${unawaitedLearns} unawaited f.memory.learn call${unawaitedLearns === 1 ? '' : 's'}; await each learn before done()`);
}
await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle);
} catch (error) {
try {
Expand Down
Loading
Loading