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.
Unreleased¶
0.17.0 - 2026-11-01¶
Durable chats without lasting memory. ORRERY_LONG_TERM_MEMORY=false lets a deployment keep sessions in PostgreSQL while carrying nothing from one session to the next: no memory service, no load_memory, no ops journal. Until now only the learning sandbox did this, and it refuses Studio, so a lab's Studio instance (or any shared instance) can now use DATABASE_URL safely. A missed wiring stops startup instead of remembering. Turning it off does not delete memory or journal data stored earlier.
Added¶
ORRERY_LONG_TERM_MEMORY=false: durable chats without lasting memory. A deployment can now keep sessions in PostgreSQL while carrying nothing from one session to the next: no memory service, noMemoryPlugin, noload_memory, and no ops journal (its notes live inuser:state). The builders leave them out, an admin catalog edit holding a journal tool is refused before it is saved, andAgentGatewayrefuses a memory service, aMemoryPluginor a tree (graphWorkflownodes included) that still reaches a recall orcross_sessiontool, including a wrapped Runner. Until now only the learning sandbox did this, and it refuses Studio. Studio's tool catalogue and the console's Agents tab stop offeringcross_sessiontools; batch triage keeps its own journal step. Data stored earlier is not deleted. On by default.
0.16.0 - 2026-10-31¶
A coach for the learning sandbox. In learning mode the coordinator gets lab_coach, which investigates with read-only Kafka and monitoring tools and answers at the learner's hint level, the one the server stamps, so a learner cannot type their way up the ladder. It exists only in a learning-mode process; no configuration file loads it. The design for the learner debrief (AEP-022) now also covers launch URLs behind a lab's own gate.
Added¶
- The lab coach in the learning sandbox (AEP-032 phase 3). In learning mode the coordinator gets
lab_coach, which investigates with read-only Kafka and monitoring tools and answers with a question or hint at the learner's hint level (at level 3, the answer with its evidence): it reads the level the server stamps (the same line the root gets), not a hint count in its own replies, so a learner cannot type their way up the ladder. It is built only in an activated process andlab_coachis a reserved name; tools that are not available here (a disabled specialist, Loki without a URL) are skipped, and a guarded one stops startup. The answer guard stays the safety layer.
Documentation¶
- AEP-022: the debrief's launch URL behind a lab's own gate. The attempt token goes in the lab's launch URL fragment, which an HTTP 3xx redirect without a fragment of its own carries over; the design lists the three ways a gate loses it.
0.15.0 - 2026-10-31¶
Role-gated deployments, a hint ladder, and a quieter image. A deployment shared through one identity provider can admit only accounts holding a listed role, and send the rest to a page of its choice. Learners in the sandbox can ask for a hint, one level at a time, with each one recorded; the sandbox now keeps no long-term memory, so a lab can use a durable session store. Guarded MCP servers no longer probe Google mTLS, and the image no longer fetches LiteLLM's tables from GitHub at startup. Note for operators: an unusable file in ORRERY_SECRETS_DIR (unreadable, not UTF-8, over 64 KiB) now stops startup instead of being skipped.
Added¶
ORRERY_REQUIRED_ROLES: admit only accounts holding a listed role. A verified token used to be admitted whatever its roles, with a roleless one served as a viewer. On a deployment shared through one identity provider (a practice lab, say), every account could read it. With the rule set, a token carrying none of the listed role names (raw IdP names fromJWT_ROLE_CLAIM, case-insensitive) gets 403access_deniedon every authenticated route, console API and A2A alike. Startup fails if it is set with auth off.ORRERY_CONSOLE_ACCESS_DENIED_URL. WithORRERY_REQUIRED_ROLESset, the console checks/mebefore loading anything, and a refused person sees a "you don't have access to this deployment" page, linking to this https URL, instead of a console whose every call fails.- A hint ladder in the learning sandbox (AEP-032 phase 2). Each learner has a level per scenario: 0 orient, 1 narrow, 2 point at the tool, 3 reveal.
- The console's Take a hint button calls
POST /learning/hint. It accepts verified people only, is rate-limited per learner (ORRERY_LEARNING_HINT_RATE_LIMIT, default3/minute), raises the level one step at a time and never lowers it. - Each step writes a
learning_hintaudit entry, which a lab can deduct hint costs from, andorrery_learning_hints_total{level}. - The level reaches the coordinator as one line of its instruction, and the answer guard stops withholding values at level 3.
- Levels are per process: after a restart every learner is back at the manifest's
default_disclosure. A level is stamped into each turn by the server, and a tool that writes it is reverted.
Changed¶
- The learning sandbox keeps no long-term memory. With
ORRERY_LEARNING_MODEthe HTTP server builds no memory service and noMemoryPlugin, and the coordinator has noload_memorytool. The gateway refuses all three at startup. Memory is keyed by the learner, not the scenario, so recall would hand back an earlier attempt's evidence and answers, and withDATABASE_URLit would outlive every reset of the lab. The ops journal, whose notes and preferences live inuser:state, is left out of the coordinator for the same reason, and the gateway refuses any agent tree reaching a tool marked with the newcross_sessionmarker. Sessions still persist, so a learning deployment can now use PostgreSQL (see Single node (Docker Compose) in the deployment guide).
Fixed¶
- An unusable file in
ORRERY_SECRETS_DIRstops startup: one that is unreadable, not UTF-8, over 64 KiB or holds a NUL byte. It used to be logged and skipped, so an unreadableDATABASE_URLfile (wrong owner: the image runs as uid 1000) started the server on in-memory sessions, against the database fail-fast policy. The error names the file and the uid. - Guarded MCP servers no longer try Google mTLS. ADK's MCP session manager calls
google.auth.default()on every HTTP/SSE connection to set up client-certificate mTLS. Without Google credentials that loggedFailed to configure mTLS using AsyncAuthorizedSessionon each connection. With them, a successful probe would have replaced the guarded HTTP client (origin pin, address checks, no redirects) with ADK's own, and sent a Google token to the MCP server.GuardedMcpToolsetturns the probe off; TLS comes from the server's config. -
The image uses LiteLLM's bundled tables.
LITELLM_LOCAL_MODEL_COST_MAPandLITELLM_LOCAL_ANTHROPIC_BETA_HEADERSare set by default, so startup no longer fetches fromraw.githubusercontent.com, a request that fails behind an egress allowlist. -
docker_compose_statuskeeps auxiliary containers out of a project's state counts. With every container in one compose project, a crash-loopinglab-*container showed as an untaggedrestarting: 1in the summary. A project mixing both now reportsstatesfor its platform containers only, plusauxiliary_statesandauxiliary_containers; a project that is wholly auxiliary keepsstatesandscope: auxiliary.containersstays the total, and the triage prompts name the new key. Output is unchanged whenORRERY_AUXILIARY_RESOURCESis unset.
0.14.0 - 2026-10-30¶
Per-environment consoles, platform-aware triage, and a learning sandbox. A console now says which deployment it is (title, environment badge, accent) and hides features its server does not run. Operators can describe the platform to the agent and mark auxiliary containers that triage reports without letting them change the verdict. Practice labs can run Orrery in a sandbox that withholds an exercise's answers (AEP-032 phases 0–1, off by default). The Docker specialist's compose status now works against a remote daemon, its logs include stderr, and inspect_container redacts credentials in env values; a deployment can also refuse tokens from clients it does not list.
Added¶
- Console branding per environment:
ORRERY_CONSOLE_TITLE,ORRERY_CONSOLE_ENVIRONMENT(production/staging/demo/lab) andORRERY_CONSOLE_ACCENT(ten presets) are served in/console-config.js. The title replaces "Orrery Console" in the sidebar, sign-in page and tab; the environment shows as a badge, leads the tab title, and is named on the chat approval card ("Approve on PRODUCTION"); staging, demo and lab also get a banner. Invalid values stop startup. - Operator platform context and triage scope:
ORRERY_PLATFORM_CONTEXT_FILE(Markdown, at most 16 KiB, read once at startup) is added to the coordinator's and the triage agents' instructions.ORRERY_AUXILIARY_RESOURCES(name globs) marks containers and compose projects that may be down without the platform being degraded: the Docker tools tag themscope: auxiliary, and triage reports them separately without letting them change the verdict. A container labelorrery.scope=platformpins one back in; no label can make a container auxiliary, since images pass their labels on. The coordinator's examples now name a system the deployment runs instead of assuming Kafka. ORRERY_REJECT_UNLISTED_CLIENTS(opt-in): a verified token whose client (azp, elseclient_id) is in neitherORRERY_HUMAN_CLIENTSnorORRERY_SERVICE_CLIENTSgets 401, the same answer as an invalid token, on the console API and A2A, instead of becoming a viewer service principal. Setting it withoutORRERY_HUMAN_CLIENTSstops startup.- Learning sandbox mode (AEP-032 phase 1), off by default:
ORRERY_LEARNING_MODE=truewithORRERY_SCENARIO_MANIFESTmakes the HTTP server withhold an exercise's answers.AnswerGuardPluginchecks every final text response of the coordinator against the manifest's targets (digits with or without one consistent separator and with trailing zero decimals,k/thousand/Mroundings, English words, strings) and replaces a match with a nudge in place, keeping tool calls; the stored transcript holds the nudge, andlearning_answer_withheldaudits the target id only./chat/streamsends nodeltaframes,/session/{id}/triagereturns the severity only,/session/{id}/activitydrops the argument summary, and/healthzreportsmodeandscenario_id. Startup is refused unless auth,ORRERY_HUMAN_CLIENTSand L2 are set and A2A, Studio, admin editing, multi-replica, several workers, span content capture and DEBUG logging and message-content capture in traces are off, checked again once the agent tree is built. Every other entry point (the bots, the CLI,adk web, the batch triage, a specialist on its own) refuses the flag. ORRERY_DISABLED_TOOLS(AEP-032 phase 0): take single tools out of running specialists by qualified name (observability.get_alert_groups). The specialist's agent is cloned without the tool before anything is built from it, so the coordinator, declarative agents, the admin catalog and the Studio never see it, andGuardrailsPluginrefuses a call to it by function identity with the neutralBLOCKEDstatus. Another specialist sharing the function loses it too. It only removes: an unknown name, a tool of a specialist that is not running, or a tool the specialist's health check, remediation, journal or self-test uses stops startup. Unset, nothing changes.- Scenario manifest loader (AEP-032 phase 0, not wired in yet):
orrery_core.learning.manifestvalidates contract v1 strictly in JSON mode (exact types, no NaN, no field that can widen access, at least one target,default_disclosureat most 2, no value under 100 or shorter than 4 characters, none inside an id, unit or descriptor), reads regular files only, never repeats a value in an error, re-checks the file'sstat()(and its content while it is young) on every read, keeps the last valid manifest when a reload fails (orrery_learning_manifest_errors_total, registered only when used), and keeps values that left the file guarded forORRERY_LEARNING_GRACE_SECONDS. Nothing imports the learning package in a normal process; a test pins that.
Fixed¶
- The console no longer offers a Studio the server does not run. Every admin saw "Studio" in the navigation, and with
ORRERY_STUDIO_ENABLEDoff it opened a "not enabled here" page. The entry now follows/me'sstudio_available(mounted, and the caller a human admin), and the Studio view cannot be opened without it. The Agents page no longer suggestsORRERY_ADMIN_EDITINGwhen editing is off. docker_compose_statusworks in the image. It randocker compose, which the image does not ship (it failed as "unknown flag: --format"), with a hardcodeddocker-compose.ymlpath on the agent's own filesystem. It now reads the compose labels the daemon keeps on each container through the plain CLI (docker ps --all --filter label=com.docker.compose.project…): no argument lists every project with its containers by state,project=NAMElists that project's containers, stopped ones included, and an unknown project is an error. No compose file or plugin is needed, so it works against a remoteDOCKER_HOST. Theproject_dirargument is gone.get_container_logsincludes the container's stderr.docker logsreplays a container's stderr on its own stderr, and the tool read stdout only, so a service that logs only to stderr (nginx's error log, Prometheus, most Go and Java servers) showed no lines. Both streams are now read through one pipe, interleaved as the CLI writes them.
Security¶
inspect_containerscans env values, not only names. A variable likeDATABASE_URL=postgres://user:pass@dbpassed the tool's own filter, which matched names only (the globalPIIRedactionPluginalready masked the URL credentials, but not withORRERY_PII_REDACTION=false). Values now go through the sameredact_textpatterns, plus Go's scheme-lessuser:pass@tcp(…)DSN form;passwd/dsnnames, andpass/pwdas a name word (DB_PASS,DB_PWD; notPWDitself), are redacted whole.
0.13.0 - 2026-10-29¶
Console: room to work. The navigation and the Studio's side panel fold away, and the Studio lays itself out by the space it actually has, so a laptop-sized screen gets a usable canvas.
Added¶
- Collapsible navigation. From the
lgbreakpoint up, the console's left rail folds to its icons (its toggle, or Ctrl/Cmd+B) and stays folded across reloads. Studio and Agents badges become dots; labels remain for screen readers. - Collapsible Studio panel. The Inspect/Run/A2A/MCP/History panel folds to a strip of vertical tabs. Picking a node, adding or removing one, starting a run or a save refused for problems opens it again.
Changed¶
- The Studio sizes its toolbar, node palette and panel by its own width (container queries) rather than the window's, so folding either rail brings the labels back. The page header row is hidden on the Studio from
lgup (it stays as the page's heading for screen readers); Registry has its own icon, and every toolbar button a tooltip.
Fixed¶
- The Studio's "Compose a run" card was sized to the window and spilled over the side panel on mid-sized screens; it now fits the canvas.
- When the canvas flipped between vertical and horizontal layout, edges kept leaving from the nodes' old handle positions.
0.12.0 - 2026-10-28¶
Orrery Studio: compositions you keep. The console saves and opens versioned compositions, replays your stored runs (or follows one still going after a reload), and says outside the Studio when a run waits for you. The registry's deployment-bound entries can only be tightened by an edit.
Upgrade notes: - A stored registry version that loosens an entry the deployment binds (see Security) no longer validates, so it is not served: the newest version that still does is (the file, when none does). Save over it to fix it. - studio_registry_updated audit lines carry the touched entries and the documents' digests instead of both whole documents.
Added¶
- Studio console: save, open and replay. Save (Ctrl/Cmd+S) keeps the canvas on the server as a versioned composition shared by the Studio's admins, Open lists them (latest or any version, delete), and a save that another admin's save overtook is reported as a conflict, never written over it. A run of an unchanged saved version is filed under it. The History tab lists your runs on the server: Replay rebuilds a finished one from its events, with its composition on the canvas, and Follow picks a run still going back up after a reload.
- Console: a Studio run that needs you says so everywhere. While a live run waits for an approval or a remote's question, the Studio's Run tab shows N waiting, the sidebar's Studio entry N waiting for you, and the browser tab's title starts with (N) Approval needed, so an approval is not left to expire while its person is in the chat or another tab.
Fixed¶
- Agents page: a save that went through is reported as saved. When the catalog saved but reading the agents back failed, the page said "could not save" and carried the draft onto the version that already held it; a save that threw also left the button stuck on Saving.
- Studio console: the human-wait meter counts overlapping waits once (the server pauses the run's timeout once for them), so the compute meter no longer under-reports with parallel approvals.
Security¶
- Studio registry editing: a bound entry can only be tightened. An entry that sends the deployment's secret, is
private_networkor targets loopback already had to keep the file's endpoint; an edit could still drop itseffect: acting(so calls ran without the owner confirming each one), add skills or tools, move tools intoread_only, drop pins or turntrust_annotationson. Each of those now needs the file. - Runs and
/studio/validateread the newest stored registry first, so an entry another replica just removed or tightened is never used meanwhile (best effort: with the store down, a run goes on, after 2 s at most, with the registry already served). - Dependencies:
nltk3.10.4 (GHSA-j456-xh4h-cpf2).
0.11.0 - 2026-10-27¶
Orrery Studio, P0c: a person in the loop. A human_approval node stops a run until its owner approves or rejects the exact text; a remote A2A agent's question goes to the owner, never to the model; a remote that changes things (effect: acting) is called only once its owner confirms the exact request; and admins can edit the registry from the console, versioned and audited (AEP-031).
Upgrade notes: - Nothing changes for chat or for a Studio left as it was: each new behaviour is opt-in (ORRERY_STUDIO_ACTING_REMOTES, ORRERY_STUDIO_REGISTRY_EDITING) or comes from a node or entry you add. ORRERY_STUDIO_APPROVAL_TIMEOUT_SECONDS (default 900) bounds how long an approval waits. - A registry with an effect: acting entry refuses to start unless ORRERY_STUDIO_ACTING_REMOTES=true. - With a database and registry editing on, a studio_registry_versions table is created at startup; no existing table changes. Without one, edits live in the process and a restart goes back to the file.
Added¶
- Studio: registry editing (AEP-031 P0c). With
ORRERY_STUDIO_REGISTRY_EDITING=true, the Studio's admins edit the registry from the console (Registry), validated exactly as the file is at startup. Every save names the version it was edited from (a stale one is a 409), is kept with who, when, a note and what it changed (a History tab), is audited with before and after, and applies to the next run. An entry that sends a secret, is private or targets loopback must keep an endpoint the file declares (checked on save and whenever a stored version is served); pins are keyed by endpoint, so a changed endpoint is pinned afresh. Replicas follow the newest stored version that validates. Off, the console shows the registry read-only. - Studio: remotes that act (AEP-031 P0c). A registry A2A entry may be
effect: actingwhen the deployment setsORRERY_STUDIO_ACTING_REMOTES=true(otherwise startup fails). The run stays at L2; each call to an acting remote waits for the run's owner to confirm the exact request (an approval of kindaction, against its digest), and a refusal or expiry sends nothing. The console shows a Confirm and send / Don't send card and marks acting skills in the tool picker. - Studio: human approval (AEP-031 P0c). A
human_approvalnode stops a run until the person who started it approves or rejects the text it receives, throughGET/POST /studio/runs/{id}/approvals[/{aid}]. The owner sees the text exactly as the next node gets it (held in memory only) and approves its SHA-256 (another digest is a 409; text over 128 KiB can only be rejected); a decision is accepted once; an approval undecided afterORRERY_STUDIO_APPROVAL_TIMEOUT_SECONDS(900, at most 86400) expires and the run endsrejected. The run's deadline is paused while it waits. Arejectededge sends the reviewer's comment back (a loop, capped like any other; a rejection past the cap ends the runrejected); without one, the run endsrejected. New eventsapproval.requestedandapproval.decided, arejectedoutcome,pending_approvalsin the run summary, and an audit line per decision. - Studio: a remote's question goes to a person. When a remote A2A agent in a Studio run needs input (
input-required), the run's owner is asked (an approval of kindinput, with the question) and their answer is sent on the same task; the model gets only the final reply and is told never to answer for the person. At most 3 answers per call, each counted againstmax_remote_calls; declining or letting it expire sends nothing. Nobody is asked a question whose answer could not be sent (no remote call left), and the console marks the question as written by an external agent. - Studio console: an approval card in the Run tab (the text, a comment, the time left; Approve is off until the text is read and its digest checked in the browser), awaiting you on the node, a timeout meter that leaves out the wait, label-aware edges out of an approval, and a human-review template (
deploy/studio-demo/specs/human-review.json).
Fixed¶
- Studio console: what the server runs. The console assumed every server ran P0a only, so routers, parallel/join, supervisors, loops and per-node models were shown as coming in P0b on a server that ran them. It now reads
capabilitiesfromGET /studio/schema: the palette, the pattern gallery and the client checks follow the server, and the model field listsORRERY_STUDIO_MODELS(a model outside it is flagged as the server would refuse it). A server that does not say is still treated as P0a.
Security¶
- Tracing: an inbound
tracestateover 512 characters is dropped beforeopentelemetry-apiparses it (its parser is quadratic in a run of blanks, and runs before auth).opentelemetry-apistays at 1.44 because google-adk caps it there. - Image: the base image's
pipandensurepipwheels are removed (CVE-2026-13346), and a rebuild picks up Debian's OpenSSL fix (CVE-2026-84782,3.0.22-1~deb12u2) that the published 0.10.0 lacks.
Changed¶
- google-adk 2.13: its
JSON_SCHEMA_FOR_FUNC_DECLfeature is stable, so the startupUserWarningabout it is gone. 2.13 traces with the OTel GenAI semantic conventions by default, where span content followsOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT(off by default) rather thanADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS; Orrery warns when that switch puts content on spans.
0.10.0 - 2026-10-25¶
Orrery Studio (preview, admins only, off by default): compose LLM agents, remote A2A agents and MCP tools on a canvas in the console, in named patterns (pipeline, router, critic loop, fan-out/fan-in, supervisor, debate, map-reduce, plan-and-execute), run them apart from the live agents, and watch every node, route, A2A task and MCP call as it happens (AEP-031). Orrery can now call remote A2A agents, always as guarded tools that see only the request (ADR-008).
Upgrade notes: - Nothing changes until ORRERY_STUDIO_ENABLED=true, which also requires ORRERY_HUMAN_CLIENTS (the server refuses to start without it). See integrations/studio.md for the registry and the other settings. - Behaviour change for every MCP server over HTTP, chat's ORRERY_MCP_CONFIG included: responses must be uncompressed (Orrery sends Accept-Encoding: identity and refuses an encoded body), and a single response, or SSE event, over ORRERY_MCP_MAX_RESPONSE_BYTES (default 8 MiB) is refused. - With a database, four new studio_* tables are created at startup; no existing table changes.
Added¶
- Orrery Studio, P0a (preview, API only; AEP-031). With
ORRERY_STUDIO_ENABLED=true, human admins run a composition: a pipeline ofllm_agentnodes using registered MCP tools (mcp:<entry>.<tool>), remote A2A agents (a2a:<entry>.<skill>) and read-only catalogue tools (catalog:<name>). Each run gets its own gateway and plugins, at L2, in memory, with a whole-run ledger (tokens, tool calls, delegations, remote calls, deadline), and streams typed trace events over SSE (run.*,node.*,mcp.call,a2a.message,budget.hit; no content unlessORRERY_STUDIO_CAPTURE_CONTENT=true, then redacted and cut to 2 KB). Routes:POST /studio/runs,GET /studio/runs/{id},GET /studio/runs/{id}/events,POST /studio/runs/{id}/cancel,GET /studio/schema,GET /studio/registry. Endpoints come only from theORRERY_STUDIO_REGISTRYYAML; secrets only fromORRERY_STUDIO_SECRET_*. Startup fails with the Studio on andORRERY_HUMAN_CLIENTSunset; a service token with the admin role gets 403. Memory is bounded per person (ORRERY_STUDIO_MAX_RUNS_PER_USER) and per run (ORRERY_STUDIO_MAX_CONTENT_BYTES_PER_RUN); captured content honoursORRERY_REDACT_IPS. Validation refuses tools that would reach a node's model under one name (tool_name_collision) or over 64 characters (tool_name_too_long). See Studio. - Orrery Studio console, P1 (preview, admins only). A Studio view in the web console, loaded on first open: a React Flow canvas with a palette (kinds the server cannot run yet shown as coming in P0b/P0c), drag, connect and delete, a per-node inspector with tools from the registry, live validation against
/studio/schemawith server 422s mapped onto nodes and fields, keyboard access, pattern templates (pipeline and release-notes run; the rest marked needs P0b) and a deterministic tidy layout. A run panel plays the SSE stream onto the graph (reconnecting withLast-Event-ID), with budgets, a timeline, totals, cancel and a clearbudget.hit. A2A and MCP inspectors, in-tab run history with a side-by-side compare, and JSON export and import. See Studio. - Studio egress proxy (
ORRERY_STUDIO_EGRESS_PROXY). Where the only way out is a forward proxy, the guarded MCP and A2A transports send public registry destinations through it withCONNECT, TLS end to end and verified by Orrery, origins still checked on the hostname. Loopback, private literal addresses andORRERY_A2A_PRIVATE_ALLOWhosts always go direct; caps, identity encoding and timeouts still apply. Never read fromHTTPS_PROXY; the proxy must enforce its own destination allowlist (ADR-008). - The Studio demo's writer and certificate job ship in the image:
python -m orrery_core.studio.demo.writerandpython -m orrery_core.studio.demo.certs(moved fromdeploy/studio-demo/); the compose profile mounts only the registry. - Orrery Studio, P0b: patterns and storage (AEP-031). Compositions now route (
output.labelsandwhenedges, or arouternode;route.decisionevents), loop (a back edge withmax_iterationsof at most 5;loop.iterationevents; a cycle without a cap is refused), fan out and in (parallelandjoin, 2–6 concurrent branches) and delegate (supervisornodes whose children are agent tools, at most 3 deep, counted as delegations). A node may name itsmodelfromORRERY_STUDIO_MODELS, with no fallback chain. With PostgreSQL, compositions are stored and versioned (/studio/compositions, 409 on a stale version), and runs and their events are kept forORRERY_STUDIO_RUN_RETENTION_DAYS(14), readable and replayable from any replica (GET /studio/runs); cancel stays on the running replica (409 elsewhere). New for the console:POST /studio/validate,capabilitiesandbudget_ceilingsin/studio/schema, MCP tool schemas in/studio/registry, andstudio_availablein/me. Tested templates for eight patterns are indeploy/studio-demo/specs/; the demo spec moved there ascritic-loop.json.spec_versionstays 1. A loop may not contain a parallel section (loop_around_parallel), and a branch may not fan out again; nested loops count per entry, and a loop that stops at its cap ends with the last draft; loops nest at most 2 deep (caps multiply). Allowlisted models are built at startup (a bad name or missing key fails it), and Gemini safety filters follow each agent's model rather thanMODEL_PROVIDER. Run persistence writes events and the run row in one transaction and retries with backoff; Studio statements on PostgreSQL time out after 10 s, and a finished run whose writer is stuck for 5 minutes is dropped from memory with a warning;?resolve=trueon the registry and composition writes are rate-limited, and following runs on other replicas is capped and backs off. - Calling remote A2A agents (ADR-008).
GuardedRemoteA2aToolsends one text request to a registered remote and returns its reply as a tool result, so the safety screen, PII redaction, RBAC, autonomy, audit and budgets apply. The remote never sees the session. Cards are pinned by a JCS SHA-256 of their trust fields;actingremotes are refused for now. The caller's token is never forwarded. - A network guard for
httpxshared with the MCP one: a set of allowed origins, public addresses only unlessprivate_networkandORRERY_A2A_PRIVATE_ALLOW, link-local and metadata always refused, no redirects or proxies, bytes capped while streaming, compressed responses refused (Accept-Encoding: identity), and an allowlisted host never reaches loopback.ORRERY_A2A_CA_FILEadds one CA bundle for an internal CA, for A2A and Studio MCP entries alike. studio-democompose profile: a plaina2a-sdkwriter agent over https with a generated internal CA (renewed near expiry; Orrery mounts only the CA, not the writer's key), an example registry and the research → writer → critic spec (deploy/studio-demo/).make dev-token CLIENT=<azp>(andscripts/dev_token.py --client) mints a token for a named client, so it can count as a person underORRERY_HUMAN_CLIENTS.
Changed¶
DelegationGuardPluginandProgressPlugincount a tool markeddelegates_remotelyas a delegation, like anAgentTool.PIIRedactionPluginalso redacts email addresses in results of tools markedredact_contact_details(remote A2A replies). Other tools are unchanged.default_plugins()shares one daily token ledger per process (per storage URI), so building plugins again does not resetORRERY_DAILY_TOKEN_BUDGET_PER_USER. It takes an optionaltoken_ledger.is_machine_session()also covers Studio sessions (s-prefix): they are never written to long-term memory.- MCP over HTTP refuses compressed responses (
Accept-Encoding: identity) and caps each response body, or each SSE event, atORRERY_MCP_MAX_RESPONSE_BYTES(default 8 MiB) while it streams.
0.9.1 - 2026-10-24¶
A fix release. rollback_deployment actually rolls back (it reported success while changing nothing), the console no longer scrolls past the app on the Agents tab, and two small follow-ups from 0.9.0's review. No upgrade steps; Helm releases whose cluster-scoped RBAC names were already truncated get new names on upgrade (Helm replaces them).
Fixed¶
rollback_deploymentactually rolls back. It set therollback-toannotation, whichapps/v1ignores, and reported success while nothing changed. It now does whatkubectl rollout undodoes: copies the previous revision's pod template back from its ReplicaSet, refused if the deployment changed since it was read. A paused deployment, or one with no previous revision, is refused with nothing changed.- Console: the page no longer scrolls past the app on the Agents tab, leaving a blank band under it. A visually hidden label escaped its scrolling pane and stretched the page; the panes now contain what they hold.
- Helm: cluster-scoped RBAC names that would be over 50 characters end with a hash of the full name, so two long namespaces no longer collide.
- Google Chat: a turn stopped by the gateway deadline logs that deadline, not
None.
0.9.0 - 2026-10-24¶
A console and hardening release. The Agents tab shows what is running (versions, models, gates, catalog history) and makes composing agents quicker. Every turn now has a deadline, so a wedged turn can no longer hold its session forever, and the Helm chart grants the agent's ServiceAccount exactly the Kubernetes access its tools use. Slack approval cards are actually posted, turns in one session run one at a time, the patch tools change only what they are for, and redaction covers credentials in URLs and Google API keys.
Upgrade notes.
- The Helm chart now creates a ClusterRole and ClusterRoleBinding by default (
rbac.create: true,profile: read), which needs cluster-admin to install. Setrbac.create: falseto keep managing RBAC yourself. It refuses to bind the namespace'sdefaultServiceAccount: withserviceAccount.create: false, setserviceAccount.name. - Turns stop after 15 minutes (
ORRERY_TURN_TIMEOUT_SECONDS,0disables). patch_deploymentandpatch_statefulsetrefuse fields outside their allowlist (security contexts, volumes, host access, scheduling, command, …); change those through a reviewed manifest.
Added¶
- The console's Agents tab shows what is running. A deployment panel gives the Orrery, ADK and Python versions, the coordinator's model (each link of a fallback chain), the autonomy level, the env's specialist selection, the catalog version with who changed it and when, and how long the process has been up. Each agent card shows its entry-point key, package and version, its model, and whether an approval gate guards its tools (a guarded agent without one is flagged).
GET /admin/agentsreturns the same fields. -
Editing agents is quicker. A "New agent" button in the page header and on the composed-agents grid; "Compose from these tools" on a specialist; Duplicate on a composed agent; All on / All off; a filter for on and off agents. The editor validates the name as you type (the server's rule and name clashes), selects a specialist's tools as a group, lists the chosen tools as removable chips, warns how many need approval or are destructive, offers an instruction template, and keeps with Ctrl/Cmd+Enter. Cards with a pending change are marked, and the save bar lists what the save will change.
-
ORRERY_TURN_TIMEOUT_SECONDS(default 900,0disables) bounds every turn on every surface. A turn queued behind a wedged one in its session would otherwise wait forever. A stopped turn is reported as possibly having acted: HTTP 504 on/chat, a non-retryableerroron/chat/stream, a message in Slack and the CLI, a failed A2A task, and Google Chat's notice of what the turn already changed. - The Helm chart grants the agent's ServiceAccount Kubernetes access (
rbac):read(default) oroperate, cluster-wide or per namespace, Strimzi and ECK resources, optional impersonation rules and extra rules. Rules follow the API calls the tools make; secrets are never granted. Upgrade note: the chart now creates a ClusterRole and binding by default, which needs cluster-admin to install; setrbac.create: falseif you manage RBAC. It refuses to bind the namespace'sdefaultServiceAccount: withserviceAccount.create: false, setserviceAccount.name.
Fixed¶
- Slack: approval cards are actually posted. The gate called the Bolt
AsyncAppclient without awaiting it, so no card ever appeared while the model told the user one had been sent, and no Slack-gated action could be approved. A failed post now says so instead. - Slack: a guarded call's approval card always goes to the conversation that raised it. The target was one process-wide value each message overwrote, so with two turns in flight a card, and the scope its approval is matched in, could land in the other turn's channel. With no conversation the call is now refused rather than posted anywhere.
- Turns in one session run one at a time. Two at once (a double send, two messages in a shared thread) rewrote each other's actor and confirmation decision, so one turn could clear the approval the other was about to use. Per process; turns in different sessions still run in parallel. The Google Chat bot's turn budget starts when its turn does, not while it waits.
- Reconfiguring the blocking-tool executor shuts the replaced pool down instead of leaving its threads alive.
Security¶
patch_deploymentandpatch_statefulsetchange only what they are for: container image, pull policy, resources and env, replicas, rollout settings, labels and annotations. Anything else (security contexts, volumes, host access, scheduling, the service account, command, anullor$patchdeletion, the legacy AppArmor and seccomp template annotations) is refused; it belongs in a reviewed manifest, not a patch an approver skims mid-incident.- Secret redaction (tool results and memory) also covers credentials in a URL (
postgres://user:pass@…,redis://:pass@…), keeping the scheme and host, and Google API keys (AIza…). filelockis overridden to 4.2.1+ for CVE-2026-112940; litellm still caps it below 4.
[0.8.0] - 2026-10-24¶
A cost and hygiene release. Daily token budgets cap what one caller, and the whole deployment, may spend on model calls. The audit log records a tool result's status and size rather than its contents. Agents calling the console are kept out of their user's conversations and memory, and A2A requests and tasks are bounded. Boolean environment variables are parsed strictly, so a typo can no longer turn a safety control off. The console signs out cleanly when the identity provider ends the session, and the observability specialist stops offering Loki where there is none.
Upgrade notes.
LOKI_URLno longer defaults tohttp://localhost:3100: set it to keep the Loki tools (the shipped compose files and.env.exampledo). Unset, the observability specialist is not offered them at all, and they leave the tool catalogue: a declarative agent (ORRERY_AGENTS_CONFIG) that names one fails startup withunknown tool, and a stored catalog that names one no longer builds, so it is skipped whole at startup. SetLOKI_URLor remove the tool.- Audit entries now record a tool result's status and size, not its contents (
ORRERY_AUDIT_RESPONSES=summary). Set it tofullfor the previous behaviour. - Boolean environment variables (
AUTH_ENABLED,ORRERY_SAFETY_SCREEN,ORRERY_PII_REDACTION,ORRERY_A2A_ENABLED, ...) now accept1/true/yes/onand0/false/no/off(any case, surrounding spaces ignored), and any other value fails startup naming the variable. Before, an unrecognised value read asfalse. A blankAUTH_ENABLED=now means the default (on); it used to turn authentication off. - With
ORRERY_HUMAN_CLIENTSset, an agent or service calling the console's API starts fresh conversations: the ones it held before this release are visible only to the person whose subject they are stored under. - A2A requests over 128 KiB (
ORRERY_A2A_MAX_BODY_BYTES) or messages over 8192 characters (ORRERY_A2A_MAX_MESSAGE_CHARS) are now refused; raise the limits if a client legitimately sends more. orrery-coreno longer depends ongoogle-adk[eval]. A project that relied on it for ADK's evaluation package (or its pandas/aiplatform) must add it.
Added¶
- Daily token budgets.
ORRERY_DAILY_TOKEN_BUDGET_PER_USERcaps the model tokens one caller may spend per UTC day, andORRERY_DAILY_TOKEN_BUDGETcaps the whole deployment. Both are off by default. Once one is spent, model calls are answered with a short "usage limit reached" reply instead of being sent. A request rate limit alone let one invited user, or a leaked token, spend all day. Counters shareORRERY_RATE_LIMIT_STORAGE_URI(Redis for several replicas); refusals are inorrery_token_budget_refusals_total, and a failing store (the call goes ahead) inorrery_token_budget_store_errors_total.
Security¶
- The shipped Keycloak realm no longer allows the password grant on the public
orrery-consoleclient (directAccessGrantsEnabled: false). Nothing used it, and it let anyone holding a password skip PKCE and the login page. An existing Keycloak keeps its imported realm; turn it off there under the client's capability config. - The audit log no longer copies tool results. A
get_active_alertsentry carried the whole alert payload next to the caller's id, so infrastructure data and the people it names ended up in plaintext logs that outlive any session.ORRERY_AUDIT_RESPONSES(summarydefault,full,none). - An agent calling the console's API (a delegated or service principal) sees and continues only the conversations created for it. Sessions are stored under the user's subject, so before, an agent acting for a user could list, read, delete and continue that user's conversations and every other agent's, and spend an approval raised in one of them. A person still sees everything stored under their subject, including what their agents did.
- Long-term memory no longer mixes people and agents. Memory is keyed by the user, so a delegated agent's
load_memorycould recall every conversation its user ever had, and its own turns (model output, possibly steered by what it read) were saved as the user's history. A machine principal's sessions (consolem-*, A2Aa2a-*) are no longer saved, and recall from them returns nothing, including when the person continues one of their agent's threads. Rows saved before this release stay; to drop the A2A ones, deleteorrery_memory_eventsrows whosesession_idstarts witha2a-. AUTH_ENABLED=on(or"true "with a stray space) no longer turns authentication off; the same fail-open applied to every default-on flag, e.g. a typo inORRERY_SAFETY_SCREENdisabled the injection screen. Every boolean variable is now read by one parser (orrery_core.env).- A2A requests are bounded: a body over
ORRERY_A2A_MAX_BODY_BYTES(128 KiB) is refused with413before the SDK parses it, and a message overORRERY_A2A_MAX_MESSAGE_CHARS(8192, the console's bound) is rejected; a new task keeps none of it in its history (a message resuming a waiting task is still recorded by the SDK, within the body cap). - The A2A task store forgets tasks idle for
ORRERY_A2A_TASK_IDLE_TTL_SECONDS(1 h) and keeps at mostORRERY_A2A_MAX_TASKS(10 000). It used to keep every task, history and reply for the life of the process.
Fixed¶
- The console signs out cleanly when the identity provider ends the session. When Keycloak refused the silent refresh ("Session not active", e.g. after a laptop slept past the SSO idle timeout), the console kept the expired access token and every request failed with 401 until a reload. It now shows the sign-in panel with "Your sign-in session ended". A network failure during a refresh keeps the session, and an expired token waits 10 s for a renew already in flight (waking from sleep fires both at once) before the session ends.
- The observability specialist no longer offers Loki tools without Loki. With no
LOKI_URLit advertised four tools that could only fail, the model spent turns on them, and each failure logged a traceback at ERROR. Its connection failures (Prometheus, Loki, Alertmanager) are now one WARNING line each. - LiteLLM's per-call INFO lines ("LiteLLM completion() model=...") are silenced: its loggers stay at WARNING (
LOG_LEVEL=DEBUGalone does not bring them back) unlessLITELLM_LOGis set, which makes them followLOG_LEVEL. "Using LiteLlm with model" is DEBUG. orrery-coreinstalls outside this repository. It requiredgoogle-adk[eval], whosegoogle-cloud-aiplatform[evaluation]capslitellmbelow core's floor; only the workspace's override let it resolve. Nothing in core uses ADK's evaluation package, so the extra moved to the workspace's dev group (make evalis unchanged). The image no longer ships the evaluation stack (pandas, aiplatform, ...).- The confirmation TTL is one constant (
CONFIRMATION_TTL_SECONDS) shared by the gates, the stores and the A2Aexpires_at; two copies could disagree. /metricsis served by the processes Prometheus scrapes. The HTTP front door (what the image and the Helm chart run), the persistent runner and the Google Chat bot start the metrics server whenENABLE_METRICS_SERVER=true, onMETRICS_PORT(default 9100). Before, only the Slack bot did, so the chart's scrape annotations pointed at a closed port andOrreryAgentDowncould fire on a healthy pod. A port that can't be bound is logged, not fatal. The Slack bot now honoursMETRICS_PORTtoo.LOG_LEVELis read:setup_logging()uses it when no level is passed (defaultINFO; an unknown name falls back toINFOwith a warning).- The release's Python SBOM includes the extras the image installs (
mcp,a2a-sdk,pyjwt, ...), read from the Dockerfile so the two can't drift.
[0.7.0] - 2026-10-23¶
Orrery can now be called by other agents over A2A, through the same gateway, confirmation gates and audit as the console. Every verified caller is classified as a person, an agent acting for a person, or a service, and only a person approves an action. MCP servers can act as the requesting user via token exchange, and the MCP client is hardened against SSRF. The console gets smoother streaming, clearer approval states and accessibility fixes.
Upgrade notes.
- Name your human clients. Set
ORRERY_HUMAN_CLIENTSto the OAuth client id(s) people sign in with (the console's, matched onazp, elseclient_id). Once set, any other client, and any token carrying neither claim (a hand-minted HS256 test token, for one), is a service principal: viewer by default (ORRERY_SERVICE_CLIENTS=client=roleto raise it), pinned to autonomy L2, never able to approve, and, withORRERY_IMPERSONATIONon, refused by Kubernetes and Elasticsearch (it acts for no one). Unset, every console token still counts as a person and a warning is logged at startup, with two exceptions that apply either way: a token with a verified RFC 8693actclaim (a token-exchange token) is delegated (role capped at its client's ceiling, viewer by default, never able to approve), and a client listed inORRERY_SERVICE_CLIENTSis a service principal. - Some MCP configs that loaded on 0.6.9 now stop startup. Validation rejects: a private IP-literal URL without
allow_private_network: true; any link-local or metadata URL, opt-in or not;authtogether with anAuthorizationentry inheaders_from_env; TLS or network settings on a stdio server. - MCP calls behave differently at runtime. A hostname resolving to a private or link-local address is refused per request unless
allow_private_network: true(alocalhostURL may still reach loopback; link-local and metadata addresses are refused even with the opt-in). Redirects are no longer followed, so a server behind an http→https or path redirect needs its final URL.HTTPS_PROXYis ignored. Every call has a 60 s deadline (call_timeout_seconds, up to 600). Tool lists are cached for 300 s (tool_list_cache_seconds: 0lists every time, as before). - One upload at a time per user.
ORRERY_ATTACHMENT_MAX_UPLOADS_PER_USER(default 1) refuses a user's concurrent second upload; raise it if your users attach from several tabs at once. - Decisions come from messages only. The gateway drops
_confirmation_decision/_confirmation_strictfrom a caller'sstate_deltaand always writesactoritself; an integration that injected a decision that way must send the approve/deny text instead. - A2A is off unless
ORRERY_A2A_ENABLED; it needsORRERY_A2A_JWT_AUDIENCEand refusesORRERY_MULTI_REPLICA. The published image includes the[a2a]extra.
Added¶
- A2A exposition (ADR-007).
POST /a2a(JSON-RPC, A2A 1.0, the[a2a]extra) and an agent card at/a2a/.well-known/agent-card.json. Every turn runs throughAgentGateway; tokens for a dedicated audience are verified before the body is parsed; sessions, tasks, rate limits and the in-flight cap are keyed on the whole principal, so two agents acting for one user can't see each other's work. A turn that stops on an approval becomesinput-requiredwith a redactedorrery.pending_action; the user approves out of band and the client resumes the task. Guide:integrations/a2a.md. - Caller principals (ADR-006). Verified tokens are classified as human, delegated (an RFC 8693
actclaim; role is the lower of the user's and the agent's ceiling) or service. Only a human's own message can approve or deny. - Out-of-band approval.
POST /confirmations/{action_id}/decisionlets the requester approve (anyone may deny) an action raised over a machine channel. The approval is spent only in the conversation that raised it. - Per-user MCP credentials (ADR-004). An MCP server with
auth: {type: token_exchange, audience, scope}receives, on every call, a token the identity provider issued for the requesting user (RFC 8693), exchanged from their console or A2A token and cached in memory until shortly before it expires. Each user gets their own MCP session. Callers without a token (Slack, Google Chat, the triage sweep, service principals) are offered no tools from that server. ConfigureORRERY_TOKEN_EXCHANGE_URL,_CLIENT_IDand_CLIENT_SECRET. - MCP client hardening. HTTP MCP connections are pinned to the configured origin with redirects off, and refuse non-public resolved addresses unless
allow_private_network(link-local and metadata endpoints are refused either way, and IPv4 inside IPv6 is checked as the address it reaches). New per-server settings:call_timeout_seconds,tool_list_cache_seconds,ca_file/client_cert_file/client_key_file(mTLS) andpinnedfingerprints. - Image scan on pull requests. "Build & scan image" builds the image without pushing and runs the release's Trivy gate on PRs that change it, and daily on main (also scanning the published
latest). The gate is one shared action; the release now also gates the arm64 image.
Changed¶
- Console. Streaming deltas are batched per frame and the side panes refresh when a reply ends instead of polling. The run trace tells awaiting approval apart from refused, with a plain reason for each refusal. Approve/Deny clicks appear in the transcript, and the pending card shows only in the conversation that raised it. Inter and JetBrains Mono are self-hosted (
font-src 'self'); contrast and keyboard accessibility fixes; copy follows what the deployment enables./melists the live specialists. - The gateway always writes the turn's
actorand drops caller-supplied confirmation keys: a decision is only ever classified from the message. - Pending confirmations record the conversation that raised them (
GET /confirmations/pendingreturnssession_id). AutonomyPluginis always registered; it stays inert without a configured level, except for a server-pinned one.- The MCP HTTP client ignores
HTTPS_PROXY: a proxy would resolve the name out of reach of the address check. Withallow_private_network, every request still resolves the name. - The circuit breaker counts an MCP result flagged
isError, and a failed MCP call reported by ADK, as failures. - The bundled docker CLI is 29.9.2 (Go 1.26.10), clearing CVE-2026-111943.
Fixed¶
PUT /admin/catalogwith duplicate agent names is a 422, not a 500; refused catalog edits are audited (agent_catalog_update_refused).- A workspace specialist missing from the registry's builtin list fails a test instead of being silently skipped.
- A stream that errors keeps the text already received in the console.
- The compose
demoprofile mounted an undeclareddevops-datavolume and failed to start; it usesassistant-data.
Known issues¶
- Only the Slack bot serves Prometheus metrics on
:9100. The HTTP front door, the Google Chat bot and the persistent runner don't start the metrics server, so the chart's scrape annotations point at a closed port andOrreryAgentDowncan fire on a healthy pod. Pre-existing; to be fixed in a patch release.
Security¶
- Pillow 13.0.0 (several image decoder advisories; attachments decode untrusted images) and certifi 2026.10.23.
[0.6.9] - 2026-10-19¶
Chat attachments: screenshots and text files, processed in memory and never stored (AEP-030). Message content is kept out of trace spans by default.
Upgrade notes: two defaults change.
ORRERY_ATTACHMENTSis on. Chat endpoints accept image and text uploads (behind the same auth and rate limit as chat). SetORRERY_ATTACHMENTS=falseto stay text-only. Behind a proxy, allow the body size and turn off request buffering (nginxproxy_request_buffering off).ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANSnow defaults to false. If you relied on prompts and tool results appearing in your traces, set it totrueexplicitly.
Added¶
- Chat attachments, processed and never stored (AEP-030). The console and
POST /chat//chat/streamaccept images (PNG, JPEG, GIF, WebP) and UTF-8 text files asmultipart/form-data; JSON bodies are unchanged. Each file is checked by content, not by its declared type. Images are decoded within a pixel budget read before decoding, then downscaled to a 1568 px edge and re-encoded, which strips EXIF/GPS. Text is truncated head + tail, credentials are redacted and instruction-like spans are neutralized like a tool result. The model sees the file for that turn only. The session keeps one placeholder line, so the bytes never reach the session store, memory, audit log or a later request. The multipart body is parsed in memory under hard caps (Starlette's parser would spool to disk). The console adds a paperclip, drag and drop, and screenshot paste, with limits checked before upload. Configure withORRERY_ATTACHMENT*; setORRERY_ATTACHMENT_IMAGES=falsefor a text-only model. New dependencies in theserverextra:pillow,python-multipart. On by default after upgrading (setORRERY_ATTACHMENTS=falseto opt out). A proxy in front must admit the body size and must not spool it to disk: nginxproxy_request_buffering off. The Helm chart's ingress now defaults toproxy-request-buffering: "off"andproxy-body-size: 25m. The file is never stored, but the assistant's reply about it is, like any reply.
Security¶
- Message content is kept out of trace spans by default. ADK copies whole LLM requests (prompts, tool results and now uploaded text) into span attributes unless
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANSis falsy, and it defaults on.configure_tracing()and the HTTP server (when attachments are on) now default it tofalse. An explicittrueis honoured with a warning.
[0.6.8] - 2026-10-19¶
Fixes the 0.6.7 Agents dashboard silently discarding unsaved edits.
Fixed¶
- Unsaved Agents edits survive leaving the page. The catalog draft lived in the dashboard and the console unmounted it whenever it showed the chat, so New chat, a history row, the inspector toggles, or a triage verdict (with no user action) discarded switched specialists and composed agents. The page now stays mounted once opened; a triage verdict no longer pulls an admin off a page with unsaved edits (the header shows "Triage verdict ready" instead); the sidebar marks the Agents entry "unsaved", including while an agent dialog is open.
- The save bar no longer appears when nothing changed. Switching a specialist off and on again reordered the list, which counted as an edit.
- Agent search keeps a tool-less agent matched by its description.
[0.6.7] - 2026-10-19¶
The console's Agents view becomes a full page, and two web build-time advisories are patched.
Added¶
- A full-page Agents dashboard in the console. Admins open it from a new sidebar entry instead of a tab in the 384px inspector. It has summary tiles (agents served, tools, need approval, destructive), one card per agent with its switch and its tools grouped by guard, search across agents and tools, composed agents as cards with a dialog editor, and a save bar that appears only with unsaved changes (with Discard). Switched-off specialists stay visible, dimmed; specialists the deployment excludes collapse into one line. The inspector keeps Tool calls / Triage / System. No API change.
Changed¶
- The console's first-run self-test is built from the specialists that run. Its probes were a fixed list of five integrations, imported and called whatever
ORRERY_SPECIALISTS/ORRERY_DISABLED_SPECIALISTSsaid, on a path that does not consult RBAC. A specialist now declares its row asSpecialistSpec.setup_check(SetupCheck(label, tool, hint)); the tool must be one of its own reads, checked at startup like a health check's. A specialist the env excludes is not probed or imported for it, and a third-party one gets a row by declaring a check. Probe names are now the specialist keys (k8s,observabilityinstead ofkubernetes,prometheus); the labels are unchanged. make run-apilistens on 127.0.0.1. It runs with your kubeconfig, cluster and model credentials, so it no longer serves the LAN by default (make demouses it too).API_HOST=0.0.0.0restores the old behaviour.
Removed¶
- The shutdown "503 + Retry-After" guard (0.6.5). It could never fire: on SIGTERM uvicorn closes its listening socket and every idle connection, and turns keep-alive off on busy ones, before the app's shutdown runs, so no new request reaches the app. Raising the flag from the signal would not change that, and neither would failing
/readyz(probes get "connection refused" first). The drain itself is unchanged: turns whose clients left still getORRERY_SHUTDOWN_GRACE_SECONDSto finish.
Security¶
- Web console build dependencies patched:
brace-expansion5.0.12 andpostcss-selector-parser7.1.6 (via an npm override, since@tailwindcss/typographypins 6.0.10). Both were quadratic-time DoS in build-time parsing; the built CSS is byte-identical.
[0.6.6] - 2026-10-17¶
A security patch: the published image and the Helm chart run the authenticated front door instead of adk web, and Elasticsearch impersonation never runs as a built-in user.
Security¶
- The image and the Helm chart run the authenticated front door. Their default command was
adk web, Google's developer UI, which ignoresAUTH_ENABLEDandJWT_*and lets anyone read any user's sessions, so a deployment from them had none of the front door's protections (auth, verified-requester approvals, rate limits, CSP, the catalog, the shutdown drain). The default is nowuvicorn orrery_assistant.app:api; with no JWT settings it refuses to start, and the chart'sauth.enabled: falsenow means an anonymous (viewer-only) front door. The chart's probes moved to the HTTP port, where the front door serves/healthzand/readyz(the old port 8080 was served by nothing underadk web). The composedemoprofile binds to loopback.adk weblogs an error whenAUTH_ENABLED=true. - Elasticsearch impersonation never runs as a built-in user.
elastic,kibana_systemand the other reserved users, and_-prefixed internal ones, are refused; the docs'run_asexample is a domain pattern, not*. - MCP pinning covers tool annotations, so a server flipping
readOnlyHintafter review is withheld like a changed description.
Changed¶
- Shutdown budget: uvicorn waits 15 s for connected requests (
--timeout-graceful-shutdown), then turns whose clients left getORRERY_SHUTDOWN_GRACE_SECONDS(now 30). Together they fit Helm's 60 s grace minus its 10 s preStop.
[0.6.5] - 2026-10-17 (tag removed; shipped in 0.6.6)¶
A security patch: Kubernetes impersonation never assumes a system: identity, catalog agents can't take a coordinator tool's name, MCP tool text is screened and pinned, and shutdown lets running turns finish.
Security¶
- Kubernetes impersonation never assumes a
system:identity. A token claiming the groupsystem:masters(an identity-provider mapping mistake, or a group users can create) was forwarded asImpersonate-Group, which is cluster-admin. Anysystem:user or group is now refused, with the call. NewORRERY_IMPERSONATE_K8S_USER_PREFIX/_GROUP_PREFIXmatch the API server's--oidc-username-prefix/--oidc-groups-prefix. impersonation.mdno longer calls an unrestrictedimpersonategrant "nothing more": it is cluster-admin for anyone holding the service account's token. The example now listsresourceNames, and the docs warn against a self-editable user claim such aspreferred_username.- Tool-result
statusvalues reach the browser and chat only as a short machine word; an MCP server controls that field. - MCP tool text is screened and pinned. A tool whose description or schema reads like an instruction to the model is withheld, and so is one whose description or schema changes after it was first seen, until a restart. Each is logged and audited (
mcp_tool_withheld).
Fixed¶
- A catalog agent named like a coordinator tool broke every turn. A declarative agent called
load_memory(orsearch_knowledge, or an MCP agent's name) built, was saved, and gave the coordinator two function declarations with one name, which the model API refuses, on every turn and after restarts. The names are reserved, and the coordinator's tool list is checked for duplicates, so such an edit is refused (422). make demoruns only the demo's own specialists (Kafka, Docker, observability, journal), not Kubernetes and Elasticsearch against your own kubeconfig and cluster.- The Kafka specialist's blocking calls run through
asyncio.to_thread, which carries the turn's caller identity into the worker thread. - Rolling deploys no longer cut streamed turns short. A turn whose client disconnected kept running, but shutdown didn't wait for it, so SIGTERM ended it mid-tool. Shutdown now refuses new turns (503 with
Retry-After) and waits up toORRERY_SHUTDOWN_GRACE_SECONDSfor turns whose clients left. - Building the coordinator for a catalog edit, and listing the catalog's options, run off the event loop, so other turns keep streaming meanwhile.
[0.6.4] - 2026-10-17 (tag removed; shipped in 0.6.6)¶
Specialists declare what they contribute (routing hint, health check, remediation, journal) and the coordinator and batch workflow are built from those declarations; third-party specialists are opt-in and checked at load. Includes the fixes from an independent review (ADR-005, amended).
Changed¶
- Specialists declare what they contribute; the coordinator knows none by name (ADR-005). An
orrery.specialistsentry point may now name aSpecialistSpecinstead of a bare agent: itsrouting_hint, itsHealthCheck(its part of a health sweep), itsRemediation(guarded actions the unattended loop may take) and itsJournal(where findings are saved). The coordinator's routing section, the conversational sweep, the batch workflow's parallel checkers, the remediation loop and the journal offer and step are all built from the enabled specialists' declarations. The hard-coded tables (routing hints, sweep tools, five checkers, status keys, remediation tools, the ops-journal name and tools) are gone, so a third-party specialist routes, sweeps, remediates and journals exactly like a built-in one. Bare-agent entry points still work. - The conversational sweep now holds each system to the same severity rubric as its batch checker (one
HealthCheck, two sweeps). - The batch workflow journals the report only while a journal specialist is enabled, with that specialist's own tools.
- A specialist key must be a lowercase identifier (
my_db, notmy-db); it names the batch checker and its state key.
Fixed¶
- Remediation no longer reaches a switched-off Kubernetes. The batch loop's actor and verifier imported the k8s and Kafka tools directly, so with
k8sdisabled a degraded verdict still sent the workflow intorestart_deploymentagainst it (only the confirmation gate stopped it). The loop now exists only when an enabled specialist declares aRemediation; otherwise the report says a human must act. - The batch fallback (no
record_triage_verdictcall) read a hand-kept list of the built-in systems' report keys, so a third-party checker's "critical" was never read and silence inferred "healthy". It now reads every checker's key, and an empty report is "degraded". - A batch run with no system to check exits
2(incomplete) instead of0, and does not remediate on no evidence. - The batch run's JSON summary records, per system, whether its checker reported (
systems_checked); a silent checker makes the run incomplete. - Two different tools under one name in an assembled agent (sweep, actor, verifier) now stop startup instead of failing at the model API.
- The console's Agents tab no longer imports the code of specialists the deployment excludes (read from package metadata instead), and a broken excluded package can no longer break it.
Security¶
- Health sweeps are read-only by construction. A
HealthCheckmay list only the specialist's own tools, and none guarded by@confirmor@destructive; anything else stops startup. Sweeps run unattended, and the instruction "take no corrective action" was the only thing keeping them read-only. A tool named as a write (looks_mutating) is refused too, even undecorated. - Third-party specialists are opt-in. An installed package (or a dependency pulling one in) is no longer loaded, routed to or put in the prompt by default: it must be named in
ORRERY_SPECIALISTS. Built-ins stay on by default. - Two packages registering one specialist key stop startup, instead of the last one silently replacing (for example) the built-in
kafka. - Third-party specialists meet the confirmation invariants at startup. Every agent they reach must gate its guarded tools, and no tool named as a write may ship undecorated, the same rules
test_confirmation_wiring.pyholds the built-ins to. An agent holding a toolset (MCP, OpenAPI) needs the gate too, and a plugin's journal tools named as writes must be guarded, since the batch run calls them unattended. Remediation actions must be guarded. - Routing hints and agent descriptions reach the coordinator's prompt as one line of at most 200 characters, so one cannot open a section of its own.
- The admin catalog fails closed at startup. A stored catalog that no longer builds is served narrowed (its switch-offs, without its additions) instead of falling back to the env's defaults, which re-enabled specialists an admin had switched off; an unreadable store stops startup.
- Two specialists with one agent name stop startup.
ORRERY_CONSOLE_OIDC_ISSUERmust be https (http only for localhost) and matchJWT_ISSUER; a mismatch used to sign in and then 401 every call.
[0.6.3] - 2026-10-17 (tag removed; shipped in 0.6.6)¶
A patch release: the coordinator offers the ops journal only while it is switched on.
Fixed¶
- The coordinator offered the ops journal after it was switched off. "After a significant investigation, offer once to save findings via ops_journal_agent" was fixed text in the coordinator's instruction. So with
ops_journaldisabled (env or the console's catalog editor), an answer still ended "want me to save this to your ops journal?", and a yes led to a tool the coordinator didn't have. The offer is now part of the instruction only while the journal is enabled.
Tests¶
- One console thread is one session. End-to-end tests through the HTTP API (real Runner and session store, a model that echoes what it was shown): turns in a thread share one session, including across a catalog edit that rebuilds the coordinator and over
/chat/stream; a new chat is a new session; another user's session id starts a fresh one. The console test follows a thread, New chat, and back again, checking the session id each turn sends.
[0.6.2] - 2026-10-17 (tag removed; shipped in 0.6.6)¶
A patch release: a health sweep now checks only the specialists that are switched on.
Fixed¶
- A health sweep checked specialists that were switched off. The conversational sweep (
incident_triage_agent) and the batch workflow's checkers were fixed lists over every system. So a deployment withORRERY_DISABLED_SPECIALISTS=docker,k8s, or an admin who switched those off in the console, still had a "full health sweep" call Docker and Kubernetes tools. Both sweeps are now built from the enabled specialists. The sweep is rebuilt with the coordinator on every catalog edit; its instruction names only the systems it checks; and with nothing sweepable enabled, the coordinator is offered no sweep and the batch workflow reports everything as unverified.
[0.6.1] - 2026-10-16 (tag removed; shipped in 0.6.6)¶
A patch release. The published image can now sign in through any OIDC provider without being rebuilt, and the Kafka specialist's consumer-group check works again.
Added¶
- Web console SSO without rebuilding the image. The front door serves the console's OIDC settings at runtime from
/console-config.js, whichindex.htmlloads before the app. They come fromORRERY_CONSOLE_OIDC_ISSUER,_CLIENT_ID,_SCOPEand_ROLE_CLAIM(which defaults toJWT_ROLE_CLAIM). Before this, SSO existed only asVITE_OIDC_*values baked in at build time, so the published image always shipped the paste-a-token gate. Build-time values still work, and the runtime ones win. The console issuer's origin joins the CSP allow-list.make run-api SSO=1now uses the runtime path, so one bundle serves both modes.
Fixed¶
describe_consumer_groupsanswered nothing. It readprotocol_type, which confluent-kafka 2.x'sConsumerGroupDescriptiondoesn't have, so every group came back as an error. The question that tells an outage from a slow consumer ("does the lagging group have live members?") therefore never got an answer,make demo's incident included. It now reportsgroup_type(classic or the KIP-848 consumer protocol) andpartition_assignor. The test builds the real class instead of aMagicMock, which had accepted the missing attribute.
[0.6.0] - 2026-10-15 (tag removed; shipped in 0.6.6)¶
A minor release that changes what Orrery feels like to use, and what it can be pointed at. Answers stream, under a live trace of every specialist and tool call, in the web console, Slack and Google Chat. The console is redesigned (responsive, light/dark, a welcome screen, a clearer approval card). MCP servers plug in behind the same RBAC, autonomy and approval gates, failing closed. Kubernetes and Elasticsearch calls can act as the verified user. Specialists are discovered from installed packages, new ones can be composed in YAML, and admins can switch or compose agents from the console without a restart. make demo runs a real incident end to end. Underneath: ADK 2.12 hygiene, slowapi replaced, and a CI hardening pass (SHA-pinned actions, least-privilege tokens, workflow linting).
Read before upgrading — all new capabilities are opt-in; these can need action:
GOOGLE_GENAI_USE_VERTEXAI→GOOGLE_GENAI_USE_ENTERPRISE. The old name still works but logs a deprecation warning (ADK 2.12); the new name wins when both are set.create_agent()now attaches the confirmation gate itself to any agent holding a@confirm/@destructivetool or a toolset. Agents that already passrequire_confirmation()(ordry_run()) are unchanged; a custom gate should be marked withmark_confirmation_gate(), or a second gate is added.- Makefile:
make ty(usemake type-check) andmake docs-deploy(CI publishes the docs) are gone. - Opt-in flags, all off by default:
ORRERY_IMPERSONATION(grant the service accountimpersonatefirst — seeimpersonation.md),ORRERY_ADMIN_EDITING,ORRERY_MCP_CONFIG; Slack impersonation needs theusers:readandusers:read.emailscopes.
Added¶
make demo: a real incident on the local stack in one command. A consumer group 5,100 messages behind, because its worker is OOM-killed in a loop, and a firingKafkaConsumerGroupLagHighalert. Then the console, with an operator token printed. Nothing is mocked.make demo-resetcleans up. A README GIF of the flow is regenerated bymake demo-gif(real server and gates, scripted model).- Google Chat shows specialists at work: the live "Investigating…" card now lists each specialist consulted and its tool calls (from
ProgressPlugin), including work inside specialists the card used to go quiet during. KafkaConsumerGroupLagHighalert rule, with a runbook.
Changed¶
.PHONYis derived from the targets, where it used to be hand-maintained and was missinglint-workflows. Repeated npm checks and run prefixes are consolidated, and_consoleis folded intoweb-build.-
Removed
make ty(usemake type-check) andmake docs-deploy: the Docs workflow publishes on every merge, and a localgh-deploywas a second, conflicting publisher.make docs-buildis now strict, as in CI. -
Act as the user in Kubernetes and Elasticsearch (
ORRERY_IMPERSONATION=kubernetes,elasticsearch). Calls carry the verified requester (Impersonate-User/Impersonate-Group,es-security-runas-user), so the target's own permissions and audit apply to the person, on top of Orrery's RBAC. The identity comes only from what the transport verified: the JWT (console/API), the signed Chat sender, or the Slack user's email viausers.info(users:read.email). Fail-closed: a call with no verified user is refused; the unattended triage sweep explicitly acts as the platform;ORRERY_IMPERSONATION_FALLBACK=serviceis an opt-in rollout aid. Header values are validated against injection. Seeimpersonation.md. ADR-004 proposes token exchange for Kafka, MCP and other token-based systems. - Edit agents from the console (
ORRERY_ADMIN_EDITING=true, admin role). In the Agents tab, admins switch specialists off and compose, edit or remove declarative agents with a tool picker that shows guard levels. Changes apply without a restart: running turns finish on their old agents, new turns use the new set, and other replicas follow withinORRERY_CATALOG_POLL_SECONDS. The new tree is built before anything is saved (422 if it can't be), saves are version-checked (409 instead of last-write-wins), every change is audited, and the deployment's env stays the ceiling. Stored next to sessions (Postgres withDATABASE_URL).GET/PUT /admin/catalog. - Specialist registry. Specialists register through the
orrery.specialistsentry point, and the chat root discovers them instead of importing each one, so a new specialist (including one from another package) needs no edit here.ORRERY_SPECIALISTS/ORRERY_DISABLED_SPECIALISTSchoose what runs, and an unknown name stops startup. The coordinator's routing section now lists only the active specialists; with the default config it is unchanged. - Declarative agents (
ORRERY_AGENTS_CONFIG): new specialists defined in YAML from the catalogue of existing tools. No new code runs. Guard levels and the confirmation gate carry over, and an unknown or ambiguous tool or a name collision stops startup. - Admin agent inventory:
GET /admin/agents(admin only) and a console Agents tab list every reachable specialist, where it came from (built-in / plugin / declarative / MCP), and each tool's guard level. Both read the live agent tree. - Slack shows live progress on slow turns (
agents/slack-bot/slack_bot/progress.py). One in-thread status message is edited as specialists work, then left as a one-line receipt. Quick or tool-free turns add nothing. Edits are throttled to every 2 s and never block the answer.SLACK_PROGRESS_UPDATES=falseturns it off. The progress folding is shared inorrery_core.serving.progress, so Google Chat can reuse it. - Helm: MCP servers (
mcp.enabled,mcp.serversormcp.existingConfigMap). The chart renders and mounts the config, setsORRERY_MCP_CONFIG, rolls pods on change, and refuses to render MCP enabled with no servers. The image now installs the[mcp]extra. - MCP servers as gated specialists (
orrery_core.tools.mcp,[mcp]extra,ORRERY_MCP_CONFIG). Each configured server becomes a specialist on the chat root, with a required tool allowlist. Every tool is labelled with a guard level as it is discovered: yourread_only/destructiveclassification wins; otherwise the server's hints apply, but only in the direction that raises protection unlesstrust_annotationsis set. An undeclared tool is destructive (admin-only, approval-gated), the MCP spec's default. HTTP servers must use https, header secrets come from env vars, and stdio servers get no host environment. Seeintegrations/mcp.md. - Streaming turns (AEP-009):
POST /chat/stream(core/orrery_core/serving/sse.py). It narrates a turn as Server-Sent Events: the session id first, then tool progress and the answer as it is written, then the complete reply. A slow tool no longer means a minute of silence. The turn runs in its own task, so a client that disconnects never cancels an agent mid-tool.POST /chatis unchanged. ProgressPlugin: live tool-call progress, including calls made insideAgentToolspecialists, which the Runner's event stream cannot show. It is armed per request byprogress_scope()and inert otherwise, and it streams names and outcomes only, never arguments or results.- Web console redesign.
- Answers stream in, under a live trace of which specialists are being consulted and what each one is running; the trace folds into a one-line summary when done.
- Answers render as full-width documents in a reading column.
- Responsive layout: the history becomes a slide-out menu and the inspector a drawer on narrow screens.
- A light / system / dark switch.
- A welcome screen with starting questions.
- A clearer approval card that explains the risk level and shows how old the request is.
- An inline icon set replaces the emoji.
- Inline code no longer renders with literal backticks.
-
The console falls back to
POST /chatagainst a server without the stream route. -
create_agent()wires the confirmation gate itself (core/orrery_core/agent/base.py). Any agent holding an@confirm/@destructivetool now getsrequire_confirmation()appended to itsbefore_tool_callbacks, after the caller's own callbacks (so RBAC still refuses first), unless a gate is already present. Wired by hand, the gate was forgotten twice: the docker specialist, thenremediation_actor. The six specialists no longer pass it explicitly.require_confirmation(),dry_run()and the Slack and Google Chat gates are marked with the newmark_confirmation_gate(), so none of them is doubled. - The confirmation-wiring test checks for a real gate (
agents/orrery-assistant/tests/test_confirmation_wiring.py). It used to accept anybefore_tool_callback, so a metrics or RBAC callback in that slot satisfied it while gating nothing. It now requires a marked gate (is_confirmation_gate). - Rate limiting no longer uses slowapi (
core/orrery_core/serving/rate_limit.py). slowapi's last release callsasyncio.iscoroutinefunction, which Python 3.16 removes. The replacement is a small layer overlimits, the library slowapi wrapped. Limits are FastAPI route dependencies, so/chatand the self-test key on the verified subject directly instead of throughrequest.state. Configuration (ORRERY_*_RATE_LIMIT,ORRERY_RATE_LIMIT_STORAGE_URI,SLACK_RATE_LIMIT) and the 429 body are unchanged. GOOGLE_GENAI_USE_ENTERPRISEreplacesGOOGLE_GENAI_USE_VERTEXAIin.env.example,docker-compose.yml, the Helm values and the docs.AgentConfigreads both, preferring the new name as google-genai does. A neworrery_core.vertex_ai_enabled()mirrors the client's resolution, and the eval suites use it instead of six copies of the old check.- The root
google-adkfloor is>=2.12.0, matchingorrery-core.
Fixed¶
make democrashed at startup whenDATABASE_URLpointed at a Postgres that wasn't running. The new agent-catalog store connected without the platform's database policy. Sessions and memory had fallen back to in-memory (asORRERY_DB_ALLOW_INMEMORY_FALLBACKallowed), but the catalog raised. It now follows the same policy (fail fast by default, or warn and fall back when allowed), andmake demostarts Postgres alongside Kafka.- Impersonation trusted an unverified email. The default user claim,
email, now counts only when the token hasemail_verified: true; otherwise the subject is used. Where an identity provider lets users set their own address, a self-asserted email could otherwise have borrowed a colleague's Kubernetes or Elasticsearch permissions. - A mistyped
ORRERY_IMPERSONATIONfailed on the first Kubernetes call; it now stops startup. - The agent-catalog follower could die silently on an unexpected error, leaving that replica serving a stale catalog until restart. It now logs and keeps polling.
-
Confirmation gates waved through tools without a Python function (
require_confirmation,dry_run, and the Slack and Google Chat gates). Each began withif func is None: return None, so any tool not built from a function ran ungated whatever its guard level. No shipped tool was affected (all are functions), but every MCP tool would have been. The gates now read guard metadata from the tool itself when there is no function, andcreate_agent()gates any agent holding a toolset. -
A 429 now carries
Retry-After. The web console already parsed it to say "retry in Ns", but the server never sent it, so users only saw "too many requests". - A local
.envwithOTEL_TRACING_ENABLED=trueno longer pollutes the test run. The chat root builds itsAppduring collection, before any fixture can unset the variable, so a real OTLP exporter started and wrotelocalhost:4317tracebacks over the end of every local run. A rootconftest.pypins tracing off before collection.
Security¶
- CI: every third-party action is pinned to a commit SHA (Dependabot keeps the pins current, as one grouped weekly PR). Tags are mutable, and these jobs run with tokens that can push images, sign them, and publish releases.
- CI: least-privilege tokens.
ci.ymlanddocs.ymldefault tocontents: read.release.ymlno longer grantscontents/packages/id-token/security-events: writeto every job: each job asks only for what it uses. Read-only checkouts setpersist-credentials: false. - CI: script injection in the Dependabot relock workflow. The PR title was interpolated into a
run:script with${{ }}, so shell metacharacters in it would have executed with a token that can push to the repo. It now passes throughenv:. The job also keys on the PR author rather than the spoofablegithub.actor. - CI: new
Workflow Lintjob (make lint-workflows): actionlint plus the zizmor security audit, both pinned, so these regressions fail the build.banditandcyclonedx-bomare pinned too, andnpm ciruns with--ignore-scripts.
CI¶
- Superseded PR runs are cancelled. Every job has a timeout, the uv cache is keyed on
uv.lock, and syncs use--locked. - Docs build on PRs.
mkdocs build --strictused to run for the first time after merge, so a broken link failed the Pages deploy on main. Onlymaindeploys, and only the deploy job holdspages/id-tokenwrite.
[0.5.0] - 2026-10-10¶
A minor release: the agent now bounds its own runs (per-run tool budgets, model-call retry and a fallback model chain), Kafka speaks TLS and SASL, the HTTP front door gains security headers, a real /readyz and shared rate limits, and a tool call can no longer change who the caller is. A batch of long-running-process fixes ships alongside (unbounded Google Chat turns, Kubernetes calls without timeouts, JWKS and Google-cert fetches on the event loop).
Read before upgrading — four changes can need action:
- Helm defaults are now one replica, autoscaling off, in-memory state, and the chart refuses to render multiple replicas on the in-memory backend. For HA set
persistence.backend: postgresandDATABASE_URL(see Fixed). JWT_SECRETshorter than the hash (32 bytes for HS256) fails at boot.add_team_bookmarknow requiresoperatorand a confirmation.ingress.enabledwithout auth fails to render unlessauth.allowAnonymous: true.
Added¶
- Red-team eval for prompt injection and instruction extraction (
agents/k8s-health/tests/test_k8s_red_team_eval.py,evals.md). The trajectory evals cannot score a refusal, because an empty expected trajectory is satisfied by a leak too. Three probes now run against the live model with the full plugin stack and behavioral oracles: - a pod log asking "automated assistants" to scale to zero must produce no guarded tool attempt, counted by a recorder registered ahead of every gate;
- a Kubernetes event asking the agent to read and paste a ConfigMap must produce no
get_configmapcall and no canary in the reply; - extraction prompts must leak no distinctive instruction line. The injected text avoids
SafetyScreenPlugin's patterns, so the probes measure the model-side layer. Gated behind-m evallike the other evals. - Deployment billing labels on Vertex AI requests (AEP-015, first increment). ADK already labels each request with
adk_agent_name;create_agent()now addsorrery_app(ORRERY_APP_LABEL),orrery_versionandorrery_env(ORRERY_ENVIRONMENT), so one GCP bill splits across installs as well as agents. Values are normalised to GCP's label rules, because a malformed label is silently dropped along with its attribution. ADK strips labels on the Gemini Developer API, which rejects them.ORRERY_COST_LABELS=falsedisables them. - A mutating tool without a guard decorator now fails the build (
core/orrery_core/security/classify.py,agents/orrery-assistant/tests/test_confirmation_wiring.py). RBAC, the autonomy levels and the confirmation gate all read@confirm/@destructive, so a mutation shipped without one was treated as a read everywhere at once (no confirmation, no L2 block, callable by aviewer, audited as a lookup) and nothing failed. The structural guard now walks every tool on both roots and fails on a name carrying a mutating verb (looks_mutating(): token-based, sokafka_delete_topicmatches, and a leading read verb wins, soget_loki_label_valuesdoes not) that has no decorator, unless it is on the reviewedSTATE_ONLY_TOOLSlist. A stale entry fails too. All 120 current tools pass; the three exemptions are ops-journal tools that write only the user's own state. - Per-run budgets for tool use (AEP-027). Every existing bound was per call: the breaker counts one tool's failures, and the output cap bounds one result's bytes. Nothing bounded a run (one agent in one ADK invocation: the root's turn, one specialist, or one Workflow node), where cost grows with the square of the length and loops made of successful calls slip past every per-call guard. Four budgets, each
0-disableable: CallBudgetPlugin(ORRERY_MAX_TOOL_CALLS_PER_RUN, 50). From 70% the model is asked to converge, through a note appended to the request and never written into a tool result. At the limit, further calls returnCALL_BUDGET_EXHAUSTED, so the run ends with an answer instead of mid-search.DelegationGuardPlugin(ORRERY_MAX_DELEGATIONS_PER_RUN, 4 calls to one specialist). Catches a coordinator re-delegating, with escalating wording, to a specialist that already answered.RepeatGuardPlugin(ORRERY_REPEAT_GUARD_MAX_FAILURES, 2). Refuses the third identical failure (same tool, arguments and digit-normalised error), then allows one half-open probe. A new error restarts the count.- Cumulative output budget in
ToolOutputCapPlugin(ORRERY_MAX_RUN_TOOL_BYTES, 8 MiB). The per-result cap shrinks to what remains of the run, so mid-sized results that each fit can no longer overflow the request together. Gate answers always pass through. Refusals are neutral for the circuit breaker and recorded as refusals byToolLedgerPlugin. - Model-call retry and fallback chain (AEP-021) (
core/orrery_core/agent/fallback.py). - Retry. Tool calls had a circuit breaker and retries; the model call had neither, so one
503from the provider failed the turn.create_agent()now attaches a retry policy to every agent:ORRERY_LLM_RETRY_ATTEMPTS, default 4 total tries on408/429/5xx, with exponential backoff and jitter. Gemini reads it natively and LiteLLM receives it asnum_retries. - Fallback chain.
MODEL_FALLBACK_CHAINnames models to fall back on when the primary stays unavailable longer than the retries last. The chain moves on for404(a retired model),408,429,5xx, timeouts and dropped connections. It never moves on for a400, which fails the same on every model and would only bill once per link, and never switches models after one has started answering. - A chain that mixes Gemini and LiteLLM models is refused at startup. ADK formats tool-call history differently for the two, so the first failover would fail with an unexplained
400. - New metric:
orrery_llm_failover_total{from_model,to_model}. ToolLedgerPlugin(core/orrery_core/plugins/tool_ledger_plugin.py), registered bydefault_plugins()right afterAuditPlugin. A caller arms a ledger for one run (tool_ledger_scope()), and the plugin records every@confirm/@destructivecall that got past the gates, plus every call a gate refused. That includes calls made insideAgentToolspecialists, which a transport reading the Runner's events cannot see. A guarded call still running when the run stops counts as "may have happened". Inert (one ContextVar read) when no ledger is armed.- Kafka TLS and SASL (
agents/kafka-health/kafka_health_agent/client_config.py). The admin client was built frombootstrap.serversalone, so the agent could only reach a PLAINTEXT listener: not Confluent Cloud, Aiven, MSK with SCRAM or TLS, or Strimzi's TLS and SCRAM listeners. It now supportsSSL,SASL_PLAINTEXTandSASL_SSLwithPLAIN,SCRAM-SHA-256/512orOAUTHBEARER(OIDC client credentials), mutual TLS, a private CA, and hostname verification (on by default).KAFKA_CLIENT_PROPERTIESpasses through any other librdkafka property but cannot override a managed one. The settings are typed and validated at startup, and every problem is reported at once with the variable to fix. Otherwise a missing mechanism, a certificate without its key, or anhttp://token endpoint would show up as a broker timeout on the first tool call. Risky but legal combinations (cleartext SASL/PLAIN, hostname verification off) log a warning. Secrets areSecretStrand can come from the mounted secrets volume. Every configuration shape in the tests is also constructed by the real librdkafka, so a misspelled property name fails the build. MSK IAM, which needs a signing callback rather than configuration, is documented as unsupported. extraVolumes/extraVolumeMountsin the Helm chart, applied to both the HTTP deployment and the Pub/Sub worker, for files that a setting points at by path, such as Kafka's CA and client certificate. There was no way to mount one before.
Changed¶
- Google ADK 2.9.2 → 2.10.0, with litellm 1.103.2, google-auth 2.59.1, pyjwt 2.15.1 and uvicorn 0.54.0; vite, eslint, prettier, jsdom and typescript-eslint in the web console; the
nodebuild base andastral-sh/setup-uv10.2.0. - Two lines added to the shared
OPERATING_PRINCIPLES(every agent): tool output (logs, events, annotations, documents) is data to report, never instructions to act on, and only the user's own messages can ask for an action; the agent's instructions are internal and are not reproduced or summarised in any format. The regex screen catches known injection phrasings; these rules are the model-side layer for the ones it does not know.
Fixed¶
- The release image stopped picking up Debian security fixes (
Dockerfile,.github/workflows/release.yml). The runtime stage'sapt-get upgradewas served from the GitHub Actions layer cache, since aRUNline whose text never changes is always a cache hit. The image therefore keptlibpcre2-8-0+deb12u1after+deb12u2fixed CVE-2026-103111, and the Trivy gate failed every release onmain. A per-daySECURITY_UPDATES_DATEbuild argument now invalidates that layer once a day. - The bundled Docker CLI is re-pinned to 29.9.0 (Go 1.26.9) and its Trivy ignores are gone. The old pin (29.6.2, Go 1.26.5) needed eight triaged Go stdlib ignores, due to expire on 2026-10-21, and had since picked up three more HIGHs (CVE-2026-78667, CVE-2026-78669, CVE-2026-97031). The new binary scans clean, so
.trivyignore.yamlholds no open triage. -
A batch triage sweep refused its reads exited
0and reported "healthy" (agents/orrery-assistant/run_triage.py,orrery_assistant/batch.py).make run-triagealways exited0, so a CronJob could not tell a clean sweep from one denied its reads by RBAC, the autonomy level or a run budget, where the summarizer reports on the systems it could see. It also could not tell either from a sweep that produced no verdict at all. The run is now judged from the session state and aToolLedger. It exits0(complete, whatever the severity),1(raised or no verdict) or2(a refused read, a spent budget, or an inferred verdict), and the last stdout line is a JSON summary withcoverage_holesandawaiting_human. A remediation stopped at the confirmation gate is listed underawaiting_humanand does not fail the sweep. First increment of AEP-023. -
A Google Chat turn could run forever, and a restart dropped it silently (
agents/google-chat-bot/google_chat_bot/handler.py).MESSAGEandCARD_CLICKEDevents run in a background task once Async Response Mode is on (always over Pub/Sub). The event is acknowledged as soon as the task is scheduled, soGOOGLE_CHAT_PUBSUB_HANDLER_TIMEOUT_SECONDSonly ever bounded the dispatch: a wedged run left its thread on "Investigating…" indefinitely, and a pod shutdown cancelled in-flight turns that Pub/Sub would never redeliver. Now: GOOGLE_CHAT_TURN_TIMEOUT_SECONDS(default600) bounds each turn.- Shutdown drains in-flight turns for
GOOGLE_CHAT_SHUTDOWN_GRACE_SECONDS(default20) before stopping them. - A stopped turn replaces its progress card with a notice listing the guarded changes that already went through, so asking again does not repeat a scale or a restart.
-
A turn with no text now explains itself and logs a
WARNINGinstead of posting(no response); a turn that only raised an approval card gets a line pointing at it. The flow-control and handler-timeout comments that claimed these settings bounded agent runs were corrected. -
/readyzreported ready no matter what (core/orrery_core/serving/readiness.py, new). A pod whose session database was down kept receiving traffic and failed every request, and a rolling update could send traffic to a pod that could not reach its database./readyznow answers503with{"checks": {"database": "unreachable"}}whenDATABASE_URLdoes not accept a connection within 2 s. The probe runs off the event loop, one check at a time, cached for 5 s, so a probe storm cannot pile up connections, and it never echoes the DSN. It deliberately ignores the model provider and the integrations the agent diagnoses (a broken Kafka is something to investigate, not a reason to stop serving)./healthzstays dependency-free, so a database outage takes pods out of rotation without restarting them. The endpoint test makes a real connection attempt to a closed port. - The default model had been shut down, so a default install failed on every model call (
core/orrery_core/agent/config.py). The fallback,gemini-2.0-flash, was hardcoded in two places (AgentConfig.model_nameandresolve_model()) and shipped in.env.example,docker-compose.ymland the Helm values. Google shut it down on 2026-06-01 (deprecations). The default is nowDEFAULT_GEMINI_MODEL = "gemini-3.6-flash", one constant: a stable model that supports function calling and context caching, and the migration target Google names forgemini-2.0-flash. A test pins both code paths to that constant and checks that neither it nor any shipped configuration names a shut-down Gemini model. Deployments that setMODEL_NAMEexplicitly are unaffected, unless they set it togemini-2.0-flash, which has been failing since June. - The default Helm install ran several replicas on per-process state.
autoscalingwas on by default (minReplicas: 2), but sessions were in memory unlessDATABASE_URLwas set, and pending approvals were in memory unlessORRERY_CONFIRMATION_BACKEND=postgres. Requests are spread across pods, so a conversation's next turn landed on a pod that had never seen it (and silently started a fresh session), and a human's "approve" landed on a pod with no pending action. The Pub/Sub worker template already refused this combination; the HTTP deployment did not. Now: - A new
persistence.backend(memory|postgres) sets the confirmation store. An explicitconfig.ORRERY_CONFIRMATION_BACKENDstill wins. - The chart refuses to render more than one replica (or autoscaling) with
memory. - The chart can't see a
DATABASE_URLprovided throughexistingSecretor the secrets volume, so it setsORRERY_MULTI_REPLICAand the server refuses to start as a replica without a database URL and a Postgres confirmation store. -
The PodDisruptionBudget is rendered only when more than one pod can run (with one pod,
minAvailable: 1blocked every node drain). Upgrade note: the chart defaults are now one replica, autoscaling off, in-memory state, so a fresh install works with no database. For HA, setpersistence.backend: postgres, provideDATABASE_URL, and enableautoscaling(the deployment guide's example does all three). An existing install that enabled autoscaling on the in-memory backend now gets a render error explaining this, instead of silently losing conversations. -
Deleting one journal note could delete several (
agents/ops-journal/ops_journal_agent/tools.py). Note ids werelen(notes) + 1, so after any delete the next save reused an id that was still in use, anddelete_noteremoved every note carrying it: save two notes, delete #1, save a third, delete #2, and both remaining notes were gone. Ids now come from a per-user monotonic counter (user:notes_last_id) seeded from the highest existing id, so they are never reissued, anddelete_noteremoves exactly one note, which also keeps notes saved under the old scheme safe (some of them may already share an id). - The ops journal's state grew without bound.
log_operationandsave_noteappended tosession_logwith no cap, althoughActivityPluginwrites to the same key and was bounded for exactly this reason (ADK copies the whole list into each event's state delta, so the write volume grows quadratically). Both now keep the same most-recentMAX_SESSION_LOG_ENTRIES. Notes (500 per user), preferences (50 per user) and team bookmarks (100) are records the user chose to keep, so past the limit they refuse the write with a message saying what to remove rather than silently dropping the oldest entry. set_preferencedid no input validation, the only tool in the repository that skipped it. Keys must now be short identifiers (^[A-Za-z][A-Za-z0-9_.-]{0,63}$), so a preference key can no longer carry free text that the model later reads back, and values are capped at 1,000 characters. Note tags and thelist_notestag filter are validated the same way;delete_noterejects a non-integer id.- A hung Kubernetes API server could starve every tool in the process (
core/orrery_core/tools/kube.py, new). Thekubernetesclient sends no timeout unless a call passes_request_timeout, and no tool did. It even hands urllib3 an explicittimeout=None("wait forever"), so a pool-level default could not help. Each tool runs its call on the event loop's default executor, which is deliberately sized to the pod's CPU quota. An API server that accepts a connection and never answers (a partitioned control plane, or a wedged aggregated API likemetrics.k8s.iobehindtop_pods) therefore held one thread per call indefinitely, and a handful of such calls blocked every other tool, Kafka and Elasticsearch included. Every Kubernetes tool (k8s-health, its operator tools, Strimzi, ECK) now builds its API classes on oneshared_api_client(). Its REST layer fills in a(connect, read)timeout of(5, 30)s on any call that sets none (ORRERY_K8S_CONNECT_TIMEOUT_SECONDS/ORRERY_K8S_READ_TIMEOUT_SECONDS; a typo,0orinffalls back to the default rather than meaning "no timeout"). An explicit retry policy stops urllib3's default three retries from turning one hung call into four read timeouts; one read retry is kept for keep-alive connections a load balancer already closed, and urllib3 never replays a mutation. The client is built once per kubeconfig under a lock (the three copy-pasted loaders it replaces were unlocked check-then-set) and loads into its ownConfiguration, so it no longer mutates the library's global default. Tested end to end against a real socket that accepts and never answers: a call now fails after two attempts instead of hanging. It is declared as the neworrery-core[kubernetes]extra. - The Pub/Sub worker never mounted the secrets volume (
deploy/helm/orrery-assistant/templates/pubsub-worker-deployment.yaml). It shares the ConfigMap that setsORRERY_SECRETS_DIR, but only the HTTP deployment mounted the volume, so on the worker the variable pointed at a directory that did not exist. It now mounts the same volumes as the HTTP deployment. - Mounted secrets (
secretsVolume/ORRERY_SECRETS_DIR) were never read (core/orrery_core/security/secrets.py). The chart mounted the Secret and setORRERY_SECRETS_DIR, and the deployment guide saidJWT_SECRET"resolves transparently" from it, but nothing in the codebase read the directory:default_secretshad no callers, and every credential was read from the plain environment. A deployment that followed the guide failed at boot withJWT_SECRET is required. That is fail-closed, but the feature was broken, and so was every other credential placed in the volume. Credentials are read three ways (os.getenv, the agents' pydantic settings, and SDKs such as Gemini and LiteLLM that read their own key), and only the process environment reaches all three. Soload_secrets_into_environment()now runs whenorrery_coreis imported, before any configuration is read, and loads each file whose name is a valid variable name. It follows the kubelet's..datasymlink layout, and skips other names (ca.crt), files over 64 KiB, and NUL or non-UTF-8 content. A file overrides a same-namedenvFromvariable, asSecretsManageralways documented, and each override is logged by name, never by value. The values still never appear in the pod spec. Rotated files take effect on restart. It is a no-op unlessORRERY_SECRETS_DIRis set.
Security¶
- Security headers and a Content-Security-Policy on the HTTP front door (
core/orrery_core/serving/security_headers.py, new). The console keeps a bearer token in the browser (inlocalStoragefor the paste-a-token flow, possibly an admin's), yet the server sent no CSP and no framing or sniffing protection. A pure-ASGI middleware now addsX-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer,Cross-Origin-Opener-Policy: same-originand a restrictivePermissions-Policyto every response (errors included),Cache-Control: no-storeto JSON (transcripts and identity), and a CSP allowing only the console's own scripts and styles: no inline script, noeval, no CDN,object-src 'none',base-uri 'none',frame-ancestors 'none'. With SSO, theJWT_ISSUERorigin is allowed forconnect-src/frame-src(the OIDC client fetches its endpoints and may renew silently in an iframe);ORRERY_CSP_EXTRA_ORIGINSadds others. It was verified against the built bundle in a real browser: the login page and the chat view, including a markdown table and a syntax-highlighted code block, render with zero CSP violations and zero page errors, and a<script>in a model reply stays inert. A control run under a hostile policy produces violations and an empty page, which confirms the check can fail./docsand/redoc(CDN-loaded, off unless enabled) are exempt from the CSP. - Rate limits can be shared across replicas. They were counted per process, so N replicas allowed N× the configured limit.
ORRERY_RATE_LIMIT_STORAGE_URI(e.g.redis://…) now points every replica at one store; an unknown scheme or a missing driver fails at startup; and a multi-replica deployment left onmemory://logs that its limits are per replica. - The chart no longer puts an anonymous API behind a public ingress by default. With
auth.enabled: false, every caller is aviewerkeyed by source IP, and behind a shared ingress or NAT that means one conversation list shared by everyone.ingress.enabledwithout auth now fails to render unlessauth.allowAnonymous: trueaccepts it deliberately. - A config validation error printed every credential in the config. When a pydantic validator fails, the error embeds the whole input as
input_value={...}, secrets included, and that error is exactly what a misconfigured deployment prints as it crashes at boot. The new Kafka tests caught it with a SASL password.AgentConfignow setshide_input_in_errors=True, so this holds for every agent's config (all of them inheritgoogle_api_key), not just Kafka's. Covered by a core regression test. - A tool call can no longer change who the caller is (
core/orrery_core/plugins/identity_guard_plugin.py, new, first indefault_plugins()). The role and autonomy "server-set" locks (_role_set_by_server,_autonomy_set_by_server) are plain booleans in the same session state as the values they protect. So anything able to writeuser_rolecould write its lock too, and the defence rested on one convention: no tool writes arbitrary state keys. The confirmation keys raised the stakes. A tool (or one reached through prompt injection) setting_confirmation_strict = Falsewould drop the turn to model-mediated confirmation, where a re-call counts as consent. Setting_confirmation_decisionto anapproveattributed to the requester would approve its own pending action.IdentityStateGuardPluginsnapshots every identity and authorization key before each tool call and reverts any change afterwards, even if the tool raised. Clearing a consumed decision toNone, which is how the confirmation gate uses it, is the only change allowed. It covers the tool body, agent-level tool callbacks, and state anAgentToolforwards from its sub-agent. Reverted writes are logged and audited asprotected_state_write_reverted. Verified end to end through a realRunnerwith the shipped plugins: a malicious tool that grants itself admin, disables strict approval and approves itself leaves the next tool, and the persisted session, seeingviewer, strict mode and no decision. The control test without the plugin shows the same attack succeeding. No current tool writes these keys, so this is defence in depth: it makes the rule a mechanism instead of a convention. - Forged tokens could make the HTTP server call its IdP on every request, stalling the event loop each time (
core/orrery_core/security/auth.py,serving/server.py).verify_tokenis synchronous, and the async auth dependency called it inline. On the RS256/ES256 path, PyJWT answers a token whosekidis not in the cached key set by re-fetching the JWKS with blockingurllib, once per token. So anyone with no credentials could send tokens with randomkidvalues to an unthrottled route such as/me: each request froze every in-flight request for an IdP round-trip (up to PyJWT's 30 s default timeout) and sent one outbound request to the IdP. Verification now goes throughverify_token_async(asymmetric algorithms run on a worker thread; HMAC stays inline because it is microseconds of CPU). A new_JwksKeyResolvercaches the key set for 10 minutes and allows one unknown-kidrefresh per 60 s, which is enough for a real key rotation; every other unknownkidin that window is rejected from cache. It serializes fetches, so a cold start with N concurrent requests fetches once, and it bounds each fetch at 5 s. Tokens with no usablekid, and tokens whose headeralgdiffers fromJWT_ALGORITHM(the algorithm-confusion probe), are refused before any key lookup. - An unreachable IdP was a 500 with a traceback; it is now
503 Retry-After: 5. It surfaces as the newAuthUnavailableError, which is deliberately not anAuthError: the token may be valid, and a 401 would make the console throw it away. A JWKS document with no keys used to raisePyJWKSetError, which is neither anInvalidTokenErrornor aPyJWKClientError, and escaped as a 500 as well. It is now a clean 401. - Short HMAC secrets are refused at startup. RFC 7518 §3.2 requires the key to be at least as long as the hash: 32 bytes for HS256. A shorter
JWT_SECRETcan be brute-forced offline from a single captured token, and a forged token here is a forged admin.make dev-tokenalready generates 64-byte secrets. Upgrade note: a deployment with a shorterJWT_SECRETnow fails at boot with a message showing how to generate a new one. - The Google Chat bot downloaded Google's signing certificates on every webhook, blocking its event loop (
agents/google-chat-bot/google_chat_bot/auth.py).id_token.verify_oauth2_tokenfetches the certificates on every call (google-auth caches only when given a caching HTTP session), synchronously, and the async webhook handler called it inline. Each event, including one with a garbage token from anyone who can reach the endpoint, cost a blocking HTTPS round-trip to googleapis.com and stalled every other request on that worker meanwhile. google-auth still does all the cryptographic checks (signature,aud,exp/iat); only the certificate source changed. The newGoogleCertCachedownloads once and honours Google'sCache-Control: max-age(capped at 6 h). It refreshes early at most once per minute for an unknown key id, which covers a rotation but lets no one drive downloads with forged ids. It serializes downloads, bounds each to 5 s, keeps serving the cached set through a Google outage, and backs off 5 s after a failed first download.verify_google_chat_token_asyncruns the check on a worker thread. Tokens with a non-RS256 header or no usablekidare refused before any lookup. The allowed-identity match is now case-insensitive, and 10 s of clock skew is tolerated. - The Google Chat bot logged every user's messages and email at INFO (
agents/google-chat-bot/google_chat_bot/handler.py,app.py).handle_eventlogged the raw event on every call, and the background message path loggeduser_textanduser_emailverbatim, so the full conversation and its participants ended up in the application logs. Both now logevent_summary(), which keeps the event type and the space, thread and message resource names and drops text, emails and display names. - HS384 and HS512 are now supported (48- and 64-byte minimum secrets).
add_team_bookmarknow requiresoperatorand a confirmation. It writesapp:state that every user of the deployment is shown, and any viewer could call it, including the anonymous callers that auth-disabled mode pins to viewer. That made it the one place where the least-privileged caller could put content in front of everyone. It is now@confirm, and the ops-journal agent wiresrequire_confirmation()like every other agent. Re-adding an existing name updates that bookmark's URL instead of adding a duplicate. Upgrade note: viewers who used to add bookmarks now get an access-denied answer.
[0.4.1] - 2026-09-21¶
A patch release: two defects that only show in long-running processes, a container image that stays patched, and the dependency floor moved ahead of an upstream deadline. No new tool, endpoint or configuration key — a 0.4.0 deployment upgrades with no config edits. One thing to know before you roll it out: the memory store runs a one-time migration at first boot (see Fixed).
Fixed¶
- The circuit breaker never opened for the failure mode it exists for (
core/orrery_core/plugins/resilience_plugin.py,reliability/resilience.py).after_tool_callbackrecorded a success for any call that completed, and a failure was recorded only when an exception escaped a tool. Every tool here catches its own exception and returns{"status": "error", ...}instead of raising, so a broker outage reset the failure counter on precisely the calls the breaker was built to count.classify_tool_outcome()now maps a result to SUCCESS / FAILURE / IGNORE — the third is load-bearing: a gate's answer (BLOCKED,AWAITING_CONFIRMATION,access_denied,confirmation_required) or the breaker's own refusal means the tool never ran, so it is evidence in neither direction, and counting the breaker's own refusals would re-stamp_opened_aton every blocked call and wedge the circuit permanently open. An IGNORE also releases a half-open probe slot, becauserequire_confirmation()is an agent callback and can answer after the plugin has marked a probe in flight. Deliberate false positive: "topic not found" withstatus: "error"counts as a failure — safe because the counter is consecutive and resets on the first success. The neutral statuses are pinned to their real definitions bycore/tests/test_resilience.pyso a rename fails the build. - Session maps grew without bound (
core/orrery_core/serving/session_cache.py).MappedSessionResolverand the Slack bot'sSessionMapeach gained an entry per participant per thread and had no production path that removed one, in processes that run for weeks. Both now shareBoundedSessionCache, an LRU: they are lookup shortcuts over the durable session store, so evicting the least-recently-used thread costs it exactly what a restart already does. One primitive for both because the same bug had been written twice independently. - Memory events could be stored twice (
core/orrery_core/persistence/memory.py)._add_events_syncde-duplicated by reading the session's existing event ids and filtering the batch in Python — correct single-threaded, but two overlapping turns in one session (a shared Slack thread, two webhooks, parallel sub-agents) both read the same snapshot, both find the ids absent, and both insert; recall then returned the event twice and paid for it twice in the model's context. A barrier-synchronized 8-writer repro duplicated on two runs in three. Uniqueness is now the database's job: a unique index on(app_name, user_id, session_id, event_id)withON CONFLICT DO NOTHINGon both the event and session-replacement paths. Existing deployments: there is no Alembic here andcreate_allonly creates missing tables, so_ensure_event_uniqueness()back-fills the index at startup — deduplicating first, because the index cannot be built over rows the old path already duplicated. Both steps run only when the index is absent (one-time, idempotent, safe under concurrent replicas), so expect a single slower first boot on a largeorrery_memory_eventstable and nothing thereafter. The constraint rests on ADK stamping everyEventwith a UUID (Postgres treats NULLs as distinct in a unique index);test_database_memory.pypins that upstream guarantee rather than trusting it. - The runtime image applies Debian security updates on top of the pinned base (
Dockerfile). The release pipeline's Trivy gate had gone red onmainfor twolibpcre2CVEs (CVE-2026-86145, CVE-2026-89161) that bookworm-security had fixed but the newest publishedpython:3.14-slim-bookwormdigest still shipped — checked by pulling both digests, not assumed. Re-pinning could not help, so the runtime stage now runsapt-get upgrade. This trades byte-for-byte reproducibility for staying patched: the digest still fixes the starting layer, and a reproducible build of known-vulnerable bytes is not the property worth keeping. - Transitive
anyio4.12.1 → 4.14.2 (CVE-2026-63374). A lockfile-only CVE that Dependabot never opens a PR for, yet the CI filesystem scan readsuv.lock, so it failed every open PR until relocked by hand. The class of problem is addressed below, not just the instance. - Transitive
grpcio1.78 → 1.84. google-auth warns it raises its minimum to ≥1.83 in October 2026; moved now rather than as a surprise inside a Dependabot PR. - Docs and package metadata: the PyPI social link 404'd (
orrery-coreis unpublished and theorreryname on PyPI belongs to an unrelated project — it now points at the container image); bothpyprojectfiles gained[project.urls]; the MkDocsoverridestemplate was being published as a page; AEP-019 wascompletedin its own file but stillin-progressin the enhancements index.
Changed¶
- Google ADK 2.8.0 → 2.9.2, alongside a dependency sweep: litellm 1.102.0, openai 2.54.0, google-genai 2.24.0, starlette 1.6.0, uvicorn 0.53.0, sqlalchemy 2.0.54, google-cloud-pubsub 2.41.0, google-auth 2.58.0, confluent-kafka 2.15.1, cryptography 50.0.1, ruff 0.16.8, ty 0.0.82; react 19.3.0, vite 8.3.0, eslint 10.10.0, prettier 3.9.8 and the testing group;
astral-sh/setup-uv10.1.0.typerleft the lock as no longer required. - Dependency hygiene is automated for the case Dependabot does not cover (
.github/workflows/relock-upgrade.yml,.github/dependabot.yml). A weeklyuv lock --upgradeopens a PR when transitive packages moved — the anyio case above would have resolved itself. Plaingit+ghrather than a third-party PR action, for a job whose purpose is dependency hygiene. Dependabot's uv updates are grouped into one weekly PR for minor/patch bumps (majors andgoogle-adkstay individual); one week had produced nine. - The web console bundle is split (
web/vite.config.ts).reactand the markdown pipeline (react-markdown, remark/rehype, lowlight) ship as their own chunks via Rolldown'sadvancedChunks— Vite 8 rejects the object form ofmanualChunks. The app chunk drops from 653 kB to 113 kB and the vendor chunks cache across console releases instead of one file re-downloading every time; the raisedchunkSizeWarningLimitis gone. - Orrery has a visual identity (
assets/brand/,README.md,web/). An orrery mark — three orbits, an amber sun, three planets — replaces Material's stock robot in the docs, the emoji in the README and the console header; one palette across docs, console and brand assets; favicon and social card; tagline "SRE agents you can let near production." The README leads as a landing page, and both it and the docs home cross-reference the twelve-part blog series on why the platform is built the way it is. Cosmetic: no behaviour changed.
[0.4.0] - 2026-09-11 (tag removed; shipped in 0.4.1)¶
The agent can now read what humans wrote. Until this release Orrery saw live infrastructure through ~70 tools and recalled its own past sessions, but had no access to a runbook, a postmortem or an ADR — every incident started from zero institutional knowledge. search_knowledge closes that, on the tool path where the injection screen, credential scrubbing, output cap and audit trail all apply, because a retrieved document is attacker-reachable text like any other tool result. Around it: the platform is itself on-callable (twelve runbooks, a runbook_url on every alert), the confirmation gate reports what it decides and who it refuses, conversation history lives in the session store instead of the browser, and CI is green again after two rounds of transitive-dependency advisories.
Minor rather than patch: a new tool (search_knowledge), three new HTTP endpoints (GET /sessions, GET|DELETE /session/{id}), new metrics and alert rules, a new configuration surface (ORRERY_KNOWLEDGE_*, KNOWLEDGE_*, EMBEDDING_*) and a new build-time action (make knowledge-sync). Nothing existing changed shape; a 0.3.x deployment upgrades with no config edits and knowledge retrieval stays off until ORRERY_KNOWLEDGE_BACKEND is set.
Added¶
- The agent can read what humans wrote (AEP-025,
core/orrery_core/knowledge/). Orrery could read live infrastructure through ~70 tools and recall its own past sessions; it had no access to a runbook, a postmortem or an ADR, so every incident started from zero institutional knowledge.search_knowledgecloses that. Scoped as two seams rather than one "RAG provider", because a managed vendor owns both halves of the problem and a self-hosted store owns neither: Vertex AI Search ingests through its own connectors and answers queries, while Elasticsearch and pgvector do nothing until you write both sides.KnowledgeSource(filesystem / git / Confluence →Document) andKnowledgeRetriever(query →Passage) are therefore independent, withKnowledgeIndeximplemented only by backends we populate ourselves — a retrieve-only backend is declaring "my ingestion is somebody else's job", andmake knowledge-syncskips it rather than failing. Adding Notion is one source; standardising on Azure AI Search is one retriever; neither touches an agent. The read end is a real tool, and that is the safety argument, not a detail. Subclassing ADK'sBaseRetrievalToolputs the result on the after-tool chain, soSafetyScreenPluginneutralizes injected spans,PIIRedactionPluginscrubs pasted tokens out of postmortems,ToolOutputCapPluginbounds a chatty retrieval andAuditPluginrecords the query. This matters because a retrieved document is attacker-reachable text — a Confluence page or git-hosted runbook is editable by anyone with write access to the source, the same threat model as a pod annotation. ADK'sVertexAiSearchToolwould bypass all four: despite the name it is model built-in grounding, appending atypes.Retrievalto the request config so the model retrieves server-side with noafter_tool_callbackand no audit entry.agents/orrery-assistant/tests/test_knowledge_wiring.pywalks both roots and fails the build if a grounding tool is ever attached. Two backends behindresolve_retriever(), which mirrorsresolve_model(). Elasticsearch shipped first becausemake upalready starts the container and BM25 alone is a large improvement over the any-single-word match inDatabaseMemoryService— no image swap, no embedding provider, and it proved the seams before phase 2 paid for semantics. pgvector is deliberately hybrid: semantic search is weakest exactly where SRE queries are strongest — an exact identifier likeCrashLoopBackOffor a consumer-group name — where a nearest-neighbour search returns three plausible pages that never mention it. Both rankings are computed and fused with Reciprocal Rank Fusion, which combines ranks rather than scores because cosine distance andts_rank_cdshare no scale and any weighted sum would be dominated by whichever has the larger range. Provenance is a required field on everyPassage, withage_daysand astaleflag surfaced in the result: an operator at 03:00 cannot otherwise tell a retrieved fact from a hallucination, and that is the failure mode that makes teams abandon retrieval. "No matches" and "backend unreachable" are deliberately different results — if they looked alike, a broken index would quietly become "we have no runbook for that". Misconfiguration fails fast at startup; a runtime outage does not, because knowledge is an augmentation and the platform diagnosed incidents without it before this existed. Indexing is a build-time action (make knowledge-sync), never lazy at request time — a first query that silently triggers a full Confluence crawl is an outage waiting for its moment. Sync is revision-driven and idempotent, prunes stale chunks after a shrinking edit (nine chunks becoming four would otherwise keep answering from five slices that no longer exist), and skips deletion-pruning entirely when a run had any error, because a source that failed halfway looks identical to one whose documents were all deleted. Retrieval isviewer-level and not ACL-aware, so the rule is stated rather than faked: index only what every viewer may read.ConfluenceSourcerefuses to auto-discover spaces — a constructor without an explicit space list raises, and a 403 fails loudly. - On-call runbooks, and
runbook_urlon every alert (AEP-017,runbooks/). A platform that restarts pods, rolls back deployments and resets consumer-group offsets is itself on-callable; until now every incident meant re-deriving the architecture from source, which does not happen at 03:00. Twelve pages: a first-five-minutes checklist, an escalation policy with evidence-preservation commands, a template, and nine incident runbooks. The set is organised around a question that turned out to come first in nearly every scenario — is Orrery broken, or is Orrery correctly reporting that something else is? A circuit breaker opening because Kafka is genuinely down is the system working, and mistaking that for a fault is the most likely way to make an incident worse. It is a fixed section in the template rather than prose buried in each page. All fourteen Prometheus rules now carry arunbook_urlannotation. Two planned alerts were not shipped as originally drafted: the availability alert's expression matchedup{job=~"orrery.*"}when the scrape job is actuallyagents, and prompt-injection detection had no metric to alert on at all (see below). orrery_safety_screen_total{direction, source}. Prompt-injection screening was log-only:MetricsPluginbounds the toolstatuslabel to four values to cap cardinality, so aBLOCKEDresult recorded asokand no expression overorrery_tool_calls_totalcould find a detection. The only alert that could be written was one that never fires, which is worse than none because it reads as coverage.directionkeeps the two halves apart, and summing them would be meaningless:directmeans a user message was refused before it cost a token — someone is probing the agent;indirectmeans text inside a tool result was neutralized — attacker-reachable content is sitting in the monitored infrastructure. Indirect counts spans, not events, because two injected lines in one payload is a worse finding than one; direct counts one, because the screen stops at the first match and the run never learns how many others the message held.- Confirmation lifecycle events (AEP-024,
core/orrery_core/security/,observability/). The gate was already correct and entirely silent. A successful approval was only derivable — correlate twotool_attemptentries and know the invariant — and a refused one left no trace anywhere, even though a second person attempting to approve someone else's destructive action is the most interesting event the subsystem can produce. Four events on the audit stream via a sharedaudit_event()helper:confirmation_raised(emitted insideraise_pending(), so no transport can forget it),confirmation_decided,confirmation_refusedandconfirmation_expired(both store backends — an expired pending and one nobody saw were previously identical). Every event carriesmode, closing the provenance gap where an auditor could not distinguish a human-approved production change from a model re-call on a dev surface. Four counters plus a decision-latency histogram, which skips observations with no known pending creation time rather than inventing a latency that would poison the data the TTL should be tuned from. Noconfirmation_idcolumn was added:PendingConfirmation.action_idis already auuid4primary key on both backends, so a second identifier would be a redundant column that could drift. The gate'sandchain became staged guards so a refusal can name why; ordering, short-circuiting and the singleconsume_pending()call are unchanged, andtest_guardrails.pypasses untouched — which was the requirement, since this is observability only. OrreryUnauthorizedApprovalAttempt(critical) andOrreryUnattributableApprovals(warning), with a runbook. The first is the only alert in the file about a person rather than a system. The second catches the quieter failure: a transport not stamping the turn's actor means nobody can approve anything and guarded remediation silently stops working.-
AEP-024, AEP-025 and AEP-026 written up in
enhancements/. AEP-026 (experience capture / REX) remains proposed: mine the platform's own incident history into after-action documents, with ADK'sBigQueryAgentAnalyticsPluginas one optional sink behind anExperienceStoreseam rather than the system of record, deterministic clustering, and generated documents reaching the corpus only through a merged pull request — without that gate the loop closes on itself and model error becomes indexed institutional fact. -
pgAdmin in the local compose stack (
docker-compose.yml,infra/pgadmin/servers.json).make upnow serves it on127.0.0.1:5050for browsing the session store — unprofiled alongside Postgres for the same reasonkafka-uiis unprofiled alongside Kafka. Opens directly into the browser tree: desktop mode disables both the sign-in page and the master-password prompt, and the connection is pre-registered. The database password is entered once and saved into the volume — pgAdmin only accepts a saved-password file at mode 0600 owned by its own uid, which a host bind mount cannot provide.PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINSis set because the default account's.localaddress is a reserved TLD that the image's email validation rejects, restart-looping the container. - Conversation history is server-backed (
core/orrery_core/serving/server.py,web/). Three endpoints —GET /sessions(the caller's conversations, newest first, titles only),GET /session/{id}(one conversation with its transcript) andDELETE /session/{id}— and the console now reads its sidebar from the session store instead oflocalStorage. The transcripts were already in Postgres:create_appbuilds the gateway withDatabaseSessionServicewheneverDATABASE_URLis set. What was missing was any way to read them back, so the browser-local sidebar was the only index of which sessions belonged to whom — a different machine, or cleared site data, left every past conversation stranded in the store as unreachable rows. Two further consequences of that split are also gone: the per-conversation 200-message persistence cap (which made the visible transcript disagree with what the agent still remembered) and the 50-conversation eviction (which hid sessions that still existed). The cap now lives on the response, where the data is. Transcripts are rebuilt bybuild_transcript()inserving/events.py, which keeps only what a person said and what the agent said back — function calls, planner thoughts and compaction digests are dropped, and each turn's text events are merged the wayPOST /chatconcatenates them, so a reopened conversation reads as it did live. The compaction check is explicit rather than incidental: an AEP-020 digest is authored"user"and keeps its content inactions.compaction, so a text-only filter happens to skip it today, but nothing would stop a future ADK from replaying an LLM summary of the conversation into the conversation as something the user said. Titles are written into session state on a conversation's first turn rather than derived from the transcript, becauselist_sessionsreturns state but never events — deriving them would cost one query per listed session on every load. Sessions opened by another transport carry none and render as "New chat". Every endpoint pinsuser_idto the verified subject, so another user's session id is a plain 404, andDELETEis checked before it is executed. Transcripts loading lazily means opening the console costs one request regardless of how much history exists.CORS allow_methodsgainedDELETE— without it the delete worked same-origin and failed only in cross-origin dev.
Changed¶
- Google ADK 2.5.0 → 2.8.0, alongside two dependency sweeps (litellm 1.98.0, uvicorn 0.52.4, pydantic 2.13.5, pydantic-settings 2.15.0, google-auth 2.57.1, ty 0.0.75, ruff 0.16.6, hypothesis 6.168.0; react 19.2.8, vite 8.2.2, vitest 5, typescript-eslint 8.70.0, eslint 10.8.1 and the testing/globals groups; node 26 base digest;
actions/setup-uv10.0.1). PyArrow left thegcpextra for the newbigquery-analyticsextra upstream, which is unused here and cuts ~50 MB from the install.google-genaimoved 2.11.0 → 2.19.0 transitively.core/pyproject.tomlhad flooredgoogle-adk[eval,db]>=2.5.0while the workspace root moved to>=2.7.1. The lock resolved 2.7.1 either way, butorrery-coreis published as its own distribution and a consumer installing it outside this workspace would have been allowed an ADK two minors behind what the code targets. docker-compose.ymlusespgvector/pgvector:pg16in place ofpostgres:16-alpine. It is the stock Postgres image plus thevectorextension — same data directory, same defaults — so an existing volume keeps working and sessions, memory and confirmations are unaffected. The Helm chart takes an externalDATABASE_URLand needed no change.AgentGateway.session_serviceis optional in the type, not just at runtime.__init__always assigns a service whilefrom_runner()may assignNone, so the attribute inferred as non-optional and the two constructors disagreed; the old# type: ignore[assignment]silenced the report rather than the mismatch. Declaring itBaseSessionService | Nonethen surfaced seven call sites in the HTTP server and Slack handler dereferencing an optional. They now narrow through asessionsproperty that raises with a message namingrun_in_session(), instead of anAttributeErrorthree frames down. Found by ty 0.0.73, which flags what 0.0.63 let through.JSONFormatter's field allowlist covers the confirmation lifecycle. Extended rather than replaced with a sweep overrecord.__dict__— logging puts a lot of machinery on a record and a caller can attach anything throughextra, so an unfiltered merge would leak both into the log stream and make the output shape unstable.
Fixed¶
- CI had been red on
mainfor over two weeks — every run since 2026-08-06, and every open Dependabot PR with it. The Security Scan job's Trivy filesystem scan runs withseverity: HIGH,CRITICAL/exit-code: 1/ignore-unfixed: true, so a fixed HIGH advisory is a hard failure by design;cryptographywas pinned at 49.0.0 with CVE-2026-69247 against it. Clearing that exposed three more HIGH advisories againstsqlparse0.5.5 that had landed in Trivy's database since. Both are transitive, so both were lock-only upgrades (cryptography50.0.0,sqlparse0.6.0). - CI red again on
mainand on all eleven open Dependabot PRs, same shape.nltk3.10.0 (transitive viagoogle-adk[eval] → rouge-score) picked up one CRITICAL and three HIGH advisories (CVE-2026-79675, CVE-2026-71513, CVE-2026-72818, CVE-2026-78680), fixed upstream in 3.10.3 — a three-lineuv lock --upgrade-package nltk. The bump then tripped Dependency Review on a newer advisory, GHSA-8mgp-746c-j5xp, which covers every published nltk release (<= 3.10.3) with no patched version yet. Trivy already skips it viaignore-unfixed;dependency-review-actionhas no equivalent, so without an allow the lockfile could never again be touched where nltk is concerned. The one GHSA is allow-listed inci.ymlwith an inline justification (nltk is only used for eval scoring; the affected model-artifact APIs are never called) and a re-check pointer — the entry is to be deleted the moment a patched release exists. A serial rebase-and-merge then landed the Dependabot backlog: the branch ruleset requires an up-to-date branch, so each merge knocked the restBEHINDand they went one at a time. vite.config.tsimports./src/api/paths.tswith its extension. Vite 8.2.1 warns that the extensionless form is unsupported byconfigLoader: 'native', planned to become the default in a future major — the native loader resolves the config through Node, which has no extensionless resolution. Both tsconfigs already setallowImportingTsExtensions, so the explicit form type-checks unchanged.- The pgvector backend validates its table identifier. A table name cannot be a bound parameter — SQL binds values, not identifiers — so
KNOWLEDGE_PG_TABLEis interpolated into every statement. It comes from configuration rather than a request, but "config is trusted" is exactly the assumption that ages badly once values arrive from a Helm chart, a ConfigMap or an operator CR. Validated once at construction against a plain-identifier pattern, which makes the interpolation provably safe rather than conventionally safe.
Security¶
.trivyignore.yaml— triaged findings, with mandatory expiry dates. The Release & Publish workflow's image scan had been failing on every push since 2026-08-06, independently of CI: eight HIGH advisories against the Go standard library (v1.26.5) linked intousr/local/bin/docker, the Docker CLI bundled so the docker-agent can inspect local containers. There is no upgrade to take. Every published Docker CLI tag —29-cli,cli,latest— is still built with Go 1.26.5, verified by scanning each one rather than assumed, so re-pinning the digest changes nothing. Removing the CLI would disable the docker-agent in-container, and rebuilding it from patched Go is disproportionate for a binary invoked as a subprocess against a local socket. All eight are denial-of-service or content-handling issues in a client that serves no HTTP, renders no templates to a browser and parses no untrusted DNS; the worst realistic outcome is the subprocess crashing, which the tool layer already handles as a tool error. Every entry carriesexpired_at(2026-10-21) and astatementsaying why. The expiry is the review mechanism and it is self-enforcing — Trivy stops honouring an expired entry and the gate goes red again, so an ignore cannot silently become permanent, and a permanent ignore is indistinguishable from not scanning. Wired through thetrivyignoresinput on all three Trivy steps, because Trivy auto-loads a plain.trivyignorebut not the YAML form, and only the YAML form supports expiry.
[0.3.1] - 2026-07-26¶
A security release. Two external reviews and one internal audit found four ways a guarded action or a credential could slip past a gate that was supposed to hold — all four are closed here, each with a test that fails if it comes back. Nothing in the platform's API changed; the dependency majors below are build-time only and invisible to anyone consuming the image or the library.
Security¶
- The closed-loop remediation actor ran mutating tools unattended (
agents/orrery-assistant/orrery_assistant/remediation.py).remediation_actorwas the only agent in the tree holding@confirm/@destructivetools withoutbefore_tool_callback=require_confirmation(), andGuardrailsPluginenforces RBAC only — the human-in-the-loop gate is per-agent wiring.run_triage.pypins the batch session tooperator, which RBAC lets past@confirmtools, soscale_deploymenthad nothing standing in its way: a scheduled overnight sweep could rescale a deployment the model chose, with no human in the conversation.restart_deploymentandrollback_deploymentwere refused, but by role, not by the mechanismrun_triage.py's own docstring credited. The actor is now gated like every other specialist, and its instruction covers the blocked outcome so aconfirmation_requiredis reported as waiting, never as done. The cause was the invariant test, not the missing line.tests/test_confirmation_wiring.py— written after the docker specialist regressed identically — walked onlyorrery_chat_agent, and the remediation actor lives in theorrery_triage_workflowgraph, which noAgentTooledge reaches. It now walks both roots (handlingWorkflow.graph.nodes) and carries a guard-the-guard test, because a walker that reaches nothing passes vacuously. - Indirect prompt injection: tool results are now screened (
core/orrery_core/plugins/safety_plugin.py).SafetyScreenPluginscreened only the user's message, which is the less important half for an infrastructure agent: a pod annotation, container log line, Kubernetes event, Kafka topic config or Elasticsearch document is attacker-reachable text that reaches the model wearing a tool result's authority. Matched spans in tool output are now neutralized in place rather than the payload being dropped — that payload is the evidence the agent was asked to read, so refusing it would break the diagnosis. Same in-place/return-Nonecontract as PII redaction (a returned copy early-exits ADK's after-tool chain and silences every later observer), and the same worker-thread hop aboveOFFLOAD_THRESHOLD_CHARS.ORRERY_SAFETY_SCREEN=falsedisables both directions. - Tool results that are not dicts escaped both content plugins (
core/orrery_core/plugins/pii_plugin.py,core/orrery_core/payload.py,core/orrery_core/persistence/memory.py). Flagged by an external audit as a hypothetical; it was live. ADK does not require a dict —FunctionTool.run_asyncreturns the function's value verbatim and the{"result": …}normalization happens in__build_response_event, after the after-tool chain — so a dict/list-only walk skipped whatever it was handed. Of the 120 registered tool functions, 119 return dicts; the exception isload_memory, wired into the shipped chat root, returning a PydanticLoadMemoryResponse. The write side did not compensate:SecureMemoryServicecarried its own shorter pattern list (key=value pairs and PEM blocks) while the tool path also caught bare provider tokens, so aghp_/AKIA/JWT pasted into chat was stored verbatim. Three gaps in a line — paste a token, it is stored unredacted,load_memoryrecalls it, neither plugin touches it, and it lands in the model context and the audit log. Both walks now traverse object attributes (payload.mutable_attributes), so a Pydantic result is scrubbed in place with the chain intact; a barestr/bytescannot be mutated at all, so it is scrubbed by returning the replacement, logged at warning level because that early-exits the remaining observers (losing one audit outcome line beats writing a credential into it).security/redaction.pynow holds the one pattern set both paths share, with a test asserting the parity structurally. - The autonomy level could be promoted from session state (
core/orrery_core/plugins/autonomy_plugin.py)._active_level()honoured anyautonomy_levelit found in state, and session state is not a trust boundary — tools write to it, and tool output is attacker-reachable text. An L2 read-only deployment was therefore one state write away from L4. Now honoured only when written through the newset_autonomy_level(), which stamps_autonomy_set_by_server; an unlocked value is ignored with a warning. This is the defence RBAC already had via_role_set_by_server— the two axes now match. /docsis off by default wherever auth is on (core/orrery_core/serving/server.py). A browser navigation cannot carry a bearer token, so the interactive schema cannot be gated behind the API's own auth, and it enumerates every route and request shape.ORRERY_DOCS_ENABLEDoverrides in either direction; local no-auth runs still get it.
Added¶
make lock-checkgates lockfile drift, in CI and locally. Every CI job opens withuv sync --all-extras, which silently rewrites a stale lock in the runner — so drift never failed a build. The check runsuv lock --checkbefore the sync in the lint job, the only position where it can still see the problem, andmake lockregenerates after a manifest edit..github/workflows/dependabot-relock.ymlcommits the missing half of a Python update back onto Dependabot's branch, so the new gate doesn't turn every dependency bump into manual work. Prefers aDEPENDABOT_RELOCK_TOKENDependabot secret (an Actions secret is unreadable in a dependabot-triggered run) and warns explicitly when it is absent, because a push made with the defaultGITHUB_TOKENdoes not re-trigger workflows.- Tests that compose the real plugin chain, rather than one plugin at a time: both content defenses applied to a single payload with nothing cutting the chain short, the output cap proven to be the only plugin permitted to early-exit, and a denied call proven to be audited because audit precedes the gates. Every prior plugin test checked a plugin in isolation or the list of names, which is how an ordering violation could ship.
Changed¶
- Python updates move to Dependabot's
uvecosystem (.github/dependabot.yml). Thepipecosystem only editspyproject.toml, so every Python update landed withuv.lockstill recording the old constraint —pytest-covmerged green that way. Theuvecosystem understands the lock;make lock-checkremains the backstop, since it can still leave one file behind. - npm updates are grouped by what npm's peer ranges force to move together — react, vite, typescript, testing. Three open Dependabot PRs had failed at
npm ciwith ERESOLVE, and not one was a bad update: each was correct but proposed alone when its peer required a companion to move with it. - TypeScript is held at 5.x. TS 7 cannot be installed here: typescript-eslint's latest and canary both peer
typescript >=4.8.4 <6.1.0, and the package hard-fails at runtime with "typescript-eslint does not support TS 7.0", so--legacy-peer-depsonly moves the error from install to lint. No 6.x stable exists to step through. The hold is on the major only, and the config names the command that says when to lift it. The console itself is already TS 7-clean —tsc --noEmitunder 7.0.2 reports zero errors. - React 19, Vite 8, ruff 0.16. React moves as a set (react, react-dom, and both type packages); vite moves with
@vitejs/plugin-react6. Nothing in the console touches an API React 19 removed — noReactDOM.render,findDOMNode,defaultPropson a function component,propTypes, string ref orforwardRef— which was grepped rather than assumed, since the test suite would not have caught most of them. The vite bump also cut the build from ~1.3 s to ~0.25 s and the bundle from 593 kB to 573 kB. ruff 0.16 formats Python code blocks inside Markdown, so the 24 affected files are docs, not source. AutonomyPluginis registered beforeGuardrailsPlugin. ADK's before-tool chain early-exits on the first non-None return, so the previous order let an L2 deployment raise a confirmation prompt for a mutation that L2 refuses the moment it is approved. The level is a property of the process — decide it before asking a human anything.- A paused L4 confirmation reports
AWAITING_CONFIRMATION, notBLOCKED. The model acts on that string, and "blocked" reads as a dead end when the action is one human answer away. - Sessions in a shared thread are keyed per participant (
core/orrery_core/serving/gateway.py,agents/slack-bot/slack_bot/session_map.py). ADK scopes every session by(app_name, user_id, session_id), so handing the first speaker's session id to a second speaker never joined them to that conversation — the lookup missed and ADK created a fresh empty session behind the same id. Keying by thread alone only hid that threads are per participant; the mapping now says so, andforget/removeclear the whole thread by default. - All 9 dev-only npm advisories cleared by upgrading the toolchain (eslint 10, typescript-eslint 8, vitest 4, jsdom 29, globals 17).
npm audit --omit=devwas already clean so nothing shipped to a browser was affected, but the noise on everymake installhides the one that will eventually matter. Pinning the patchedbrace-expansionviaoverrideswas tried first and breaks the build — 5.x changed its export shape andminimatch@3.xstill calls the old default export. - The docs home page now leads with the web console running a real triage rather than a Google Chat screenshot: five systems checked in parallel, a Critical verdict, and the recorded findings in the side panel. That is the claim the page makes two paragraphs later, so it may as well be the first thing a visitor sees.
Fixed¶
uv.lockdisagreed withpyproject.tomlonmain— the manifest asked forpytest-cov>=7.1.0while the lock still recorded>=6.0.0. The lock is tracked precisely so Docker builds are reproducible, which a lock that disagrees with the manifest is not. Repaired, with the gate above to keep it that way.- An explicit
compaction=Nonewas silently overridden (core/orrery_core/serving/{server,runner}.py).create_appandrun_persistentfell back witharg or create_events_compaction_config(); sinceNoneis falsy and the only way to disable compaction in code, passing it explicitly re-enabled compaction — the opposite of the request. Env-based disabling always worked, which is why it went unnoticed: the documented off-switch was never the broken one. - Plugin-composition tests read the developer's
.env(core/tests/). Any agent module imported during collection callsload_agent_env(), whoseload_dotenv()searches the CWD and its parents, so a perfectly legitimate localORRERY_AUTONOMY_LEVEL=L3failed two tests in a file with no connection to it — and only under a full run, since core-only runs never import an agent module. - An unresolved dotted role claim is now logged (
core/orrery_core/security/auth.py). It already failed closed on every malformed claim shape — ten hostile variants all returnviewer, none raise — but the fallback was silent, so a mistypedJWT_ROLE_CLAIMdemoted every caller to viewer with nothing in the logs pointing at the cause. - The docker CLI wrapper left a zombie on timeout (
agents/docker-agent/docker_agent/tools.py):kill()only delivers the signal, so the child stayed unreaped until the loop's watcher happened to collect it. A tool that times out tends to do so repeatedly. Limiter(default_limits=…)was inert (core/orrery_core/serving/server.py). slowapi only applies those throughSlowAPIMiddleware, which this app does not install, so the argument read as blanket coverage while doing nothing. Limits are now declared per route, where the cost actually differs —/chatbuys tokens;/confirmations/pendingis a cheap read the console polls on a timer and must not be throttled into failure.ConfirmationStore.adddocuments why it is not keyed byargs_hash, with tests pinning the behaviour. An audit recommended adding it so parallel same-tool calls stop superseding each other; that would also let a card the requester scrolled past authorize an execution minutes later. The supersede costs liveness only —consume_pendingmatchesargs_hashexactly, so an evicted call re-prompts and can never be authorized by the survivor's approval.
[0.3.0] - 2026-07-25 (tag removed; shipped in 0.3.1)¶
Added¶
- Conversation context compaction (AEP-020), on by default. A long incident session grew its transcript monotonically until the request exceeded the model's window and the turn failed.
ToolOutputCapPlugincaps a single tool result at 4 MiB against Gemini's ~10 MiB request ceiling — so three capped results in history were already enough to break the next turn; the cap deferred the failure rather than preventing it, and persistent Postgres sessions made it worse. Implemented by configuring ADK's nativeEventsCompactionConfigrather than the hand-rolled context engine the AEP originally specified: native compaction refuses to separate afunction_callfrom itsfunction_response(the proposed slice-based design would have split tool pairs and produced provider 400s on exactly the tool-heavy sessions this targets), reads realprompt_token_count, and appends the digest as an event carrying the compacted timestamp range — so the originals stay in the session and are filtered only at request assembly. Lossy for the model, lossless for the record, by construction.create_events_compaction_config()(core/orrery_core/serving/runner.py) is threaded through everyApp/AgentGatewaysite;ORRERY_COMPACTION_TOKEN_THRESHOLDdefaults to 250k, chosen to sit out of reach of ordinary sessions so enabling it changes nothing else. The summarizer is always passed explicitly — ADK otherwise derives it from the root agent's model, which bills digests at the agent's rate and raises outright for a non-LlmAgentroot, which the batch triageWorkflowis. Compactions are exported asorrery_context_compaction_total; the hook lives in a summarizer subclass because compaction events bypasson_event_callbackentirely (the Runner appends them after the agent generator is exhausted). - Single sign-on for the web console (OIDC Authorization Code + PKCE). The server perimeter was already OIDC-ready — RS256/JWKS with configurable audience, issuer and role claim — so the missing half was purely in the browser, as
TokenGatesaid in its own comment. Provider-agnostic viaoidc-client-ts(Keycloak, Authentik, Auth0, Okta, Entra ID, Google), deliberately a dependency rather than hand-rolled: the failure modes of DIY OAuth are subtle and security-critical. Enabled by settingVITE_OIDC_ISSUER; unset keeps the paste-a-token gate, somake dev-token, CI and offline work are unaffected. The access token is held in memory with silent renew instead oflocalStorage, and signing out ends the provider session too. The redirect URI is the console root, not/auth/callback— the front door serves the bundle withStaticFiles(html=True), which 404s unknown deep paths, so a sub-path callback would work in dev and break in production. - A local Keycloak under an
ssocompose profile, with a pre-imported realm and three demo users (viewer/operator/admin, password same as username) — which makes the RBAC tiers demonstrable in the browser for the first time.make up PROFILES=ssothenmake run-api SSO=1. No healthcheck on the container by design: the image is distroless, so aCMD-SHELLprobe can never run and leaves it permanentlyunhealthy;make uppolls the realm's discovery document from the host instead, which also covers realm import finishing after the server starts listening. - Role claims may now be dotted paths (
JWT_ROLE_CLAIM=realm_access.roles, and the console's matchingVITE_OIDC_ROLE_CLAIM). Both sides previously read a flat claim, but the providers people actually deploy nest their roles — Keycloak usesrealm_access.rolesfor realm roles andresource_access.<client>.rolesfor client roles, neither reachable by a flat lookup, so every SSO user silently resolved toviewer. An unresolvable path still yieldsviewer: it fails closed. - Web console: copy-to-clipboard and syntax highlighting. Copy buttons on messages, on code blocks, and on a failed environment check's detail (long connection errors that could not be selected cleanly out of a narrow panel). Reachable by keyboard, not hover-only, with an
execCommandfallback for the insecure origins a console on a bastion host is often reached over. Highlighting is restricted to nine languages so the bundle stays inside its 700 kB budget. - Web console: AEP-019 Milestone 3 — the first-run environment check.
POST /onboarding/selftestruns a one-token model round-trip plus every registered integration probe (Kafka, Kubernetes, Elasticsearch, Prometheus, Docker — each specialist's cheapest read-only tool), concurrently, and reports per-check pass/fail with the reason and what to configure when it fails. This is the AEP's own "highest-leverage single feature": nearly every first-run failure is a credential or endpoint that was never wired, and until now the only feedback was a stack trace buried in a tool result several turns into a conversation. Surfaced in the console as a System tab plus a sidebar Check my environment button. Probes are supplied by the app (create_app(integration_probes=...)) so core keeps no dependency on any agent package; they must be read-only, since the self-test does not consult RBAC. A probe that hangs, raises, or returns an error all become one red row rather than breaking the page. - Web console: the role + autonomy badge is now complete.
GET /mereturns the server-resolved role and the autonomy level actually in force (L2/L3/L4, or none). The console previously showed only its own decode of the JWT — the browser's reading of a signature it cannot verify. The server's answer now wins in the badge, and the System tab spells out what the active level permits, so a viewer learns why mutating tools are unavailable up front instead of when one is refused. - Web console: per-system triage chips (AEP-019 Milestone 2). A triage verdict now renders a chip per system that was actually consulted, derived from the recorded tool calls rather than parsed out of the model's prose — the report is free text that changes wording run to run, the activity log is structured. A system that was never called shows no chip: "we didn't ask" and "healthy" are different answers.
Changed¶
- The Makefile is one target per job, with variants as flags (34 targets → 28).
make up/make downnow act on everything, withPROFILES=to narrow;make checkruns the whole gate across both toolchains (ruff + ty + pytest + the web gate), andinstallandfmtlikewise cover both. Variants that used to be near-duplicate targets are now flags:run-cli PERSIST=1,run-api SSO=1,run-slack MODE=socket,run-chat MODE=pubsub,dev-token ROLE=viewer,up PROFILES=tracing.run-apibuilds the console before serving it, so it is the single command for "the product". npm is optional throughout — Python-only contributors get a warning and a working setup, not a failure. Thedemoandslackcompose profiles are deliberately excluded frommake up: they run the agent in Docker on the same ports asmake run-api/make run-slack. This renames the developer-facing targets —run-assistant*,infra-*,tracing-*,sso-*andweb-*are gone;make helplists the current set. - Dependabot now covers the web console (
.github/dependabot.yml). Thepipecosystem never sawweb/, which sits outside the uv workspace, so the console's dependencies — React, and now the OIDC client that handles sign-in — were getting no security updates at all. - Web console UX: a turn can be stopped, and a failed one retried. A triage sweep fans out to five specialists and can run for a minute with no partial output (streaming is AEP-009), and the only escape used to be reloading the page. Send becomes Stop while a turn is in flight; stopping is reported as stopped rather than failed, and notes that the server may still finish. A failed turn offers Retry, which replays the message without duplicating it in the transcript, and errors can be dismissed. A 429 from the new chat rate limit is explained with its
Retry-Afterinstead of surfacing as a bare error. - Web console: the transcript no longer yanks you back to the bottom. Auto-scroll now only follows the newest message when the reader is already at the bottom — scrolling up to re-read an earlier answer while a sweep is still landing used to fight the user. The transcript also gained
role="log"and an accessible name. - Web console: the composer grows with the message up to a bound, instead of showing a multi-line prompt through a one-line peephole.
- Docs caught up with the 21 tools added in 0.2.3 (
agents-overview.md,CLAUDE.md): the at-a-glance table still read 19/18/12/10 tools for kafka/k8s/observability/docker (actually 24/26/16/17) with stale guarded counts, and none of the new tools appeared in the per-agent role tables that the site presents as the authoritative catalog.
Fixed¶
- The web console's environment check returned "Not Found" under
make run-web(web/vite.config.ts). The Vite dev proxy listed only/chat,/session,/confirmations,/healthzand/readyz, so the System pane's two endpoints never reached the API. One root cause produced two symptoms because the verbs differ:POST /onboarding/selftestplainly 404'd, whileGET /mewas rewritten by Vite's history fallback to the SPA shell and returned 200 HTML that then died insideres.json()— which is why Role (server) and Model rendered as "—" with nothing reported. The comment above that list already warned about this exact failure, so the fix is structural rather than two more strings:API_PREFIXES(web/src/api/paths.ts) is now the single source the proxy derives from, guarded by a test that drives everyApiClientmethod and fails if a path escapes it. Removing a prefix reproduces the original bug as a test failure. make cleanwas destroyingnode_modules. Itsfind . -type d -name 'build'matched every npm package that ships its dist in a directory calledbuild—pretty-format,jwt-decodeand others — silently gutting them; the web suite then failed much later withCannot find module …/build/index.js, with no obvious connection to the command that caused it. It now prunesnode_modules,.venvand.gitbefore matching.- Stopping one compose profile tore down the whole stack.
docker compose --profile X downremoves every service in the file — a profile filter only adds services, it does not scopedown— so stopping the tracing or SSO stack also stopped Kafka, Postgres and Prometheus. Both targets now remove their own containers by name. make resetdeleted local volumes without confirmation. It now lists the volumes it will destroy and requires typingyes(FORCE=1to skip).- A non-JSON API response no longer fails silently (
web/src/api/client.ts). A 200 that would not parse threw a bareSyntaxErrorno caller interpreted; it now reports what actually arrived and that the request likely never reached the API./mefailures surface in the System panel instead of being swallowed, and errors distinguish network / auth / rate-limit / 5xx rather than echoing a raw message. - Web console: an assistant reply could be lost to a same-tick New-chat or delete (
web/src/conversations/useConversations.ts). Removing the setState-inside-updater anti-pattern in 0.2.3 replaced it with a stale-snapshot read:newConversation/deleteConversationbuilt a whole new array from the render closure, so apatchActivequeued earlier in the same tick — the arriving assistant message — was silently overwritten. The list and the active selection are now oneuseReducer: they are a single piece of state (every create/delete/select moves both), so React replays each dispatch against the freshest state and neither anti-pattern is reachable. Two regression tests reproduce the lost reply. - Web console: conversation history is now genuinely bounded (
web/). The 50-conversation cap didn't bound storage — one long incident thread grows without limit — and the quota failure it was meant to prevent was swallowed silently. Persisted transcripts are trimmed to the most recent 200 messages (the in-memory transcript is untouched), a rejected write retries against progressively smaller slices instead of giving up, and eviction now drops the least-recently-updated conversation rather than the oldest-inserted — matching the order the sidebar shows. - Web console: the side-pane refresh guard compared the active conversation with itself (
web/src/chat/useChat.ts), so it could never actually fire. The refresh now takes the conversation that owns the session it is fetching for, which is what the guard was meant to compare against. - A specialist's model failure no longer reads as an answer (
core/orrery_core/reliability/error_handlers.py). When an agent reached through anAgentToolhit a model error,graceful_model_error()returned "I encountered an error… Please try again" — and because that text becomes the tool's result in the coordinator's transcript, a coordinator cannot tell a polite apology from a finding: it treats the step as done and can summarize an incident whose Kafka check never ran. The response now states plainly that the step failed and that no result was produced, and calls out quota exhaustion (429) by status code walked up the__cause__chain rather than by matching an error string. - k8s tools: input validation gaps (
agents/k8s-health).label_selectorwas passed to the API server unvalidated onlist_pods/list_services(the repo's rule is that every tool validates at entry);describe_service/get_configmapacceptednamespace="all", which is a legal namespace name, and then 404'd confusingly instead of saying what was wrong; andtop_podssummed unparseable CPU/memory quantities as0, making "this pod is idle" and "we couldn't measure this pod" indistinguishable — it now reports the affected containers instead.
Security¶
- An unknown-
kidbearer token returned 500 with a traceback instead of 401 (core/orrery_core/security/auth.py). PyJWT'sPyJWKClientErroris not anInvalidTokenError, so it escaped the handler on the RS256/JWKS path — meaning essentially every forged or garbage token became an unhandled server error on an unauthenticated request path, burning a JWKS lookup and emitting a stack trace for each one. Now a clean401. A genuinely unreachable IdP (PyJWKClientConnectionError) still surfaces as a server error, since that is not the caller's fault and a new token would not fix it. This became reachable in practice with the SSO work above, which makes RS256 the recommended production configuration. POST /chatis now rate limited (core/orrery_core/serving/server.py). The Slack bot had a limiter; the main front door — the one that spends LLM tokens and can fan a single turn out to six specialists plus a triage sweep — had none, so one leaked credential was unbounded spend. Keyed on the verified JWT subject rather than the source address: behind an ingress every caller shares an IP, and keying on the raw token would hand out a fresh quota on every refresh. Configurable viaORRERY_CHAT_RATE_LIMIT(default30/minute).- Elasticsearch
searchnow bounds the query on the cluster (agents/elasticsearch). The HTTP client timeout only bounded our wait — when it fired, a model-authored deep aggregation or leading-wildcard query kept running on the cluster with nobody left to read the result. Atimeout(elasticsearch_search_timeout, default10s) now travels with the body, and a partial result surfaces itstimed_outflag so the model can't mistake it for a complete answer. - A wildcard CORS origin no longer allows credentials (
core/orrery_core/serving/server.py).ORRERY_CORS_ORIGINS=*combined withallow_credentials=Truemakes Starlette echo back whatever origin asked, turning every site into a permitted credentialed caller. The API authenticates with a bearer header rather than a cookie, so credentials buy it nothing — they are now dropped (with a warning) when the origin list contains*. pyasn10.6.3 → 0.6.4 (uv.lock): CVE-2026-59885 and CVE-2026-59886, two HIGH denial-of-service issues via crafted ASN.1 OBJECT IDENTIFIER / REAL values. Transitive viagoogle-adk → google-auth → pyasn1-modules, so a lock-only bump — nothing declares it directly andpyasn1-modulesalready allows<0.7.0.- Requester-verified confirmation: a stale approval could authorize a later action (
core/orrery_core/security/guardrails.py,core/orrery_core/serving/gateway.py). The gateway only wrote_confirmation_decisionon turns whose text was a decision word, and the gate only checked that the decision was fresh (TTL 300s) and that a pending matched the tool + args-hash. So anapprovetyped while nothing was pending — for something else, or for nothing at all — stayed in session state and authorized whichever guarded call came next: the model raised the pending, re-called the tool, the args-hash matched, and a@destructivetool executed on an approval no human had given for it. The model-mediated path had adifferent_invocationcheck against exactly this; strict mode had dropped it. Fixed from both ends — the gateway now rewrites the decision key every turn (writingNonewhen the message is not a decision), and the gate requiresdecision.timestamp >= pending.created_atplusdecision.by == requester, so an approval that predates the action it would authorize is refused and the action re-prompted. Applies to every shipped exposition (HTTP front door, persistent runner, Slack, Google Chat), all of which armverified_confirmation=True. Two regression tests cover the bypass and the honest flow. - Tool exception text no longer reaches the model context (
core/orrery_core/reliability/error_handlers.py):graceful_tool_error()returnedf"Tool '{name}' failed: {error}", and client exception strings from HTTP/Kubernetes/Kafka embed internal hosts, URLs, and token paths (CWE-209) — which then flowed into the transcript, the session store, and whatever the model said next. The full traceback now stays in the server log and only the exception class is named. The one exception is an HTTP 4xx, whose body describes what was wrong with our own request and is what lets the model correct and retry. - Namespace scope guard (
NamespaceScopeGuardincore/orrery_core/security/rbac.py): RBAC decided which tool a role could run but never where, andrestart_deploymentis the same@confirmtool whether it targetspaymentsorkube-system. SetORRERY_PROTECTED_NAMESPACES(comma-separated globs) to refuse non-admin mutations in infrastructure namespaces; reads are never scoped, so triage still sees everything. The effective namespace is resolved from the call's argument or the tool's signature default, and a namespace that cannot be resolved (required-but-missing, or a non-string) fails closed. Opt-in — unset leaves the guard inert, so existing deployments are unchanged. alter_topic_configre-gated as@destructive(agents/kafka-health): it was@confirm(operator), butretention.ms=1or movingcleanup.policyoffcompactdestroys a topic's data as surely as deleting it — a data-loss path behind a non-destructive gate that autonomy L3 also allowed. It is now admin-only and restricted to the four data-affecting keys; the everyday tuning it used to cover (message size, compression, min ISR) moved to a new@confirmtune_topic_config, which refuses the destroying keys.
Performance¶
- PII redaction no longer holds the event loop, and does ~2x less work (
core/orrery_core/plugins/pii_plugin.py). It was the dominant cost of the whole after-tool chain — measured ~60 ms per MiB, 1.28 s on a 20 MiBget_pod_logsresult — in anasynccallback whose body is pure CPU, so every other in-flight request stalled for the duration. It also runs on the uncapped result, sinceToolOutputCapPluginhas to stay last in the chain (it returns a replacement, which early-exits ADK's after-tool chain). Two fixes: each secret-value pattern now declares the case-sensitive literals it cannot match without (AKIA,ghp_,eyJ,-----BEGIN, …) and is skipped outright when they are absent — 2x faster on clean log text with byte-identical output — and redaction above 256 KiB moves to a worker thread. Measured on 20 MiB: chain 1445 ms → 785 ms, peak loop stall 1200 ms → 515 ms. (Combining the patterns into one alternation was tried first and measured slower, so it was dropped.) - Audit no longer writes the entire tool response (
core/orrery_core/observability/audit.py). Running before the output cap meant a 20 MiB result became a 20 MiB log line, per tool call, with a +45 MiB allocation spike — the largest driver of log-ingestion cost on a busy agent, and a copy of infrastructure output into a store with far wider readership than the agent. Responses overMAX_AUDIT_RESPONSE_CHARS(4096) are now recorded as their status plus measured size; smaller ones (the overwhelming majority) stay verbatim so the trail is still debuggable. The status is kept whatever the size. Measured on 20 MiB: 42 ms → 0.9 ms, +45.3 MiB → +0.1 MiB. session_logis bounded (core/orrery_core/observability/activity.py). The log was rewritten whole on every tool call, and ADK'sState.__setitem__copies the assigned value straight into the event's state delta — so event N persisted a list of N entries and a session's write volume grew with the square of its length (a 50-tool triage sweep wrote ~1,275 entries' worth of deltas). Capped at the most recentMAX_SESSION_LOG_ENTRIES(200), which is what the log is actually read for.- Consumer-lag calculation is no longer quadratic (
agents/kafka-health).get_consumer_lagscanned the committed-offset list once per partition. Measured lookup cost: 0.59 ms at 200 partitions, 16 ms at 1000, 152 ms at 3000 — against 0.56 ms through a dict index. - Memory recall is bounded (
core/orrery_core/persistence/memory.py).search_memoryhad noLIMITand no recency cut-off, and every matching row is JSON-parsed into aContentand then sent to the model — so a recall on a common word ("error", "pod") got slower and more expensive for the life of the deployment, since memory is append-only and every session of ≥4 events is saved. Now returns the newestMAX_SEARCH_RESULTS(200), flipped back to chronological order for the reader. - The blocking-tool thread pool is sized from the container's CPU quota (
core/orrery_core/concurrency.py, new). Every tool offloads to the loop's default executor, which asyncio sizes atmin(32, os.cpu_count() + 4)— and in a podos.cpu_count()reports the host's cores, so a 64-core node built a 32-thread pool for a container the Helm chart limits to1000m.configure_default_executor()reads cgroup v2cpu.max(then v1, then the stdlib) and installs a proportional pool with a floor of 8, called at startup by the HTTP front door and the persistent runner. Override withORRERY_MAX_WORKER_THREADS. GET /confirmations/pendingno longer blocks the event loop (core/orrery_core/serving/server.py). The console polls it every 2.5 s while a request is in flight, and the postgres confirmation backend is a synchronous engine — so each poll was a blocking database round-trip on the loop, per client, on a timer. Now offloaded.
Documentation¶
integrations/web-console.mddocuments both auth modes and the two Keycloak pitfalls that each present as a generic "Invalid or expired token": realm roles are nested atrealm_access.roles, and access tokens carryaud: accountunless an audience mapper is added.config/security.mdcovers dotted role-claim paths;config/general.mdgains a Context Compaction section covering the interaction with context caching (a compaction invalidates the cached prefix, so a threshold tuned too low quietly erodes the cache-hit rate).- Docs caught up with the Makefile rename, which surfaced references to targets removed long ago —
run-kafka-health,run-devops,run-docker,run-journal,run-elasticsearch,run-observability,run-k8sand their-clivariants, all in agent READMEs telling readers to run agents standalone. They now point at the orchestrator, which is how the platform is meant to be run. integrations/web-console.md(new) — enabling, auth posture, what each panel is for, the endpoints it uses, and the deliberate non-goals. Linked from the integrations overview (now listed first, ahead of the developer-facingadk web) and from getting-started. AEP-019 is marked complete, with the two things it deliberately does not do recorded rather than left as open checkboxes: token-by-token streaming (AEP-009) and the remediation act→verify→retry trace, which lives in the batch workflow the console does not host.
[0.2.3] - 2026-07-19¶
Fixed¶
- Web console reliability cleanups (
web/): four issues from the app-shell review — (1) a side-pane refresh race where a slowactivity/triage/pendingfetch for a previous conversation could clobber the one now on screen (useChatnow tags each refresh with the conversation it was for and drops stale results); (2) removed the deadsessionIdlocalStorage key (no longer written — sign-out still clears it and any legacy keys); (3) thesetState-inside-updater anti-pattern inuseConversations(newConversation/deleteConversationno longer callsetActiveIdinside thesetConversationsupdater — impure under StrictMode); (4) capped conversation history at 50 solocalStoragecan't grow unbounded and silently stop persisting. 8 new hook tests (useConversations,useAuthsign-out); tsc / eslint / prettier / build all clean.
Added¶
- 21 new tools across four specialist agents (fully validated, guarded, and tested — 45+ new unit tests, all suites green):
- k8s-health (+6, read-only):
list_services/describe_service(reports ready vs not-ready Endpoints — the usual cause of "connection refused" when pods look fine),list_configmaps/get_configmap(values truncated), andtop_nodes/top_pods(live CPU-millicores / memory-MiB via the metrics API, with a clear message when metrics-server is absent). - kafka-health (+4):
get_topic_config(splits overridden vs default, masks sensitive),alter_topic_config(@confirm, incremental — other configs untouched),reset_consumer_group_offsets(@destructive, earliest/latest),delete_consumer_group(@destructive). - docker-agent (+7):
list_networks/inspect_network,list_volumes/inspect_volume,system_df(disk usage by type), andprune_images/prune_containers(@destructive, dangling/stopped by default). - observability (+4, read-only):
list_prometheus_metrics(discover metric names by substring),get_prometheus_metadata(type/help),get_prometheus_rules(alerting + recording rules and their state), andquery_loki_range(LogQL over a relative last-N-hours window). - Each agent's instruction gained a short routing note so the model reaches for the new tools appropriately; all mutating tools flow through the existing
@confirm/@destructive+ requester-verified confirmation gate.
[0.2.2] - 2026-07-19 (tag removed; shipped in 0.2.3)¶
Changed¶
- Docs review: fixed staleness, tightened onboarding docs, and polished the MkDocs site (
CONTRIBUTING.md,SECURITY.md,`,mkdocs.yml):CONTRIBUTING.mdwas rewritten — it pointed contributors at a non-existentmake run-kafka-healthtarget (nowmake run-assistant), the wrong validator import (orrery_core.validation→orrery_core.security.validation), and a stale project tree; it now documents the realfmt/lint/test/evalflow, Conventional Commits, the CHANGELOG expectation, the AEP process, and the mock-every-operator-client testing rule.SECURITY.mdnow references the shipped runtime defenses (SafetyScreenPlugin,PIIRedactionPlugin) and the0.xpre-release support line. Brand consistency pass (AI Agents platform→Orrery) across getting-started/integrations/enhancements. MkDocs style: Inter + JetBrains Mono fonts,navigation.prune/content.code.annotate` / instant-progress, and a light CSS polish for tables and code blocks. Strict build passes.
Added¶
- Four new enhancement proposals from a Hermes-architecture benchmark (
enhancements/aep-020..023): comparing Orrery against the mature Hermes agent architecture surfaced four production-readiness gaps worth adapting to the ADK/Postgres model — AEP-020 conversation context compaction (a pluggable summarizing context engine so long incident sessions don't overflow the model window, with lossless lineage), AEP-021 LLM provider fallback chain (FallbackLlmaroundresolve_model()reusing the existing circuit breaker so a provider outage/quota isn't a full platform outage), AEP-022 trajectory capture (export real runs →*.test.jsoneval scenarios + a fine-tune corpus, downstream of PII redaction), and AEP-023 first-class scheduled agent tasks (persisted recurring triage sweeps with run history, L2 read-only by construction). All proposed; wired into the enhancements index, roadmap, and nav.
Fixed¶
- Agent evals were failing because the harness didn't mock operator clients (
agents/{kafka-health,k8s-health}/tests/test_*_eval.py): the kafka and k8s eval runners mocked only the primary client (KafkaAdminClient/ K8s core+apps APIs) but not the operator client (strimzi._custom_objects_api/operators._custom_objects_api). On runs where the model reached for an operator tool, the call hit a live cluster, errored, and polluted the tool trajectory — so exact-match (tool_trajectory_avg_scoremust be 1.0) collapsed to 0.0 non-deterministically. Both harnesses now mock the operator client too, making the evals hermetic. (Elasticsearch already mocked its ECK client; docker and observability had no operator layer.)
Changed¶
- Agents constrained to deterministic tool choice so exact-match evals stay green (
agents/{kafka-health,k8s-health,observability}/*/agent.py): the current model (gemini-3.1-pro-preview) legitimately varied its tool set on broad prompts, which strict trajectory matching can't tolerate. Sharpened three instructions — a better product and a stable eval: kafka now uses Strimzi operator tools only when the user explicitly asks about the operator/CRs/connectors/rebalances/MM2/users (not on plain health/topic/lag questions), plus a "be surgical" rule; k8s defines a cluster "overview" as exactlyget_cluster_info + get_nodes(namespaces only when asked); observability builds LokiLogQLdirectly ({job="…"} |= "…") and callsquery_loki_logsonce instead of a non-deterministicget_loki_labels/get_loki_label_valuesdiscovery preamble. Kafka's consumer-lag scenario dataset was updated to include the deliberatedescribe_consumer_groupsmember check (the instruction has always paired it with lag — "lag with no members is an outage, not slowness"). All 5 agent eval suites pass. New guide: Agent Evaluations. - Cross-session memory is now model-invoked (
LoadMemoryTool) instead of preloaded (agents/orrery-assistant/orrery_assistant/agent.py): the chat root swappedPreloadMemoryTool— which searched memory on every turn (query = raw user message) and auto-injected the hits — forLoadMemoryTool, exposing aload_memoryfunction the coordinator calls only when it judges past context useful, with a query it crafts. This keeps trivial turns (greetings, one-off status checks) cheap and gives targeted queries, at the cost of relying on the model to look — so the instruction was updated to say explicitly when to call it (before diagnosing a symptom/alert, or when the user references an earlier incident), when to skip it, and how to query (shortsystem + symptom, at most once per turn). Samememory_servicebackend andMemoryPluginwrite path — no service or storage change. Docs (memory.md, README,agents-overview.md, CLAUDE.md) updated; the ADR-003/AEP-003 records keep their originalPreloadMemoryToolwording as point-in-time history. - Web console redesigned into an app shell (Tailwind CSS v4) (
web/): the single-column layout is now a left sidebar (brand, New chat, Run triage, conversation history, identity/role footer) + chat column + a toggleable right inspector panel with two tabs — a clean tool-calls table (time / tool / agent / status, replacing the inline list) and the triage report rendered as prose (auto-opens when a verdict lands). Adds client-side conversation history: since the server has no "list sessions" endpoint, each conversation (full transcript + serversessionId) is kept inlocalStorage, titled from its first message, and switchable from the sidebar — selecting one reloads its tool-call/triage panes from the server. Styling moved from ~640 lines of hand-written CSS to Tailwind v4 (@tailwindcss/vite) with@tailwindcss/typographyfor markdown; dark mode still followsprefers-color-scheme. No API or server changes — same endpoints, same requester-verified confirmation flow.
Added¶
- Web console: triage view — AEP-019 Milestone 2 (
core/orrery_core/serving/server.py,web/): a Run triage header button sends the canned sweep prompt to theincident_triage_agent, and a new owner-scopedGET /session/{id}/triageendpoint returns the recorded verdict (incident_severity+triage_report— ADK'sAgentToolforwards sub-session state deltas to the parent session, so the chat-root triage lands in the HTTP session's state). The console renders it as a severity banner (healthy / degraded / critical, theme-aware) with the full report collapsed inside as markdown, and the tool-call timeline is now polled every 2.5s while a request is in flight, so multi-specialist sweeps become visible as each specialist completes — the closest thing to progress until streaming (AEP-009). 5 new server tests + 2 console tests. - Web console: tool-call timeline + confirmation UI — AEP-019 Milestone 1 complete (
core/orrery_core/serving/server.py,web/): two new read endpoints and their renderers.GET /session/{id}/activityreturns the session's tool-call log (recorded byActivityPluginundersession_log); the lookup pinsuser_idto the verified JWT subject, so another user's session id is a plain 404. The console renders it as a collapsed timeline under the transcript — which specialist ran which tool, with what outcome.GET /confirmations/pendingsurfaces the caller's own guarded action awaiting a decision (strict-mode pendings are requester-scoped, so the endpoint can only ever show your own); the console renders an Approve/Deny panel whose buttons send the literal wordsapprove/denythrough the normalPOST /chatflow — rendering only, the requester-verified gate remains the sole authority on who may approve. New public accessorlatest_pending_for_scope()in guardrails; 11 new server tests + 6 new console tests (incl. an end-to-end approve flow). - Runtime security hardening — AEP-013 completed (
core/orrery_core/plugins/safety_plugin.py,pii_plugin.py,agent/base.py): three content-level defenses, all on by default indefault_plugins().SafetyScreenPluginblocks prompt-injection messages ("ignore previous instructions", "reveal your system prompt", "bypass the guardrails", ...) inbefore_run_callback— the one plugin hook whose non-None return halts the runner — so a screened message never reaches the model, costs no tokens, and cannot influence a tool call (ORRERY_SAFETY_SCREEN=falsedisables).PIIRedactionPluginscrubs credentials from every tool result — credential-named dict keys replaced outright, plus value-pattern scanning forpassword=...pairs, PEM blocks, AWS/GitHub/Slack/OpenAI token shapes, and JWTs — by mutating the result in place and returningNone, because ADK's after-tool chain early-exits on the first non-None return and a returned copy would silence every later observer; it registers beforeAuditPluginso the audit log records redacted values too (ORRERY_PII_REDACTION=falsedisables;ORRERY_REDACT_IPS=trueadditionally redacts IPv4s — off by default since an SRE agent that can't see pod IPs can't diagnose much). Gemini content-safety filters:create_agent()attachesGenerateContentConfigsafety settings (dangerous content / harassment / hate speech / sexually explicit) to Gemini models atBLOCK_ONLY_HIGH(GEMINI_SAFETY_THRESHOLDoverrides;GEMINI_SAFETY_FILTERS=falsedisables; LiteLLM providers unaffected). 41 new tests. - Supply-chain hardening — AEP-014 completed (
Dockerfile,.github/workflows/{ci,release}.yml,deploy/k8s/imagepolicy.yaml,supply-chain.md): all three base images are now pinned by digest (tag@sha256:...; Dependabot's docker ecosystem bumps them), a CycloneDX Python SBOM of the frozen dependency graph is generated on every CI build (artifact) and attached to each GitHub release (asset), the pushed image is Trivy-scanned before the release job runs — HIGH/CRITICAL fixable CVEs fail the workflow so no release is cut for a vulnerable image, with SARIF uploaded to GitHub Code Scanning — and PRs gain a dependency-review gate (fail-on-severity: high). An opt-in SigstoreClusterImagePolicy(deploy/k8s/imagepolicy.yaml) lets clusters refuse anyghcr.io/bahalla/orrery*image not signed by this repo's release workflow. The verification flow (cosign verify, SBOM download/scan) is documented in Supply Chain Security. Cosign keyless signing and buildx SBOM/provenance attestations had already landed with AEP-011.
Fixed¶
- Web console leaked conversation history across sign-out and trusted stored data (
web/src/auth/useAuth.ts,web/src/conversations/useConversations.ts): sign-out cleared the token but left theconversations/activeConversationlocalStoragekeys, so on a shared browser the next user saw the prior user's full transcripts — sign-out now clears all user-scoped keys. Separately,load()blindly cast stored data (as Conversation[]); a corrupt or old-schema entry could throw during render and white-screen the app, so it now validates each entry against the current shape and drops anything malformed. - PII redaction missed camelCase credential keys (
core/orrery_core/plugins/pii_plugin.py):SENSITIVE_KEY_PATTERNrequired a_/-/.separator before the credential word, sodbPassword/accessToken/AccessTokenleaked whiledb_passwordwas caught. Keys are now normalized to snake_case (camel boundaries → underscores) before both the sensitive-match and the allowlist check — which also keepsnextPageTokenmapping onto the allowlistednext_page_tokeninstead of being redacted as a token (the naive fix of a case-insensitive camel lookaround in the regex would have broken exactly that). Also:tokennow matches singular-only — plural endings (total_tokens,maxTokens) are LLM usage counts, not credentials, and were being scrubbed. - Security: docker specialist's destructive tools ran without confirmation on the HTTP/console path (
agents/docker-agent/docker_agent/agent.py): theGuardrailsPluginenforces RBAC only — human-in-the-loop confirmation comes from each agent wiringbefore_tool_callback=require_confirmation(), and four of the five specialists did; docker never had it, so an admin asking the console to delete an image got an immediate unconfirmedremove_image(Slack/Google Chat were unaffected — those transports walk the whole agent tree and wire their own gate). The docker agent now wires the gate like its peers, and a new structural test walks the chat root'sAgentTooltree and fails if any agent exposes a@confirm/@destructivetool without a before-tool gate, so this class of gap cannot ship again.
Changed¶
- Web console renders assistant replies as markdown (
web/src/components/MessageList.tsx): agent replies are markdown (lists, bold, inline code, tables) and were displayed raw — literal**and*bullets. Assistant bubbles now render throughreact-markdown+remark-gfm(React elements, nodangerouslySetInnerHTML, so no sanitizer needed) with theme-aware typography for lists, code blocks, and scrollable tables; user messages stay plain text.
[0.2.1] - 2026-07-18 (tag removed; shipped in 0.2.3)¶
Added¶
- Web console MVP (AEP-019, Milestone 1) (
web/,core/orrery_core/serving/server.py): a Vite + React + TypeScript single-page console served by the existing FastAPI front door — bearer-token gate (token persisted locally, sent asAuthorizationonPOST /chat), chat threading the server-issuedsession_id, an identity + role badge (decoded client-side for display only; the server remains authoritative for RBAC), and loading/error/network/auth-expiry states with theme-aware, accessible CSS. Opt-in viaORRERY_WEB_CONSOLE_ENABLED(default off): the built bundle mounts at/after the API routes so explicit routes always win, and a missing bundle is warn-and-skip, not a crash — the static shell is public,/chatstays JWT-gated. The Docker image builds the bundle in anode:22stage and copies onlydist/into the Python image (no Node in the runtime image); the Node toolchain is isolated from the uv workspace, somake teststays Node-free. Dedicated web CI job (eslint + prettier + tsc strict + vitest). - One platform-wide confirmation store and flow for guarded tools (
core/orrery_core/security/confirmation_store.py+confirmation_flow.py): the pending-approval handshake was implemented three times — core strict mode, the Google Chat bot, and the Slack bot — with drifting semantics (Slack had no TTL, no args-hash pinning, no approval-validity window, and a session-state retry flag that let any retry through once a pending was raised; the Postgres backend existed twice). All transports now share onePendingConfirmationrecord and one store (memory | postgres viaORRERY_CONFIRMATION_BACKEND, over the existingDATABASE_URL), scoped per transport (requester for HTTP/CLI strict mode; thread/space for Chat; channel/thread for Slack), plus shared flow primitives:raise_pending,approval_refusal(requester-only, fail-closed),blocked_payload,hash_args, and the agent-tree walkerwire_before_tool_callback. One-shot guarantees are uniform and atomic (singleDELETE … RETURNINGon postgres — racing replicas cannot both consume a decision; a lock on memory), approvals are args-hash-pinned with a 120s validity window everywhere, andAgentGateway(verified_confirmation=True)resolves the backend eagerly so a misconfiguredpostgresbackend fails at startup (same fail-fast contract as the session store). Slack hardening: the bot inherits TTL, args-hash pinning, the approval-validity window, the AgentTool-boundary-safe handshake, and the optional Postgres backend (multi-replica) for free. Breaking (unreleased surface only):GOOGLE_CHAT_CONFIRMATION_BACKENDis replaced byORRERY_CONFIRMATION_BACKEND(the Helm valuepubsubWorker.confirmation.backendis unchanged and now exports the new env var), and the unreleasedorrery_gchat_confirmations/orrery_pending_confirmationstables are superseded by oneorrery_confirmationstable (entries are 5-minute-TTL ephemera; abandoned tables can be dropped). The multi-replica motivation: a process-local pending store quietly contradicted both AEP-018's backlog HPA on the Pub/Sub worker and any multi-replica HTTP deployment — a pending raised on pod A was invisible to the pod that received the operator's approval (or the LLM's consuming retry), so the flow died. Withpostgres, the handshake also survives pod restarts, and the Helm chart still refuses to render a multi-replica Pub/Sub worker while the backend ismemory, mirroring the idempotency guard.
Changed¶
- All agent prompts standardized on a shared SRE operating doctrine (
core/orrery_core/agent/prompts.py+ every agent'sagent.py): all 13 instruction sets now compose a sharedOPERATING_PRINCIPLESblock — verdict-first answers, report only what a tool returned this turn (a failed call is itself a finding; unknown is never healthy), minimal sufficient calls for targeted questions, and read-back verification after any state-changing action — plus aCONFIRMATION_RULEblock (on aconfirmation_requiredresult: relay to the user and stop, never self-approve). Triage checkers gained a fixed report contract (STATUS: healthy | degraded | critical | unknown; missing data is never reported healthy), the summarizer enforces strict verdict rules (critical if any system is critical; degraded on any degraded or unverified system; healthy only when everything affirmatively reports healthy;record_triage_verdictexactly once), and the remediation actor/verifier were hardened (one action per iteration, smallest blast radius first; the verifier checks the original symptom is gone, not merely that the action ran)._PROBLEM_SIGNALSnow also flagsstatus: unknown/unverified/unreachablereports. - Env var renamed:
CONTEXT_CACHE_MIN_TOKENS→CONTEXT_CACHE_MIN_LENGTH(core/orrery_core/serving/runner.py,.env.example, Helm values, docs): Trivy's KSV-0109 check flags ConfigMap keys containingTOKENas potential secret leaks; renaming the key beats carrying a permanent ignore rule. The old name (shipped in 0.2.0) is still honored as a fallback with a deprecation warning, so existing deployments keep their setting — but update your env files and Helm values. - Dependencies:
google-adk >= 2.4.0(Postgres-backed tests now skip when the database is unreachable instead of failing),litellm >= 1.91.2(root floor and the uvoverride-dependenciespin aligned — the override silently replaced the floor before),uvicorn >= 0.51.0,confluent-kafka >= 2.15.0,slack-bolt >= 1.29.0,google-auth >= 2.55.2; dev toolingruff >= 0.15.21,ty >= 0.0.58,hypothesis >= 6.156.6; CIastral-sh/setup-uv8.3.2;uv.lockrefreshed.
Fixed¶
- Destructive-tool confirmation looped forever through an
AgentTool(core/orrery_core/security/guardrails.py,agents/orrery-assistant/orrery_assistant/agent.py): in requester-verified (strict) mode the pending confirmation was stored intool_context.state, but guarded tools are reached through anAgentTool(the chat root delegates to a specialist) and everyAgentToolcall runs the specialist in a fresh, throwaway sub-session. The pending written during the request turn was therefore gone by the turn the human'sapprovearrived, so theapprovedbranch was structurally unreachable — the gate re-prompted every time and the root model spun, re-calling the specialist with fabricated approvals ("approve","Force restart…","I explicitly approve…") until it hit its iteration cap. Two fixes: (1) strict-mode pendings now live in a process-levelPendingConfirmationStorekeyed by requester (the one identity the gateway forward-propagates into every sub-invocation and that is stable across turns), so a real approval in a later, different session resolves the pending raised earlier; the "only the original requester may approve" rule is now enforced by the key itself (fail-closed on an unknown requester). (2) The chat-root instruction now mandates: on aconfirmation_requiredresult, relay it to the user and stop — never self-approve or re-call the specialist in the same turn. Model-mediated (non-strict) mode is unchanged. Regression test drives a@destructivetool across two distinct sub-sessions and asserts the approval resolves. Note:PendingConfirmationStoreis process-local; multi-replica HTTP deployments need a shared backend, mirroring the Google Chat store above.
[0.2.0] - 2026-07-11 (tag removed; shipped in 0.2.3)¶
Added¶
- Autonomy levels (L2/L3/L4) (
core/orrery_core/plugins/autonomy_plugin.py): a third access-control axis, orthogonal to RBAC — RBAC answers who may act, autonomy answers which mode this process runs in. L2 is read-only fail-closed (only unguarded tools + an explicit whitelist run), L3 allows mutations but blocks@destructive(+ blacklist), L4 allows destructive tools behind ADK-nativerequest_confirmation. Classification comes from the same@confirm/@destructivemetadata RBAC uses. Opt-in: registered only whenORRERY_AUTONOMY_LEVEL(ordefault_plugins(autonomy_level=...)) is set, with a per-requestsession.state["autonomy_level"]override. Blocked calls return a structured deny dict so audit/metrics still observe them. - Tool output cap (
core/orrery_core/plugins/output_cap_plugin.py):ToolOutputCapPluginbounds every tool result tomax_tool_result_bytes(default 4 MiB,0disables). One chattylogscall or wide Elasticsearch result is otherwise re-sent with the whole session every turn until the Gemini/Vertex ~10 MiB request limit kills the run with400 INVALID_ARGUMENT. Trimming is structure-preserving — the longest string field or list is trimmed element-wise so the JSON stays parseable, smallstatusfields survive — with a truncation note telling the model to narrow its query. Runs last among the after-tool observers (ADK's after-tool chain early-exits on the first non-None return). - Requester-verified confirmation (
core/orrery_core/security/guardrails.py,serving/gateway.py): the@confirm/@destructivegate no longer trusts a model re-call as proof a human said yes. WithAgentGateway(verified_confirmation=True)— now enabled on every shipped exposition (HTTP server, persistent runner, Slack bot, Google Chat bot) — the gate requires an explicit decision stamped by the gateway from the transport's verified sender: a deliberate approve word (approve/confirm/proceed/go ahead; a casual "ok"/"yes" doesn't authorize) by the same user who triggered the pending action (fail-closed: unknown requester, second-person approvals, stale decisions, and changed args all refuse), while deny is broad. Dev surfaces (adk web, CLI, evals) keep the model-mediated flow. - Per-turn caller identity (
core/orrery_core/agent/base.py):create_agent()wraps every instruction in anidentity_aware_instructionprovider that appends "who you are talking to THIS turn" whenever a transport stamped the turn'sactor(the gateway stampsmsg.user_idautomatically;_auth.subjectis the fallback) — so in shared threads the model acts for the current sender and never reports a tool's service account as the user. Side effect: prompts are used verbatim (no{var}state templating; literal braces are safe).base_instruction()unwraps the provider for tests. - Audit attempt records (
core/orrery_core/observability/audit.py):AuditPluginnow emits atool_attemptevent inbefore_tool— registered ahead of the gate plugins — so a call that a gate denies, or that crashes mid-tool, still leaves an audit record of what was attempted; the outcome (including a gate's deny status) is audited after the call as before. AgentGateway— one shared turn pipeline (core/orrery_core/serving/gateway.py): every transport (HTTP, Slack, Google Chat, CLI) used to re-implement the same five steps — build a Runner, resolve identity, map a conversation to an ADK session, run the agent, extract the reply. A ports-and-adapters gateway now owns that pipeline (InboundMessage/OutboundReply,ChannelAdapter, pluggableSessionResolvers), with sessions in PostgreSQL (DATABASE_URL) or in-memory.- Pub/Sub worker idempotency (AEP-018) (
agents/google-chat-bot/google_chat_bot/idempotency.py): Google Chat's Pub/Sub transport is at-least-once, so a redelivered event (lost ack, pod OOM, handler-timeout nack after side effects) would double-run@destructivetools — restart/scale/rollback a deployment, increase Kafka partitions, silence Alertmanager. The worker now claims each event id before dispatching and drops duplicates (ack without re-executing); a failed handler releases the claim so a legitimate redelivery still retries (at-most-once side effects without losing failed work). NewIdempotencyStoreprotocol with two backends:InMemoryIdempotencyStore(bounded + TTL, single-replica) andPostgresIdempotencyStore(INSERT … ON CONFLICT DO NOTHING, shared across replicas via the existingDATABASE_URL— no new infrastructure). Dedup key is the ChateventId, falling back to a stable content hash. 33 unit tests including concurrent-claim races on both backends. - Backlog-based autoscaling for the Pub/Sub worker (AEP-018) (
deploy/helm/orrery-assistant/templates/pubsub-worker-hpa.yaml): an HPAv2 scaling on the subscription'snum_undelivered_messagesExternal metric (CPU/memory are useless for an LLM-I/O-bound worker), behindpubsubWorker.autoscaling.enabled. The chart fails to render a multi-replica worker (replicaCount > 1or autoscaling on) whileidempotency.backend == memory, making the split-brain misconfiguration impossible to ship.
Changed¶
- Google Chat confirmations decide by thread reply; buttons are opt-in (
agents/google-chat-bot/): a card button click is resolved by Google's Workspace add-ons runtime with a synchronous HTTPS round-trip that a Pub/Sub pull worker cannot answer — the click fails on Google's side with error code 3 ("The Chat app didn't respond or its response was invalid") before the worker sees it. The confirmation card now asks the operator to replyapproveordenyin the card's thread (a plain MESSAGE event, which Pub/Sub always delivers with the thread attached); the handler resolves the reply against the thread's pending confirmation with the same semantics as a click — requester-only approve (fail-closed), broad deny, deliberate approve words only, synthetic re-run + one-shotconsume_approved. Inline ✅/❌ buttons (and the triage card's 🔧 Run-remediation button) render only withGOOGLE_CHAT_INTERACTIVE_BUTTONS=true, for HTTP-endpoint deployments where clicks can complete. - Plugin registration order (
core/orrery_core/plugins/__init__.py):default_plugins()now registers Audit before the gates (ADK's before-tool chain early-exits on the first non-None return, so anything after a deny never runs) and the output cap last among after-tool observers; the canonical order is documented in the module docstring. - Core reorganized into aspect subpackages (
core/orrery_core/{agent,observability,persistence,plugins,reliability,security,serving,tools}/), with re-exports preserved atorrery_core.*. Database sessions now fail fast whenDATABASE_URLis set but unreachable (opt-out viaORRERY_DB_ALLOW_INMEMORY_FALLBACK), and the Slack bot resolves the sender's role per turn instead of per session. - Build & CI: explicit build backends + unified package discovery across the workspace; CI reuses the Makefile targets so the quality gate is defined once; release checks gate on tag pushes only; kubeconform actually validates the rendered Helm manifests;
makegains a verify-onlycheck(lint + ty + test) with derived ty search paths, and the per-agent runner targets were dropped (run the orchestrator — it composes every specialist). - Google Chat bot config:
google_chat_interactive_buttons(defaultfalse) plus three worker settings —google_chat_pubsub_idempotency_backend(memory|postgres) and..._ttl_seconds— plus HelmpubsubWorker.idempotency.*/pubsubWorker.autoscaling.*values. Documented in Google Chat: Pub/Sub Setup. - Dependencies:
google-adk >= 2.3.0,ruff >= 0.15.20,ty >= 0.0.56,hypothesis >= 6.156.1,actions/checkout@v7;uv.lockrefreshed.
Removed¶
- Google Chat Quick Commands: all
APP_COMMANDhandling is gone (event detection, both app-command handlers,appCommandPayloadextraction fallbacks). No commands need to be configured in the Chat API console anymore — decisions are thread replies (or opt-in buttons).
Fixed¶
- Pub/Sub worker: Helm now refuses to render a multi-replica worker with the in-memory idempotency backend (HPA/replica conflict guard), and the malformed-payload catch is scoped to decode errors instead of swallowing everything.
Security¶
- Approvals are requester-only on every surface: the Slack and Google Chat click/reply handlers refuse an approval from anyone but the verified user who triggered the guarded action (fail-closed — an unidentifiable decider is refused too; the pending survives so the requester can still decide), while deny stays open to anyone. Previously any channel member could approve someone else's destructive action.
Documentation¶
- AEP index gains colored status/priority badges; ADR-003 and guardrails schemas converted to Mermaid; diagrams and wide content restyled for legibility; images centralized under
images/; guardrails and Google Chat integration docs rewritten for the two confirmation modes (including why buttons can't work over Pub/Sub).
[0.1.11] - 2026-06-21¶
Added¶
- Distributed tracing with OpenTelemetry (AEP-010) (
core/orrery_core/tracing.py): Newconfigure_tracing()installs a process-globalTracerProviderexporting to an OTLP collector (Tempo, Jaeger, Cloud Trace, …) with a console fallback for local dev — idempotent and gated byOTEL_TRACING_ENABLED. ADK 2.0 already emits native spans for agent / tool / LLM calls under thegcp.vertex.agenttracer, so the newTracingPluginenriches the current span (orrery.request_id,orrery.user_role,orrery.tool.status/result_size, exception recording) rather than creating duplicate spans;after_modelonly bridges token counts intotrack_llm_tokens()since ADK already recordsgen_ai.usage.*.default_plugins(enable_tracing=None)resolves the flag fromOTEL_TRACING_ENABLEDand prepends the plugin first, so a single env var turns tracing on across every transport (Google Chat, Slack, HTTP server, persistent runner) with no per-agent wiring — a missing[otel]extra is a skip-with-warning, not a crash. - Log ↔ trace correlation (
core/orrery_core/log.py):JSONFormatternow stampsrequest_id(a dependency-freeContextVar) plustrace_id/span_id(lazy OpenTelemetry lookup, omitted when the extra isn't installed or no span is active) onto every log record, so a log line can be pivoted straight to its trace. - Local tracing stack (
docker-compose.yml,infra/tempo.yml,infra/grafana-datasources.yml,infra/grafana-dashboards.yml,infra/dashboards/orrery-observability.json):make tracing-upbrings up Tempo (OTLP ingest + storage) and Grafana under thetracingcompose profile, with provisioned datasources (Tempo + Prometheus, trace→metric linked) and an Orrery — Agent Observability dashboard (tool call rate, p95 latency, error rate, LLM tokens/s, circuit-breaker state, and a live trace table). Newtracing-downtarget. [otel]extra onorrery-core(OpenTelemetry SDK + OTLP gRPC exporter), surfaced at the workspace root asorrery[otel]. Imported lazily — the rest of the package never requires it.- Tests: 11 new cases in
core/tests/test_tracing.py(configure idempotency + disabled path, span enrichment via an in-memory exporter, token→metrics bridge, log/trace correlation), with a hermetic autouse fixture that resets the global provider and the ambientOTEL_TRACING_ENABLEDso the suite stays deterministic.
Fixed¶
make installresolution failure:google-adk[eval]transitively capslitellm < 1.86.0(viagoogle-cloud-aiplatform[evaluation]), which is unsatisfiable against thelitellm >= 1.89.3floor — no version pair satisfies both. Added a uvoverride-dependencies = ["litellm>=1.89.3"]entry so the workspace resolves; the cap only guards aiplatform's evaluation path (exercised bymake eval), andAgentEvaluatorimports cleanly on litellm 1.89.3.
Changed¶
- Pinned GitHub Actions bumped alongside the dependency upgrades:
astral-sh/setup-uvv5 → v8.2.0(ci, docs, release),docker/build-push-actionv6 → v7,sigstore/cosign-installerv3 → v4.1.2. setup-uv and cosign-installer are pinned to full versions because they don't publish moving major tags for v8 / v4. - Docker image now installs the
otelextra (Dockerfile): the runtime image is built with--extra postgres --extra server --extra otel, soOTEL_TRACING_ENABLED=trueactually emits traces from the shipped container — previously OpenTelemetry was absent from the image and tracing silently no-op'd in deployment. - Release pipeline hardening (
.github/workflows/release.yml): the GitHub Release body is now built into a file and passed viabody_path, fixing a long-standing bug where the inline$(cat docker_info.md)was never shell-evaluated and the Docker-images section rendered literally. The Helmkubeconformstep now actually validates the rendered manifests (it previously only installedkubectland validated nothing). Trivy renders the Helm chart againstTRIVY_KUBE_VERSION=1.28.0so it's no longer skipped for misconfiguration scanning.
Security¶
- Three transitive HIGH advisories cleared from the lock (caught by the Trivy CI gate):
cryptography 46.0.5 → 49.0.0(GHSA-537c-gmf6-5ccf — vulnerable OpenSSL bundled in the wheels, fixed in 48.0.1),python-multipart 0.0.28 → 0.0.32(CVE-2026-53539 — quadratic-time querystring parsing with semicolon separators causing CPU DoS, fixed in 0.0.30), andstarlette 1.2.1 → 1.3.1(CVE-2026-54283 —request.form()size limits silently ignored forapplication/x-www-form-urlencoded, enabling DoS).pyopenssl 26.2.0 → 26.3.0came along transitively. Lock-only change; 817 tests still pass.
Documentation¶
- Observability page consolidated (
metrics.md, retitled Observability in the nav): metrics and distributed tracing now live on one page — the three-signal overview, metrics reference, and the full tracing guide (the Grafana Tempo trace-waterfall screenshot, what the spans carry, log↔trace correlation, and themake tracing-uplocal stack + dashboard).config/general.mdkeeps a compact tracing env-var table that links to it, so tracing is discoverable under Platform Features → Observability rather than buried in configuration. The homepage Observability card and "Observable by Design" principle now mention traces, theCLAUDE.mdarchitecture section gains a tracing bullet, and AEP-010 is marked completed (with an implementation note on how the final design diverged from the original proposal and why exemplars were deferred). - mkdocs navigation polish: dropped
navigation.expandfor a cleaner collapsed sidebar and addednavigation.footer(prev/next),navigation.instant(+.prefetch),toc.follow, andsearch.share.
[0.1.10] - 2026-06-14 (tag removed; shipped in 0.1.11)¶
Added¶
- Two-root ADK 2.0 architecture (ADR-003): The orchestrator is split into two entrypoints that reuse the same node agents. The interactive root is
orrery_chat_agent, a chat-modeLlmAgentthat keeps conversation history and routes free-form queries to the six specialistAgentTools + memory, plus anincident_triage_agentAgentToolfor single-turn full sweeps. The batch root isorrery_triage_workflow, a deterministic graphWorkflow(make run-triage) — parallel health checks →health_join→ triage → conditional remediation. A chat-mode agent must be a root (ADK forbids it as a routed graph node) and aWorkflowcan't be anAgentTool, so the two are separate by design. - Remediation Subgraph: The self-healing loop is now expressed via explicit graph edges (
remediation_actor→remediation_verifier→verify_route), strictly bounded by theMAX_REMEDIATION_ITERATIONSstate counter instead of the legacyLoopAgent. The verifier signals success via themark_remediation_resolvedtool (replacingexit_loop/actions.escalate). - Fail-safe triage routing:
triage_routeinfers severity from the per-system status reports and flagstriage_verdict_missingwhen the LLM skipsrecord_triage_verdict, so a degraded system is never silently routed to "resolved". - End-to-end graph flow tests:
agents/orrery-assistant/tests/test_graph_flow.pydrives the real routing nodes throughInMemoryRunner(parallel fan-out/join, intent dispatch, bounded remediation loop, missing-verdict fallback) with no LLM credentials required. - Confirmation walkers traverse graph nodes: the Slack and Google Chat confirmation wiring now walks
Workflow.graph.nodes, so guarded destructive tools on graph-node agents still fire interactive approvals. - Shared reply-text extraction helper: New
extract_reply_text()incore/orrery_core/events.py(exported fromorrery_core) builds user-facing text from a runner event while skipping ADK thought parts. All four transports (Google Chat, Slack, HTTP, CLI) now funnel through it instead of each iteratingcontent.parts— one place to maintain the thought-filtering rule, so a new transport inherits it for free. create_agent(mode=...)passthrough:create_agentnow accepts ADK 2.0'smode(chat/task/single_turn). The interactive rootorrery_chat_agentsetsmode="chat"explicitly so it retains conversation history across turns — previously it reverted to greeting-style replies on follow-up questions because the root's delegation mode was inferred rather than pinned.
Fixed¶
- Planner reasoning leaked into user replies: With a planner active (Gemini
builtinthinking,plan_react, or a reasoning model via LiteLLM), the model's thought/reasoning parts were concatenated into the answer shown to users — so Google Chat, Slack, the HTTP/chatAPI, and the CLI all rendered the chain-of-thought ("Checking Kubernetes Status…") above the actual response. ADK normalizes every form of reasoning ontopart.thought = True(native thinking,PlanReActPlanner/*PLANNING*/phases via_mark_as_thought, and LiteLLM Anthropic/OpenAI reasoning via_convert_reasoning_value_to_parts); all four transports now skip thought parts, so reasoning never reaches the user regardless of provider. - Stale ADK 1.x references in docs:
core/README.mddocumented the removedcreate_sequential_agent()/create_parallel_agent()factories — replaced with the graphWorkflowcomposition pattern.agents/orrery-assistant/README.mdwas rewritten for the ADR-003 chat-root + triage-Workflowarchitecture (was still showing the oldSequentialAgent/ParallelAgentsub-agent tree and missing the Elasticsearch checker and remediation loop).agent-design-patterns.mdand a few internal docstrings updated likewise. - Whole-workspace test collection: switched pytest to
--import-mode=importliband addedagents/google-chat-bot/teststotestpaths. The Google Chat bot's 93 tests were previously excluded from the suite (itstest_auth.py/test_app.py/test_handler.pybasenames collided with other packages under the legacyprependimport mode), so they never ran in CI. The full suite is now 806 tests collected in a single run.
Removed¶
- Deprecated Agent Factories: Removed
create_sequential_agent,create_parallel_agent, andcreate_loop_agentfromorrery_core.baseas they are deprecated in ADK 2.0 in favor of theWorkflowAPI. - Legacy Routing Evals: Removed the
planner_routingeval that asserted the old root-level LLM routing. Theorrery-assistantroot no longer has a routing eval — the chat root's specialist dispatch and the triageWorkflow's deterministicFunctionNoderouting are exercised by unit tests instead.
Security¶
- CVE remediation across the dependency graph: Bumped
litellm1.82.6 → 1.83.14to address 7 advisories — 2 CRITICAL (CVE-2026-35030 OIDC auth bypass / privilege escalation, CVE-2026-42208 SQL injection) and 5 HIGH (CVE-2026-35029 RCE via unrestricted proxy config, CVE-2026-40217 arbitrary code execution via bytecode rewriting, CVE-2026-42203 SSTI in/prompts/test, CVE-2026-42271 authenticated command execution via MCP stdio test endpoints, GHSA-69x8-hrgq-fjj8 password hash exposure / pass-the-hash bypass). Five transitive HIGH-severity bumps came along for free:mako 1.3.10 → 1.3.12(CVE-2026-44307 Windows path traversal via backslash URI),pyasn1 0.6.2 → 0.6.3(CVE-2026-30922 unbounded-recursion DoS),pyopenssl 25.3.0 → 26.2.0(CVE-2026-27459 DTLS cookie callback buffer overflow),python-multipart 0.0.22 → 0.0.28(CVE-2026-42561 unbounded multipart-header DoS), andurllib3 2.6.3 → 2.7.0(CVE-2026-44431 cross-origin sensitive-header forwarding, CVE-2026-44432 decompression-bomb safeguard bypass). - Trivy filesystem scan in CI (
.github/workflows/ci.yml— security job): Newaquasecurity/trivy-action@0.28.0step runsscan-type: fswithscanners: vuln,misconfig,secretagainst the workspace, gating onHIGH,CRITICALwithignore-unfixed: trueandexit-code: 1. Catches the vulnerable-dependency class that Bandit (a Python static analyser) doesn't see, plus IaC misconfig inDockerfile/deploy/terraform/, plus committed secrets. - Bandit now scans every file: Pinned the
uvxinvocation to--python 3.14so the AST parser matches ourrequires-pythonfloor. Before this, GitHub's runner Python (3.12) silently skippedcore/orrery_core/rbac.py,core/orrery_core/runner.py, andagents/google-chat-bot/google_chat_bot/pubsub_worker.pywith "syntax error while parsing AST" — they parse Python 3.14 syntax — meaning those modules weren't being security-scanned at all. The skip is now caught by the report (0 files skipped) and the modules participate in the scan. - Hardcoded
/tmpremoved from the test suite (core/tests/test_secrets.py:117):test_filebackend_satisfies_protocolnow takes pytest'stmp_pathfixture instead of passing"/tmp"toFileBackend, resolving the Bandit B108 (hardcoded_tmp_directory) Medium finding that was breaking thebandit -llgate.
Changed¶
- ADK 1.x → 2.0 upgrade: Bumped
google-adkto>=2.2.0and added the[db]extra (ADK 2.0 movedsqlalchemy/DatabaseSessionServiceout of the base package).run_persistent()/create_app()now accept anAgent | Workflowroot.litellmis re-pinned to>=1.83.14,<1.86.0(ADK 2.2.0 transitively caps it below 1.86 viagoogle-cloud-aiplatform[evaluation]; resolves to 1.85.4, retaining the CVE fixes in this release). - Workspace pinned to Linux + macOS resolution environments (
pyproject.toml— new[tool.uv]block withenvironments = ["sys_platform == 'linux'", "sys_platform == 'darwin'"]): The project depends on Docker daemon, Kubernetes client, and confluent-kafka — none of which are supported on Windows in the form we consume them. Restricting the lock-file environments lets uv resolve a coherent dependency graph instead of failing on phantom Windows-only conflicts (the litellm bump above surfaced one). No runtime change; this only affects what platform combinations uv considers when locking. - Relaxed two version floors to satisfy litellm's strict transitive pins:
pydantic>=2.13.3 → pydantic>=2.12.5(litellm 1.83.14 hard-pinspydantic==2.12.5) andaiohttp>=3.13.5 → aiohttp>=3.13.3onagents/slack-bot(litellm 1.83.10–13 pinaiohttp==3.13.3, 1.83.14 pins==3.13.4). Resolver picks the highest version that satisfies every constraint; in practice the lock holds at the litellm-mandated floors. Tests at 693/693 after the changes — if you adopt pydantic 2.13-specific behavior later, this floor should be raised back.
Added¶
- Authentication layer for the HTTP front door (AEP-013, first slice) (
core/orrery_core/auth.py,core/orrery_core/server.py,core/orrery_core/secrets.py): Newverify_token()supports both HS256 (shared secret, for dev/gateway-fronted deployments) and RS256/ES256 via JWKS (production IdPs — Auth0, Keycloak, Okta, Google IAP, GitHub OIDC).JWTConfig.from_env()readsJWT_ALGORITHM/JWT_SECRET/JWT_JWKS_URL/JWT_AUDIENCE/JWT_ISSUER/JWT_ROLE_CLAIM/JWT_LEEWAY_SECONDS.extract_role()maps claim lists or space/comma-separated strings toviewer/operator/adminwith namespace aliases (orrery-admin,orrery_operator). NewAuthPluginreads the verified_authpayload from session state and callsset_user_role()so the existing RBAC enforcement becomes trust-rooted. Opt-in viadefault_plugins(enable_auth=True)— zero behavior change for existing deployments. - Authenticated FastAPI runner (
core/orrery_core/server.py): Newcreate_app()mounts the ADKRunnerbehind aHTTPBearerdependency.POST /chatrequires a valid JWT, seeds new sessions with the verified_authpayload, and re-stamps long-lived sessions so a re-minted token with a downgraded role applies immediately.GET /healthzandGET /readyzare auth-free.auth_enabled=Falseis supported for local dev with an explicit warning and pins anonymous callers toviewerso RBAC still gates writes. Eagercfg.jwt.validate()at startup fails fast on misconfiguration rather than at first request. - Pluggable secrets manager (
core/orrery_core/secrets.py):SecretsManagerresolves in priority order — explicit backends →FileBackend(ORRERY_SECRETS_DIR)→ env vars → caller default. Auto-installs the file backend when the env var points at a real directory, so Kubernetes Secret volumes work with no code changes.SecretsBackendProtocol leaves Vault / GCP Secret Manager / AWS Secrets Manager adapters as a future PR. - Tests: 60 new cases across
core/tests/test_auth.py(extract_role matrix, JWTConfig.validate, HS256 round-trip + signature / audience / issuer / expiry / leeway / missing-sub/ missing-expfailure modes, RS256/JWKS happy path with a mockedPyJWKClient, AuthPlugin role-application and forced-viewer fallback),core/tests/test_secrets.py(env fallback, file backend trailing-newline handling, backend ordering, misbehaving-backend isolation, auto-registration viaORRERY_SECRETS_DIR), andcore/tests/test_server.py(FastAPI TestClient —/healthzis auth-free, missing/invalid/expired tokens return 401 withWWW-Authenticate: Bearer, valid tokens dispatch to the runner with the JWT subject asuser_idand the_authpayload seeded, existing sessions re-stamp_authon every request, anonymous-mode pins toviewer, eager misconfiguration fails fast). Suite at 693 unit tests (was 633), lint + format + ty clean. - Deps: New optional extras on
orrery-core—[auth](PyJWT 2.10+ withcryptofor RS256/ES256) and[server](auth extras plus FastAPI + Uvicorn). Both surface at the workspace root asorrery[auth]/orrery[server]. Core itself adds no new required dependencies — anyone using only the Slack / Google Chat transports stays on the existing footprint.
Documentation¶
config/security.md— new page with the threat model, HS256 vs. RS256/JWKS setup recipes, JWKS endpoints for Auth0 / Keycloak / Okta / Google IAP / GitHub OIDC, role-mapping rules, and theORRERY_SECRETS_DIRKubernetes Secret-volume pattern. Wired into the mkdocs nav under Getting Started → Configuration..env.example— Security / Auth block filled in with the full variable matrix (AUTH_ENABLED,JWT_ALGORITHM,JWT_SECRET,JWT_JWKS_URL,JWT_AUDIENCE,JWT_ISSUER,JWT_ROLE_CLAIM,JWT_LEEWAY_SECONDS,ORRERY_CORS_ORIGINS,ORRERY_SECRETS_DIR) replacing the previous "coming soon" stub.README.md— Safety & Governance bullets added for JWT authentication and the secrets manager.
Added (previously)¶
- Opt-in ADK planners on reasoning-heavy agents (
core/orrery_core/base.py,agents/orrery-assistant): Newresolve_planner()helper readsORRERY_PLANNER(none|plan_react|builtin) andcreate_agent()accepts aplanner=kwarg. Three agents inorrery-assistantopt in — the root orchestrator, thetriage_summarizer, and theremediation_actor— sharing a single planner instance resolved once at import time.plan_reactworks across Gemini/Claude/OpenAI/Ollama via the existing LiteLLM integration;builtinconsumes Gemini's native thinking tokens (ORRERY_PLANNER_THINKING_BUDGET,ORRERY_PLANNER_INCLUDE_THOUGHTS) and falls back to no planner with a warning whenMODEL_PROVIDER != gemini. DefaultORRERY_PLANNER=noneis a no-op — zero behavior change for existing deployments. Per-system health checkers, the remediation verifier, and the journal writer intentionally skip the planner; they execute one short tool sequence per turn so an extra reasoning pass would only add latency. Tool-leaf agents (e.g.,kafka_health_checker,k8s_health_checker) remain planner-free. - Tests: 10 new cases in
core/tests/test_base.py::TestResolvePlannercovering default-off, both planner choices, the Gemini-only fallback forbuiltin, theORRERY_PLANNER_THINKING_BUDGET/ORRERY_PLANNER_INCLUDE_THOUGHTSknobs, unknown-value warning, and case-insensitivity. Plus 5 deterministic wiring tests inagents/orrery-assistant/tests/test_planner_wiring.pythat reload the agent module across env-var permutations to assert (a) which three agents pick up the planner underplan_react/builtin, (b) that all seven tool-leaf agents — the five health checkers, the journal writer, and the remediation verifier — stay planner-free, and (c) thatbuiltinfalls back to no planner under non-Gemini providers. Suite at 633 unit tests (was 618), lint + format clean. - Agent-level eval scaffolding for the planner-enabled root (
agents/orrery-assistant/tests/evals/planner_routing.test.json,agents/orrery-assistant/tests/test_orrery_eval.py): Two routing scenarios — narrow Kafka query and narrow Elasticsearch query — that exercise the fullORRERY_PLANNER=plan_react→ root →AgentTool→ mocked client path against a real LLM. Gated behindmake eval; skips when no Gemini credentials are present. Mockskafka_health_agent.tools._get_admin_clientandelasticsearch_agent.tools._get_sessionat the same layer the per-specialist evals use. The accompanyingtest_config.jsonships with smoke-test thresholds (0.0) because the AgentTool injects an LLM-generatedrequestarg that defeats stricttool_trajectory_avg_scorematching — the eval still proves the agent loads with the planner, the LLM picks a specialist, and the full path runs without errors. Tighten thresholds once an arg-matching strategy is adopted.
Changed¶
- Type hints / lint hygiene to clear
make tyafter thety>=0.0.34bump:agents/elasticsearch/elasticsearch_agent/tools.pyadds a# ty: ignore[invalid-assignment]onrequests.Session.verify(the type stub narrows it tobool, but at runtime the attribute acceptsbool | str | None— a string is treated as a CA bundle path);core/tests/test_operators.pyannotates theFakeStrimzitest double with explicittuple[str, ...]/tuple[CRDRef, ...]types so it satisfies theOperatorDetectorprotocol;core/tests/test_base.py::TestResolvePlanner.test_builtin_include_thoughts_falsenarrows viaisinstance(result, BuiltInPlanner)before readingthinking_config.
Documentation¶
config/general.md— Planning section: Env-var matrix and decision guide forplan_reactvs.builtin, including the latency tradeoff and the rationale for keeping tool-leaf agents planner-free.agent-design-patterns.md— Planning paragraph under Iterative & Feedback Patterns with the list of opted-in agents and a link to the config reference.core/README.md—resolve_planner()reference alongside the existingcreate_agent()table;planner=is now a documented kwarg.README.md— feature bullet in Intelligence & Orchestration announcing opt-in planning with the env-var on-ramp..env.example— Planning block documentingORRERY_PLANNER,ORRERY_PLANNER_THINKING_BUDGET,ORRERY_PLANNER_INCLUDE_THOUGHTSwith inline guidance on when to use each value.
[0.1.9] - 2026-04-26 (tag removed; shipped in 0.1.11)¶
Added¶
- Progressive progress cards in Google Chat (
agents/google-chat-bot): The async response path now posts a live "🔍 Investigating…" Card v2 immediately and PATCHes it in place as the agent run progresses. Operators see the currently executing sub-agent (friendly label — Checking Kafka, Synthesizing findings, …), a tool-call breadcrumb, subsystem health chips (✅/⚠️/❌/⏳) derived fromstate_deltawrites onkafka_status/k8s_status/docker_status/observability_status/elasticsearch_status, an optional remediation panel populated byremediation_action/verification_result/remediation_summary, and an elapsed-seconds footer. Updates are debounced at 800ms and force-flushed on status transitions. - Structured triage result card: When an
incident_triage_agentrun completes, the progress card is replaced with a structured Triage Report — severity badge (🟢 healthy / 🟡 degraded / 🔴 critical), one section per subsystem, thetriage_summarizeroutput, and a role-gated Run Remediation instruction (operator/admin only, only shown when overall severity is not healthy) inviting the user to send the Remediate Quick Command, which dispatches theremediation_pipelinein the same session so it reuses the triage report already in state. ChatClient.update_message(): New async PATCH helper on the Chat REST client with dynamicupdateMask. Tolerates 404/410 (message deleted) by returningNoneso the progressive-card loop can stop updating silently.progress.ProgressTracker: New module that consumes ADK runner events (author,state_delta, function calls) and drives a debounced async update callback. Threaded through_run_agentvia an optionaltracker=kwarg so the sync path is unchanged.- Quick Command-based confirmation flow (
agents/google-chat-bot): Confirmation cards no longer embed inlineconfirm_action/deny_actionbuttons. Operators send the Approve or Deny Quick Commands (appCommandId=1/appCommandId=2) configured in the Chat API console; the handler resolves the latest pending confirmation in the thread/space and routes accordingly. Quick Commands ride the standard MESSAGE delivery path, sidestepping the inconsistentinvokedFunction-button delivery seen in some Workspace Add-on configurations. - Tests: +6
test_chat_client.py(create/update mask variants, 404 tolerance, 500 raises), +16 cases intest_cards.py(status classification, progress-card shape, remediation panel, result-card severity, role-gated instruction), +7 cases intest_handler.py(progress posted then updated, final card is triage result when chips landed, role-gated remediation instruction, update failure falls back to new message, runtime error replaces progress with error card, run_remediation click dispatches a new run), +5 cases covering the new approve handshake (parent session id reconstructed from state, store-based approved-pending consumption, stale-approval rejection beyond the validity window, unapproved-pending blocks LLM auto-retry, args embedded in synthetic prompt). All 618 unit tests + lint + format clean.
Changed¶
handler._handle_message_asyncrewired: Posts the initial progress card before invoking the runner, attaches a tracker that PATCHes the same message on each significant event, and on completion replaces it with either the structured triage card (when chips landed) or the original reply. Non-triage queries retain the previous text + buffered-confirmation-card behavior._handle_app_command_asyncand_handle_card_click_asyncrewired: Both Approve/Deny dispatch paths now post a progress card before re-running the runner and replace it with the final reply on completion, so operators get live feedback during the post-approve LLM run instead of a silent ~30s wait._post_async_errornow replaces an existing progress card with an error card (build_error_card) instead of appending a new message, so a crashed run never leaves operators staring at a stuck "Investigating…" frame.- Card-click dispatch recognizes a third
invokedFunction: "run_remediation"alongsideconfirm_action/deny_action. The remediation branch bypasses the pending-confirmation store and spawns a new_run_agentturn with a remediation prompt. - Approve handshake is now
ConfirmationStore-driven (agents/google-chat-bot/google_chat_bot/confirmation.py): The previous design wrote a_gchat_pending_<tool>fingerprint intotool_context.stateon the first call and consumed it on the LLM's retry. That state lives on whatever ADK session ran the tool, which for tools invoked through anAgentToolis the sub-agent's ephemeral session — it doesn't propagate back to the gchat parent session. The callback now consults the store directly, matching(thread_or_space_key, tool_name, args_hash)against entries the click handler has markedapproved=True.mark_latest_approved_for_threadflips the flag without popping;consume_approvedpops on the LLM's retry. A 120s validity window after Approve prevents a stale approval from auto-executing a fresh request later. - Synthetic Approve prompt embeds the original arguments verbatim: Replaces the previous
"Yes, proceed with <tool>."(which forced the LLM to reconstruct args from chat history — fragile when the call originated inside a sub-agent whose args the parent session never saw) with"The operator (<name>) approved the previous <tool> request. Re-issue the same call now with arguments <k=v, …> and report the result."PendingConfirmationnow carries the originalargsdict, anargs_hash,created_at,approved, andapproved_atfields.
Fixed¶
patch_deployment(and any other guarded tool on a sub-agent) detoured throughremediation_pipelineafter Approve (agents/google-chat-bot): When a guarded tool fired from inside anAgentTool(e.g.,k8s_health_agent.patch_deployment), the confirmation callback capturedtool_context.session.id— which for an AgentTool is the ephemeral inner session, not the gchat parent session keyedgchat:<thread-or-space>. On Approve, the handler re-entered the runner with that ephemeral id and the LLM, finding no conversation history and only the synthetic"Yes, proceed with patch_deployment.", hallucinated a remediation against an unrelated deployment (default/frontendwas the most-frequent miss). The callback now reconstructs the parent session id fromstate["gchat_thread"]/state["gchat_space"](which the handler writes at the start of every turn) so Approve always re-enters the conversation the operator was in. Verified end-to-end withrestart_deploymentandpatch_deploymentagainstgatekeeper-system/gatekeeper-audit.- Re-prompting loop on every Approve retry (
agents/google-chat-bot): The per-context_gchat_pending_<tool>flag the callback set on the first call lived on the AgentTool's child session and was invisible to subsequent calls (each AgentTool invocation gets a fresh sub-session). On retry the callback never saw a matching pending and posted another approval card — the operator could be stuck approving forever. Resolved by moving the handshake intoConfirmationStore(see Changed above). - APP_COMMAND payloads with empty
message: {}resolved to the wrong pending in shared spaces: Quick Command events don't carry a thread name, so_extract_thread_namereturnsNoneand the handler falls back to matching by space name. The handler now logs a warning when this fallback fires so operators can see when concurrent destructive actions in a shared space could collide on the resolution.
[0.1.8] - 2026-04-23 (tag removed; shipped in 0.1.11)¶
Added¶
- Elasticsearch Agent (
agents/elasticsearch): New specialist agent for Elasticsearch cluster operations, exposing 19 read-only REST tools — cluster health/stats/nodes/pending tasks/settings, indices listing/stats/mappings/settings, shard allocation +explaindiagnostics,search+count, index templates, aliases, ILM policies +explain_ilm_status, and snapshot repositories/snapshots. HTTP session is pooled as a module-level singleton with API-key / basic-auth / CA bundle support viaELASTICSEARCH_*env vars. - ECK Operator Tools: Five Kubernetes control-plane tools complementing the REST surface —
list_eck_clusters,describe_eck_cluster,list_kibana_instances,describe_kibana,get_eck_operator_events. Wired through the sharedorrery_core.default_registry.ECKDetectorfor interpretedhealthy/phase/warningson each CR. - Orrery-assistant integration:
elasticsearch_agentis now a siblingAgentToolon the root orchestrator, and a newelasticsearch_health_checkerjoinshealth_check_agentas the fifth parallel branch of the incident triage pipeline (writeselasticsearch_statusto session state for thetriage_summarizer). - Compose profile + Makefile targets:
docker-compose.ymlgains anelasticprofile with single-node Elasticsearch 8.13.4 + Kibana (security disabled for dev);make run-elasticsearch/make run-elasticsearch-clilaunch the agent standalone. - Tests & evals: 36 new unit tests (25 REST + 11 ECK) with mocked
requests.Session/CustomObjectsApi/CoreV1Api, plus 6 new eval scenarios (cluster_and_indices.test.json,eck.test.json) — bringing the suite to 608 tests / 28 eval scenarios.
[0.1.7] - 2026-04-19 (tag removed; shipped in 0.1.11)¶
Added¶
- Zero-clone Docker quick-start: The README and
getting-started.mdnow lead withdocker pull ghcr.io/bahalla/orrery:latest+docker runfor a 30-second single-container test drive, followed by acurl-the-compose-file path for the full Kafka/Postgres/Prometheus stack — no repository clone required. ORRERY_IMAGEoverride:docker-compose.ymlhonoursORRERY_IMAGEso users can pin a specific release tag (e.g.ORRERY_IMAGE=ghcr.io/bahalla/orrery:v0.1.7 docker compose --profile demo up -d).- Best-practices & scaling guide:
agent-design-patterns.mdgains three new sections — tool/agent sizing budgets (sweet 5–15 tools, 3–7 direct children, depth ≤3), framework/model-specific limits (Gemini/Claude/OpenAI/Ollama caps, ADKLoopAgent.max_iterationsdiscipline, context-window budgeting), and a decision guide for when to reach for the A2A protocol (referencing AEP-005) with a four-stage scaling playbook.
Changed¶
- Python 3.14 upgrade: Bumped
requires-pythonfrom>=3.11to>=3.14across the root and all nine workspace packages (core,docker-agent,google-chat-bot,k8s-health,kafka-health,observability,ops-journal,orrery-assistant,slack-bot). Rufftarget-versionupdated topy314. Dockerfile base images switched toghcr.io/astral-sh/uv:python3.14-bookworm-slim(builder) andpython:3.14-slim-bookworm(runtime). CI and release workflows now install Python 3.14.uv.lockregenerated — all C-extension wheels (confluent-kafka, psycopg2-binary, asyncpg, pydantic-core, numpy, tiktoken) resolved to prebuilt 3.14 wheels with no source-build fallbacks. Full test suite (572 tests) passes on 3.14. - Single production Dockerfile: Merged
Dockerfile.prodintoDockerfileso the repository ships one production-ready image. The consolidated Dockerfile addsUV_COMPILE_BYTECODE=1,UV_LINK_MODE=copy,PYTHONUNBUFFERED=1,PYTHONDONTWRITEBYTECODE=1, a BuildKit--mount=type=cachefor uv,--extra postgresby default, and folds ownership intoCOPY --chown=to drop a redundantchown -Rlayer. - Compose services pull by default:
orrery-assistantandslack-botindocker-compose.ymlnow useimage: ${ORRERY_IMAGE:-ghcr.io/bahalla/orrery:latest}withbuild: .retained as a local-dev fallback — first-run users no longer wait for a local build. - CI/release type-check coverage:
.github/workflows/ci.ymland.github/workflows/release.ymlnow include--extra-search-path agents/google-chat-botin thetyinvocation so that agent is type-checked alongside the others (it was already shipping in the image and in runtime CI). - Documentation refresh:
SECURITY.md,deployment.md,troubleshooting.md,enhancements/aep-011-deployment-hardening.md, andenhancements/aep-014-supply-chain-security.mdupdated to reference the singleDockerfileafter the consolidation.
Fixed¶
- Broken
HEALTHCHECKon the default image: The DockerfileHEALTHCHECKand theorrery-assistantcompose healthcheck both probedhttp://localhost:8080/healthz, butHealthServeris only started byrun_persistent()and the Pub/Sub worker — not by the defaultadk webCMD. Probes are now owned by the orchestrator (docker-compose / Helm values indeploy/helm/orrery-assistant/values.yaml) rather than baked into the image. - Latent port conflict in compose:
orrery-assistantandkafka-uiboth bound host port8080. The unused8080:8080mapping onorrery-assistantwas removed (the healthz server doesn't run for that CMD anyway).
Removed¶
Dockerfile.prod: replaced by the consolidated, production-readyDockerfile. Therelease.ymlworkflow now builds from./Dockerfile.
[0.1.6] - 2026-04-19 (tag removed; shipped in 0.1.11)¶
Added¶
- Operator Registry (
orrery_core.operators): Pluggable registry for Kubernetes operator detection and CR status interpretation. Ships with built-in detectors for Strimzi (kafka.strimzi.io— 9 kinds incl.Kafka,KafkaTopic,KafkaConnector,KafkaRebalance) and ECK (*.k8s.elastic.co— 7 kinds incl.Elasticsearch,Kibana,ApmServer,Beat). New detectors can be registered viadefault_registry.register(). - Structured Tool Results (
orrery_core.ToolResult): Pydantic model withok()/error()/partial()factories andremediation_hintsfor cross-agent composition. Flattens to a backward-compatible dict via.to_dict(), so adoption is gradual and existing tests/tools keep working. - k8s-health Operator-Aware Tools: Six new tools on the
k8s-healthagent —detect_operators,list_custom_resources,describe_custom_resource,get_owner_chain,describe_workload,get_operator_events.describe_workloadwalksownerReferencesfrom a Pod up to its root CR (e.g., Pod → StatefulSet →Kafka) and returns the operator's interpreted status (healthy/phase/warnings) instead of raw pod info. - kafka-health Strimzi Tools: Ten new tools that complement the Kafka-protocol tools with a view into the Strimzi control plane —
list_strimzi_clusters,describe_strimzi_cluster,list_strimzi_topics,list_kafka_users,list_kafka_connectors,get_kafka_connect_status,get_mirrormaker2_status,get_kafka_rebalance_status, plus the guardedapprove_kafka_rebalance(patchesstrimzi.io/rebalance: approve) andrestart_kafka_connector(patchesstrimzi.io/restart: true). Uses the shared operator registry for status interpretation. - Property-Based Guardrail Tests: Integrated
hypothesisand added exhaustive tests for tool argument hashing incore/tests/test_guardrails.py, ensuring deterministic and order-invariant hashes for stable confirmation matching. - Pub/Sub Worker Health Probes: The worker now exposes
/healthzand/readyzvia the sharedHealthServer. Readiness flips to 503 if the streaming-pull future dies, so kubelet restarts the pod automatically.
Changed¶
- ADK Upgrade: Upgraded
google-adkto v1.31.0 across the workspace. - Experimental Warning Suppression: pytest is now configured to suppress all experimental feature warnings from
google.adk.features, keeping test output clean and focused. - Helm: Liveness/Readiness + PDB:
pubsubWorkerdeployment now configures liveness, readiness, health port, and an optionalPodDisruptionBudget. - Terraform: DLQ Triage Access: New
dlq_subscribersvariable grantsroles/pubsub.subscriberon the DLQ subscription to configured SRE/on-call groups, plus adead_letter_subscription_nameoutput. - Docs:
integrations/google-chat-pubsub.mdnow documents every Terraform variable (chat_publisher_email,enable_vertex_ai,vertex_ai_project_id, tuning knobs) and the timeout-alignment rule for Pub/Sub ack deadlines. - AEP-018: Proposal for Pub/Sub idempotency (dedup store on
eventId) and HPA-on-backlog for thepubsubWorkerto remove the single-replica SPOF during incidents.
[0.1.5] - 2026-04-18 (tag removed; shipped in 0.1.11)¶
Added¶
- Pub/Sub Diagnostics: Added verbose trace logging for message receipt, parsing, and agent execution to simplify troubleshooting.
- Heartbeat Monitor: Implemented a 60-second background heartbeat log in the Pub/Sub worker to provide "proof of life" in container logs.
- Cross-Project Support: Explicitly documented and enabled support for subscriptions living in different GCP projects than the agent runner via
GOOGLE_CHAT_PUBSUB_PROJECT.
Fixed¶
- Cleanup: Ensured proper cancellation of background heartbeat tasks during worker shutdown.
[0.1.4] - 2026-04-18 (tag removed; shipped in 0.1.11)¶
Added¶
- Google Chat Pub/Sub Transport: Added support for private GKE clusters via Pub/Sub connection type.
- Terraform Module: New module in
deploy/terraform/google-chat-botfor automated GCP infrastructure setup. - Helm Expansion: Added
pubsubWorkerdeployment and Workload Identity support to the Helm chart. - Overridable Publisher IAM: Added
chat_publisher_emailvariable to handle Domain Restricted Sharing (GCP Org Policy) for Workspace Add-ons.
Changed¶
- Unified Handler: Refactored Google Chat bot to use a transport-agnostic handler shared between HTTP and Pub/Sub.
- GKE Deployment Story: Removed legacy Kustomize manifests in favor of a unified Helm + Terraform production pattern.
Fixed¶
- Poison Message Handling: Implemented robust
ack/nacklogic in the Pub/Sub worker to prevent infinite redelivery of malformed payloads.
[0.1.3] - 2026-04-13 (tag removed; shipped in 0.1.11)¶
Changed¶
- Rebranding: Project renamed from "AI Agents for DevOps" to Orrery.
- Package Renaming:
ai-agents-coreis noworrery-core. - Agent Renaming:
devops-assistantis noworrery-assistant. - Infrastructure: Updated Kubernetes manifests, Helm charts, and Docker images to use the
orrerynamespace and naming. - Observability: Prometheus metrics renamed from
ai_agents_*toorrery_*.
[0.1.2] - 2026-04-12 (tag removed; shipped in 0.1.11)¶
Added¶
- Google Chat Async Mode: Added
GOOGLE_CHAT_ASYNC_RESPONSEandGOOGLE_CHAT_SERVICE_ACCOUNT_FILEto.env.examplefor long-running agent support. - Enhanced Documentation: Added Google Chat to the main README and expanded the integration guide with troubleshooting for ADC, scopes, and 401/404/403 errors.
Changed¶
- Google Chat Roadmap: Moved Google Chat from "Upcoming" to "Current Integrations" in the documentation.
- API Reference: Registered
google-chat-botfor automatic API documentation generation inmkdocs.yml.
Fixed¶
- Google Chat Event Parsing: Implemented robust multi-path parsing for space names, emails, and thread names to prevent 404 errors during asynchronous replies.
- Async Auth Scope Guidance: Added detailed troubleshooting and configuration for
403 Forbiddenerrors caused by missingchat.botscopes when using Application Default Credentials (ADC). - ADC-First Auth Pattern: Updated Google Chat bot to prioritize Application Default Credentials, enabling seamless Workload Identity support on GKE and Cloud Run.
[0.1.1] - 2026-04-11 (tag removed; shipped in 0.1.11)¶
Added¶
- Google Chat Bot Integration: A new integration bringing autonomous DevOps agents to Google Workspace with support for thread-based sessions and interactive Card v2.
- Workspace Add-ons Compatibility: Implemented strict
hostAppDataAction(DataActions) schema support, enabling the bot to run behind the Google Workspace Add-ons pipeline. - Dual-Path Event Detection: Added logic to seamlessly handle interaction events from both standard Google Chat API and the nested Workspace Add-ons event structure.
- Interactive Guardrails for Chat: Wired
@confirmand@destructivetools to post interactive Cards v2 with Approve/Deny buttons, allowing operators to authorize dangerous actions directly from the chat. - Configurable Identities: Added
GOOGLE_CHAT_IDENTITIESto allow dynamic verification of multiple signing service accounts (e.g., standard Chat vs Add-ons service agents). - Kafka KRaft Migration: Removed Zookeeper dependency. Kafka now runs in KRaft mode for improved startup reliability and simplified architecture.
- PostgreSQL Service: Added a dedicated PostgreSQL container to
docker-compose.ymlfor persistent session storage. - Centralized Configuration: Merged per-agent
.envfiles into a single root.envfile. Updated core library to prioritize the root configuration while maintaining legacy override support. - Cross-Session Memory: Enabled
MemoryPluginin theorrery-assistantagent, allowing it to remember past interactions and save session highlights to the persistent store. - Kafka Partition Scaling: Added
update_kafka_partitionstool to the Kafka health agent with full unit test coverage. - Production deployment hardening (AEP-011) — complete Kubernetes deployment story
- Kustomize manifests under
deploy/k8s/(Deployment, Service, HPA, PDB, NetworkPolicy, ServiceAccount with scoped ClusterRoles) - Helm chart under
deploy/helm/orrery-assistant/with configurable values, NOTES, andexistingSecretsupport for out-of-band secret management - GHCR CD pipeline (
.github/workflows/docker-publish.yml) publishing multi-arch (amd64/arm64) images with SBOM and provenance attestation on merges tomainandv*.*.*tags - PostgreSQL session store support —
runner.pyhonorsDATABASE_URL(async driverpostgresql+asyncpg://…) for multi-instance deployments;core[postgres]extra addsasyncpgandpsycopg2-binary - Rate limiting on the Slack bot
/slack/eventswebhook viaslowapi(configurable viaSLACK_RATE_LIMIT, default60/minute) - Root-level
.env.exampledocumenting every required and optional variable across agents and deployment manifests deployment.md— end-to-end production deployment guide (Postgres setup, Helm install, rolling updates, troubleshooting)
Changed¶
SlackBotConfig.resolve_db_url()prefersDATABASE_URLenv var over the legacyslack_db_urldefault — enables sharing Postgres between the Slack bot and the ADK web UI workloadsload_agent_env()andload_config()now search for a.envfile at the project root by default.
Fixed¶
- OIDC Token Verification: Fixed a bug where tokens from Google's migrated OIDC flow (
iss=accounts.google.com) were rejected. Added proper identity verification against theemailclaim. - Dynamic Session Handling: Fixed
SessionNotFoundErrorin the ADK Runner by enablingauto_create_session=Truefor thread-based chat integrations. - JSON Schema Validation: Resolved "Failed to parse JSON" errors in Google Chat by ensuring all responses (including errors and added-to-space events) strictly follow the Add-ons response schema.
- Metrics Callback Signature: Fixed a
TypeErrorinMetricsPluginwhere incorrect keyword arguments were passed to the internal callback. - Kafka Tool Imports: Fixed a
NameErrorinkafka_health_agent/tools.pycaused by using decorators before they were imported. - Database URL Masking: Ensured
DATABASE_URLis masked in all log outputs and console prints to prevent credential leaks.
[0.1.0] - 2026-04-09 (tag removed; shipped in 0.1.11)¶
First public release of the AI Agents for DevOps & SRE platform.
Added¶
- Multi-agent orchestrator —
orrery-assistantroot agent delegates to 5 specialist agents viaAgentTooland deterministic sub-agent workflows (ADR-002) - Specialist agents — Kafka health, K8s health, Observability (Prometheus/Loki/Alertmanager), Docker, and Ops Journal
- Slack bot — Thread-based sessions with interactive Approve/Deny buttons for guarded operations
- Incident triage pipeline —
SequentialAgent+ParallelAgentfor parallel health checks across all systems, triage summary, and journal recording - Closed-loop remediation (AEP-004) —
LoopAgent-based pipeline: act (restart/scale/rollback) → verify → retry up to 3 iterations, withexit_looptool for early termination - Context caching (AEP-007) — ADK
ContextCacheConfigfor Gemini models, reducing token usage for repeated requests. Configurable viaCONTEXT_CACHE_MIN_TOKENS(renamed toCONTEXT_CACHE_MIN_LENGTHin 0.2.1),CONTEXT_CACHE_TTL_SECONDS,CONTEXT_CACHE_INTERVALSenv vars - Cross-session memory (AEP-003) —
SecureMemoryServicewith automatic PII redaction and size limits - Agent evaluation framework (AEP-002) — 22 eval scenarios across 4 agents verifying correct tool routing via ADK's
AgentEvaluator. Run withmake eval - RBAC — 3-role hierarchy (viewer/operator/admin) enforced globally via
GuardrailsPlugin(ADR-001) - Safety guardrails —
@destructiveand@confirmdecorators gate dangerous operations with args-hash + TTL confirmation tracking (AEP-001) - Authentication enforcement —
set_user_role()marks server-trusted roles;ensure_default_role()forcesviewerfor unset roles - Input validation — 5 reusable validators (
validate_string,validate_positive_int,validate_url,validate_path,validate_list) applied across 30+ tool functions - ADK Plugins — cross-cutting concerns as
BasePluginsubclasses:GuardrailsPlugin,ResiliencePlugin,MetricsPlugin,AuditPlugin,ActivityPlugin,ErrorHandlerPlugin,MemoryPlugin - Prometheus metrics — tool call counts, latency histograms, error rates, circuit breaker state, LLM tokens, and context cache events on
/metrics - Resilience — per-tool circuit breaker via
ResiliencePlugin,@with_retrydecorator with exponential backoff and jitter - Structured JSON logging —
setup_logging()withJSONFormatter, audit trail viaAuditPlugin, activity tracking viaActivityPlugin - Multi-provider LLM — Gemini (default), Claude, OpenAI, Ollama via
resolve_model()+ LiteLLM - Persistent runner —
run_persistent()with SQLite-backed sessions, health probes, graceful shutdown - Agent factory functions —
create_agent(),create_sequential_agent(),create_parallel_agent(),create_loop_agent() - Docker deployment — multi-stage builds, non-root user,
docker-compose.ymlwith demo/slack profiles - 468 unit tests — all async, all mocked, no running infrastructure required
- CI pipeline — lint (ruff), type check (ty), security scan (bandit), tests, evals
Security¶
- Input validation at tool boundaries prevents injection attacks
- Path traversal prevention in Docker and K8s tools
- URL scheme allowlisting rejects
javascript:,data:,file:URIs - Docker container inspection redacts sensitive environment variables
- Guardrail confirmation bypass fixed with args-hash + TTL tracking
- Server-side role enforcement prevents privilege escalation