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.4.0 - 2026-09-11¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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