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 retryableConflictError(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.ExceptionHandlerRegistryresolved in insertion order withisinstance, and the built-in defaults register a baseIstosErrorhandler 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 newQueueStore.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_agentstakeapprovals=gateand suspend the turn on a gated call: anapproval_requestevent goes out (carryingapproval_id) and the tool runs only after a human decides. Nothing polls — the waiting agent holds anasyncio.Eventwoken by a query on its own.istos/approvals/<service>-<node>/decidekey;list_approvals(app)anddecide_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 failedtool_resultwith 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'sauthorizerremains the enforcement gate, and Istos warns withIstosSecurityWarningwhen 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_manifestdoes the same for a manifest already in hand. Onlyhandleentries become tools — a mesh tool is aquery_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_agentcaptures a run — every completion the model returned and every tool result that came back — into aTrajectorythat saves as JSON;replayre-runs it with the model and the mesh pinned to the recording, so a difference means your code changed, andresult.diffnames 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:istosadditionally checks each recorded tool still exists and still accepts the recorded arguments.EvalCase/run_evalgrade a whole trajectory against a live model (expect_toolsin order,forbid_tools,expect_text,max_tool_calls, or your owncheck=) and record each case for later replay.
Changed¶
OpenAIChatModelnow shares oneaiohttpconnection pool acrosscomplete()calls instead of opening a session per call, so a multi-step or multi-agent loop reuses connections. It closes withaclose()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.mdnow 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_handlersbuild a catalogue from@handle(same name/doc/schema path MCP uses);run_agentruns plan →query_once→ observe until the model returns text ormax_steps;drive_channelwires that into a@channelwith durable history reload.OpenAIChatModeltalks 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 synthetictransfer_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 persistedhandoffframes. The caller'stokenforwards 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.completionspan per model turn and anistos.agent.toolspan 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 activeistos.agent.name; tool spans carry the tool name/prefix and flag errors. Token telemetry rides onModelReply(model/finish_reason/usage), whichOpenAIChatModelfills from the response — a custom model may leave it unset. All spans are no-ops unlessIstos(enable_tracing=True); OpenTelemetry stays optional.
Changed¶
- Durable agents now recover their full tool transcript on reconnect.
history_to_messagesreconstructs the persisted channel log into chat messages: eachtool_callframe becomes an assistanttool_callsmessage followed by thetoolmessage carrying its result, so a resumed session sees the same context it had live rather than plain text with the tool steps erased. Atool_callwith no recorded result (a crash mid-tool) is dropped to keep the sequence valid. Passinclude_tools=Falseto rebuild plain text only. drive_channelnow bounds its reused message log withmax_messages(default 40,Noneto disable) so a long-lived durable session no longer grows the model context without limit.run_agenttakes the same option (opt-in, defaulting off) and trims before each completion; the window keeps leadingsystemmessages and never begins on an orphaned tool frame. The bound counts messages, not tokens.run_agentno 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 amessageevent flaggederror=Trueinstead of onlydone, so a channel turn always sends output back.
[0.2.1] - 2026-07-21¶
Added¶
- Error replies now carry an explicit
__istos_errordiscriminator, so a failure is recognised by one field rather than guessed from body shape. It is authoritative in both directions:trueis an error,falsemarks a normal result even when the success value legitimately carrieserror/code/messagekeys — 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 oferror,codeandmessage, so nothing on the wire breaks. New helperistos.reply_err(...)builds a stamped envelope a handler canreturninstead of raising.
[0.2.0] - 2026-07-17¶
Changed¶
-
Breaking. Capability manifests are served per service at
.istos/capabilities/<service_name>, andapp.discover_capabilities()returns every service's manifest keyed by name. The bare.istos/capabilitiesis unchanged and still answers, but it cannot answer for a fleet: every node serves it on the same key and@handledeclares its queryablecomplete=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*/healthreachesa/healthandb/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. -
Breaking.
query_onceand@queryraise when the responder failed, instead of handing back the error payload as data. A handler that raises replies with anErrorResponsepayload, which is an ordinary dict on the wire, soreply.get("clients")on a failed reply returned[]and an outage could not be told apart from an empty result.stream_queryandopen_channelalready raised; queries now match them. Thecodepicks the class, soexcept NotFoundError(alsoUnauthorizedError,ForbiddenError,RateLimitError) works across a hop; any other code arrives asIstosErrorwith itscodekept, andcorrelation_idcomes 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 NoneMulti-reply queries (several responders on one key) are unchanged: the list holds whatever each responder said, error envelopes included. Use
is_error_payloadon 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[], andapp.enqueue(...)raisedUnauthorizedErrorwhatever the owner actually sent. All three now raise the owner's error. A worker whose token the owner rejects died on aKeyError; it now logs the refusal and keeps polling. -
Breaking. The queue owner's refusal reply is a standard
ErrorResponseenvelope. It carriederrorandcodebut nomessage, which is why callers had to hand-roll the check. Itserrorfield now holds the code, as everywhere else, so a caller readingreply["error"]seesunauthorizedrather than the sentence. -
Breaking.
retry=no longer retries an error the responder will only repeat.not_found,unauthorized,forbiddenandvalidation_errorfail on the first attempt;rate_limit_exceeded, 5xx and transport faults retry as before. This applies to@handleretry as well, so a handler raisingNotFoundErrorunderretry=3runs once.is_retryable(exc)is the rule. -
IstosErrortakescorrelation_idas a constructor argument instead of having it attached afterwards. -
CODE_TO_STATUSandDEFAULT_ERROR_STATUSmoved fromistos.http.gatewaytoistos.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*/healthsweep could silently return a subset.discover_capabilities()uses it.is_error_payload(reply),error_from_payload(reply)andis_retryable(exc)on the top-levelistosnamespace, for replies you decode yourself and for multi-reply results.is_error_payloadpreviously lived inistos.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.
@handledeclares its queryablecomplete=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*/healthovera/healthandb/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,codeandmessage. A handler that legitimately returns all three is read as a failure. The structural fix is out-of-band signalling, either Zenoh'sQuery.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_errordiscriminator (backward-additive, no wire break) rather than out-of-band signalling.
[0.1.2] - 2026-07-15¶
Fixed¶
@streamnow ends its Zenoh query as soon as the generator finishes, instead of leaving it to be garbage-collected. The consumer'sget()returns only once every matching queryable has finished, so the lingering query left everystream_query/ SSE client waiting out the full timeout after the last chunk — a browserEventSourcesat idle forhttp_timeout_s(60s by default) on a stream that had already completed, andevent: endarrived only when the timeout expired. Streams now close immediately; the test suite dropped from 260s to 67s as a side effect.
Added¶
examples/fable-workflowgained--serve: the loop behindGET /runas SSE, so it can be driven withcurl -Ninstead of the CLI.
[0.1.1] - 2026-07-15¶
Added¶
JobContext— a worker that names actxparameter is handed the delivery'sjob_id,queue,attempt,max_attemptsandlast_error(the previous attempt's failure), plusis_retry/is_last_attempt. Opt-in and additive: a worker withoutctxis unchanged. Resolves alongsideDepends(...), 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 seeslast_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 viaws=, 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=Truebrokerless 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 orcron=), 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@handleendpoints (/mcp; batch + 202 notifications)istos.http.asgi.lifespan/Istos.serving(serve_http=…)— co-host the mesh inside FastAPI/StarletteGET /livez,/readyz,/metricswhenhttp_portis set.istos/capabilities+export_capabilities()(includeschannel+ optionalwebsocket)- AsyncAPI /
serve_docs()includes@streamand@channel - Request envelope on attachments (
tok/cid/tp) require_auth=True(raisesIstosSecurityErrorwithout an authorizer)JWTAuthorizer,require_roles(istos[jwt])authorizer=on subscriberstoken=on@query,query_once,publish_once,stream_query,open_channelRateLimitMiddleware— token-bucket rate limits- Middleware wraps
@handle,@stream,@channel,@subscribe(stream/channel once per session) - Structured logging (
log_level,json_logs) IstosError/ErrorResponse/@exception_handlerIstosTestClient(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
@channelfabric keys)
Extras¶
istos[all]= redis + sqlalchemy + otel + s3 + jwt