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
21 changes: 21 additions & 0 deletions .changeset/screen-flow-headless-satisfaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/service-automation": minor
"@objectstack/runtime": minor
---

A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names.

An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated.

Two independent halves:

- **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`).
- **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing.

**Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `<object>Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen.

**Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `<object>Id`, or when its value equals what the bag carries under `recordId`, `<object>Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too.

⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause.

⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change.
66 changes: 66 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,72 @@ producer), and `'service'` when the decision to continue belongs to your service
rather than to whoever holds the run id. Declaring neither leaves your pauses
refused on the generic route, and the engine warns about it at registration.

### A screen the caller already answered does not pause

A `screen` node exists to collect input. When the run's **caller already supplied
it**, there is nothing left to collect, so the run continues past the screen
instead of suspending.

That is what makes a screen flow reachable from a headless invoker. An
`ai.exposed` action whose target is a screen flow is started with `params`; those
bind to the flow's `isInput` variables, and before this the screen suspended
anyway — returning a form to a caller with no way to submit one, so the run
parked forever.

The screen is treated as answered only when **all** of these hold:

- the caller named **at least one of this screen's own fields** — a run that
named none of them is not driving the screen, and it renders as always. The
row id a launch carries does **not** count as naming a field: both dispatch
doors seed it into the params bag under `recordId` and the camelCase
`<object>Id` alias (and under an action's declared `recordIdParam`), and none
of those is the caller supplying a screen value.

**Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `<object>Id`, or when its value equals what the bag carries under `recordId`, `<object>Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen.

Name a field you want a headless caller to fill something else;
- every `required` field has a value (an empty or blank string is no value),
supplied by that caller. A value that came from the trigger record, from an
earlier node or from a declared `defaultValue` does not answer a `required`
field; optional fields may come from anywhere;
- the field has no `visibleWhen` the caller left unanswered. The server cannot
evaluate that predicate — it has no rendered form and no collected values — so
a conditional `required` field the caller did not name keeps the screen
interactive. (The resume door makes the opposite call for the opposite reason:
there, enforcing a hidden field's `required` would dead-end a run at Submit.)

Two screens never take this path, because they declare nothing to satisfy and a
bag must not answer them vacuously: a **message-only** screen (no `fields`), and
any screen whose author wrote `waitForInput: true` — that flag is an explicit
"show this", and a confirmation step is not something a params bag may skip.

**Interactive runs are unaffected.** A console launch carries the record it was
launched from and that record's id — never a value for the screen's own fields —
so the form renders exactly as before, including when the subject record carries
a column named like one of the fields and including when a field is named like
one of the seeded id keys. ⛔ `waitForInput: false` remains the wrong tool for
the headless case: it skips the form for interactive users too.

⚠️ One known gap, on the trigger-record leg only: a run continued from the
**durable** suspended-run store judges against a JSON copy of its context, so a
later wizard screen whose field collides with a **non-scalar** column (an array
or object) of the trigger record can read as caller-supplied. Scalar columns are
unaffected, as is any run that has not been through a pause.

⚠️ This does **not** make every screen flow completable from a headless caller. A
call that omits the inputs still parks, and the MCP tool set has no verb to
resume a parked run.

### `list_actions` publishes a flow action's inputs

An action's `params` in the MCP action listing come from its declared
`params[]`. A `type: 'flow'` action rarely declares any — its input contract is
the **target flow's `isInput` variables**, which is what the caller's `params`
bag binds into. Those are published instead, in declaration order, carrying the
`label`, `type`, `required` and select `options` of the screen field that
collects each one. An action that declares its own `params[]` keeps them: the
flow is read only when the action itself declares nothing.

### Parallel approvals — one aggregating node, not two pauses

"Finance **and** legal must both sign off, concurrently" is **one `approval`
Expand Down
86 changes: 83 additions & 3 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,7 @@ export function actionLooksDestructive(_deps: ActionExecutionDeps, action: any):
return Boolean(action?.mode === 'delete' || action?.variant === 'danger');
}

export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any, objectName: string): any {
export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any, objectName: string, flow?: any): any {
// [#15079] `operation` before `type`, on the LISTING face. A declarative
// update always requires a current record — that is contract point 7, and
// the executor refuses without one — so the answer cannot be left to
Expand All @@ -999,7 +999,7 @@ export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any
const description =
(typeof action?.ai?.description === 'string' ? action.ai.description : undefined) ??
(typeof action?.label === 'string' ? action.label : undefined);
const params = summarizeActionParams(deps, action, obj);
const params = summarizeActionParams(deps, action, obj, flow);
return {
name: action.name,
objectName,
Expand Down Expand Up @@ -1029,7 +1029,7 @@ export function jsonTypeOf(_deps: ActionExecutionDeps, t: string | undefined): '
}
}

export function summarizeActionParams(deps: ActionExecutionDeps, action: any, obj: any): any[] {
export function summarizeActionParams(deps: ActionExecutionDeps, action: any, obj: any, flow?: any): any[] {
const fields: Record<string, any> = obj?.fields ?? {};
const out: any[] = [];
for (const p of (Array.isArray(action?.params) ? action.params : [])) {
Expand All @@ -1055,9 +1055,89 @@ export function summarizeActionParams(deps: ActionExecutionDeps, action: any, ob
...(enumVals.length > 0 ? { enum: enumVals } : {}),
});
}
// [#15705] A FLOW action's input contract is its flow's `isInput`
// variables, not `action.params` — a flow-typed action almost never
// declares `params`, so this listing answered with no `params` key at all
// while the MCP `list_actions` tool description promised "its input
// parameters". An agent could see the action, could invoke it, and had no
// way to learn a single input name.
//
// Second, never first: a declaration the AUTHOR wrote on the action wins
// outright, so this can only fill a silence. `flow` is optional and the
// caller resolves it (`domains/mcp.ts` asks the automation service's
// `getFlow`), which keeps this function pure and leaves every existing
// 3-argument call site — and every non-flow action — byte-identical.
if (out.length === 0) out.push(...summarizeFlowInputParams(deps, flow));
return out;
}

/**
* A screen flow's input contract, projected onto the same param shape
* {@link summarizeActionParams} emits for a declared param (#15705).
*
* The flow's `isInput` variables ARE the contract — they are what
* `seedDeclaredVariables` binds from the caller's `params`, so their names are
* exactly the keys an invoker must send. The variable declaration carries only
* `name` / `type` / `defaultValue`, so everything an agent needs beyond the
* name (`label`, `required`, select `options`) is read off the screen node
* that collects the variable — the same field spec a paused run surfaces.
*
* `required` comes from the screen field alone: a flow variable has no
* `required` key, and inferring one from "declares no `defaultValue`" would
* invent a contract the author never wrote. A variable no screen collects is
* still listed — it is a real input, and omitting it would hide the very names
* this exists to publish — just without the screen-only enrichments.
*/
export function summarizeFlowInputParams(deps: ActionExecutionDeps, flow: any): any[] {
const variables: any[] = Array.isArray(flow?.variables) ? flow.variables : [];
if (variables.length === 0) return [];
const screenFields = collectScreenFieldSpecs(flow);
const out: any[] = [];
for (const v of variables) {
const name: unknown = v?.name;
if (v?.isInput !== true || typeof name !== 'string' || !name) continue;
const field = screenFields.get(name);
const type = jsonTypeOf(deps, field?.type ?? v?.type);
const description = typeof field?.label === 'string' && field.label ? field.label : undefined;
const enumVals = Array.isArray(field?.options)
? field.options
.map((o: any) => (typeof o === 'string' ? o : o?.value))
.filter((x: any): x is string => typeof x === 'string')
: [];
out.push({
name,
type,
required: field?.required === true,
...(description ? { description } : {}),
...(enumVals.length > 0 ? { enum: enumVals } : {}),
});
}
return out;
}

/**
* Every screen field a flow declares, by field name, first declaration
* winning. Walks ALL `screen` nodes rather than just the first: a multi-step
* wizard collects its inputs across several screens, and a contract that
* stopped at screen one would publish a subset while looking complete.
*
* Object-form screens contribute nothing by construction — their `fields` is
* empty because the client renders the object's own form — so they are simply
* skipped rather than special-cased.
*/
function collectScreenFieldSpecs(flow: any): Map<string, any> {
const byName = new Map<string, any>();
for (const node of Array.isArray(flow?.nodes) ? flow.nodes : []) {
if (node?.type !== 'screen') continue;
for (const field of Array.isArray(node?.config?.fields) ? node.config.fields : []) {
const name: unknown = field?.name;
if (typeof name !== 'string' || !name || byName.has(name)) continue;
byName.set(name, field);
}
}
return byName;
}

/**
* Resolve an action's declared `params[]` to their effective value-shape
* inputs (ADR-0104 D2). A field-backed param inherits type/multiple/
Expand Down
Loading
Loading