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
33 changes: 26 additions & 7 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -873,15 +873,34 @@ author predicate. The v1 `verification:` shape remains supported and compiles
to the same kernel fields; no kernel verb or verification field is added by
this decision.

TypeScript may additionally accept a callback such as
TypeScript additionally accepts a callback such as
`.gate(value => value.length < 200, "keep the summary short")`. That callback
is author code: `flows check` cannot prove it, YAML cannot serialize it, and
the journal cannot replay the closure. A TypeScript runtime must execute it as
runtime control flow and journal the resulting step outcome before dependents
continue. It must never stringify the function into a spec or silently label
it preflightable. Authors who need portable, inspectable gates use a named data
check; plugins may contribute named checks only by compiling them to existing
kernel primitives.
the journal cannot replay the closure. The authored runtime executes it as
runtime control flow — once, in the authoring process, on the value read back
from the step's `step.completed` — and journals the verdict as a lowered
`<step>.gate` deterministic step: a passing predicate journals
`{"gate":"predicate","step":"<id>","verdict":"pass","because":…}` as that
step's stdout with exit 0; a failing one (or one that throws) journals
`"verdict":"fail"` on stderr with exit 1, and the run fails as `gate_failed`
naming the step and the author's reason. Dependents therefore wait on a
journaled fact, and resume/replay read that fact rather than re-running the
closure. The function is never stringified into a spec, and `flows check`
prints no gate line for it — a predicate is runtime-only and unprovable
before execution, by construction. A step takes one `.gate()`. Authors who
need portable, inspectable gates use a named data check; plugins may
contribute named checks only by compiling them to existing kernel primitives.

`artifact_exists` is the named gate for "the agent wrote this file":
`.gate({ type: 'artifact_exists', path: 'review/security.md' })`. The worker
that spawned the agent CLI snapshots the agent's working directory before the
run and content-diffs it after, and journals the changed paths as
`output.artifacts` on the agent's `step.completed`; `AgentResult.artifacts`
is read from that journal entry, never from a later look at the disk, and the
gate lowers to a deterministic step that checks the journaled list. An agent
whose final message is a JSON object owns its output shape and journals no
artifacts; gate such a step on a deterministic check instead. The relay
transport journals none, because the agent ran on another host.

- Are YAML helper verbs (`slack:`, `mcp:`) core spec vocabulary or compile-time expansion into `run`/effect steps? Leaning: expansion — the kernel spec stays seven words; helpers stay a surface concern.
- Helper generation cadence: generated from relayfile adapter manifests at build time vs published per-adapter packages. Leaning: generated, with hand-tuned verb names for the top providers.
Expand Down
14 changes: 7 additions & 7 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ For a working local starting point, use the [small agent starter](../README.md)
| Example | Status | Observed result | Elapsed |
|---|---|---|---:|
| [dependency-upgrade-bot](dependency-upgrade-bot/) | **BLOCKED** | SDK refuses unsupported `budget` header before entering the body; exit 2 | [5.138s](../docs/evidence/ws13/review/gallery/gallery-dependency-upgrade-bot.txt) |
| [pr-review-pipeline](pr-review-pipeline/) | **BLOCKED** | SDK refuses unsupported `budget` header before entering the body; exit 2 | [3.539s](../docs/evidence/ws13/review/gallery/gallery-pr-review-pipeline.txt) |
| [pr-review-pipeline](pr-review-pipeline/) | **RUNNABLE** | `budget:` headers have been accepted since #306; agent artifacts are journaled by the worker and both gate forms (`artifact_exists`, predicate) are lowered — see `packages/sdk/tests/agent-artifacts-live.test.ts` for the same shape through the built CLI and a real daemon. Needs a `flows.json` naming an authenticated agent CLI. | — |
| [pr-reviewer](pr-reviewer/) | **PASS (local, stand-ins)** | The wepost PR reviewer as a v2 flow: 17 journaled steps end to end through the real kernel with a wrapper agent and an API shim; happy, red-tests and draft paths proven. See its README. | — |
| [research](research/) | **PASS** | All model probes passed; three lane reports and synthesis produced; exit 0, `completionReason: synthesized` | [690.935s](../docs/evidence/ws13/followup/default-budget/gallery-research.txt) |

Expand All @@ -23,12 +23,12 @@ These are individual runs from a separate clone on an authenticated macOS
host, against the packed candidate CLI. Research uses its documented source
shim. These timings are not clean-machine measurements.

**Dependency-upgrade-bot and pr-review-pipeline need the SDK/kernel capability
owner.** Their authored budgets are currently rejected. Their postfix artifact
gates and workspace permission declarations also require runtime support.
Removing those requirements would weaken what the examples promise; this
branch leaves them intact. The local agent worker handles stream-only steps
and cannot supply workspace isolation.
**Dependency-upgrade-bot still needs the SDK/kernel capability owner** for
its workspace permission declarations: the local agent worker handles
stream-only steps and cannot supply workspace isolation, and a
`"...: readwrite"` annotation is refused because nothing enforces it.
Budget headers and postfix artifact gates are supported; pr-review-pipeline
uses them without workspace scoping.

**Research now prints provider preflight activity.** Each CLI/model probe names
its timeout on stderr, while stdout remains the final structured result.
Expand Down
27 changes: 16 additions & 11 deletions examples/pr-review-pipeline/pr-review-pipeline.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@
// looks specifically for conflicting verdicts rather than just concatenating
// opinions.
//
// STATUS: typechecks against the real `@relayflows/surface` package (see
// ../tsconfig.json / `npm --prefix packages/surface run typecheck:examples`)
// but does not run yet — `f.agent` parks without an attached worker. Unlike
// the other two examples, this one never calls `f.human`, so nothing here
// depends on that verb being wired up.
// STATUS: runs with `flows run pr-review-pipeline.flow.ts --local-agent
// --input '{"diffRange":"main...HEAD"}'` from a checkout with a `flows.json`
// naming the agent CLI. Each lens is gated on a journaled `artifact_exists`
// check — the worker that spawned the agent journals the files it wrote, and
// the gate reads that journal — and the consensus step on a predicate whose
// verdict is journaled as `agent-N.gate`. The steps run in the invoking
// directory (no `workspace:` scoping: the local agent worker accepts
// stream-only steps, and a "...: readwrite" annotation is refused because
// nothing enforces it). Unlike the other two examples, this one never calls
// `f.human`, so nothing here depends on that verb being wired up.

import { flow } from "@relayflows/surface";

Expand All @@ -36,6 +41,7 @@ export default flow<PrReviewInput>(
const diff = await f
.run(`git diff ${input.diffRange}`)
.gate((out) => out.trim().length > 0, "nothing to review — the diff is empty");
await f.run("mkdir -p review");

await Promise.all(
LENSES.map((lens) =>
Expand All @@ -45,12 +51,10 @@ export default flow<PrReviewInput>(
`Review this diff for ${lens} issues ONLY — ignore everything else. ` +
`Write every finding, or an explicit "no issues found", to ` +
`${findingsPath(lens)}.\n\n${diff}`,
workspace: "review/: readwrite",
})
.gate(
(r) => r.artifacts.includes(findingsPath(lens)),
`the ${lens} reviewer must write ${findingsPath(lens)}, even to report nothing`,
),
// A named gate: preflightable by `flows check`, evaluated against
// the artifacts the worker journaled for this step, never the disk.
.gate({ type: "artifact_exists", path: findingsPath(lens) }),
),
);

Expand All @@ -65,8 +69,9 @@ export default flow<PrReviewInput>(
`reached opposite verdicts on the same spot in the diff, resolve it ` +
`or mark it UNRESOLVED with both positions. Write your reconciled ` +
`verdict to review/consensus.json.`,
workspace: "review/: readwrite",
})
// A predicate gate: author code, run once on the journaled result; its
// verdict is journaled as `agent-N.gate` so resume/replay never re-run it.
.gate(
(r) => r.artifacts.includes("review/consensus.json"),
"the consensus step must write review/consensus.json",
Expand Down
31 changes: 30 additions & 1 deletion packages/schema/flows.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@
"references_input",
"subprocess_gate",
"word_count_bounds",
"regex_match"
"regex_match",
"artifact_exists"
]
},
"ExitCodeGate": {
Expand Down Expand Up @@ -390,6 +391,29 @@
],
"additionalProperties": false
},
"ArtifactExistsGate": {
"title": "ArtifactExistsGate",
"description": "Passes when the step's journaled `output.artifacts` lists `path`: a file the\nagent's worker measured as created or changed under its working directory.\nReads the journal, never the disk, so replay and resume see the same verdict.",
"type": "object",
"properties": {
"type": {
"title": "type",
"description": "type in the Relayflows spec.",
"type": "string",
"const": "artifact_exists"
},
"path": {
"title": "path",
"description": "Working-directory-relative POSIX path, as the worker journals it.",
"type": "string"
}
},
"required": [
"type",
"path"
],
"additionalProperties": false
},
"NamedDataGate": {
"title": "NamedDataGate",
"description": "NamedDataGate in the Relayflows spec.",
Expand All @@ -413,6 +437,11 @@
"$ref": "#/$defs/RegexMatchGate",
"title": "NamedDataGate alternative 4",
"description": "See RegexMatchGate."
},
{
"$ref": "#/$defs/ArtifactExistsGate",
"title": "NamedDataGate alternative 5",
"description": "See ArtifactExistsGate."
}
]
},
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/.claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"mcp__relaycast__*"
]
}
}
1 change: 1 addition & 0 deletions packages/sdk/src/authored-flow-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type AuthoredFlowExecutionErrorCode =
| 'lease_exceeded'
| 'unsupported_completion'
| 'unsupported_gate'
| 'gate_failed'
| 'unsupported_header'
| 'unsettled_derived_work'
| 'unsupported_promise_lifecycle'
Expand Down
87 changes: 87 additions & 0 deletions packages/sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,93 @@ export async function executeAuthoredFlow<Input = undefined>(
localAgentStream, budget, definition.header.budget, options.rootRunId,
);

/**
* Predicate gates (docs/SURFACE.md §6). The closure runs here, once, on the
* value the journal handed back; the VERDICT is then journaled as a lowered
* `<id>.gate` deterministic step that succeeds or fails, so a resume or
* replay reads the recorded verdict and never re-runs author code. A false
* verdict fails the step as `gate_failed`, carrying the author's reason.
*
* Durability across a resume: the verdict is appended to the root run's
* `predicate-gates` stream BEFORE the gate run is opened. A resumed body
* re-executes and reaches the same gate; it finds the recorded verdict and
* reuses it, so the gate run's spec (which embeds the verdict) is identical
* under its admission key and the closure is never re-run. Without a root
* run there is nothing to resume, and the closure simply runs.
*/
const PREDICATE_STREAM = 'predicate-gates';
interface PredicateRecord { gate: 'predicate'; step: string; verdict: 'pass' | 'fail'; because?: string; threw?: string }
// The stream is read once per execution; concurrent gates (Promise.all)
// share the single in-flight load, so none of them can observe an empty
// map while the read is still pending and re-run a closure whose verdict
// was already recorded.
let recordedVerdicts: Promise<Map<string, PredicateRecord>> | undefined;
async function loadRecordedVerdicts(rootRunId: string): Promise<Map<string, PredicateRecord>> {
const verdicts = new Map<string, PredicateRecord>();
let offset = 0;
for (;;) {
const page = await journal.streamRead(rootRunId, PREDICATE_STREAM, offset, 1000);
for (const message of page.messages) {
const record = (message as { message?: unknown }).message ?? message;
if (typeof record === 'object' && record !== null && (record as PredicateRecord).gate === 'predicate'
&& typeof (record as PredicateRecord).step === 'string'
&& ((record as PredicateRecord).verdict === 'pass' || (record as PredicateRecord).verdict === 'fail')) {
verdicts.set((record as PredicateRecord).step, record as PredicateRecord);
}
}
if (page.messages.length === 0 || page.next_offset <= offset) break;
offset = page.next_offset;
}
return verdicts;
}
async function recordedVerdict(id: string): Promise<PredicateRecord | undefined> {
if (options.rootRunId === undefined) return undefined;
recordedVerdicts ??= loadRecordedVerdicts(options.rootRunId);
return (await recordedVerdicts).get(id);
}
async function applyPredicateGate<T>(operation: { id: string; predicateGate: unknown }, value: T): Promise<T> {
const gate = operation.predicateGate as { predicate: (value: T) => boolean; because?: string } | undefined;
if (gate === undefined) return value;
const id = operation.id;
let record = await recordedVerdict(id);
if (record === undefined) {
let verdict: boolean;
let detail: string | undefined;
try {
verdict = gate.predicate(value) === true;
} catch (error) {
verdict = false;
detail = error instanceof Error ? error.message : String(error);
}
record = {
gate: 'predicate', step: id, verdict: verdict ? 'pass' : 'fail',
...(gate.because === undefined ? {} : { because: gate.because }),
...(detail === undefined ? {} : { threw: detail }),
};
if (options.rootRunId !== undefined) {
await journal.streamAppend(options.rootRunId, PREDICATE_STREAM, record);
(await recordedVerdicts)?.set(id, record);
}
}
const literal = `'${JSON.stringify(record).replaceAll("'", "'\\''")}'`;
const command = record.verdict === 'pass' ? `printf '%s' ${literal}` : `printf '%s' ${literal} >&2; exit 1`;
try {
await observeStep(`${id}.gate`, 'deterministic', () => lowerDeterministic(`${id}.gate`, command, false), options.onProgress);
Comment thread
cursor[bot] marked this conversation as resolved.
} catch (error) {
if (record.verdict === 'pass') throw error;
throw new AuthoredFlowExecutionError(
'gate_failed',
`step "${id}" failed its predicate gate`
+ (record.because === undefined ? '' : `: ${record.because}`)
+ (record.threw === undefined ? '' : ` (predicate threw: ${record.threw})`),
'verification_failed',
error instanceof AuthoredFlowExecutionError ? error.runId : undefined,
);
}
return value;
}
lifecycle.applyPredicateGate = applyPredicateGate;

function llmOperation(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
function llmOperation(prompt: string, options: LlmOptions): Step<unknown>;
function llmOperation(prompt: string | TemplateStringsArray, ...values: unknown[]): Step<unknown> {
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/authored-flow-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ function collectMembers(values: Iterable<unknown>): unknown[] | undefined {
* called the operation's then method.
*/
export class AuthoredFlowLifecycle {
/**
* Installed by the executor: runs an operation's predicate gate (if any) on
* its resolved value before the operation fulfills. Lives on the lifecycle
* so EVERY authored operation — core steps, helpers, MCP, plugins — passes
* through it; a gate accepted on a Step must never be silently ignored.
*/
applyPredicateGate: (<T>(operation: { id: string; predicateGate: unknown }, value: T) => Promise<T>) | undefined = undefined;
private readonly graph: AuthoredPromiseGraph;
private readonly activeResolverProbes: ResolverProbe[] = [];
private readonly invocations = new Map<OperationToken, AuthoredOperationInvocation[]>();
Expand Down
Loading
Loading