Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.3.3] - 2026-08-22

Fixed

  • durability="exactly_once" now claims the idempotency key before running the handler instead of checking a flag first and setting it after. The old ordering left the entire handler body between the two round trips, so every concurrent redelivery of one request passed the check and ran the side effects — at-least-once with a result cache, not exactly-once. A duplicate that arrives while the first call is still running now gets a retryable ConflictError (conflict, 409) and executes nothing; once the first call lands, retries return its cached result. Claims carry a lease (@handle(idempotency_lease_s=), default 300s) so a node that dies mid-handler does not strand the key, and a handler that raises releases its claim so failed work stays retryable.

Two consequences of the old design go with it: a handler returning None no longer re-runs on every redelivery (check_processed could not tell "never ran" from "ran and returned None"), and mark_processed is no longer best-effort — swallowing it is what left the key unmarked.

StoragePlugin gains claim_processed() / release_claim(), implemented atomically per backend: Lua scripts on Redis, an insert-as-claim plus a conditional lease-takeover UPDATE on SQLAlchemy. Existing SQLAlchemy ledgers are migrated in place (create_all skips existing tables, so the two new columns are added with ALTER TABLE), and idempotency records written by earlier versions still read correctly on both backends. A custom plugin that predates these methods keeps working but degrades to at-least-once with a result cache, and warns once instead of passing silently.

  • app.exception_handler(SomeSubclass) now fires. ExceptionHandlerRegistry resolved in insertion order with isinstance, and the built-in defaults register a base IstosError handler first — so it matched first and silently shadowed every subclass handler an application registered afterwards. Resolution now walks the exception's MRO and picks the most specific match. (Handlers registered against an ABC virtual subclass no longer match; MRO only.)

[0.3.2] - 2026-08-04

Added

  • app.queue(..., ha=True) warns at registration when the app's storage is process-local. HA has always needed shared storage (Redis/SQLAlchemy) so the standby can recover the queue, but the misconfiguration was invisible until the failover it exists for: election succeeds, a leader binds, and the standby only reveals its empty store when it takes over — losing every enqueued and in-flight job at the moment HA was meant to save them. Only the known process-local backends warn (InMemoryStoragePlugin, or no storage at all, via the new QueueStore.is_shared); a custom plugin is taken at its word, since it may well be shared. Advisory only: nothing is refused, and a single non-HA owner on volatile storage is unchanged.

[0.3.1] - 2026-07-26

Added

  • Human-in-the-loop approval for irreversible tools (istos.agent.approval, app.approvals()). A tool's owner declares the requirement — @handle("billing/refund", approval="moves real money") — and it travels in the capability manifest, so every agent that discovers the tool inherits the gate instead of each one being configured separately. run_agent / run_multi_agent / drive_channel / drive_agents take approvals=gate and suspend the turn on a gated call: an approval_request event goes out (carrying approval_id) and the tool runs only after a human decides. Nothing polls — the waiting agent holds an asyncio.Event woken by a query on its own .istos/approvals/<service>-<node>/decide key; list_approvals(app) and decide_approval(app, id, approved=…) fan out over those keys from any node, so the operator need not know which one is waiting. An approver may correct the arguments instead of refusing. Fail-closed throughout: a denial or a timeout never runs the tool (both come back to the model as a failed tool_result with the reason), and a gated tool with no gate raises at the start of the run rather than passing silently. Pending requests are written through to the app's storage, so an operator can still see what was outstanding after a restart. approval= is advisory for agents — the handler's authorizer remains the enforcement gate, and Istos warns with IstosSecurityWarning when the decide key is left open.
  • tools_from_discovery(app, services=[…]) builds mesh tools from capability manifests instead of hand-written schemas: an agent node reads another service's parameters and docstrings off .istos/capabilities/* rather than duplicating them. tools_from_manifest does the same for a manifest already in hand. Only handle entries become tools — a mesh tool is a query_once — and the result is a snapshot, so call it again to pick up nodes that joined later.
  • Agent eval and replay harness (istos.testing, istos eval). record_agent captures a run — every completion the model returned and every tool result that came back — into a Trajectory that saves as JSON; replay re-runs it with the model and the mesh pinned to the recording, so a difference means your code changed, and result.diff names it (a tool no longer called, arguments moved, a tool gone from the catalogue, a different final answer, an approval that no longer happens). istos eval trajectories/ does that in CI with no model and no network; --app main:istos additionally checks each recorded tool still exists and still accepts the recorded arguments. EvalCase / run_eval grade a whole trajectory against a live model (expect_tools in order, forbid_tools, expect_text, max_tool_calls, or your own check=) and record each case for later replay.

Changed

  • OpenAIChatModel now shares one aiohttp connection pool across complete() calls instead of opening a session per call, so a multi-step or multi-agent loop reuses connections. It closes with aclose() or by using the model as an async context manager; a long-lived model can leave it to the loop.
  • The prefix → tool-name rule now lives in one place (istos.discovery.naming) for both the MCP adapter and mesh tools, so the two catalogues cannot drift. It also scrubs every character a tool name disallows rather than only /.

Security

  • SECURITY.md now lists 0.3.x as the supported line (0.2.x is no longer supported).

[0.3.0] - 2026-07-23

Added

  • Agent loop over mesh tools (istos.agent): MeshTool / tools_from_handlers build a catalogue from @handle (same name/doc/schema path MCP uses); run_agent runs plan → query_once → observe until the model returns text or max_steps; drive_channel wires that into a @channel with durable history reload. OpenAIChatModel talks to OpenAI-compatible /v1/chat/completions (LM Studio, vLLM, …) with tool calls. Tools are key expressions on the fabric, so an agent is a service that calls other services — not an in-process graph.
  • Multi-agent handoff (Agent, run_multi_agent, drive_agents): a Swarm-style transfer where the model hands the conversation to another agent by calling a synthetic transfer_to_<name> tool. The loop swaps the active (model, tools, system) but keeps the shared message history, so context carries across. Handoff graphs may cycle, so a specialist can hand back to the router (triage → specialist → triage). The active agent persists across turns and is restored on reconnect from persisted handoff frames. The caller's token forwards to whichever agent's tools run, so authorizers see the original principal regardless of how many handoffs occurred. Remote specialists on other nodes stay reachable as mesh tools.

  • Agent OpenTelemetry spans: the loop now opens an istos.agent.completion span per model turn and an istos.agent.tool span per tool call, nested under the handler's request span so they join the end-to-end trace over Zenoh. Completion spans carry GenAI attributes (gen_ai.response.model, gen_ai.usage.* token counts, finish reason) and, for multi-agent, the active istos.agent.name; tool spans carry the tool name/prefix and flag errors. Token telemetry rides on ModelReply (model / finish_reason / usage), which OpenAIChatModel fills from the response — a custom model may leave it unset. All spans are no-ops unless Istos(enable_tracing=True); OpenTelemetry stays optional.

Changed

  • Durable agents now recover their full tool transcript on reconnect. history_to_messages reconstructs the persisted channel log into chat messages: each tool_call frame becomes an assistant tool_calls message followed by the tool message carrying its result, so a resumed session sees the same context it had live rather than plain text with the tool steps erased. A tool_call with no recorded result (a crash mid-tool) is dropped to keep the sequence valid. Pass include_tools=False to rebuild plain text only.
  • drive_channel now bounds its reused message log with max_messages (default 40, None to disable) so a long-lived durable session no longer grows the model context without limit. run_agent takes the same option (opt-in, defaulting off) and trims before each completion; the window keeps leading system messages and never begins on an orphaned tool frame. The bound counts messages, not tokens.
  • run_agent no longer ends a turn silently. When a reply carries neither text nor a usable tool call — for example when every tool call was malformed and dropped by the model adapter — it emits a message event flagged error=True instead of only done, so a channel turn always sends output back.

[0.2.1] - 2026-07-21

Added

  • Error replies now carry an explicit __istos_error discriminator, so a failure is recognised by one field rather than guessed from body shape. It is authoritative in both directions: true is an error, false marks a normal result even when the success value legitimately carries error/code/message keys — closing the false-positive where such a success was misread as a failure. The change is backward-additive: when the field is absent (an older responder, or a client in another language), detection falls back to the legacy rule of a dict carrying all three of error, code and message, so nothing on the wire breaks. New helper istos.reply_err(...) builds a stamped envelope a handler can return instead of raising.

[0.2.0] - 2026-07-17

Changed

  • Breaking. Capability manifests are served per service at .istos/capabilities/<service_name>, and app.discover_capabilities() returns every service's manifest keyed by name. The bare .istos/capabilities is unchanged and still answers, but it cannot answer for a fleet: every node serves it on the same key and @handle declares its queryable complete=True, so Zenoh asks exactly one node and never reaches the rest. A wildcard did not help either, since the key was identical everywhere. Distinct keys are what Zenoh fans out over, the same way */health reaches a/health and b/health. Services sharing a name share a key and one of them answers, so name them distinctly; replicas of one service are meant to share it, as the manifest describes the service rather than the process.

    # one arbitrary node, whichever Zenoh picked
    manifest = await app.query_once(".istos/capabilities")
    
    # every service
    fleet = await app.discover_capabilities()
    for service, manifest in fleet.items():
        ...
    
  • Breaking. query_once and @query raise when the responder failed, instead of handing back the error payload as data. A handler that raises replies with an ErrorResponse payload, which is an ordinary dict on the wire, so reply.get("clients") on a failed reply returned [] and an outage could not be told apart from an empty result. stream_query and open_channel already raised; queries now match them. The code picks the class, so except NotFoundError (also UnauthorizedError, ForbiddenError, RateLimitError) works across a hop; any other code arrives as IstosError with its code kept, and correlation_id comes with it for matching the responder's log line. Callers that tested for errors by hand can drop that code:

    # before
    reply = await app.query_once("clients/get", id="acme")
    if reply and reply.get("code") == "not_found":
        return None
    
    # after
    try:
        return await app.query_once("clients/get", id="acme")
    except NotFoundError:
        return None
    

    Multi-reply queries (several responders on one key) are unchanged: the list holds whatever each responder said, error envelopes included. Use is_error_payload on each.

  • Breaking. The queue calls check the same envelope, at the single Zenoh get they all share. app.result(...) reported {"state": "unknown"} for a refusal, app.dead_letters(...) reported [], and app.enqueue(...) raised UnauthorizedError whatever the owner actually sent. All three now raise the owner's error. A worker whose token the owner rejects died on a KeyError; it now logs the refusal and keeps polling.

  • Breaking. The queue owner's refusal reply is a standard ErrorResponse envelope. It carried error and code but no message, which is why callers had to hand-roll the check. Its error field now holds the code, as everywhere else, so a caller reading reply["error"] sees unauthorized rather than the sentence.

  • Breaking. retry= no longer retries an error the responder will only repeat. not_found, unauthorized, forbidden and validation_error fail on the first attempt; rate_limit_exceeded, 5xx and transport faults retry as before. This applies to @handle retry as well, so a handler raising NotFoundError under retry=3 runs once. is_retryable(exc) is the rule.

  • IstosError takes correlation_id as a constructor argument instead of having it attached afterwards.

  • CODE_TO_STATUS and DEFAULT_ERROR_STATUS moved from istos.http.gateway to istos.errors, re-exported from their old home. The status decides retryability and is not on the wire, so an error rebuilt from a reply recovers it from the code rather than defaulting to 500.

Added

  • query_once(..., consolidate_replies=False) for wildcard fan-out. Zenoh consolidates replies by default and drops some even when the responders answered on different keys, so a */health sweep could silently return a subset. discover_capabilities() uses it.
  • is_error_payload(reply), error_from_payload(reply) and is_retryable(exc) on the top-level istos namespace, for replies you decode yourself and for multi-reply results. is_error_payload previously lived in istos.http.gateway, which put an HTTP import on the fabric path; it is re-exported there.

Fixed

  • The RPC guide said that multiple handlers on one key produce a list. They do not. @handle declares its queryable complete=True, meaning one responder can answer the whole key, so Zenoh asks exactly one and the others are never asked; the default reply consolidation collapses same-key replies in any case. Fan-out needs distinct keys, as in */health over a/health and b/health, which does work. The same applies to .istos/capabilities, which every node serves on the identical key: a query returns one arbitrary node's manifest, and a wildcard does not help. It is a self-description endpoint, not fleet discovery. Namespacing the manifest per service would fix it and is a wire change, so it is not in 0.1.x.

Known limits

  • An error is recognised by shape, not by the transport: a reply is an error if it is a dict carrying error, code and message. A handler that legitimately returns all three is read as a failure. The structural fix is out-of-band signalling, either Zenoh's Query.reply_err() or a flag in the request envelope Istos already attaches, and both change the wire protocol. Addressed in [Unreleased] — an in-band __istos_error discriminator (backward-additive, no wire break) rather than out-of-band signalling.

[0.1.2] - 2026-07-15

Fixed

  • @stream now ends its Zenoh query as soon as the generator finishes, instead of leaving it to be garbage-collected. The consumer's get() returns only once every matching queryable has finished, so the lingering query left every stream_query / SSE client waiting out the full timeout after the last chunk — a browser EventSource sat idle for http_timeout_s (60s by default) on a stream that had already completed, and event: end arrived only when the timeout expired. Streams now close immediately; the test suite dropped from 260s to 67s as a side effect.

Added

  • examples/fable-workflow gained --serve: the loop behind GET /run as SSE, so it can be driven with curl -N instead of the CLI.

[0.1.1] - 2026-07-15

Added

  • JobContext — a worker that names a ctx parameter is handed the delivery's job_id, queue, attempt, max_attempts and last_error (the previous attempt's failure), plus is_retry / is_last_attempt. Opt-in and additive: a worker without ctx is unchanged. Resolves alongside Depends(...), which still wins on the same name.
  • examples/fable-workflow — the Fable Method as four cooperating nodes over work queues, driven by a local LLM.

Changed

  • The queue owner's claim reply now carries last_error, so a redelivered job can tell its worker why the last attempt failed. Backward compatible in both directions: an older worker ignores the field, and a newer worker against an older owner sees last_error=None.

[0.1.0] - 2026-07

Added

  • @handle / @query — 1-to-1 RPC with selector → function args
  • @stream / stream_query — chunked RPC replies
  • @channel / open_channel / ChannelClient — duplex agent sessions (WebSocket via ws=, fabric via Zenoh; ?conversation_id= resume on WS)
  • @stream_client / @channel_client — declarative clients (mirrors @query)
  • @channel(durable=True) + SessionStore — resumable conversations over the app storage ledger
  • @publish / @subscribe — 1-to-many events; durable=True brokerless replay
  • @publish(persist="s3://…") / app.persist(...) / app.replay(...) (istos[s3])
  • Work queues — app.queue(...) owner, @app.worker(...) competing consumers, app.enqueue(...), app.dead_letters(...). Lease-based redelivery, exponential-backoff retries, dead-letter, delayed jobs (delay_s) + priorities, result backend (keep_results / app.result(...)), periodic scheduling (app.schedule(...), interval or cron=), workflows (app.chain / app.group / app.chord), owner failover (ha=True, liveliness leader election), O(log n) heap store, push-nudge delivery; durable via the storage plugin
  • HTTP gateway (http_port) for @handle(..., http=…) and @stream(..., http=…) (SSE)
  • Istos(enable_mcp=True) — MCP JSON-RPC tools from @handle endpoints (/mcp; batch + 202 notifications)
  • istos.http.asgi.lifespan / Istos.serving(serve_http=…) — co-host the mesh inside FastAPI/Starlette
  • GET /livez, /readyz, /metrics when http_port is set
  • .istos/capabilities + export_capabilities() (includes channel + optional websocket)
  • AsyncAPI / serve_docs() includes @stream and @channel
  • Request envelope on attachments (tok / cid / tp)
  • require_auth=True (raises IstosSecurityError without an authorizer)
  • JWTAuthorizer, require_roles (istos[jwt])
  • authorizer= on subscribers
  • token= on @query, query_once, publish_once, stream_query, open_channel
  • RateLimitMiddleware — token-bucket rate limits
  • Middleware wraps @handle, @stream, @channel, @subscribe (stream/channel once per session)
  • Structured logging (log_level, json_logs)
  • IstosError / ErrorResponse / @exception_handler
  • IstosTestClient (query, stream, channel, publish)
  • .istos/health, .istos/ready, .istos/metrics
  • Prometheus metrics + optional OTel (istos[otel])
  • SIGINT/SIGTERM shutdown
  • InMemoryStoragePlugin, RedisStoragePlugin, SqlAlchemyStoragePlugin
  • Pydantic / type-hint validation at the boundary
  • Retry with backoff
  • Liveliness
  • Dependency injection
  • Shared memory transfers
  • CLI: istos new / docs / version / analyze
  • Architecture fitness (istos analyze) — abstractness / instability / distance
  • CI, Docker Compose, deployment docs, SECURITY.md, py.typed
  • Wire-protocol reference (including @channel fabric keys)

Extras

  • istos[all] = redis + sqlalchemy + otel + s3 + jwt