Repository navigation
Releases: bzdvdn/reactifact
Release list
0.15.2
Added
- Produce-level tracing (
AgentSpan.produces). A trace span now records
whichProduce(s) ran within the agent for that event, with how many effect
operations each authored and its latency — so a multi-produce agent (e.g. one
that shapes an answer and records a side effect) is no longer a black box in
the trace. Captured via a per-task contextvar inAgent.execute, persisted in
the SQLite/Postgres trace stores (auto-migrated column) and shown in the
dashboard as aConsume → Produceflow on each span (trigger artifact →
produce chips with+Nauthored operations, no-op produces dimmed); the
agent header aggregates it. The external sinks carry it too:LangfuseTracer
emits a child observation per produce (with its latency, so Langfuse's
waterfall shows eachConsume → Producestep) plus the
langfuse.observation.metadata.produceslist, andOTLPTraceremits a
reactifact.producesattribute (Name(ops)list). A produce that raises is
still recorded — with its partial operations — on the error span, so the
failing step is visible instead of silently missing. Produce names are readable:
Produce.nameuses the class name, the@producefunction's name, or — for a
bareProduce(Model)used only to widen an agent's allowedCreatetypes —
Produce[Model](an explicitname=still wins), so traces no longer show a
wall of genericProduce. AgentSpan.event_artifact_type— the artifact type whose change triggered
the agent (its matchedConsume), e.g.DraftAnswervsFinalResponsefor an
agent that consumes both.event_typealone (created/updated) can't tell them
apart. Persisted in both trace stores (auto-migrated) and shown on each span;
Langfuse getslangfuse.observation.metadata.event_artifact_typeand OTLP gets
reactifact.event_artifact_type(both omitted when empty, so no blank key).
Fixed
- Dashboard disclosure carets now point down when expanded. The
▸on
agent, span and LLM-call rows was styled withdetails[open]>.caret, but the
caret lives inside<summary>, so the selector never matched and it stayed
▸; it now rotates to▾for every expanded row.
Full changelog: https://github.com/bzdvdn/reactifact/blob/v0.15.2/CHANGELOG.md
0.15.1
Added
create_chat_router(request_factory=…)accepts an async factory. The
factory may now beasync def(or otherwise return an awaitable); its result
is awaited before the turn runs, e.g. to look up the authenticated user with a
query. The sync form is unchanged.
Fixed
Runtime.astreamleft a pendingqueue.get()/done.wait()task. In the
event/donewait loop the losing future was cancelled but never awaited (and
done.wait()was not cancelled at all when an event won), so closing the
stream early — a dropped SSE client, an app shutdown — left a task pending and
printedTask was destroyed but it is pending!. Both futures are now
cancelled and awaited every iteration, and the runner task is settled in the
generator'sfinally.Runtime.astreamwaited on a cancelled runner without bound. A misbehaving
agent that swallowsCancelledErrorcould hang the caller's disconnect or
shutdown path; the wait is now bounded (_CANCEL_GRACE_SECONDS).A2Amessage/streamleaked its run on client disconnect. The server's
stream generatorcreate_taskd the run but had nofinally, so a dropped
client left the run executing (burning tokens) and pending at loop shutdown
(Task was destroyed but it is pending!). The generator now cancels and
awaits the run when closed early, and the in-process task store is bounded
(_MAX_TASKS, evicting the oldest terminal task).ChatAssistant.streamleaked per-turn resources when opening failed. A
callableresources=builds a fresh instance per turn; if the store failed to
open the session (or the runtime could not be built) it was never closed.
_open/streamnow close it on that failure path.EventHubsubscriber queues were unbounded. A subscriber that never drains
could grow memory without limit; the queue is now bounded and drops the oldest
status hint when full.
Full changelog: https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md
0.15.0
Added
create_chat_router(request_factory=…). The chat router can now map the
incoming FastAPIRequestinto the per-turnrequest=mapping that
ChatAssistant.streamaccepts (e.g. the authenticated user for
ACL/identity-aware runs). Without it the turn runs withrequest=None, as
before — existing callers are unaffected.
Full changelog: https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md
0.14.2
0.14.2
Fixed
- Trace dashboard respects its mount prefix.
create_trace_router's pages
(/traces,/traces/{id},/sessions) embedded root-absolute URLs
(/api/traces,/traces,/sessions,/traces/assets/app.css), so a router
mounted withinclude_router(prefix=…)(or run behind an ASGIroot_path)
served a UI whose links andfetchcalls escaped the prefix. The templates
now use a__BASE__placeholder filled per request from the mount prefix, so
the dashboard works at any mount point (and is unchanged at the root).
0.14.1
0.14.1
Added
ChatAssistant(dispatcher=...). The canonical chat layer now passes
dispatcher=/on_dispatch_error=through to theRuntimeit builds for each
turn, so an agent that recordseffects.act(...)intents has them delivered
from a chat turn (once per stable id, after the commit) instead of left
pendingwith only the no-dispatcher warning.quick.chat_agent(...)gains
the same two arguments (reactifact.runtime.DispatchErrorHandleris the
callback type).
0.14.0
0.14.0
Added
- A2A (Agent2Agent) client and server.
reactifact.a2aspeaks the A2A
JSON-RPC binding (v0.3 shape) in both directions, with no new dependency (the
client useshttpx; the server, FastAPI from thewebextra, is imported
lazily). Client:A2AClient(Agent Card,message/send,message/stream,
tasks/get,tasks/cancel) andA2AAgentTool/a2a_tool— a reactifact
Toolso anLLMAgentcan delegate to a remote agent.remote_agent(...)/
A2ARemoteProduce— a remote agent as a first-class scheduled node (the
analogue of LangGraph'sA2ARemoteGraph): it consumes a local artifact, calls
message/send, produces a local artifact, and maps a remoteinput-required
to a localPendingQuestion(resume continues the same remote task). Server:
create_a2a_router(agents, ...)builds an Agent Card (skills from each
agent'sproduces) and a JSON-RPC endpoint where aTaskis one conversation
— amessage/sendseeds viacreate_message, runs the agents to a fixpoint,
and replies viareply; a paused HITL run maps toinput-requiredand the
next message with the sametaskIdresumes it;message/streamemits the
working task and a status update percontext.announce()progress event over
SSE. Docs indocs/en|ru/a2a.md. Verified against the officiala2a-sdk
client (the SDK resolves our Agent Card and gets a completed task from
message/send);a2a-sdkis a test-only dependency (its owna2agroup),
the runtime stays onhttpx+ FastAPI. Theexamples/a2aapp demonstrates it
end to end (serve, call directly, schedule as a node, HITL) with no LLM or
network. - Outbox for outbound side effects. A produce no longer performs external
I/O (a notification, a webhook, a deploy) directly:
effects.act(kind=..., key=..., payload=...)records aPendingAction
intent under a stable id (action:{key}), andRuntime(dispatcher=...)
performs every committed-but-undispatched action after the commit — once
per stable id, recordingdispatched(orfailed, then re-raising) as state.
flush_pending_actions()drains it without a generation (e.g. after
merge()); the runtime keeps a turn alive until the outbox is empty. Replay
reconstructs the record without running the runtime, so it never re-sends; a
retried produce re-derives the same id and creates no second intent; two
branches that reach the same action share the id and merge to one — dispatch
bookkeeping (status/dispatched_at/error) is excluded from the three-way
merge signature, so a mixed dispatched/pending pair converges instead of
raisingMergeConflict— and thedispatchedside wins whichever branch is
the merge target, so a merge never re-sends an already-sent action — while a
changedkind/key/payloadstill conflicts.PendingActionandContext.pending_actions()/
mark_dispatched()/mark_failed()/retry_action()are exported;
replay_summaryandpython -m reactifact replayreport
dispatched/pending/failedaction counts. Delivery remains at-least-once
(a crash between the send and thedispatchedcommit re-runs it), so the
dispatcher must honoridempotency_key— stated, not hidden. The offline
examples/outboxdemo walks seven cases: commit→dispatch, re-derivation,
same-generation duplicate, merged branches, replay, failure→retry, and an
app-owned retry wrapper around the dispatcher. A failed dispatch bumps
PendingAction.attemptsand firesRuntime(on_dispatch_error=action, exc)
before re-raising;retry_actionresetsattempts. The framework runs no
background relay — the nextarun()or an explicit
flush_pending_actions()drains the outbox, and retry/backoff policy stays
with the application (deliberately not a workflow engine). - Correlated structured logging.
reactifact.logging(exported as
configure_logging/get_logger) turns on structured logs under the
reactifact.*namespace — silent until configured (aNullHandlerby
default, neverbasicConfig), human-readable orjson=True, with
configure_logging(level=..., stream=...). Every record carries the turn's
correlation fields (run_id/session_id/generation/agent, plus the
request'srequest_id) so a line ties back to the exact run/agent; app code
adds fields withbind(...). The runtime emitsrun started/run finished
andaction dispatchedatINFO, generation/agent completion atDEBUG,
and failing agents/dispatches atWARNING. Logs are metadata only — no
artifact data or prompt contents.examples/starter_appshows it in a real
lifespan (LOG_LEVEL=…,LOG_JSON=1) alongside aGET /api/readyreadiness
probe backed byactive_runs(). - Hard turn deadline, graceful shutdown, in-flight registry.
Budget.max_secondsis now a hard per-turn deadline: the runtime cancels the
generation in flight (a hung LLM call is actually stopped) instead of only
checking between generations, leaves the cancelled trigger batch unconsumed
for a resume, and ends withRunOutcome.BUDGET_TIME_EXCEEDED.
Runtime.request_stop()stops at the next generation boundary
(RunOutcome.STOPPED; the current generation still commits), and
Runtime.ashutdown(timeout=…)waits for the turn, force-cancelling after the
timeout. SharedRuntimeResourcesare closed by the host app, never by the
runtime.reactifact.runtime.active_runs()/cancel_run(run_id)expose the
process's in-flight turns read-only (readiness/ops; in-process only), and
Runtime.in_flightreports whether it is running.
Changed
Budget.max_secondsis enforced hard (behavior-visible, non-breaking): the
runtime now cancels the generation in flight when the deadline passes, instead
of only checking between generations, and ends the turn with
RunOutcome.BUDGET_TIME_EXCEEDED. A run that previously overshot a hung LLM
call now stops on time. See release.md.- Logging stays silent by default (a library
NullHandler); nothing is
emitted untilconfigure_logging()is called.
Docs
patterns.md(EN + RU) gains an Outbox: external side effects recipe,
andeffects.md/durability.md/replay.md/api.md(EN + RU) document
effects.act/PendingAction/Runtime(dispatcher=...); the
reactifact/reactifact-agents/reactifact-observabilityskills are
updated too.durability.md(EN + RU) also gains an Outbox in production
checklist (idempotent dispatcher, periodic drain, app-owned retry,
observability), stating that the framework ships no relay by design. The example count is now stated once (twenty-three) across
examples.md,index.mdandquickstart.md(EN + RU) and the constitution's
demo list, replacing the disagreeing subset counts (14/15/16/17).durability.md(EN + RU) states plainly that artifact types do not need
registering — the persisted id is the qualified name by default, resolved by
import on load. It lists the only three reasons toregister_type(rename/
move, aliases, migration), notes theTYPE_IDclassvar shortcut, and calls
out the one caveat: a model that is not importable in a new process
(__main__, a notebook,<locals>,pydantic.create_model) needs an
explicittype_id.
0.13.0
0.13.0
Added
- Online evaluation over live traces.
reactifact.eval.onlinescores
finished runs sampled from aTraceStorewith the sameEvaluators as
offline evaluation, writing the verdicts back as tags (pass →eval, fail →
eval:failed) for the dashboard, plus metrics
(reactifact_online_eval_runs_total,reactifact_online_eval_score).
OnlineEvalConfigcontrols sampling (sample_rate/strategy/seed),
filters, tags andreference_fn;online_evaluate(store, config)runs one
batch andOnlineEvaluatorschedules it (run_once,run_forever,
on_report). Because a trace is truncated, the source decides what is
scoreable:trace_source()scores theRunTraceitself (typed path via
trajectory_match,output_present,no_errors; unusable metrics are
skipped, never a false zero), whilecontext_source(run_fn)rehydrates the
fullContextfor exact text/core_metrics/judge scoring. Ships a FastAPI
router (create_online_eval_router: run/report/summary, lazy import) and the
CLIpython -m reactifact eval <traces.db>. Docs indocs/en|ru/eval.mdand
thereactifact-evalskill. Thedevopsexample demonstrates it:
examples/devops/online_eval.pyscores served runs with trace-level
routed/routed_toevaluators andweb.pymounts
create_online_eval_routernext to the trace dashboard. - Trust & safety: guardrails, authorization and quota. Three opt-in,
library-level layers onRuntimeResources, acting on typed artifacts and
resource access (deterministic, testable) rather than around model messages.
reactifact.guardrails.GuardrailPolicyruns ordered checks on every
agent-producedCreate/Update:PIIGuardrail(redacts via anyRedactor),
InjectionGuardrail,DenyListGuardrail,SizeGuardrail, the generic
PatternGuardrail, withon_violation="block"(raisesGuardrailViolation)
or"flag"(recordsreactifact_guardrail_triggered_total).reactifact. authzadds aPrincipal+ capability-basedPermissionPolicy, enforced by
the runtime onrun/create/update/delete, by the tool loop on
execute, and available to produces viaresources.require_authorized(...);
denial raisesAuthorizationError.reactifact.quotaadds cross-turn,
per-principal usage limits (Quota/QuotaTracker/QuotaLLM) — checked at
turn start (RunOutcome.QUOTA_EXCEEDED) and counted through the turn, with
cache hits free and a pluggableQuotaStore(in-process by default). All off
by default; docs indocs/en/safety.mdand thereactifactskill's
references/trust-and-safety.md. Thedevopsexample demonstrates it:
examples/devops/guardrails.pydefines a custom guardrail (a destructive
production change must cite aCHG-ticket) plus secret redaction, wired
ontoRuntimeResourcesand screening the raw input inweb.py; run
examples/devops/safety.pyfor an end-to-end, no-LLM demo. - Agent skills for building with reactifact. The package ships official
Agent Skills inreactifact/skills/<name>/SKILL.md(version-matched, since
they live in the package), installed into a project with
python -m reactifact skills install [--target agents|claude|both](also
skills list/skills show). Eight skills cover the paradigm end to end:
reactifact(mental model, invariants, working loop, routing, mistakes),
reactifact-agents(produce/consume/effects/provenance),reactifact-llm
(structured output, LLMAgent/HITL, tools, budgets),reactifact-rag
(sources,fan_out_sources, recipes),reactifact-testing,
reactifact-eval,reactifact-observability, and
reactifact-from-langchain. Each has a workflow plusreferences/for
lookup; every runnable code block is executed in CI bytests/test_skills.py
(# not-runmarks illustrative fragments), so the examples cannot rot. A
rootAGENTS.mdpoints library contributors at the skills and the repo
commands. - Prometheus metrics, dependency-free.
reactifact.metricsadds a tiny
in-process collector (Metrics) and aMetricsTracerthat fills it from the
tracing hook, pluscreate_metrics_routerservingGET /metrics(theweb
extra). Counters: run/budget outcomes, agent runs, LLM calls/tokens/errors
and (withMetricsTracer(metrics, pricer=…)) cost; histograms: agent/LLM
latency.Metrics.render()emits the Prometheus text exposition directly —
noprometheus_clientdependency — andMetricsTracercomposes with the
existing sinks (tracer=[TraceStore(…), MetricsTracer(metrics)]). Span
reads/writes/edges also give domain-adjacent counters
(reactifact_artifacts_written_total{type,op},
reactifact_artifacts_read_total{type},
reactifact_relations_total{relation}), andRuntimeResources.metricsis a
no-op-until-configured sink so a produce can record app-specific signals
(call.context.resources.metrics.increment("route_total", route=…)). - Dataset evaluation and LLM-as-judge.
reactifact.evalgrows from the
deterministic metric core (§56) into a full offline-evaluation layer, with no
hosted store.Dataset/Exampleholdinputs+ optional
reference_outputs/metadata(content-hashedversion,.json/.jsonl
loading);evaluate(dataset, target, evaluators, summary=…)runs a target
over every example — the target may return aContext, a
reactifact.testing.ScenarioResult(read via.context/.trace), an
outputs mapping or aRunResult— and scores it. AnEvaluatoris any
callableEvalInput -> Feedback | bool | float | {key: score} | list | None
(Noneskips, never a silent zero);from_metricwraps the scoring metrics,
trajectory_match(mode, steps=…)matches a run's typed path
(strict/unordered/subset/supersetoveragents/events/reads/
writes).llm_judge(plusjudge_correctness/judge_relevance/
judge_faithfulness) turns anyLLMProviderinto a judge with tolerant JSON
parsing,continuousscores,choicessnapping and reference-based grading —
composable withCachingLLM/budget/metrics and faked withFakeLLM.
EvalReportgainsaggregate()/passed()/assert_passed()(the CI gate,
EvalFailure), summary evaluators (summary_pass_rate,summary_mean) and
assert_eval.reactifact.evalis now a package; the existing
EvalCase/run_suitemetrics API is unchanged. - Provider resilience: exact LLM cache and failover.
reactifact.cache. CachingLLMkeys everycomplete()by the whole request (model, messages,
temperature, …) and answers a repeat without calling the provider — no
tokens, no latency; an optionalKVCacheover anyKVBackendpersists it.
A hit is marked (raw={"reactifact_cache": "hit"}) soBudgetLLMdoes not
charge it. Deliberately not semantic (an approximate match would answer a
different question) andstream()is uncached.reactifact.routing. RouterLLMtries providers in order and falls back on failure
(should_fallback=— e.g.retryable_only— andon_fallback=); only
complete()fails over. Both are drop-in provider wrappers, composable:
resources.llm = CachingLLM(RouterLLM([openrouter_llm(), groq_llm()])). - Stable artifact type identity (schema evolution).
reactifact.types
decouples the persisted type string from Python's module layout:
register_type(Model, type_id=…, aliases=[…], migrate=…)makes a model
rename/move — or a field rename — no longer break loading old sessions,
commits, events or recordings.type_id_of/resolve/migrate_payload;
the default id stays the qualified name and pre-registry payloads keep
loading, so it is fully backward compatible. Applies toArtifact.data_type,
Create/Update.data_typeandEvent.artifact_type. - Multi-turn conversations on typed artifacts.
reactifact.recipes. conversationaddsConversation— generic over your message model (any
model withrole/text;ConversationMessageis a ready default) — with
create_turn/add_message/transcript/turn_count/conversation_state,
so apps stop hand-rolling a chat log and a prompt builder. For the common
case of separate question/answer types,Transcript+MessageSpec
render several typed turns (with per-type role and an optional renderer for a
non-text assistant payload) into oneMessagelist, leaving the artifacts
untouched.ChatMemoryon
ChatAssistantwires bounded memory:keep(prune the raw thread via
WindowPruner),summarize+summary_type(a growing digest via
RollingDigestSummarizer), andcompact_commits(Context.compact— bounds
history without touching artifacts, safe for correlated/typed messages). The
repair/devops/knowledgeexamples usecompact_commitsto bound
long-lived sessions. Docs: a new Multi-turn & chat page
(EN + RU) mapping the LangGraph pieces to reactifact, plus a minimal offline
examples/chatdemo of the single-model recipe. - Context compaction.
Context.compact(keep_commits=…, keep_versions=…)
bounds a long-lived session: it collapses commits older than the last
keep_commitsinto a baseline snapshot (and optionally trims each artifact's
retained version history). The absoluteversion/head_idare preserved, so
context_hash, commit version numbers and pending-trigger resume are
unchanged. It is irreversible —checkout/diffbelow the baseline raise
(Context.compacted_atreports it) — and it does not evict artifacts (the
memory recipes still own that).Artifactgained an absoluteversion
counter decoupled fromlen(history)so trimming history never changes
context_hash; the baseline is persisted inContext.to_dict/from_dict,
copied byclone/branch, andCommitLogreplays correctly across it. - Token and cost budgets.
Budgetgainsmax_tokens(total prompt +
completion over...
v0.12.0 — configurable artifact columns + ScenarioReport
The tracing release: the traces table now takes configurable, artifact-derived
columns — create_trace_router(store, columns=[TraceColumn(...)]) pulls a
field out of a run's artifacts (agent/type/direction/field/scope), shown in the
list (with a column chooser) and as a Fields card on the run page; scope="session"
resolves as of the row's run, so a chat's question and latest answer show across
HITL-split runs. Also ScenarioReport (token/latency/tool totals via
result.report / scenario.report). Backwards-compatible.
Added
- Configurable artifact columns in the traces table.
create_trace_router( store, columns=[TraceColumn(...)])adds columns pulled from a run's
artifacts: eachTraceColumnnames anagent, artifacttype(class or
name),direction(read/write/any) and a dottedfield(a list step takes
its first element unless an explicit index is given). Values are computed
server-side per run and returned in each/api/tracesitem asfields
(GET /api/columnslists the labels); the dashboard renders them with a
per-browser column chooser.scope="session"resolves a column as of the
row's run (every run up to it, chronological; read order reversed so the
triggering artifact is the most recent andindex=-1means "latest so far").
A chat is many runs and a HITL clarify splits one exchange into two (ask
turn, then resume), soQuestionis the message that started the current
exchange (shown on the resume row too) andAnswerthe latest reply so far.
The run page also shows the configured columns as a Fields card
(/api/traces/{id}carriesfields), so the question — and a HITL clarify,
via aPendingQuestioncolumn — are visible when opening a run, not just in
the list.
RuntimeResources(trace_truncate=…)now controls how much artifact/response
JSON a trace keeps (None= no clip), so deep fields stay resolvable. The
devopsexample shows the question, the pending clarify question and the
final answer;repair(which now mounts the trace dashboard) shows the
question, the flow stage, the pending approval and the final answer. ScenarioResult.report/Scenario.reportreturn aScenarioReport: token
totals (prompt_tokens/completion_tokens/total_tokens),llm_calls,
tool_calls,spans,agents,errorsandoutcomesfor a turn — or
aggregated across every turn of a multi-turnscenario()— withto_dict()
and a readablerender().result.llmgainedprompt_tokens/
completion_tokensalongsidetokens.
v0.11.2 — PostgreSQLKVBackend self-heals a poisoned connection
Patch release. PostgreSQLKVBackend could be wedged for good by a single
cancelled query: a client dropping a streaming turn cancels the session save
mid-flight, leaving the shared libpq connection busy/aborted, and every later
session read/write failed. Each statement now drops the connection and retries
once on any psycopg error, so the store self-heals. No API change.
Fixed
PostgreSQLKVBackendcould be wedged for good by a single cancelled query:
a client dropping a streaming turn cancels the session save mid-flight, which
leaves the shared libpq connection busy/aborted, and every later session
read/write then failed (InFailedSqlTransaction, or "another command is
already in progress" — surfacing as a 500 onDELETE /api/runs/{id}). Each
statement now drops the connection and retries once on anypsycopgerror,
so the store self-heals instead of staying poisoned.
v0.11.1 — OpenAPI schema fix for the trace export route
Patch release. The trace router's /api/traces/export handler broke OpenAPI
schema generation — its -> JSONResponse return annotation is an unresolvable
forward ref, so /openapi.json and /docs raised PydanticUserError. The
route now declares response_model=None. No behavior change.
Fixed
- The trace router's
/api/traces/exporthandler broke OpenAPI schema
generation: its-> JSONResponsereturn annotation is an unresolvable forward
ref (the module usesfrom __future__ import annotationsand imports fastapi
lazily insidecreate_trace_router), so/openapi.jsonand/docsraised
PydanticUserError. The route now declaresresponse_model=None.