Skip to content

0.14.0

Choose a tag to compare

@bzdvdn bzdvdn released this 30 Sep 23:00
· 13 commits to master since this release

0.14.0

Added

  • A2A (Agent2Agent) client and server. reactifact.a2a speaks the A2A
    JSON-RPC binding (v0.3 shape) in both directions, with no new dependency (the
    client uses httpx; the server, FastAPI from the web extra, is imported
    lazily). Client: A2AClient (Agent Card, message/send, message/stream,
    tasks/get, tasks/cancel) and A2AAgentTool/a2a_tool — a reactifact
    Tool so an LLMAgent can delegate to a remote agent. remote_agent(...) /
    A2ARemoteProduce — a remote agent as a first-class scheduled node (the
    analogue of LangGraph's A2ARemoteGraph): it consumes a local artifact, calls
    message/send, produces a local artifact, and maps a remote input-required
    to a local PendingQuestion (resume continues the same remote task). Server:
    create_a2a_router(agents, ...) builds an Agent Card (skills from each
    agent's produces) and a JSON-RPC endpoint where a Task is one conversation
    — a message/send seeds via create_message, runs the agents to a fixpoint,
    and replies via reply; a paused HITL run maps to input-required and the
    next message with the same taskId resumes it; message/stream emits the
    working task and a status update per context.announce() progress event over
    SSE. Docs in docs/en|ru/a2a.md. Verified against the official a2a-sdk
    client
    (the SDK resolves our Agent Card and gets a completed task from
    message/send); a2a-sdk is a test-only dependency (its own a2a group),
    the runtime stays on httpx + FastAPI. The examples/a2a app 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 a PendingAction
    intent under a stable id (action:{key}), and Runtime(dispatcher=...)
    performs every committed-but-undispatched action after the commit — once
    per stable id, recording dispatched (or failed, 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
    raising MergeConflict — and the dispatched side wins whichever branch is
    the merge target, so a merge never re-sends an already-sent action — while a
    changed kind/key/payload still conflicts. PendingAction and Context.pending_actions()/
    mark_dispatched()/mark_failed()/retry_action() are exported;
    replay_summary and python -m reactifact replay report
    dispatched/pending/failed action counts. Delivery remains at-least-once
    (a crash between the send and the dispatched commit re-runs it), so the
    dispatcher must honor idempotency_key — stated, not hidden. The offline
    examples/outbox demo 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.attempts and fires Runtime(on_dispatch_error=action, exc)
    before re-raising; retry_action resets attempts. The framework runs no
    background relay
    — the next arun() 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 (a NullHandler by
    default, never basicConfig), human-readable or json=True, with
    configure_logging(level=..., stream=...). Every record carries the turn's
    correlation fields (run_id/session_id/generation/agent, plus the
    request's request_id) so a line ties back to the exact run/agent; app code
    adds fields with bind(...). The runtime emits run started/run finished
    and action dispatched at INFO, generation/agent completion at DEBUG,
    and failing agents/dispatches at WARNING. Logs are metadata only — no
    artifact data or prompt contents. examples/starter_app shows it in a real
    lifespan (LOG_LEVEL=…, LOG_JSON=1) alongside a GET /api/ready readiness
    probe backed by active_runs().
  • Hard turn deadline, graceful shutdown, in-flight registry.
    Budget.max_seconds is 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 with RunOutcome.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. Shared RuntimeResources are 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_flight reports whether it is running.

Changed

  • Budget.max_seconds is 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 until configure_logging() is called.

Docs

  • patterns.md (EN + RU) gains an Outbox: external side effects recipe,
    and effects.md/durability.md/replay.md/api.md (EN + RU) document
    effects.act/PendingAction/Runtime(dispatcher=...); the
    reactifact/reactifact-agents/reactifact-observability skills 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.md and quickstart.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 to register_type (rename/
    move, aliases, migration), notes the TYPE_ID classvar 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
    explicit type_id.