Effects
This specification owns Flow's one effect, !tool: what it marks, where it is
written, where it is inferred, how it passes through functions given as
arguments, and why timing is not an effect. It owns the meaning of "pure".
Source spelling belongs to grammar.md. Function types are types.md's, what a Tool call does is tools.md's, and the task operations are concurrency.md's. Static acceptance and its diagnostics belong to checking.md.
Every rule here binds every conforming implementation. Examples and the stated reasons for a refusal are non-normative.
One effect
A function is pure or it has !tool. It has !tool when its body makes a Tool
call or calls something that does. A Tool call is a call to a @tool function
(tools.md); there is no other way to reach the outside, so there is
no other effect.
Pure means "makes no Tool calls". It does not mean the result can't depend on timing (Timing is not an effect).
flow describe(item: Item) -> View = render(item) // pure, and checked
flow read_batch(refs: List<Text>) -> Batch !tool =
Batch { views: list.map(refs, flow(r) = catalog.read(r)) }Open in playground →No mark means pure, and the compiler checks it. A function written without
!tool whose body makes a Tool call, directly or through anything it calls, is
an error at the call that does it, naming the function that needs the mark.
!tool says a function may make Tool calls; it does not require it to.
A call has !tool when the function called has it, or when a function passed
to the call passes its effect through (Effects of functions given as
arguments). Ignoring a call's result never makes the call pure.
There is no finer effect. Which Tools a function can reach is something the compiler already knows and tooling shows. Permission, cost, budgets and approval belong to the host around the run, not to an effect.
The effect lives in the checker. It is not part of a value, is not recorded in history, and never reaches the Tool protocol.
Serves foundations: visible outside influence, typed, composable crossings.
What it prevents
- A Tool call hidden in code that looks harmless. A helper named
labelcan't call a model unless its signature says!tool. - Callbacks that multiply Tool calls. A library can require a pure callback, so a sort can't call a model an unknown number of times, in an order the sort algorithm chooses.
- A dependency quietly starting to make calls. If an imported function
becomes
!tool, pure code that uses it stops compiling. - Outside contact where it must never happen. Equality, ordering, text, schemas (types.md) and every top-level constant are pure.
- Fakes where none are needed. Pure code is tested without Tool bindings.
What is written and what is inferred
Every top-level flow is written in full: its parameter types, its result
type and its effect. Its contract can be read without its body, and a mistake
is reported where it is made, not at a distant caller.
pub flow map<A, B>(items: List<A>, operation: (A) -> B) -> List<B> = ...
flow summarize(reply: Result<Document, ToolProblem<ReadError>>) -> Summary = ...
flow read_batch(refs: List<Text>) -> Batch !tool = ...Open in playground →A @tool declaration writes no effect. Calling it is a Tool call, and its
result type at the call is tools.md's.
Inferred: the types and effects of let bindings, local functions (a
flow inside a block) and anonymous functions, and a type parameter's
requirements (types.md). Tooling shows what was inferred.
flow review(input: Input) -> Report !tool = {
flow check(f) = classify(f, input.rules) // a local function: inferred
let ask = flow(q) = human.confirm(q) // anonymous: inferred !tool
...
}Open in playground →A top-level let constant is pure (semantics.md).
Effects of functions given as arguments
Every function type inside a parameter's type that has no effect written passes
its effect through: the parameter itself, a list of functions, a record holding
one. The body of the function that declares the parameter may call it without
being !tool. The effect is decided at each call instead: the call has !tool
when any function its arguments supply in such a position has !tool, and is
pure otherwise. Effect variables are never written.
pub flow map<A, B>(items: List<A>, operation: (A) -> B) -> List<B> = ...
list.map(refs, flow(r) = catalog.read(r)) // this call is !tool
list.map(items, flow(i) = render(i)) // this call is pureOpen in playground →The same holds for functions inside a parameter's type:
pub flow race<T>(work: List<() -> T>) -> T = ...
task.race([flow() = web.search(q), flow() = docs.search(q)]) // this call is !toolOpen in playground →!pure on a function type inside a parameter's type requires a pure argument
there. It is the only place !pure is written:
pub flow sort_by<T, K>(items: List<T>, key: (T) -> K !pure) -> List<T> = ...
list.sort_by(findings, flow(f) = model.rank(f))
// error: sort_by needs a pure key; model.rank is !toolOpen in playground →A function type anywhere other than inside a parameter's type, such as a record
field, a let annotation or a result, is pure when unmarked.
Marked !tool, it allows Tool calls, and a pure function fits there too:
type Step { name: Text, run: (Input) -> Output !tool }Open in playground →A pure function fits there because a value moves into the place. Inside a
declared type's type argument it fits as far as the declaration moves values:
where the type parameter appears only where values come out (a field, a
payload, a function type's result), a pure function type fits a !tool one;
where it appears only in a function type's parameters, the reverse; where it
appears in both, the effects must be equal. A type std or a host implements
gives out what it holds. Two function types that meet without a value moving
into a place, such as one name bound by two alternatives of a pattern, are equal
only with the same effect (types.md).
Serves foundations: typed, composable crossings.
Timing is not an effect
task.first, task.race and task.stop answer by timing, and the runtime
records each such choice in history (history.md), so replay is
exact. They carry no effect, because pure means "makes no Tool calls". Starting
work that makes Tool calls is already !tool through the work passed to it, so
the only results that can differ between two live runs of pure code are races
between purely computed work, and the record fixes those too.
A new effect is added only when a real program needs to see something !tool
doesn't show. The effect position is a list from the start, so adding one
breaks nothing written before.
Serves foundations: deterministic reconstruction, a general core, bounded in scope.
Considered and set aside
Writing only pub signatures in full. Two styles in one file, and a
helper's mistake surfaces at its first caller.
Writing nothing, and inferring everything. A change in a body could silently change a public contract or add an effect.
Writing parameters, inferring result and effect. It hides the one thing a reviewer most needs: whether a function touches the outside.
Written effect variables (map<A, B, e>, List<() -> T !e>). Pass-through
covers every program that needs one.
Other names (!io, !observe, !world). Every outside interaction in
Flow is a Tool call, so !tool says exactly what it marks.
Finer effects, per Tool or per kind of Tool. The compiler already knows which Tools a function reaches, and what to allow belongs to the host.
A second effect for timing (!race). It would almost always appear beside
!tool, and recorded choices already keep replay exact.
Timing operations only inside !tool functions. !tool would then mean
something other than a Tool call.