Release notes
Unreleased
Invocations run with process default services, as tracing uses OpenTelemetry's
global provider:
- Set them once at startup with set_default_services(ExecutionServices(...)).
Until then, the first invocation creates in-process ones (an in-memory event
store) and logs once that their events are lost on exit.
- assistant.invoke(...) works without GrafiRuntime or bind_services. Those
still give one invocation other services, such as a tenant's store.
- current_services() no longer raises outside a binding, unless
GRAFI_STRICT_SERVICES is set, which forbids the in-memory fallback.
- ExecutionServices.aclose() (or async with ExecutionServices(...)) closes
the services at shutdown; EventStore.aclose() releases a store's resources,
such as EventStorePostgres's connection pool.
- GrafiRuntime() without services runs with the current services, no longer
with new in-process ones. So ReActAgent.run() keeps a conversation's
history across turns again. For a store of its own, as GrafiRuntime() gave
before, pass GrafiRuntime(ExecutionServices()).
- An invocation uses the services current where invoke() is called, in every
layer, wherever its stream is read.
ReActAgent.run() without an invoke context continues a conversation of the
agent's own; before, every agent in a process shared one conversation id, the
module constant grafi.agents.react_agent.CONVERSATION_ID, now removed. A
server sharing an agent between users must pass an InvokeContext per user or
session: otherwise their turns share one history, prompts included.
EventStorePostgres connects lazily: constructing it does no I/O, and its
first use creates the events table, under an advisory lock, so processes
starting together do not race. It uses only the asyncpg driver; install it
with pip install 'grafi[postgres]'. psycopg2 is no longer needed, and the
sync engine attribute is gone (async_engine remains). A failure to reach
the database or create the table, from initialize() or the first use, is an
EventPersistenceError. pool_timeout now applies to the connection pool.
Tracing is set up with a single call: setup_tracing(project_name=...). It
follows OpenTelemetry's standard environment (OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_EXPORTER_OTLP_HEADERS, OTEL_SERVICE_NAME), returns the tracer
provider, and instruments every installed OpenInference instrumentor, not
only OpenAI's. TracingOptions, is_local_endpoint_available and the
OTEL_COLLECTOR_ENDPOINT/OTEL_COLLECTOR_PORT variables are gone: pass
endpoint="http://host:4317", scheme included, or set
OTEL_EXPORTER_OTLP_ENDPOINT. Spans also name the Phoenix project
(openinference.project.name) after project_name. The default
tracer of ExecutionServices is OpenTelemetry's global tracer, so grafi's spans
are exported without passing a tracer; it is a no-op until a provider is set.
Do not pass setup_tracing()'s result as tracer: it is now the provider. An
application with a global tracer provider of its own now exports grafi's
spans, payloads included; OPENINFERENCE_HIDE_INPUTS/_OUTPUTS or
GRAFI_SPAN_DISABLE_PAYLOADS keep the payloads off.
Spans name their attributes as OpenInference does:
- oi_span_type becomes openinference.span.kind.
- input and output become input.value and output.value, with their
mime types.
- session.id holds the conversation.
- A failure records error.type, an error_id matching its log line and
failed events, and error.details, besides OpenTelemetry's exception event
and ERROR status. These replace the duplicated error, error.message and
error.stack.
OPENINFERENCE_HIDE_INPUTS and OPENINFERENCE_HIDE_OUTPUTS redact grafi's
spans as they redact LLM spans. GRAFI_SPAN_DISABLE_PAYLOADS still works.
InMemTopicEventQueue.fetch(consumer_id) now waits indefinitely for new events.
Previously, direct calls used a one-second default timeout. Pass timeout=1.0
to preserve that behavior, or timeout=0 for an immediate read. Calls through
TopicBase.consume() already defaulted to an indefinite wait.
Cancelled queue reads now propagate cancellation and must leave the read cursor
unchanged. TopicEventQueue.unconsumed_count() is no longer abstract: a queue
backend need not implement it.
Each TopicBase.publish_data() call now creates a fresh destination publication
ID. Fan-out to different topics produces distinct IDs, with emission_id
recording the immediate source event's ID. Matched routes to the same topic are
deduplicated before delivery. Recording the same publication remains idempotent;
calling publish_data() again creates another publication. Older event records
without emission_id still decode with a value of None.
Runs now store every streaming publication and consumption record
with its actual offset. Outputs are acknowledged only once their
publications are stored. If a producer fails, stops or is
cancelled mid-stream, its unpersisted preview chunks remain unacknowledged and
may be delivered again when the input is retried. Stored stream history therefore
contains more raw records. Use get_async_output_events() explicitly for a
derived text display; its projections must not be persisted as recovery records.
Recovery restores publications in per-topic offset order, then consumer cursors,
before determining readiness. Missing or conflicting publication offsets and
consumption beyond surviving publications raise WorkflowError instead of
silently renumbering history or skipping records. Historical publications lost
through ID collisions or stream aggregation cannot be reconstructed automatically.
Nodes left ready by the stored history now run first on resume, in node-definition order, then those the new input makes ready.
A node whose tool yields nothing no longer fails the run with an IndexError:
its NodeRespondEvent records output_data as None (null in stored
events). Readers of node respond events must allow for it; older events, with
an output, still decode.
In-flight requests, including paused HITL requests from older parallel runs, may fail to resume if stream aggregation or input fan-out ID collisions removed required publications. Backward-compatible event decoding does not repair those histories; requests with complete history can still resume.
The workflow engine is rewritten. Each invocation now runs with one
coordinator that owns the run's state: a node is ready when its unread inputs
satisfy its subscription and no step of it is running; the coordinator takes
those inputs and runs the step in a task of its own, so independent nodes run in
parallel and each node runs one step at a time. A sequential run is the same
engine running one step at a time, first ready first run, and it now streams
outputs as steps commit instead of returning them all at the end. It queues each
ready node once, where the old sequential engine could queue a node again for
each publication it could consume (and then run it on inputs that did not
satisfy its subscription), so in graphs where a node becomes ready again before
it runs, steps can run in a different order and with different input batches. The per-node
polling tasks, the quiescence tracker (AsyncNodeTracker), the output listeners
(AsyncOutputQueue) and the separate sequential engine are removed: a run ends
exactly when no step is running and no node is ready, with no polling, so an
idle run uses no CPU.
Each step is committed before any node can read its output: when steps finish,
their consumption and publication records are written in one
record_events() batch (steps that finish together share one batch), and only
then do their publications become visible. A step that fails, stops or is
cancelled therefore leaves no consumption without a publication and no gap in a
topic's positions; retrying the request re-runs it. A custom event store must
write a record_events() batch atomically, as the in-memory and PostgreSQL
stores do; Postgres writes it in one transaction. A failed write fails the run
with WorkflowError, and the run writes no further steps, as the failed write
may still have landed.
A run keeps its topics' events itself; the event store is its only durable
state. Topic queues (TopicEventQueue, a topic's event_queue) and
add_event() are no longer used by workflow runs, so building a workflow with a
topic that has a custom queue raises WorkflowError. Workflows publish through
TopicBase.prepare_publication(), so building a workflow with a topic that
overrides any other public TopicBase instance method but to_dict() (publish_data(),
consume(), commit() and the like, which runs do not call) raises
WorkflowError; override prepare_publication() to transform or validate a
publication.
Internal modules and helpers of the old engine are removed:
grafi.workflows.impl.parallel_engine, sequential_engine, run_recovery,
async_output_queue and async_node_tracker, and from
grafi.workflows.impl.utils the functions publish_events() and
get_node_input().
Agent output topics that only the workflow reads and a single node writes still
stream to the caller as chunks arrive, so token streaming is unchanged for the
usual LLM-to-output pipeline. Every other topic, including internal topics,
output topics written by several nodes and in-workflow output topics, receives a
step's publications together when that step commits. A human-input request on an
in-workflow output topic therefore reaches the caller only once it is stored.
Resending the same answer event (the same event ID, answering the same
requests), for example a client retry after a failed attempt, delivers it to the
waiting node once; any other answer is a new input. Runs of one request must not
overlap, as both would restore the same history: keeping one run per
assistant_request_id at a time is up to the host. To hand items to another
node one at a time, or from a node that runs indefinitely, publish each item
from its own invocation.
A run stops when a node's failure reaches it (a node's own failure after the
node has recorded its failed event), when the caller closes the stream or is
cancelled, or on workflow.stop() (from any thread, for the runs in flight): no
new steps start, the other running steps are cancelled at once and awaited
before the close returns (a step that stopped the run, by failing or by calling
stop() itself from code carrying its context, is left to finish, unless the
caller leaves early or another stop follows), and steps
that already finished are still committed, unless the close itself is cancelled
or a store write failed. The first failure to reach the run is raised; later ones
are added to it as notes, and an interrupt or exit outranks an ordinary failure.
A KeyboardInterrupt or SystemExit raised by a tool, a store or the engine
during a run leaves the event loop once, from the task that raised it, as
asyncio does (the run's tasks never start eagerly, even under an eager task
factory); the run records it as its failure and stops its steps, and the run's
callers get a WorkflowError caused by it (or their own CancelledError, when
the loop's runner cancels them to clean up), so the runner's cleanup completes
before it raises the interrupt. One raised while an assistant or workflow
records its own invoke or respond event reaches the caller as an
EventPersistenceError caused by it; one raised while it records its failed
event is noted on the failure being raised. A cancellation during a cleanup does not
replace an interrupt already propagating.
A step the run cancelled is not a failure: an error it raises while being
cancelled (for example a transport reporting the
cancellation as an error) is logged, and its work runs again on resume. A step
that raises CancelledError without the run having cancelled it, such as a tool
whose transport times out internally, fails the run with NodeExecutionError.
The caller's task runs no tools or store calls, so a timeout around a run or its
close always raises TimeoutError; a caller cancelled while the run closes gets
its own CancelledError back, with the run's failure as a note.
Outputs are delivered at least once. An output counts as delivered when it is handed to the caller, and delivered outputs are acknowledged when the stream closes, so a human-input request received before closing can be answered on resume. Outputs never handed over, outputs of steps that were not stored, and outputs whose acknowledgement was not stored (the close was cancelled or timed out, or the acknowledgement write failed) are delivered again when the request resumes. Once a run fails it hands over no more outputs.
Closing the stream of an invocation (GrafiRuntime.invoke(), an assistant's or
a workflow's invoke()) now closes each forwarded iterator and waits for the
run's steps before releasing services. A cleanup failure does not replace the
primary error or a cancellation in progress; otherwise an explicit close raises
it.
GrafiRuntime.invoke() now binds its services only while the assistant's stream
runs (each read, and its close) and in the tasks it starts, not in the caller's
own code between reads: code in an async for body that calls a component
directly needs bind_services(runtime.services). Likewise a component's tracing
span is current only while the component's own code runs. A stream abandoned
without closing therefore leaves neither services nor spans attached to the
caller. Close streams all the same: one left open is closed only when it is
garbage collected, or when the loop shuts down, which closes it together with
the generators it reads: that can log errors, and leave the run's failure
recorded by only some of the components. A failed component's span records
its exception once.
AsyncResult now reads its source in the caller's task, as iterating the source
directly would: nothing is read ahead, so no output is counted as delivered
before the caller has it, and cancelling a read (for example a per-item timeout)
closes the source. Read from one task at a time, bounding a read with
asyncio.timeout() rather than asyncio.wait_for(), which on Python 3.11 runs
the read in another task that a second cancellation abandons, even while it is
closing the source. await returns only the items not yet read; items an
await had collected before it was cancelled or failed stay readable with async for;
later reads then raise as described next. A failed AsyncResult raises its error on
every later read, a source cut short by a cancelled read raises RuntimeError
on every later read, and awaiting one closed early with aclose() raises
RuntimeError instead of returning partial output.
Gemini, Ollama and synthetic tools now close their per-call clients, and every LLM tool closes its response stream, including when the caller stops early. A failure to close a client or stream after a completed call is logged rather than failing the call.