Changelog
July 9, 2026 · View on GitHub
All notable changes to Agents.KT are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning. Pre-1.0, minor bumps may add new public API; existing API surface is preserved.
[Unreleased]
Security — jackson-databind bumped 2.21.3 → 2.21.5 (7 advisories)
jackson-databind arrives transitively via langchain4j-core in agents-kt-rag-langchain4j (it is not a
direct dependency of any module). Dependabot flagged 7 open advisories against the 2.21.x line it pulled;
all are cleared by forcing the artifact — and the tightly-coupled jackson-core — to 2.21.5 in that
module's existing resolutionStrategy.force(...) block:
- High — array-subtype allowlist bypass in
BasicPolymorphicTypeValidator(allowIfSubTypeIsArray), GHSA-rmj7-2vxq-3g9f. - High — PolymorphicTypeValidator bypass via generic type parameters (arbitrary class instantiation), GHSA-j3rv-43j4-c7qm.
- Moderate —
@JsonViewbypass for unwrapped creator parameters (GHSA-rcqc-6cw3-h962) and for setterless creator properties (GHSA-5hh8-q8hv-fr38). - Moderate — case-insensitive deserialization bypasses per-property
@JsonIgnoreProperties(GHSA-9fxm-vc8v-hj55). - Moderate — renamed
@JsonIgnore'd setters can still deserialize via private fields (GHSA-hgj6-7826-r7m5). - Moderate —
InetSocketAddressdeserialization triggers eager DNS resolution (SSRF), GHSA-5jmj-h7xm-6q6v. This one is fixed only in 2.21.5 (2.21.4 leaves it open), which is why the bump targets 2.21.5 rather than 2.21.4.
No source or API change; a patch bump within the same minor line. Lockfile + verification-metadata.xml
regenerated for jackson-core/jackson-databind/jackson-bom 2.21.5.
[0.8.2] - 2026-07-01
Standards & trust hardening. Hardens the experimental x402 buyer (mandatory guardrails, cross-payment session limits, a signer seam, CAIP-2 network ids), adds machine-readable release-truth metadata, makes the streaming path surface provider HTTP errors instead of swallowing them, and adds explicit Kimi China/International region modes.
Added — release-truth: a single source of truth for version/provider/protocol claims (#4735)
An external audit found the advertised version + provider + protocol claims drifting across the README,
roadmap, comparison page, and POM (0.8.1 shipped while several surfaces still said 0.8.0/0.7.2). New
release-metadata.yaml is now the one place those claims live, and ReleaseMetadataConsistencyTest pins
the in-repo surfaces to it — a release that edits only the metadata fails the build until the prose moves.
Kills the drift class the de-slop epic (#3083) flagged.
Fixed — streaming now surfaces provider HTTP errors instead of swallowing them (#4882)
OpenAiClient.chatStream — and every OpenAI-compatible subclass (OpenAI, DeepSeek, Kimi, OpenRouter,
Perplexity) — previously returned the raw response body on a non-2xx streaming response without
checking the status. An error body has no data: lines, so the SSE parser emitted a lone terminal
End: a silent, empty, success-looking stream. A stream started with an expired key, a 429, or a
provider 5xx returned nothing instead of raising. sendChatStream now checks statusCode() and throws
LlmProviderException (HTTP status + provider label + a bounded slice of the error body), matching the
non-streaming chat() contract. Kimi's region-hint wrapping (#4511) still applies on auth errors.
Added — Kimi region modes: KimiRegion.China / KimiRegion.International (#4883)
Moonshot/Kimi runs two independent platforms with non-interchangeable keys. A new KimiRegion enum
plus a model { kimi("moonshot-v1-8k", region = KimiRegion.INTERNATIONAL) } DSL overload make the region
an explicit, typed choice instead of a raw baseUrl string. Additive: kimi("...") with no region is
byte-identical to before (China default preserved); KimiRegion.INTERNATIONAL.baseUrl is also usable as
the KimiClient(baseUrl = …) argument directly.
Changed — x402 buyer trust hardening: guardrails are now mandatory and bind more (#4528)
An external audit flagged that the "guardrails-first" buyer had optional guardrails (an empty
X402SpendPolicy defaulted in), checked only amount/network/payTo, and paid the seller's first offer.
Hardened (breaking, pre-1.0):
- Policy is mandatory —
X402Account.fromPrivateKeyno longer defaults the policy; an intentionally unbounded wallet must pass the explicitly-namedX402SpendPolicy.unsafeAllowAllForTesting(). - Stronger binding —
X402SpendPolicygainsallowedAssets(pin the token),allowedResourceOrigins(pin the endpointscheme://host[:port]), andmaxAuthorizationLifetimeSeconds(the signedvalidBeforeis clamped to it, so a seller'smaxTimeoutSecondscan't mint a long-lived authorization). A policy-approved recipient no longer implies any token, any URL, or any duration. - Deterministic offer selection — new
X402OfferSelector(X402Client(account, selector = …)); the defaultLowestAmountpays the cheapest permitted offer instead of the seller's first, so a seller can't orderaccepts[]to steer the buyer to the costliest.X402OfferSelector.FirstAllowedrestores the prior behavior explicitly.
Migration: pass a real X402SpendPolicy (or unsafeAllowAllForTesting()) to fromPrivateKey. 6 new tests.
Added — x402 buyer: cross-payment limits + a signer seam (#4528)
- Session/velocity limits —
X402Client(account, sessionLimits = X402SessionLimits(maxPayments, maxTotalValue, maxPaymentsPerPayee, cooldownMillis), spendStore = …)bounds the aggregate a buyer may spend across many calls (the per-payment policy only bounds one). Settled payments are recorded in anX402SpendStore(default per-processInMemorySpendStore; back it with a durable store in production so a restart can't reset a cumulative cap). A limit-exceeding payment raisesX402PaymentDeniedExceptionbefore any signature. X402Signerseam —X402Account.fromSigner(signer, policy)signs through anX402Signerinstead of owning a raw private key, so a deployment can sign with a KMS / HSM / wallet-service / scoped session key and keep permanent keys out of the application heap.fromPrivateKeynow wraps aLocalKeySigner(the default in-process key). 9 new tests.
Added — x402 accepts CAIP-2 network ids (v2 interop step) (#4528)
x402 v2 identifies networks with CAIP-2 ids (eip155:84532) instead of casual strings (base-sepolia).
The buyer now resolves any EVM eip155:<chainId> network on an offer, so it can pay a v2 seller's offers. The
current wire is otherwise x402 v1 (X-PAYMENT / X-PAYMENT-RESPONSE headers, x402Version: 1); full v2
transport (the PAYMENT-REQUIRED / PAYMENT-SIGNATURE / PAYMENT-RESPONSE headers + v2 payload envelope) is
deferred until the v2 payload schema is verified against a live v2 facilitator — we don't ship a wire we can't
test against the spec. So: experimental, x402 v1-compatible (CAIP-2 network ids accepted).
[0.8.1] - 2026-06-20
Added — x402 buyer side: agents can autonomously pay (experimental) (#4528, epic #4526)
The x402 seller half (X402PaymentGate, #4527) let an agent get paid; this adds the buyer half — an agent
can now pay for a resource it wants. X402Client drives the request → 402 → pay → retry handshake: on a
402 Payment Required it parses the seller's accepts[], signs an
EIP-3009 transferWithAuthorization (EIP-712), and replays the
request with an X-PAYMENT header.
This is the half where irreversible money moves, so it is guardrails-first. The signing key lives in
X402Account, constructed in operator code below the model layer — never serialized, logged, or placed in
a prompt; the LLM drives the request but cannot read the key or widen the policy. Every payment must pass an
X402SpendPolicy before any signature is produced: maxValuePerPayment (the blast-radius cap),
allowedNetworks, allowedPayTo (neutralizes a redirected-payTo injection), and an optional confirm
human-in-the-loop gate. A rejected payment raises X402PaymentDeniedException instead of overpaying — no
signature means no money moved.
The signing is real secp256k1 + legacy Keccak-256 + EIP-712 (BouncyCastle, promoted compileOnly → implementation; no web3j/kethereum), pinned byte-for-byte against ethers.js v6 vectors (keccak256
anchor, the 0x7c7c6cdb… EIP-3009 type hash, a known address, and the exact 65-byte signature). The end-to-end
test stands up the real X402PaymentGate fronted by a facilitator that independently ecrecovers the signer —
so a genuine signature flows buyer → 402 → sign → seller → verify → 200, hermetically. EXPERIMENTAL: real USDC
moves against a live facilitator-backed seller. Still deferred: scoped ERC-4337 session keys (on-chain caps),
the upto metered scheme, Solana, and cross-payment velocity limits. New buyer types in agents_engine.x402
(+ x402.crypto); 23 new tests.
Added — AG-UI now emits TOOL_CALL_RESULT (the executor return) (epic #4523)
AgUiServer already surfaced TOOL_CALL_START/ARGS/END, but a frontend never saw what a tool
returned. AgUiEventBridge now emits a TOOL_CALL_RESULT event after each TOOL_CALL_END,
carrying the executor return from AgentEvent.ToolCallFinished: content (the stringified
result), role: "tool", isError, and a fresh tool-message messageId tied back to the call's
toolCallId. This is the TOOL_CALL_END/RESULT pair PRD §12.7 always specified — a CopilotKit
chat can now render tool outputs, not just tool invocations. STATE events and client-tool
round-trips remain the documented AG-UI follow-ups (both blocked on threading the snapshot/resume
seam through session(input)). 2 new tests.
Docs — Agent → WASM export feasibility spike (#4548, epic #4547)
Delivered the go/no-go for compiling a typed agents.kt agent to WebAssembly (the forward-looking direction
after WasmSandbox was closed won't-do). docs/wasm-feasibility.md turns the four "walls" into measured facts
against the current tree (345 files: 48 reflection / 24 HTTP / 13 concurrency / 17 process-or-thread) and
records a proof of concept: a no-reflection, no-HTTP slice of the programming model (Agent<IN,OUT>, Skill,
the then operator) compiled to a ~98 KB .wasm and executed correctly in an isolated wasmJs probe. The
abstractions are portable; the blockers concentrate in the model adapters (HTTP/reflection) and platform glue.
Recommendation: conditional GO for a wasmJs capability profile (no subprocess tools; fetch-bound
network) gated on finishing the KSP reflection-removal — not a whole-codebase port. Doc-only; no API change.
Added — AG-UI now streams REASONING events (live model thinking) (#4629, epic #4523)
AgUiServer previously surfaced the lifecycle/text/tool/step event families; it now also bridges
AgentEvent.Reasoning (the model's thinking stream, #2406 — Claude/DeepSeek/Ollama) into AG-UI's
REASONING family: REASONING_START → REASONING_MESSAGE_START → REASONING_MESSAGE_CONTENT* →
REASONING_MESSAGE_END → REASONING_END, all keyed by one messageId (the deprecated THINKING_*
names are not used). A frontend (e.g. a CopilotKit chat) can now render live reasoning instead of a
spinner. Reasoning precedes the answer, so AgUiEventBridge opens the block on the first reasoning
chunk and closes it before any answer token, tool call, step finish, or run finish — the same ordering
discipline the text state machine already enforces. STATE events and client-tool round-trips remain the
documented AG-UI follow-ups. 2 new tests.
Added — audit-ledger now records cross-cutting agent misbehaviour (#2905, epic #2882)
agent.events.ledger(file) previously chained only tool-action verdicts (APPROVED / DENIED /
HALLUCINATED). It now folds in the misbehaviour signals that never flow through a tool body, so the one
tamper-evident Merkle chain answers "what did agents try to do that they shouldn't, and what went wrong":
a PipelineEvent.BudgetThreshold records a BUDGET_EXCEEDED row (the budget dimension + how much of the
ceiling was used), and a PipelineEvent.ErrorOccurred records an INFRA_ERROR row (the exception class
only — the message, which may carry secrets, is never stored). Two new LedgerDecision verdicts back these.
Each row now exposes a derived severity (INFO / WARN / CRITICAL) and an isMisbehaviour flag —
both a pure function of the verdict, so the hash schema is unchanged and old ledgers still verify. Read
the misbehaviour rows back with ToolAuditLedger.readMisbehaviour(path). An unrecognised verdict written by a
newer version reads as misbehaviour at WARN rather than crashing the reader (forward-compatible, fail-safe).
The writer stays unreachable through ToolEnvironment (#2883) — it only observes framework events, so a
compromised tool cannot forge or rewrite its own row. 5 new tests.
Changed — default transient-network retry across all HTTP model providers (#4560)
The shared non-streaming transport (HttpModelClientSupport.sendBounded, used by Claude, OpenAI +
DeepSeek/Kimi/OpenRouter/Perplexity, Gemini, and Ollama) now retries transient failures by default —
connection-level exceptions (IOException: connection reset, refused, no-route, unexpected EOF) and transient
HTTP statuses (408/429/500/502/503/504) — up to 3 attempts with exponential backoff (250ms→500ms). Previously
only Ollama retried; every other provider failed fast on a network blip unless you opted into
onLLMError { Retry() }. This matches the default behavior of official SDKs (e.g. OpenAI). Two deliberate
exclusions: HttpTimeoutException is not retried (the per-request timeout is your total budget —
retrying would silently multiply it; it surfaces immediately), and the original exception type is preserved
on exhaustion (rethrown as-is, not wrapped) so the agent-level onLLMError/LlmErrorDecision can still
pattern-match e is ConnectException. It sits below onLLMError (transport rides out blips first; the
handler sees only what survives, identity intact); on the final attempt a transient status is returned
unchanged so the per-provider parser still surfaces the provider's own error message. Streaming
(sendChatStream) is not retried (re-issuing mid-stream would duplicate delivered tokens — a connect-phase
follow-up). 6 tests (scripted fake HttpClient).
Added — seller-side x402 payments: X402PaymentGate (#4527, PRD §12.8) — experimental
X402PaymentGate(requirements, facilitator).gate(handler) wraps any JDK HttpHandler so a resource is served
only after a valid, settled stablecoin (USDC) payment over the x402
protocol (HTTP 402 Payment Required). Our agentic-web serve surfaces (McpServer / A2AServer /
NlWebServer / AgUiServer) are loopback HttpServers, so this fronts any of them — letting an agent
monetize itself. The safe half of x402: the seller holds no key and takes no custody — the buyer
signs an EIP-3009 authorization and a hosted FacilitatorClient (injected seam; HttpFacilitatorClient for
production) verifies + settles on-chain; we only configure a public payTo. The LLM never touches money
(gating is at the HTTP layer, outside the agent loop). Fails closed — missing/invalid payment, settle
failure, or an unreachable facilitator all return 402, never serving the resource unpaid. Per request: no
X-PAYMENT → 402 with {x402Version, error, accepts:[requirements]}; X-PAYMENT present → verify → settle
→ set X-PAYMENT-RESPONSE → serve. New package agents_engine.x402 (core, no deps). Wired into the serve
surfaces (#4557): pass payment = gate to NlWebServer.from / AgUiServer.from / A2AServer.from to gate
the served endpoint (A2A's agent-card discovery stays free; McpServer keeps a granular paidTool() follow-up
rather than a blanket gate). 7 hermetic tests (fake facilitator + in-process HttpServer). Buyer-side
autonomous payment is deliberately not included (it concentrates the irreversible-money risk — gated on
scoped session keys with signing kept below the model layer); buyer-side + an MCP paidTool() / a2a-x402
extension are follow-ups (#4526).
Added — AgUiServer: serve an agent to a frontend over AG-UI (#4523, PRD §12.7)
AgUiServer.from(agent).start() exposes an agent over the AG-UI
protocol — the agent↔user/frontend layer (MCP = agent↔tools, A2A = agent↔agent, AG-UI = agent↔user), the
only interop surface that reaches an end-user UI (e.g. a CopilotKit React chat) without us building a frontend.
Not a descriptor exporter — a runtime streaming surface: a single POST of an AG-UI RunAgentInput
({threadId, runId, messages, …}) returns an SSE stream of typed AG-UI events. It's a direct bridge over
the typed streaming AgentSession: the last user message is the agent input, and AgUiEventBridge maps each
AgentEvent to AG-UI events inside the RUN_STARTED … RUN_FINISHED envelope — Token →
TEXT_MESSAGE_START/CONTENT/END, ToolCall* → TOOL_CALL_START/ARGS/END, Skill* → STEP_STARTED/FINISHED,
Failed → RUN_ERROR (the bridge holds the small state machine that guarantees AG-UI's ordering). Same
from(agent) shape, loopback-only posture, and threat model as McpServer / A2AServer / NlWebServer
(127.0.0.1, optional bearer, gateway for network reach); hand-rolled SSE over the JDK HttpServer — no AG-UI
SDK (the community JVM SDKs are client-side only). New package agents_engine.agui. agents.kt now serves the
agentic web four ways: MCP, A2A, NLWeb, and AG-UI. 6 tests. STATE/REASONING event families and client-tool
round-trips are follow-ups.
Added — agents-kt-dir: AGNTCY DIR directory client (#4520, PRD §12.6) — AGNTCY interop
DirClient is a typed Kotlin client for the AGNTCY DIR content-addressed
directory, over generated grpc-kotlin coroutine stubs — three services on one channel:
- StoreService (content-addressed CRUD):
push/pushAll(OASF record → CID),pull(CID → JSON),lookup(metadata),delete. - SearchService (local content search):
searchRecords/searchCidsby typedDirQueryfacet (DirQueryType.SKILL_NAME,DOMAIN_NAME,AUTHOR, … — the OASF fields DIR indexes). - RoutingService (network publish + discovery):
publish/unpublish(announce records by CID),routeSearch(cross-peer discovery →DirRouteMatch{cid, peer, score}; coarse skill/locator/domain/module facets, non-routable facets rejected).
The directory pillar of the AGNTCY epic (#4517), beside OASF export/import (#4518/#4519) and
Identity-verify (#4521). The record body is a google.protobuf.Struct (JSON is the contract — no OASF protos)
via protobuf's canonical JsonFormat (whole numbers stay integral, e.g. {"id":1003}). Protos are vendored
trimmed + wire-compatible (same package/service/RPC/field-numbers; buf.validate options dropped). Auth:
plaintext (dev) / TLS / OIDC bearer; SPIFFE/mTLS via a caller-supplied ManagedChannel (fromChannel). New
feature module agents-kt-dir (agents_engine.agntcy.dir) so the grpc/protobuf/netty graph stays out of core.
7 tests (in-process gRPC round trips across all three services). With this the AGNTCY epic is complete; the
only DIR remainders are RoutingService.List and OCI referrers.
Added — OASF record import + validate: fromOasfRecord() (#4519, PRD §12.6) — AGNTCY interop
fromOasfRecord(json) parses + validates an OASF 1.0.0 record into the typed OasfRecord — the read side of
toOasfRecord() (#4518), round-tripping at the JSON level. Fail-closed: rejects a missing name /
schema_version, an unknown schema major, a skill/domain entry with neither id nor name (OASF's
at_least_one: [id, name]), or a taxonomy id that contradicts its name (exact-path check against the
vendored OasfTaxonomy — no fuzzy matching); when only one of id/name is given the other is resolved from the
taxonomy (custom or newer-than-vendored paths are kept, not invented). Merely-recommended-but-missing fields
(version, authors, created_at, description) only warn, so a record this library exported imports
cleanly. New types OasfRecord, OasfClassification, OasfValidationException. 6 tests.
Added — agents-kt-identity: AGNTCY Identity badge verify (#4521, PRD §12.6) — AGNTCY interop
IdentityVerifier.verify(compactJws, jwks) validates an AGNTCY Identity
agent badge — a W3C Verifiable Credential secured with JOSE/JWS — against an issuer's JWKS
(/.well-known/jwks.json), returning a VerifiedBadge (issuer / subject / credentialSubject) or throwing
BadgeVerificationException. The trust pillar of the AGNTCY epic (#4517), beside the OASF discovery
record (§12.6) and A2A invocation (§12.5): in a trust-gated network you accept work only from agents whose
badge a known issuer signed. Verify-only — issuance (keys/signing/vaults) is deferred to the self-hosted
stack. Fail-closed and not hand-rolled: verification delegates to the vetted nimbus-jose-jwt processor
(rejects alg: none, HS* algorithm-confusion, expired/not-yet-valid, tampered, wrong/unknown key — each a
negative test). IdentityResolver fetches the JWKS (bounded timeouts + size cap). Ships as a new feature
module agents-kt-identity (package agents_engine.agntcy.identity) so the JOSE dependency stays out of
core — same pattern as agents-kt-rag. 8 tests. Remaining #4517 subtasks: DIR client (#4520), OASF
import/validate (#4519).
Added — OASF 1.0.0 record export: toOasfRecord() (#4518, PRD §12.6) — AGNTCY interop
agent.toOasfRecord(version, authors, locators, …) emits an OASF 1.0.0
record — AGNTCY's content-addressed discovery metadata — the third discovery exporter beside the A2A
AgentCard (toAgentCard(), §12.5) and native agent.json (toAgentJson(), §12.2), and the first piece of
the AGNTCY epic (#4517: OASF + DIR + Identity-verify). The native typed agent stays the source of truth; this
is a projection over it. OASF skills are taxonomy entries, not free text: a skill becomes an OASF skills[]
entry only when annotated with .oasf("agent_orchestration/multi_agent_planning"), resolved to its uid via
the vendored OasfTaxonomy (a path → uid lookup — OASF uids are explicitly assigned per node, not a single
formula; un-annotated/unknown skills are omitted with a logged warning). Deterministic and byte-stable:
createdAt/authors/locators are caller-supplied (no hidden now()). toAgentJson() gained the same
optional provenance fields additively (metadata.authors, metadata.createdAt, spec.locators) — existing
callers serialize byte-identically. New package agents_engine.agntcy (toOasfRecord, OasfTaxonomy,
OasfLocator). The complete taxonomy is vendored (122 skills, 181 domains) directly from the hosted
schema, with OasfTaxonomyCrossCheckTest (a live-cloud-api test that self-skips offline) asserting the
vendored TSVs stay equal to schema.oasf.outshift.com so they can't silently drift. 8 tests. Record
signing, OASF import/validate, the DIR client, and Identity-verify are the remaining #4517 subtasks.
Added — NlWebServer: serve agents.kt as an NLWeb endpoint (#4542, PRD §12.9)
The serve side of NLWeb (the nlwebSearch tool, #4541, is the consume side). NlWebServer.from(agent).start()
exposes the NLWeb POST /ask contract ({query, site?, mode} → {query_id, results:[{url, name, site, score, description, schema_object}], summary?}) over a loopback JDK HttpServer — the same from(agent)
shape, serve surface, and threat model as McpServer / A2AServer: bound to 127.0.0.1, optional bearer
auth, front with a gateway for any network reach. The query is the agent's input; an NlWebSearchResult
output is served verbatim (ranked schema.org results), any other output becomes the summary answer — the
agent does the retrieval, which you back with the RAG EmbeddingStore seam (:agents-kt-rag) or anything.
New package agents_engine.nlweb (NlWebServer); the NlWebResult / NlWebSearchResult wire types are
shared with the client tool, and renderAskResponse round-trips through the client's parseNlWebResponse.
The /mcp face is McpServer's domain (expose an ask skill there); this is the /ask-over-HTTP path.
5 tests (in-process loopback round trips for both result-returning and answer-returning agents + the
serialize↔parse symmetry).
Added — nlwebSearch tool: query an NLWeb endpoint (#4541, PRD §12.9)
tools { +nlwebSearchTool(baseUrl = "https://example.com") } lets an agent on its own model query an
NLWeb endpoint — a website's natural-language interface over its
schema.org-structured content — and fold the ranked, typed results into context. Mirrors
perplexitySearch: marked untrustedOutput = true (fetched web content is wrapped in the
{trusted:false} envelope and the model is warned to treat it as data, #642), with pure
buildNlWebAskBody / parseNlWebResponse wire helpers and an injectable NlWebSearchBackend seam.
Posts to <baseUrl>/ask (no API key — NLWeb endpoints are public); NlWebSearchOptions(site, mode = LIST/SUMMARIZE/GENERATE) selects the namespace and query mode; results render as a numbered list of
schema.org matches (name, @type, description, url) plus any summarize/generate answer. The first slice
of the agent↔web-content layer (epic #4539). 8 tests. (Every NLWeb endpoint is also an MCP server, so an
NLWeb /mcp URL is equally consumable through the existing MCP client; this tool is the zero-wiring
/ask-over-HTTP path.)
[0.8.0] — 2026-06-14
Interoperable, multimodal agents — with capability grants. The largest minor since 0.5.0:
agent-to-agent interop (A2A v1), full multimodal (audio STT/TTS, vision, image generation),
a RAG seam, richer composition (handoff / firstOf / .speculative / loopUntil /
built-in aggregators / forum captains), human-in-the-loop gates, an eval harness, history
compression, an eighth model provider (Google Gemini), agent.json definition serialization,
and the capability-grants DSL (grants { allow / confirm }). Plus the planning groundwork for
the agentic-web standards (AGNTCY / AG-UI / x402 / NLWeb — PRD §12.6–§12.9). Additive: existing
public API surfaces are preserved.
Deferred to 0.9.0: the remaining Layer-2 sandbox backends — DockerSandbox (#2895), the
network hostname-allowlist proxy (#2893), and read confinement (#4546). WasmSandbox (#2894)
was closed won't-do; the rational WASM direction (agent → WASM export, #4547) is a separate
forward-looking track.
Added — Google Gemini provider adapter (#1917)
Eighth built-in ModelClient: model { gemini("gemini-2.5-flash"); apiKey = ... } for Google's
Generative Language API. A full from-scratch adapter (Gemini is not OpenAI-compatible), mapping
LlmMessage/LlmResponse to Gemini's contents/parts shape: user/model roles, system →
systemInstruction, tool calls via functionDeclarations/functionCall, tool results via
functionResponse paired by function name (Gemini has no call id), parametersJsonSchema /
responseJsonSchema for tools and constrained decoding, toolConfig.functionCallingConfig for
ToolChoice, inlineData vision parts, and thought-summary reasoning
(thinkingConfig.includeThoughts). Native SSE streaming via :streamGenerateContent?alt=sse. The
provider error envelope surfaces as LlmProviderException (same boundary contract as the other
adapters). Closes the long-standing provider gap — #1917 had been marked closed without the adapter
actually landing. 9 hermetic unit tests + 4 live integration tests (live-cloud-api, keyed from
.secrets/gemini-key): text, native streaming, function calling, and a full agentic loop — all
verified green against gemini-2.5-flash.
Added — capability grants: grants { allow / confirm } (#4545, PRD §9.2, 0.8.0)
Agent-level capability grants — the "capability grants" half of the 0.8.0 theme. grants { allow(writeFile); confirm(deploy) } references actual Tool instances: allow(...) tools are freely callable; confirm(...)
tools require the granting agent's authorization on every call — a GrantConfirmer supplied via
confirmWith { name, args -> Boolean }, not a human user (distinct from humanApproval / HumanDecision).
A missing confirmer is fail-closed (confirm-tools denied until the granting authority is wired). Opt-in:
an agent with no grants { } block is unchanged. At construction the agent validates that every tool its
skills (and auto-tools) use is granted, that granted names are real registered tools, and that allow/confirm
are disjoint. The runtime gate (in decideBeforeToolCall, beside the ToolPolicyEnforcer gate) enforces only
confirm(...) — the per-skill allowlist already keeps ungranted tools invisible to the model. The full
structure { root { delegates {} } } topology DSL and permission-manifest surfacing of grants are follow-ups.
6 tests.
Added — pluggable memory retention strategies (#4515, PRD §8.5)
MemoryBank now takes a retention: MemoryRetention strategy applied on every write. The historical
maxLines cap is just MemoryRetention.Sliding(n) and remains the default (uncapped banks use
Unbounded), so existing callers are unaffected. New strategies from the PRD's memory table:
Sliding(maxLines) (keep last N lines), TokenBudget(maxTokens, estimateTokens) (drop oldest lines
until within an estimated token budget, always keeping the most recent line), Summarized(keepRecentLines, summarize) (collapse older lines into a caller-supplied digest + keep recent verbatim), and Unbounded.
A rough estimateTokens (~4 chars/token) ships for budgeting. 10 tests.
Added — agent.json definition serialization (#4516, PRD §12.2)
Agent<*, *>.toAgentJson(version?, description?) serializes an agent's definition to the documented
agent.json document — a deterministic, byte-stable snapshot of apiVersion / kind / metadata
(name, optional version/description) / spec (the types it consumes and produces, its skills, its tools
with risk levels, and capabilities). Distinct from the permission manifest (the security/audit artifact)
and the A2A AgentCard (network discovery) — this is the portable description of the agent itself. Keys
emit in a fixed order, so the same agent always serializes identically. 3 tests.
Fixed — forum captain's inline forum_return is parsed, not leaked (#4514)
A captain that emits the forum_return call as inline JSON text (e.g.
{"name":"forum_return","arguments":{"value":108}} — what qwen3-vl emitted live) rather than as a
real tool call fired no ForumReturnException, so the raw JSON string leaked as the forum result.
Forum.deliberate now recognises an inline forum_return in the captain's verdict — by either the
inline "tool" key or the OpenAI "name" key — and extracts the value the same way the
forum_return executor does, matching the agentic loop's existing inline-tool-call robustness. Plain
answers and other tools' JSON pass through untouched. 17 tests (5 end-to-end + 12 parser unit).
Fixed — inline-mode tool conversations converge (#4513)
Models that reject native Ollama tools (e.g. gemma3) fall back to the inline {"tool":…}
prompt — but the conversation history still carried native tool_calls / "tool"-role messages the
inline model can't read, so it looped (turn-budget exceeded) or went blank on the turn after a
tool result. OllamaClient.withInlineToolPrompt now re-renders the inline-mode history into the
text the model was taught (an assistant tool call becomes its inline JSON; a tool result becomes a
readable user message), and the prompt tells the model to stop calling tools and answer in plain
text once it has a result. Verified live: the gemma3:4b inline-fallback integration tests went
from budget-exceeded to green. Live test models are configurable via OLLAMA_TEST_MODEL
(reasoning models like gpt-oss surface no content; a tool-caller such as qwen3-vl:8b works). 2
hermetic tests.
Fixed — actionable provider errors: Kimi region + Ollama empty reasoning response (#4511, #4512)
Two confusing live-failure modes now fail loud and actionable instead of as junk:
- Kimi region mismatch (#4511) — Moonshot runs two separate platforms (
api.moonshot.cn/ China,api.moonshot.ai/ International); a valid key from one returns bareInvalid Authenticationagainst the other.KimiClientnow enriches the auth error with which endpoint it's using and how to switch (CHINA_BASE_URL/INTERNATIONAL_BASE_URLconstants); the live test honorsKIMI_BASE_URL. (Verified: the same key gives.cn→401,.ai→200.) - Ollama empty reasoning response (#4512) — a reasoning model (e.g.
gpt-oss) can generate tokens but surface nocontent, no tool call, and no reasoning text, which Ollama returns as an empty message.OllamaClientno longer returns a silent emptyText(which made the agentic loop fail mysteriously) — it throws an actionable error naming the model and suggesting a tool-calling model. Live tests' Ollama model is configurable viaOLLAMA_TEST_MODEL. 5 new hermetic tests.
Changed — no listening ports: dropped the speech server, added a subprocess TTS (#4510)
Architecture call: agents.kt is an orchestration toolkit, not a media server — it must not bind a listening port. Engines run externally, reached in-process (JNI) or via subprocess (no port).
- Removed
:agents-kt-speech-server— it bound a port, which is the one thing that doesn't fit. Direct bolt-on via theSpeechToTextClient/TtsModelClientseams replaces it. (:agents-kt-whisper-jnistays — in-process, no port; the HTTP clients stay — outbound, they expose no port on our side.) - Added
SubprocessTtsClient(core) — aTtsModelClientthat pipes text to a local TTS binary throughProcessSandbox(stdin in, audio file out) and returnsContent.Audio. Local TTS with no port and no JVM TTS engine — the engine is an external binary, write-confined to a temp dir. Plugs straight into thespeaktool.
Security — WhisperModelResolver download hardening (#4509)
Red→green follow-up to #4508 (resolver-side):
- Redirect handling — the default client now follows
NORMALredirects (no HTTPS→HTTP downgrade), so real HuggingFace model URLs (302 → CDN) actually download. Pair with a pinned checksum to catch a tampered redirect target. - Unbounded download — streams to disk with a hard
maxBytescap (default 8 GiB): an over-Content-Lengthor over-cap body is rejected, no file published. - Unpinned integrity — a download with no
sha256now logs a WARNING (a compromised mirror could feed malicious bytes to native whisper.cpp).
Security — WhisperModelResolver.fromUrl path traversal (#4508)
- Path traversal — the cache filename is validated as a bare name (no separators /
../ absolute), so a hostilenamecan't escape the cache dir. Was only incidentally protected bycreateTempFile; now intentional. Adversarial red→green tests.
Added — :agents-kt-whisper-jni in-process STT module + weights-free resolver (#4505)
- New opt-in module for an in-process Whisper STT backend (no server) — the
separate-module pattern for native modality backends, like
:agents-kt-otel/RAG adapters. Ships no weights and no native artifact:WhisperModelResolverprovisions a GGML model file at runtime (download → cache → SHA-256 verify → reuse, or a local path); the whisper.cpp JNI lib is supplied by the consumer through the one-methodWhisperBackendseam.WhisperJniSttClientis aSpeechToTextClient(BlobStore → JVM WAV→PCM decode → backend), drop-in for thetranscribe_audiotool. 6 hermetic tests (resolver download/cache/checksum; WAV decode through a fake backend — no native lib/model needed). See the module README for the whisper-jni binding. Establishes that the jar is code; weights are runtime config.
Added — speech HTTP client DX: preflight + fail-fast errors (#4504)
WhisperSttClient.preflight()/QwenTtsClient.preflight()— cheap readiness probe that returns normally when the endpoint answers and throws an actionable message otherwise (how to start a server / fixbaseUrl).transcribe/speaknow wrap connection + timeout failures into the same actionableIllegalStateExceptioninstead of leaking a rawConnectException. 4 tests.
Added — end-to-end audio as tools + self-hosted Whisper/Qwen adapters (#4501)
- Multimodal as tools.
transcribeAudioTool(stt, blobStore, audioRoot)(transcribe_audio) andspeakTool(tts)(speak, returningToolResult(Content.Text, Content.Audio)) make audio end-to-end through the agentic loop — the model orchestrates transcription/synthesis itself, reusing the full tool spine (ToolPolicy + Layer-1 filesystem gate, constraints, audit, manifest, typed hooks).transcribe_audioconfines reads toaudioRoot;speak's audio ref flows through audit/snapshot via the existingToolResultpath — no attachment-path wiring needed. Bundle:speechTools(...). - Self-hosted adapters (first guests).
WhisperSttClient(STT) andQwenTtsClient(TTS) target the OpenAI-compatible/v1/audio/transcriptionsand/v1/audio/speechendpoints the common self-hosted servers expose (faster-whisper-server / Speaches / LocalAI / openedai-speech), with no API key by default and a requiredbaseUrl(optionalbearerTokenfor a fronting gateway). Both implement the existingSpeechToTextClient/TtsModelClientinterfaces, so the OpenAI hosted adapters stay drop-in swappable. 9 tests (stub-server wire pins + tool executors + agentic-loop end-to-end).
Fixed — session cancellation no longer leaks the invocation (#4499, streaming hardening)
- Cancelling collection of
AgentSession.events(or cancellingawait()) now cancels the underlying agent invocation — the documented contract, previously unenforced. The producer ran in a detached scope, so a cancelled or abandoned (take(1)) consumer left the agent making model calls in the background. The teardown lives in the events flow'sfinally(fires on external cancellation, where a downstreamonCompletionstage is skipped) and on theawait()path. Suspending invocations stop promptly; the bare-cancellation contract (no syntheticFailed) is preserved. 3 probe tests + complex-composition streaming coverage.
Fixed — concurrent composition rejects duplicate agent names (#4500, streaming hardening)
Parallel(/) andForum(*) demultiplex streamed events byagentId(the agent's name), so two participants sharing a name produced indistinguishable interleaved streams. The single-placement rule caught the same instance placed twice but not two distinct same-named instances; construction now fails loud with an actionable message naming the duplicate — the same stance as duplicate tool/skill names.speculative(n)self-racing is the documented exception. 4 tests.
Changed — flake diagnostics on the mac network-sandbox test (#4498, antifragility pass)
- The
ProcessSandboxMacTestlive network probe (flake #4370) now embeds full failure forensics in its assertion message: exit code, probe stdout/stderr (sandbox-exec complains on stderr), the python3 used, and the exact generated Seatbelt profile — so an unreproducible runner failure is diagnosable from the CI log alone.
Added — requireSandbox strict mode (#4497, antifragility pass)
ProcessSandbox.run(command, requireSandbox = true)— fail closed on hosts with no OS sandbox backend: throwsIllegalStateExceptionand the subprocess never starts, instead of the historical UNCONFINED plain-ProcessBuilderfallback (which stays the default). BringsprocessTool's fail-closed stance to the low-level API. 2 tests.
Added — session drop accounting (#4496, antifragility pass)
AgentSession.droppedEvents— live count of inner events lost when a consumer lags the producer (the non-suspending emitter forwards viatrySendinto the 64-slot buffer). Event loss is now observable in code — assert on it instead of scraping logs. Per-event drop WARNINGs are replaced by one summary line at session close (count + first dropped type); terminalCompleted/Failedstill always deliver via suspendingsend. Both session paths covered (agent.sessionand every composition operator). 3 tests.
Added — LlmErrorDecision.Retry (#4495, antifragility pass)
onLLMError { Retry(maxAttempts = 3, initialBackoffMillis = 500) }— third decision next toRethrow/RespondWith: re-run the failed model call with exponential backoff (500ms → 1s → 2s …). The handler is consulted per failed attempt, so it can switch toRespondWith/Rethrowmid-schedule; the attempt budget is per model turn; exhaustion rethrows the ORIGINAL error, identity preserved. Default behavior (no handler) unchanged — fail fast and loud. 4 tests.
Added — typed tool hooks (#4493, PRD §typed-hooks)
agent.onToolCall<Args>("tool") { args -> }(pre-execution) andonToolResult<Args>("tool") { args, result -> }(post-execution) — reified observation hooks that decode the tool's@GenerableArgs through the same KSP-aware codec path as typed tools. Filtered by tool name; undecodable payloads skip silently (hooks never kill runs); chains with existing untyped listeners. Observation only — gating stays ononBeforeToolCall. 3 tests.
Added — compaction strategies (#4492, PRD §5.7.1)
historyCompression { strategy = … }—SlidingWindow(keepRecent)drops the conversation middle behind a one-line elision marker (zero summarizer cost;keepRecentoverridespreserveRecent);Custom { middle -> replacement }takes full control;Summarizestays the default (#3865 Phase-1 behavior, source-compatible). All strategies degrade to an uncompressed turn on failure. 4 tests.
Added — pipeline stage events (#4491, PRD §10.2)
AgentEvent.StageStarted/StageCompleted— explicit stage boundaries on composite sessions: a marker pair around each direct pipeline component (agent stages named, operator legs labeledparallel/forum/loop/branch), nested pipelines marking their own stages exactly once. Consumers stop inferring stage transitions fromagentIdflips. Closes the long-standing "stage event types" roadmap gap left out of #3866 by design. Bridge events on OTel/LangSmith/Langfuse; existing count-pinned session tests updated. 3 new tests.
Added — tool usage constraints (#4490, PRD §tool-constraints)
tool { constraints { maxInvocations = 3; onlyAfter("fetch"); forbidden() } }— per-tool usage rules, the sibling ofToolPolicy: policy says what a tool may touch, constraints say when and how often it may run per invocation. Violations deny through the standard auditable path (the model self-corrects); per-invocation tracker (no cross-run leakage); manifest-visible under each tool'sconstraintskey.ForceAtStep/RequiresApprovaldeferred (approval is already first-class). 4 tests.
Added — multimodal: audio STT + image generation + TTS (#3867 first slice, P0.5)
SpeechToTextClient/ImageModelClient/TtsModelClientfun-interfaces with OpenAI adapters:OpenAiSpeechToTextClient(Whisper, multipart),OpenAiImagesClient(Images API, b64),OpenAiTtsClient(speech, mp3). Bytes land in the caller'sBlobStore; typedContent.Image/Content.Audiorefs travel through the agent graph.baseUrlinjectable; wire shapes pinned by stub-server tests incl. the acceptance flow (audio → transcript → image).- Not yet:
Content.Audioinside chat messages (gpt-4o-audio blocks) and non-OpenAI providers — remaining on #3867. 3 tests.
Added — W3C trace propagation across MCP/A2A (#3873 slice 1, P1.6)
TraceContextPropagation(core, no-op default, zero OTel dependency) — outbound MCP and A2A HTTP requests carry the installed propagator's headers (traceparent/tracestate);McpServer/A2AServermake the inbound remote context current for the dispatch scope.OtelTracePropagation.install()(:agents-kt-otel) wires the seam to OpenTelemetry's W3C propagators. Distributed agent traces now connect at the process boundaries instead of starting fresh. Remaining on #3873: runtime-native span hierarchy + coroutine ContextStorage. 3 tests.
Docs — async-loop premortem (#3874, P2.5 groundwork)
docs/premortem-async-loop.md— the suspend-native loop design, decided once before anyone codes it: the blocking residue isModelClient.chat+ mid-stream HTTP reads (the loop internals are already suspend), the target is achatSuspendmigration per adapter withrunBlockingsurviving only in the public blocking shims, full blast-radius table, the ticket's acceptance gates kept, and the interim workarounds named (MCP/A2A hosting for actor-shaped deployments;firstOffor latency). Implementation stays post-1.0.
Added — patterns recipe library (#3878, P2.2)
docs/patterns.md— Anthropic's "Building Effective Agents" catalog mapped 1:1 onto Agents.KT primitives: ReAct, prompt chaining, routing (handoff), parallelization (/+.aggregate), orchestrator-workers (forum), evaluator-optimizer (loopUntil+evalGate), reflexion, multi-agent debate (consensusCaptain), speculative execution (firstOf), HITL (humanApproval/HumanGateRegistry), and RAG (ragRetriever). Every recipe uses shipped operators — several from this release line. Linked from the README composition section.
Changed — executeAgentic decomposition, slice 1 (#2791)
- One
snapshotNow(...)builder replaces the three identical 9-fieldSessionSnapshotconstructions (budget checkpoint / interrupt / turn boundary) — the #2755 memory-slice semantics now live in one place. - One
resolveCapDecision(...)dispatch replaces the five copy-pasted Stop/Extend/Checkpoint budget-cap blocks (DURATION / TURNS / TOKENS / TOOL_CALLS / CONSECUTIVE_TOOL) with an exhaustivewhenover the sealedBudgetDecision— a new variant is now a compile error, not a silent fall-through. Pre-existing quirk preserved deliberately: CONSECUTIVE_TOOL never re-armed its threshold on extend (rearmThreshold = false). - Behavior-preserving (full suite green); the
ToolCalls-branch extraction continues on #2791.
Added — cross-model eval regression (#3876, P2.4)
suite.runAcrossModels("label" to agent, …)— runs every eval case against each labeled per-model agent and reports divergence (cases passing on some models, failing on others) viaCrossModelEvalResult.divergentplus atoMarkdown()case × model matrix for CI artifacts. Duplicate labels fail loud. Hermetic viaDeterministicModelClient; live runs ride the existing live-tagged suites. 3 tests; docs/eval.md CI example.
Added — @Generable schemas in the permission manifest (manifest v2, #3875)
- Manifests gain a top-level
schemassection: JSON Schema for every@GenerableIN/OUT type in the agent graph (KSP-aware cache-then-reflection probe), keyed by FQN with per-schema sha256, folded intomanifestHash— a type change now bumps the manifest. Reviewers see shapes, not just names. - Manifest format v2; loaded manifests preserve their own version (fixed:
fromJsonpreviously stamped the current constant over a baseline's version), and version differences verify with a non-fatalmanifest.version.changedinfo finding (okis now severity-aware — behavior-preserving, all pre-existing finding types are "high"). 3 tests.
Added — executor { args, env -> } + the first ToolEnvironment slice (#2889 / #2883)
- New executor shape:
tool { policy { … }; executor { args, env -> … } }—envis a per-call, policy-gatedToolEnvironment(v1 ABI:readText/writeText/env(name)); an operation the declared policy doesn't grant throwsToolPolicyViolationbefore it happens (paths normalized, fail-closed without a declaration). Both loop chokepoints are covered through the single executor seam — no loop changes. - Single-arg
executor { args -> }keeps compiling and running (back-compat pinned by test); the builder form carries aWARNINGdeprecation pointing at the new shape, per the one-release migration window. Subprocesses stay withprocessTool; blobs/clock/ledger-envelope recording land with the rest of #2883. 4 tests.
Added — mechanical -SNAPSHOT-on-main enforcement (#4428)
checkSnapshotPolicyGradle task: a non--SNAPSHOTversion is only legal on the tagged release commit itself; anything else fails with the runbook-step-8 hint. CI runs it on every push tomain(release-PR refs exempt by event type — they legitimately carry the release version before the tag exists; the task is deliberately not wired intocheckfor the same reason). Closes the enforcement gap the runbook's post-release bump rule left open.
Added — ToolPolicy ↔ capability comparator + exec capability (#2887)
ToolPolicy.exec— declared subprocess stance (exec { allow() }/exec { deny() }; legacy manifests parse asunspecified). Serialized in manifest JSON/YAML; the manifest verifier flagstool.exec.widenedon an unspecified/deny → allow jump (narrowing passes).ToolPolicyCapabilityComparator(agents-kt-detekt) — the declare-vs-do gate: fortool { policy { … }; executor { … } }declarations, the executor body's statically-extracted capabilities must be a subset of what the policy grants; using more than declared fails the build with a widen-or-remove hint. Over-declaration passes (a manifest-review concern). Un-policied tools stayToolBodyForbiddenApis' business. Syntactic, callee-name based — same honest limits as the extractor. 11 new tests across the three modules.
Added — built-in forum captains (#3877, P2.1)
consensusCaptain(quorum)— N identical member verdicts or fail loud with the full tally;weightedCaptain(weights)— weighted vote keyed by panelist name (default 1.0);byzantineCaptain()— median of numeric verdicts (1-d geometric median, robust to ⌈n/2⌉−1 adversarial members; vector Krum is a tracked follow-up). All three are deterministic transcript captains — the strategy name is the captain's agent name, so audit events carry which aggregation decided the verdict. 5 tests through real forum deliberations.
Added — HumanGateRegistry: the named HITL adapter (#3868, P1.5)
gates.guard(agent, input)returnsGateOutcome.Completed(output)orGateOutcome.Paused(gate)when a tool callshumanApproval { }/interrupt(...). ThePendingGatecarries gateId / reason / payload for the reviewer;approve(reviewer, comment)/reject(...)/resolve(HumanDecision, ...)resumes from the snapshot exactly where the run left off (manifest-hash restore guard applies) and resolves exactly once.- Snapshots are also persisted to an optional
SnapshotStoreas crash evidence; full post-restart rehydration (re-supplying agent + input) is a tracked follow-up. Audit events ride the existing #2489 channel. 4 tests.
Added — loopUntil + evalGate (#3870, P1.4)
agent.loopUntil(maxIterations, feedback?) { predicate }(also onPipeline) — the named reflexion / evaluator-optimizer shape: re-run until the predicate approves the output, feedingfeedback(out)(or the output itself whenIN == OUT) back as input. NamedloopUntilrather than aloop { until { } }DSL block so the existingloop { next }trailing-lambda overload stays source-compatible.evalGate(rubric, threshold)— pass/fail gate over the LLM-as-judge rubric (one judge call per check,lastVerdictkeeps the rationale; threshold validated against the rubric's range). 7 tests incl. the full reflexion shape against a scripted judge.
Added — speculative execution: firstOf / .speculative(n) (#3869, P1.3)
firstOf(a, b)races distinct agents;agent.speculative(3)races the same agent against itself. First success wins at the winner's latency; losers are cancelled but not awaited (sacrificial-worker precedent — suspending losers stop promptly, blocking bodies finish in the background, discarded). A failing branch doesn't settle the race; all-fail throws.onRaceSettled { winner, cancelled, elapsedMillis -> }audit signal;firstOf.session(input)streams every racer's events and completes under the winner's id.- Budget honesty documented: losers' partial tokens are real provider spend — bound N; cross-branch accounting of cancelled partial usage is a tracked gap. 6 tests.
Added — built-in aggregators on / (#3872, P1.2)
(a / b / c).aggregate { … }— one-line ensemble patterns over a parallel fan-out:majorityVote()(deterministic first-encountered tie-break),selectByMax { },bestOfN { scorer }(each output scored exactly once),weighted(weights)(missing agents default to 1.0). Pure sugar overthen: builds a deterministic reducer agent namedaggregate-<strategy>, so audit/streaming events carry the strategy name and the result is an ordinaryPipeline<IN, OUT>. All-branches-failed surfaces as the parallel stage's failure (Failedterminal on sessions). 6 tests.
Added — handoff named operator (#3871, P1.1)
triage handoff { on<BillingTask>() then billing; … }— the named hand-off primitive: identical routing semantics and sealed-exhaustiveness validation asbranch, plus an audit contract — route selection fires the source agent'sonHandoff { toAgent, decisionInputType -> }listener andPipelineEvent.HandoffPerformed(observe/JSONL/OTel/LangSmith/Langfuse), so reviewers can grep transfers specifically. Unlike OpenAI-Swarm-style handoff, the target never shares the source's conversation history — it receives only its declared input type; the single-placement rule holds across the transfer. Fires on both the blocking and streaming paths.
Added — A2A protocol v1: server + typed client (#3864, P0.4)
A2AServer.from(agent)exposes anyAgent<IN, OUT>over A2A v0.2 (JSON-RPC over HTTP), following the McpServer precedent: JDK HttpServer, loopback-only bind, optional bearer auth. AgentCard at/.well-known/agent-card.jsonwith@Generableinput schemas;message/sendmaps the first text part to the agent's typed input and returns a completed Task whose artifact carries the output (JSON property map for typed OUT).a2aAgent<IN, OUT>(name, url)returns a realAgent<IN, OUT>handle for a remote A2A endpoint — drops intothen///forum/branchand skill allowlists like a local agent. Remote JSON-RPC errors throw with the remote message; auth/HTTP failures fail loud.- v1 scope:
message/sendonly — streaming, task lifecycle, andtraceparentpropagation are tracked follow-ups (#3864 / #3873). Newdocs/a2a.md; README limitation bullet replaced. 6 in-process round-trip tests (String +@Generableboth directions, card, auth, errors).
Added — history compression (#3865 Phase 1, P0.3)
agent { historyCompression { … } }— before-turn compression for long-running agents: when the history exceedstriggerMessages(default 40; customtriggerWhen { }supported), the conversation middle collapses into one deterministic digest message. Leading system messages are pinned, the most recentpreserveRecentmessages stay untouched, and the preserved window extends backward so a tool result is never orphaned from itstool_call. Rides theonBeforeTurn→Decision.ProceedWithseam, so the loop history shrinks permanently.- Degrade-don't-fail: a summarizer exception skips compression for that turn. Default
summarizer is extractive and deterministic (no LLM call); pass
summarizer { }for abstractive. - Observability:
onHistoryCompressed { },PipelineEvent.HistoryCompressed(counts only — no conversation content in audit rows), JSONL audit rows, and OTel / LangSmith / Langfuse bridge events. 6 new tests incl. a mid-run agentic-loop integration. - Phases 2 (tiered MemoryBank) and 3 (episodic/semantic split) tracked separately.
Added — RAG seam: EmbeddingStore SPI + query-aware knowledge (#3863, P0.2)
- Core knowledge seam:
skill { knowledge(key, description, retriever) }registers a query-awareKnowledgeRetriever— surfaced to the model as a knowledge tool taking aqueryargument (suspend on the session path, blocking-bridged otherwise), never inlined into the prompt. Staticknowledge(key) { content }entries are unchanged. - New
:agents-kt-ragmodule (in-repo): minimal SPI —EmbeddingStore<T>(upsert/query),Embedder,RagQuery(text + optional embedding),MatchwithProvenance { chunkId, sourceUri, hash }, metadataFilter— plus a cosineInMemoryEmbeddingStoreandragRetriever(store, embedder) { topK; minScore; filter { } }bridging any store into the skill DSL with provenance-carrying rendered results. - Adapter modules (in-repo):
:agents-kt-rag-langchain4j(wraps LangChain4jEmbeddingStore<TextSegment>, 1.16.x) and:agents-kt-rag-spring-ai(wraps Spring AIVectorStore, 1.1.x — embeds internally, noEmbedderneeded). Both translate store metadata intoProvenanceand applyFilters client-side. - Out of scope by design: embedding models, vector-DB lifecycle, re-ranking/hybrid search.
New
docs/rag.md; comparison.md vector-store row updated. 14 new tests across the four modules.
Added — streaming flows through every composition operator (#3866, P0.1)
- Every
thenoverload now chains streaming. Pipelines that mixParallel/Forum/Loop/Branchmid-chain (a then (b / c),(a / b) then reduce,head then forum,head then judge.loop { … },head then classifier.branch { … }) stream inner events from all nested agents through the parentsession(input), each tagged with its ownagentId. Previously onlyAgent then Agent,Pipeline then Agent,Pipeline then Pipeline, andwrapstreamed — the other 14 overloads fell back to terminal-only. - Internal emitter-aware
sessionInvokecores onParallel/Forum/Loop/Branch, extracted from (and now shared with) theirsession(...)extensions — one streaming implementation per operator instead of extension-local copies. - Sequential stages emit in chain order; fan-out participants interleave by arrival order
(demultiplex by
agentId). Cancellation tears down in-flight inner sessions via structured concurrency. Operators constructed outside their factory functions still fall back to non-streaming execution. Behavior pinned inCompositionStreamingChainTest(6 scenarios).
Changed — truth-surface pass 3 (June-12 delta review)
- threat-model.md opening paragraph no longer claims "does not sandbox tool execution" / "does not validate MCP request origins by default" — both contradicted the canonical table below it; rewritten to the precise lambda-vs-subprocess and loopback-default reality.
- tool-policy-enforcement.md: the stale "Layer 2 will extend enforcement" closing line names
the shipped Layer 2 and the actual remaining 0.8 work; new high-level-vs-low-level warning box
(
processToolfail-closed vs rawProcessSandbox.runwarn-and-run). - model-and-tools.md ToolPolicy section reframed from "declarative only in the 0.6.x line"
to the 0.7 enforcement reality (+ the #2889
ToolEnvironmentexecutor shape). - caching.md provider framing fixed: Kimi/OpenRouter/Perplexity are first-party providers
inheriting the OpenAI rows, not "fourth-party deployments";
ModelProvider.entriescount corrected. - roadmap.md: threat-model guide marked shipped; the demos bullet's never-shipped
Escalatedecision replaced with the real HITL primitives (#2489 / #3868). DocsConsistencyTeststale-phrase guard extended with the three newly-fixed claims.
Changed — truth-surface pass 2: the rooms the front door missed
docs/threat-model.mdis now the canonical "what's enforced where" page. Its shipped-vs-planned table was frozen at 0.5.0 — ToolPolicy enforcement, MCP auth/origin/per-client policy, and the OS sandbox all listed as planned long after shipping. Rewritten as a Boundary / Status / Enforced-by table current to 0.7.24; README,SECURITY.md, andproduction-hardening.mdnow defer to it. Anti-pattern guidance points at fail-closedprocessTool(#2914); the JSONL-retention bullet reflects the shipped exporter (#1914) + tamper-evident ledger.- README security sections no longer claim
ToolPolicyis "for review/audit" only or that tool sandboxing is "on the Phase 3 roadmap" — replaced with the enforced-vs-yours split and a link to the canonical table. Kotlin badge 2.3 → 2.4. Streaming bullet covers all seven providers. docs/streaming.mdprovider table covers all seven providers (3 nativechatStreamimplementations + 4 inherited OpenAI-compatible SSE, Perplexity live-verified); the composition flow-through gap is framed as a current 0.7.24 limitation instead of "the next v0.5.0 milestone".DocsConsistencyTestgains a stale-phrase guard: known-fixed claims ("sandboxing isn't shipped", "no tool sandboxing", "audit evidence, not", "all three first-party", stale four/five/six provider counts, "the JSONL exporter lands") now fail./gradlew testif they resurface in living docs; historical docs (CHANGELOG / RELEASE_NOTES / premortems / prd) exempt.
[0.7.24] — 2026-06-12
Perplexity: seventh model provider + web-grounded search with citations — and a truth-surface pass. Headline feature is the Perplexity connector and the perplexitySearch tool — agents can now fetch live, cited facts from Perplexity Sonar against their own model. The release also lands the docs/version-identity trust patch an external 0.7.23 review called for: SECURITY.md, production-hardening, skill-routing and HITL docs catch up with the shipped runtime, main adopts a -SNAPSHOT between-releases policy, and a new DocsConsistencyTest keeps the claims pinned. Plus dependency bumps (Kotlin 2.4.0, jline 4, detekt 1.23.8, ksp 2.3.9). Drop-in on the 0.7.x line.
Added — Perplexity connector + web-grounded search tooling (epic #3674)
PerplexityClient— seventh model provider (#3675). A thin OpenAI-compatibleOpenAiClientsubclass forapi.perplexity.ai(mirrorsDeepSeekClient/KimiClient/OpenRouterClient), selectable viamodel { perplexity("sonar") }. Model ids:sonar/sonar-pro/sonar-reasoning-pro/sonar-deep-research. Unlike Kimi/DeepSeek, Perplexity accepts OpenAI'sresponse_formatjson_schema, so its constrained-decoding gate stays on.ModelProvider,ModelConfig.perplexityBaseUrl,ModelBuilder.perplexity(...), the factory dispatch, and the permission manifest are all wired. Key from.secrets/perplexity-key/PERPLEXITY_API_KEY.perplexitySearchtool — web-grounded search with citations (#3676).tools { +perplexitySearchTool(key) }lets an agent on its own model fetch live, cited facts from Perplexity.untrustedOutput = true, so results are wrapped in the{"trusted":false}envelope and flagged as data, not instructions (#642). The result renders the answer + a numbered source list parsed fromsearch_results[](falling back tocitations[]); sources reach both the model context and the JSONL audit row.- Search controls + structured output (#3677).
perplexitySearchOptions { }maps to the documented request params:search_mode(web/academic/sec),search_recency_filter,search_domain_filter(allow +--prefixed deny),web_search_options.search_context_size,reasoning_effort, and nativeresponse_formatjson_schema viastructuredOutput(MyType::class)from a@Generabletype. OpenAiClientgains achatCompletionsPathseam (#3675). The chat-completions path is now overridable (default/v1/chat/completions);PerplexityClientoverrides it to/chat/completions(Perplexity serves no/v1segment — hitting/v1there 404s with an empty body). Behavior is unchanged for OpenAI / DeepSeek / Kimi / OpenRouter.- Additive only — no public-API change to existing surfaces. Verified end-to-end against the live
Perplexity API (connector chat + streaming, and
perplexitySearchwith real citations); live tests taggedlive-cloud-api.
Changed — truth-surface pass: docs catch up with the shipped runtime
mainnow carries a-SNAPSHOTversion between releases (0.7.24-SNAPSHOT). Post-release commits no longer masquerade under the published version's identity.checkReadmeVersionlearned the dev state: on a-SNAPSHOTversion the README must advertise a plain release strictly below the snapshot base; exact lockstep still enforced at release (runbook step 8 added).SECURITY.mdrewritten to 0.7.x reality: seven providers over four wire shapes (was "four adapters"); tool sandboxing andMcpServerauthentication are no longer "out of scope" — the Layer-1 in-JVM filesystem gate (#2890), Layer-2 OS sandbox (#1916), andMcpServerAuth.TrustedLocal/RequireBearerTokenare documented with the honest remaining-gaps list (in-JVM lambda side effects, read confinement, hostname allowlist deferred to 0.8).docs/production-hardening.mdno longer callsToolPolicy"audit evidence, not enforcement" (stale since 0.7.0) and now points subprocess tools at the fail-closedprocessTool(#2914).- Skill-routing docs match the fail-loud runtime (#3087):
docs/model-and-tools.mdand the wiki routing pages documented the pre-0.7.21 silent first-match fallback; ambiguity now documented asSkillRoutingException, with a migration note. - Provider counts unified at seven (Perplexity joined in this unreleased line):
model-and-tools.md(was "six"),SECURITY.md(was "four"),comparison.md(was "4"). docs/permission-manifest.mdstops advertisingai.deep-code:agents-kt-manifestMaven coordinates — the module has never been published to Central (onlyagents-ktandagents-kt-kspare); shown as an in-repoproject(":agents-kt-manifest")dependency with the publication status stated.comparison.mdmaturity claims move from the 0.5/0.6 era to 0.7.23.docs/regulated-deployment.mdHITL section documents the shipped primitives —humanApproval { }→ApprovalRequest→resumeWith(HumanDecision)(#2489) and theonBefore*interceptor decisions (#1907) — instead of the never-shippedDecision.Confirm.- New
DocsConsistencyTestpins provider-count sentences toModelProvider.entries, docDecision.Xreferences to the real sealed variants, and the routing table toSkillRoutingException— docs drift in these spots now fails./gradlew test.
[0.7.23] — 2026-06-04
Maintainability + an explicit model-error policy. Closes the bulk of the code-smell remediation
epic (#2790), finishes the AgenticLoop decomposition begun in 0.7.21, and makes the model-error
contract explicit with a new onLLMError recovery hook. The maintainability changes are all
behavior-preserving (no public-API change); onLLMError is the one additive public API. Over the
line, the detekt-baseline ratchet fell 423 → 415 and the main-module @Suppress("UNCHECKED_CAST")
count 42 → 30. Drop-in on the 0.7.x line.
Added — onLLMError model-failure policy + recovery hook (#3508)
- Makes the model-error contract explicit: when a model is configured, a failed model call in the
agentic loop (a down provider — surfacing as the raw transport error like
ConnectException— a 5xx, or a malformed response) fails fast and loud by default. Newagent.onLLMError { e -> LlmErrorDecision }opts into recovery:RespondWith(fallback)uses a canned/typed value (routed through the agent'scastOut) instead of throwing;Rethrow(the default with no handler) keeps it loud. The handler receives the original exception — identity is preserved (the internalLlmCallFailuremarker that makes a model failure recognizable is unwrapped at the loop boundary, soonErrorobservers andassertThrowsstill see the real error). Does not fire for budget caps (onBudgetExceeded) or cancellation. With no model configured,implementedByskills run deterministically and no model error can arise. Recovery is scoped to the agentic loop in this release; a model failure during multi-skill LLM routing still propagates loud (follow-up).
Changed — Decompose the Agent god class: InterceptorChain + ListenerRegistry (#2793)
- The before-interceptor subsystem (the three interceptor lists,
onInterceptorDecisionobservers, and decision plumbing) moves into anInterceptorChaincollaborator;decideBeforeToolCall's hand-inlined fold now reusesrunDecisionChain(dropping the duplicated fold and its second@Suppress("UNCHECKED_CAST")). The ~11 observability listener slots + the token-usage / agent-event streams + theirfire*dispatch move into aListenerRegistry. The three copy-pasted fire-listener-swallow-and-log blocks collapse into one shareddispatchSafely.Agentkeeps its publiconXDSL setters and theagent.<slot>reads (forwarding to the collaborators) — no public-API change.Agent.ktis back to its structural graph + resolution config.
Changed — Split the McpServer god class into HTTP transport + McpDispatcher (#2795)
- The transport-agnostic JSON-RPC protocol core (the
when(method)routing + every per-method handler) extracts intoMcpDispatcher, operating purely onMap → Stringenvelopes.McpServerkeeps HTTP intake only;handle()slims toauthenticate → validateAllowedHost/Origin → validateRequest → readBoundedBody → dispatcher.dispatchRequest, with the newvalidateRequest/readBoundedBodyhelpers.McpStdioServerdrives the dispatcher directly viadispatchEnvelope, removing theinternal dispatchJsonRpcback door.McpServer.kt451 → 215; no public-API change.
Changed — Extract agentSessionScope; remove operator session-extension duplication (#2797)
- The five composition operators (
branch/forum/loop/parallel/pipeline) each repeated an identical ~25-line streaming-session scaffold (channel + deferred + supervisor scope + runtime context + context-threading emitter + terminalCompleted/Failed+ cancellation ordering). OneagentSessionScope(terminalAgentId, body)now owns the lifecycle; the operators reduce to their run lambda (net −282 lines). The five emitter casts collapse to one; terminal events unify to never-drop suspendingsend; the per-operator drop-loggers collapse to one.
Changed — Split the LiveShow god file: LiveShowBanner + SpinnerAnimation (#2798)
- The ASCII banner asset moves to
LiveShowBanner.kt. The in-place inference spinner — previously a manualThread+ anAtomicBoolean runningthat shadowed theLiveShow.runningfield — becomes anAutoCloseableSpinnerAnimationso the turn handler readsspinner.use { … }; the shadowing flag is gone. CLI behavior unchanged.
Changed — One deliberation/match core for Forum and Branch (#2802)
Branch.invokeSuspendre-derived the ordered-match loop thatmatchRoutealready implements; it now delegates, and the dead emptyNullRouteexhaustiveness arm becomes a realfalseclassification.Forum's deliberation body (participants → captain, mention firing,forum_returnshort-circuit) — written twice acrossinvokeSuspendand the streamingsession— extracts into onedeliberate(input, runAgent)core differing only in the run strategy;participants/captainare now properties.
Changed — Typed GenerableCodec seam to shrink UNCHECKED_CAST clusters (#2803)
@Generabledeserialization gains aGenerableCodec<T>fun-interface and a singleKClass<T>.codec()resolution boundary (KSP-generated decoder when present, else reflective).constructFromMap/constructFromMapReflective/coerceValue/fromLlmOutputroute their casts through it, and the MCP edge (ExposedSkill) reuses the same seam — collapsing the casts that were sprinkled across the reflection and wire boundaries to one site.GenerableSupport.ktsuppressions 8 → 2. The tuned reflection edge-cases (sealed dispatch, coercion, strict keys) are unchanged.
Changed — AgenticLoop: extract resolveAllowedTools (#3423)
- The last self-contained setup block in
executeAgentic— the per-skill tool-set assembly (skill + agent-capability + memory tools, lazy knowledge tools, duplicate-name fail-fast, the authorization allowlist) — extracts intoresolveAllowedToolsreturning aResolvedToolsbundle.AgenticLoop.kt754 → 722; the remainingexecuteAgenticbody is the turn loop itself.
Changed — AgenticLoop setup extraction: SkillRouting + buildSystemPrompt (#3406)
- Follow-up to #3376. Relocated
selectSkillByLlm(LLM skill router) out ofAgenticLoop.ktinto its ownSkillRouting.kt(same package — no FQN change for itsSkillResolvercaller); it's a routing concern, not a loop one. Extracted the inline system-promptbuildStringinto a pure, unit-testedbuildSystemPrompt(SystemPrompt.kt) —SystemPromptTestpins the tool listing and the untrusted-tools security preamble (present iff a tool declaresuntrustedOutput). Behavior-preserving;AgenticLoop.kt834 → 758 (1369 → 758 since #3376 began).
Changed — AgenticLoop: collapse the executeAgentic signature into RunRequest (#3376 batch 5)
executeAgentic's accreted 11-parameter signature is folded into a singleRunRequest(reusing the value object from #3088): the six per-invocation knobs (prompt override, resume/HITL state, checkpoint callback, manifest-mismatch opt-out, attachments) becomerequest: RunRequest, leaving(agent, skill, input, request, emitter, runtimeContext). The body is unchanged — the request is unpacked into the same locals at the top.Agent.invokeSuspendForSessionnow passes itsRunRequeststraight through instead of unpacking it. Internal API; behavior-preserving (the existing resume/snapshot/memory test suites are the net). Caps the #3376 decomposition:AgenticLoop.kt1369 → 834 across batches 1–5, with rendering / coercion / client-factory / tool-execution / snapshot-restore each extracted into their own unit-tested file.
Changed — AgenticLoop decomposition: extract restoreFromSnapshot (#3376 batch 4)
- Pulled the resume/HITL restore step out of
executeAgentic's inlineif (resumeFrom != null)block intoSnapshotRestore.kt(restoreFromSnapshot): the manifest-hash fail-closed guard (#2754), namespaced memory restore (#2755), and the HITL interrupt-reply synthesis (#2488/#2489). The loop now delegates a one-liner. Previously only reachable through a full resuming invocation; now directly unit-tested (SnapshotRestoreTest— manifest-mismatch fail-closed + message restore). Behavior-preserving;AgenticLoop.kt925 → 865.
Changed — AgenticLoop decomposition: extract the tool-execution subsystem (#3376 batch 3)
- Moved the per-tool-call execution cluster out of
AgenticLoopinto a newToolInvoker.kt: the budget gate (arg-size cap + per-tool timeout), the 4-layer recovery ladder (executeToolWithBudget/executeToolWithRecovery/validateTypedArgsOrNull/recoverInvalidArguments/executeToolWithExecutionRecovery),toolArgsByteSize, and theToolCallFinishedemit. These wereprivateto the loop (only reachable through a full agentic invocation); nowinternaland directly unit-tested (ToolInvokerTest). The loop's thin event-wrapper delegates here. Behavior-preserving move;AgenticLoop.kt1196 → 930 (1369 → 930 across batches 1–3).
Changed — AgenticLoop decomposition: extract ModelClientFactory (#3376 batch 2)
- Moved the provider/client-construction cluster out of
AgenticLoopinto a newinternalModelClientFactory—defaultClientFor(the per-provider dispatch),defaultClientForTesting(the #2385 seam),semconvProviderName, andconstrainedOutputSchemaFor. The first three wereprivate(onlydefaultClientForTestingwas reachable); they now have a direct unit test (ModelClientFactoryTest, TDD RED→GREEN).AgenticLoopdelegates; behavior-preserving, no public API change.
[0.7.21] — 2026-06-02
Security + de-slop release. Headlined by a nested-agent recursion bound (#3377) and the explicit
skill-routing failure on ambiguity (#3087), plus a build-wide one-type-per-file refactor (#3199) and
new release/quality guards (#3084 / #3089), the start of the AgenticLoop decomposition (#3376), and
honest README positioning (#3085 / #3086). Internal refactors are behavior-preserving; the two
behavior changes (routing, the maxAgentDepth default) are called out below. Drop-in on the 0.7.x line.
Fixed — bound nested agent recursion with maxAgentDepth (#3377, security)
- Budgets bounded a single agentic loop, but a tool that re-invokes an agent (Swarm
absorb, agent-as-tool) spun up a fresh loop with a fresh budget — so a self-re-entering agent (A→A) or a cycle (A→B→A) recursed one full LLM loop per level untilStackOverflowError, a DoS / runaway-cost vector (triggerable e.g. by prompt injection into a tool result). NowAgentRuntimeContextcarries a nested-invocationdepth(incremented innewRuntimeContext), andbudget { maxAgentDepth }(default 16) is enforced at the invocation chokepoint: exceeding it throwsBudgetExceededException(BudgetReason.AGENT_DEPTH)before the over-deep loop starts — fast, no extra LLM calls, no overflow. An unconditional safety stop (not extendable viaonBudgetExceeded), and budget caps now bypass theonErrortool-recovery ladder so a nested cap can't be swallowed.
Changed — AgenticLoop decomposition: extract rendering + coercion (#3376 batch 1)
- First slice of breaking up the 1369-line
AgenticLoop.kt/ 765-lineexecuteAgentic. Extracted the pure tool-result/error renderers intoToolResultRendering(formatEscalatedToolError,formatDeniedToolError,wrapUntrustedToolResult,renderToolResultForLlm) and output coercion intoOutputCoercion(parseOutput,coerceSubstituteOutput) — each a newinternalfile. These wereprivateto the loop (untestable); they now have direct unit tests (ToolResultRenderingTest,OutputCoercionTest, TDD RED→GREEN). Behavior-preserving —AgenticLoopdelegates. Internal refactor, no public API change.
Changed — one-type-per-file complete across the codebase (#3199, final batch)
- Split every remaining multi-type file (rest of
model/, all ofcore/,content/,composition/,generation/,runtime/,sandbox/,testing/, and themanifest/observability/langfuse/langsmith/detektsubmodules) into one top-level type per file — ~110 new files, all same-package moves (no FQN / public-API change).checkOneTypePerFilenow passes with an empty allowlist: zero multi-type files remain anywhere. Renamed 3 files so the filename matches the kept type (Snapshot.kt→SessionSnapshot.kt,Memory.kt→MemoryBank.kt,HumanApproval.kt→ApprovalBuilder.kt), which also satisfies detekt'sMatchingDeclarationName. - Minor, non-public visibility consequence of the moves: a handful of file-
privatehelpers that were referenced across now-separate files were promoted tointernal(still module-scoped, not public): the manifest engines (ManifestVerifier/StableJson/ManifestJsonParser/StableYaml/ManifestGraph), the policy JSON/YAML helpers (ManifestMaps/ManifestJson/ManifestYaml),RuntimeContextThreadLocal,KnowledgeEntry, and thenonBlankhelper. - Behavior-preserving: full
./gradlew buildgreen (all modules + all tests + detekt 423/423 +checkOneTypePerFile0 +checkReadmeVersion). Completes #3199.
Changed — one-type-per-file: split model error/cache types (#3199, batch 3)
- Split three
agents_engine.modelfiles into one type per file (same package — no FQN/public-API change):ToolError.kt→Severity,EscalationException,ToolExecutionException(ToolErrorsealed union stays);CacheHint.kt→CacheSegment(CacheHintstays);OnErrorBuilder.kt→RepairResult,RepairScope,ToolErrorHandler(OnErrorBuilder+ theexecuteAgentFixhelper stay). Allowlist 40 → 37. Behavior-preserving pure moves; detekt baseline unchanged.
Changed — one-type-per-file: split McpServer.kt (#3199, batch 2b)
- Split the four secondary types out of
mcp/McpServer.kt(same package) —RegisteredPrompt,RegisteredResource,McpExposeBuilder,ExposedSkill→ one file each;McpServerstays (597 → 454 lines). Four now-unused imports (constructFromMap,jsonSchema,KClass,hasGenerableAnnotation, all moved toExposedSkill) removed. Completesmcp/— allowlist 41 → 40. Behavior-preserving pure moves.
Changed — one-type-per-file: split the mcp/ package (#3199, batch 2)
- Split five multi-type files in
agents_engine.mcpinto one type per file (same package — zero import churn, no FQN/public-API change):AgentMcpDsl.kt→McpServerBuilder.kt;JsonRpc.kt→JsonRpcWire/JsonRpcErrorCode/McpException(+JsonRpcstays);McpClient.kt→McpToolDescriptor;McpRunner.kt→RunnerConfig+McpRunnerBuilder;McpServerSecurity.kt→ClientPrincipal/McpHttpRequestContext/McpAuthDecision/McpServerAuth(original file removed). Allowlist 46 → 41.mcp/McpServer.kt(597 lines,ExposedSkillneeds import surgery) is deferred to batch 2b. Behavior-preserving pure moves.
Changed — one-type-per-file convention + checkOneTypePerFile guard (#3199, batch 1)
- New
checkOneTypePerFileGradle guard (wired intocheck) fails the build if a main-source.ktfile declares >1 top-level type and isn't onconfig/one-type-per-file-allowlist.txt. The allowlist is a ratchet that may only shrink — it also fails on a stale entry (a listed file that no longer violates), so a split must record its own burndown. Documented sealed-ADT exceptions stay listed. MirrorscheckReadmeVersion/checkDetektBaseline. - Batch 1 split:
mcp/McpServerInfo.kt(12 MCP wire DTOs) → one type per file in the sameagents_engine.mcppackage — zero import churn, no FQN/public-API change. Newdocs/source-layout.mddocuments the convention, exceptions, and the guard. Remaining multi-type files burn down package-by-package in follow-up batches under #3199.
Changed — skill resolution extracted into SkillResolver (#3088 stage 2, de-slop #3083)
- The skill-resolution cluster — type-compatible candidate filter, manual
skillSelection { }selector, LLM router (confidence gate), the before-skill-interceptorProceedWithcompatibility check, and the fail-loud ambiguity error — moved out ofAgent's God-object body into its ownSkillResolvercollaborator (newSkillResolver.kt).Agentkeeps aprivate val skillResolverand delegates. Internal refactor, behavior-preserving — every branch, condition, exception type, and message is identical; no public DSL change.Agent.ktis now 1017 lines (1116 → 1017 across #3088 stages 1+2). Completes the staged decomposition of #3088.
Changed — README de-slop: honest positioning + accuracy fixes (#3085, #3086, de-slop #3083)
- Replaced the unqualified hero copy ("The auditable Kotlin agent runtime for regulated teams") with a defensible positioning line ("The typed agent runtime for the JVM") plus an up-front pointer to the Security Model and threat model, and an explicit "not a compliance product / does not OS-sandbox arbitrary tool code" caveat in the intro. The honest enforce/don't-enforce tables already existed; the hero no longer contradicts them (#3085).
- Fixed accuracy drift between "Implemented today" and the limitations/roadmap (#3086): "Four LLM
providers shipped" → six (adds Kimi + OpenRouter); "Text-only I/O today" → image/document input
shipped, audio + generation still roadmap; Kotlin badge
2.1→2.3; Phase 2 roadmap no longer lists already-shipped image multimodal as planned. No fabricated benchmark claims were found.
Added — explicit securityCheck gate, checkDetektBaseline burndown, and TESTING.md (#3089, de-slop #3083)
- New
securityCheckaggregate task makes the deterministic security suite addressable on its own — sandbox write-confinement (ProcessSandbox: Seatbelt / bwrap / firejail / fallback), tool- policy enforcement (#1916), snapshot manifest guard, the arg-size cap (#2888), the tamper-evident audit ledger (:agents-kt-observability:securityTest), and the static tool-body rules (:agents-kt-detekt:test+detekt). OS-specific confinement skips cleanly off-platform; runsecurityCheckon a macOS job to exercise Seatbelt in CI. - New
checkDetektBaselinetask (wired intocheck) fails ifdetekt-baseline.xmlgrows beyond the recorded ceiling (424) — the baseline may only shrink, so new violations get fixed rather than grandfathered. - New
TESTING.mddocuments, honestly, what the default gate runs and excludes (live-llm/live-mcp/interactiveare out;live-cloud-apiis deliberately in), the security gate, the OS-specific confinement matrix, and the baseline ratchet.
Added — checkPublishedVersion release gate + release runbook (#3084, de-slop #3083)
- New
checkPublishedVersionGradle task HEADs Maven Central forai.deep-code:agents-ktandagents-kt-kspat the current project version and fails unless both resolve (HTTP 200). It is not wired intocheck— it needs network and would (correctly) fail on an unreleased version during dev — so it's the manual last gate before anything user-facing names a new version. Override the base URL with-PcentralBaseUrl=…. ComplementscheckReadmeVersion(#2873): one stops the README drifting from the build, the other stops the build advertising a version Central can't serve — the exact drift (README/Gradle at0.7.2while Central served0.7.1) an external review flagged. - New
docs/RELEASE_RUNBOOK.mdpins the release ordering (bump → build → bundle → upload → confirm resolvable → only then advertise/tag).
Changed — invocation parameters bundled into RunRequest (#3088 stage 1, de-slop #3083)
- The internal
Agent.invokeSuspendForSessionentry point no longer carries an accreted list of optional knobs (prompt override,resumeFrom/resumeWith/onTurnCheckpoint/allowManifestMismatch, attachments). They're bundled into a singleRunRequestvalue object (newRunRequest.kt); each field defaults to a fresh invocation, soinvokeSuspendand the non-streaming path are byte-for-byte unchanged. Internal API, behavior-preserving — no public DSL oragent { }surface change. First stage of the stagedAgent.ktdecomposition; collaborator extraction (skill resolution, resume/HITL state) is tracked as later stages of #3088.
Changed — skill routing is now explicit: ambiguous candidates fail loud (#3087, de-slop #3083)
- When an agent has multiple compatible skills for an output type and no
skillSelection { }selector and nomodel { }for LLM routing, invocation now throwsSkillRoutingExceptionnaming the ambiguous candidates and how to disambiguate — instead of silently routing to the first skill by registration order. An "auditable / explicit boundaries" runtime must not pick a production route implicitly. Behavior change: code that relied on silent first-match must add an explicit selector or a model. Single-candidate, selector, and model-routed paths are unchanged.
[0.7.2] — 2026-06-01
Tool-security hardening — the self-contained first phase of the capability-ABI epic (#2882), all additive and back-compat: a tamper-evident audit ledger, an argument-size cap, and the static tool-body guard rails. Plus a release guard so the README's advertised version can't drift from the build.
Added — release guard: README dependency version must match the Gradle version (#2873)
- New
checkReadmeVersiontask (wired intocheck) fails the build if theai.deep-code:agents-kt:<version>snippet inREADME.mddiffers from the Gradle project version — the exact drift an external 0.7.0 review flagged. README and version now move together.
Added — ToolCapabilityExtractor: static capability classification (#2884, epic #2882)
- New
ToolCapabilityExtractorinagents-kt-detektstatically classifies what a tool's executor body actually does —FS_READ/FS_WRITE/NETWORK/ENVIRONMENT/EXEC— by walking its call expressions and matching callee names (writeText/Files.write→ write,readText/readAllBytes→ read,URL/openConnection→ network,getenv→ env,ProcessBuilder/exec→ exec). The reusable input the upcomingToolPolicy↔capability comparator (#2887) checks against the declared policy. Syntactic by design (callee-name match, no FQN resolution) and intentionally conservative — reflection / aliasing / transitive state are Pillar-3 residual.
Added — ToolAuditLedger: tamper-evident, Merkle-chained tool-action log (#2886, epic #2882)
- New
ToolAuditLedger(inagents-kt-observability, sibling toJsonlAuditExporter) — an append-only, Merkle-chained, PII-safe record of every tool action. Each row'sentryHash = SHA-256(prevHash ‖ sequence ‖ callId ‖ toolName ‖ decision ‖ denialReason ‖ resultHash ‖ timestamp)chains to the previous, soToolAuditLedger.verify(path)recomputes the chain and pinpoints the first edited / inserted / deleted / reordered row. The tool result is stored only as a hash, never raw (Pillar 2 of #2882). - Auto-wire with
agent.events.ledger(file)— recordsPipelineEvent.ToolCalledasAPPROVED,ToolDeniedasDENIED(with reason),ToolHallucinatedasHALLUCINATED, and returns the ledger for laterverify(...). (callId-keying of denied/hallucinated rows lands oncePipelineEventcarries the callId — a scoped #2886 follow-up.)
Added — maxToolArgsBytes tool-argument size cap (#2888, epic #2882)
- New
budget { maxToolArgsBytes = … }(Long?, defaultnull= off) hard-caps a single tool call's argument byte size, checked at one chokepoint (executeToolWithBudget) before the executor runs — so an oversized (often prompt-injected) call is rejected, not executed. Resource- exhaustion guard (attack A5). Unconditional likeperToolTimeout— not extendable viaonBudgetExceeded; surfaces asBudgetExceededException(reason = BudgetReason.TOOL_ARGS_SIZE). Size is the provider wire form (ToolCall.rawArguments) when present, else the serialized arg map. Gates both the session and regular executor paths; back-compat (null = unbounded).
Added — agents-kt-detekt rule module + ToolBodyForbiddenApis (#2885, epic #2882)
- New
:agents-kt-detektmodule ships custom detekt rules (Pillar 1 static layer). The first rule,ToolBodyForbiddenApis, flags raw outside-world APIs (java.io.File,java.net.URL/HttpURLConnection,ProcessBuilder/Runtime.exec,Class.forName,Unsafe, sockets) used inside a toolexecutor { }body — a tool must reach fs/net/env only through the (forthcoming) closedToolEnvironmentABI, so every action is policy-gated and audited. Suppressible with@Suppress("ToolBodyForbiddenApis")+ a reviewed reason. Wired into the project's own detekt run (scoped to main source — test fixtures legitimately exercise tools). Consumers opt in viadetektPlugins("ai.deep-code:agents-kt-detekt"). - Honest limit: syntactic (matches the callee name, not a resolved FQN) — reflection / aliasing / transitive state changes are residual risk covered by Pillar 3 (process isolation). The capability extractor (#2884) builds on this module next.
[0.7.1] — 2026-05-31
Hardening release driven by external review of 0.7.0. The headline fix makes the manifest
verify gate honest; the rest corrects docs/KDoc that lagged the code. No behavior change beyond
the verifier (which now flags genuine widenings it previously missed).
Fixed — manifest verify compares policy sets, not coarse scores (#1923 hardening)
ManifestVerifierpreviously compared coarse per-tool scores (networkallowAll=2 /hosts=1 / none=0; filesystem any-globs=1 / none=0), so real widenings slipped through: adding a host withinhostsmode (["api.internal"] → ["api.internal", "evil.example"]) or broadening a write glob without changing the count (a narrow upload-folder glob → a root-level glob) were not flagged. It also keyed tools by name withputIfAbsent, so two agents with a same-named tool collided and one agent's widening was hidden.- Now it compares the actual policy sets, keyed by
agentName.toolName. Network widening = mode escalation (denyAll/unspecified → hosts → allowAll) or a host the baseline did not list; filesystem / environment widening = a glob / variable the baseline's set did not contain (newtool.environment.widenedfinding). Pure narrowing (removing entries) is not flagged; conservative on added entries — semantic glob-coverage subset-checking is a later refinement. Regression tests pin each previously-missed case. Both the CLIverifyand the GradleverifyAgentManifestinherit the fix. Surfaced by external review of 0.7.0.
Fixed — docs/KDoc drift surfaced by review
- Provider count:
docs/model-and-tools.md/docs/providers.mdsaid four providers; six ship (OLLAMA,ANTHROPIC,OPENAI,DEEPSEEK,KIMI,OPENROUTER). Kimi (#2697) + OpenRouter (#2701) are first-party providers extending the OpenAI adapter. - Layer-2 KDoc:
SandboxedTools.ktno longer says "macOS only; Linux is #2892" — bwrap + firejail ship in 0.7.0; the runtime error now reads "no OS sandbox backend (need macOS sandbox-exec or Linux bwrap/firejail)", andprocessToolis documented as the fail-closed public path. - PUBLISHING.md bundle example bumped off stale
0.5.0paths.
[0.7.0] — 2026-05-31
Boundaries you can enforce externally. The 0.6 line made tool policies declarative and
auditable; 0.7.0 makes them enforced. A tool's declared ToolPolicy now constrains it at
runtime — Layer 1 (in-JVM filesystem-argument gate, #2890) plus Layer 2 OS sandboxing (#1916):
macOS Seatbelt, Linux bubblewrap, a firejail setuid fallback, and a plain
ProcessBuilder + loud UNCONFINED warning where no tool is present. Subprocess-shaped tools are
confined to their declared write roots, a derived environment allow-list, a working directory, and a
default-deny network. And the deterministic permission manifest is now reachable outside
Gradle via the standalone agents-kt CLI (generate / inspect / verify) — a drop-in CI
gate that fails when a change widens a capability boundary.
Deferred to 0.8 (tracked, not shipped here): WasmSandbox (#2894), DockerSandbox (#2895), the
network hostname-allowlist proxy (#2893; default-deny ships, selective allow does not), and the
grants { } hierarchical structure DSL.
Added — standalone agents-kt CLI: permission manifest from a binary (#1923)
- New
:agents-kt-climodule (Gradleapplicationplugin) — the "externally" half of the 0.7.0 arc. The deterministic permission manifest, previously reachable only through a Gradle task, is now generatable / inspectable / verifiable from a binary, so non-Gradle consumers (CI gates, ops, regulators) can enforce capability boundaries:agents-kt generate --entrypoint <FQN> [--classpath a:b] [--format json|yaml] [--out file]agents-kt inspect <manifest.json> [--format json|yaml]agents-kt verify (--entrypoint <FQN> [--classpath a:b] | --current <file>) --baseline <file>- Exit codes:
0ok ·1verify findings (policy widened) ·2usage ·3runtime.
- The reflective entrypoint→manifest loader was extracted from the Gradle plugin into a
Gradle-free
agents_engine.manifest.ManifestEntrypointLoader, shared by the plugin and the CLI — a build and the CLI produce byte-identical manifests (samemanifestSha256).verifyraises the sametool.risk.increased/tool.network.widened/tool.filesystem.write.widenedfindings as theverifyAgentManifestGradle task. Seedocs/cli.md. (A jlink/native single-file image is a packaging follow-up; the entrypoint-loading commands reflect into arbitrary user classes and need a real JVM.)
Added — injectable HttpClient on every provider client (#2385)
model { httpClient = … }lets multiple agents share one networking surface — a connection pool, a bounded executor that rate-limits concurrent LLM calls, an outbound proxy, or anHttpClientalready wired to your telemetry. All four provider clients (Ollama/Claude/OpenAI/DeepSeek) take an optionalhttpClient: HttpClient?constructor param;ModelConfig.httpClientis threaded into each bydefaultClientFor()(DeepSeek inherits it via itsOpenAiClientsuperclass).- Opt-in, never automatic.
null(default) → each client builds its own, byte-for-byte unchanged. The framework provides the seam; the rate-limit/circuit-breaker/bulkhead policy lives in your injected client. Seedocs/model-and-tools.md→ "Sharing a networking surface".
Added — automatic in-JVM tool-policy enforcement (Layer 1 of #1916, #2890)
- A tool's declared
ToolPolicyis now enforced at runtime by default. When a tool call carries an absolute filesystem-path argument that falls outside the tool's declaredread/writeglobs, the call is denied before its executor runs — surfacing through the existingonToolDenied/PipelineEvent.ToolDeniedaudit path (withtoolPolicyRisk+usedDeclaredCapability). No hand-writtenonBeforeToolCallinterceptor is required anymore. Paths are normalized first, so..traversal cannot escape a declared glob. - Opt-in by declaration: a tool that declares no filesystem stance
(
filesystemleftUnspecified) is never gated — existing tools are unaffected. - Escape hatch:
agent { enforceToolPolicies = false }restores the prior 0.6.0 declare-only (inert) behavior. - Scope (this is Layer 1): in-JVM, filesystem-argument enforcement for in-process
tools. Relative-path precision and
network/environmentisolation require the Layer 2 OS sandbox (ProcessSandbox/WasmSandbox/DockerSandbox, tracked under #1916). Seedocs/tool-policy-enforcement.md. - This flips the
ToolPolicyEnforcementTest0.6.0-gap tripwire (#2395) from "restricted write still happens" to "restricted write is blocked."
Added — Layer 2 OS sandbox, first slice: macOS write-confinement (#2906, under #2891)
- New
agents_engine.sandbox.ProcessSandbox— runs a command under macOS Seatbelt (sandbox-exec) with a generated profile that denies by default and allows file writes only under a single canonical folder. A write to any path outside that folder is blocked by the kernel, not just the in-JVM Layer-1 gate — so it holds even for paths the tool constructs itself.seatbeltProfile(root)is a pure, unit-testable function;isSupported()is false off macOS andrunthrows there. - New
sandboxedEchoToFileTool(folder)— the simplest demonstration: a tool that echoes text into a given path, OS-confined tofolder. In-folder writes succeed; out-of-folder writes return anERRORand create no file. - The sandbox now builds its profile from a tool's declared
ToolPolicy(#2909):ProcessSandbox.forPolicy(policy)derives the writable roots from thefilesystem.writeglobs (each glob's directory prefix viaglobToWriteRoot) and opens network only fornetwork = AllowAll;ProcessSandbox.forWritableRoots(roots)confines writes to several folders at once. This is the bridge that lets Layer 1's declaration drive Layer 2's OS enforcement. processTool(name, policy) { args -> command }(#2914) auto-sandboxes a subprocess tool from its declared policy — no hand-wiring ofProcessSandbox. It returns the command's stdout on success (or anERROR:string), carries the policy onto theToolDefso Layer-1 (#2890) gates path args too, and fails closed (refuses to run rather than executing unsandboxed) where no OS sandbox is available.- Linux backend (#2892) —
ProcessSandboxdispatches by OS at run time: macOS Seatbelt, Linux bubblewrap (bwrap), then Linux firejail (the setuid fallback). The Linux paths bind/mount the whole filesystem read-only, re-mount the declared write roots read-write, and drop the network unless opened — same write-confinement contract as Seatbelt, enforced by the kernel. firejail still confines where unprivileged user namespaces are restricted (e.g. Ubuntu 24.04'sapparmor_restrict_unprivileged_userns) andbwrapcan't start. On a host with no sandbox tool,runno longer throws — it runs the command via a plainProcessBuilderand prints a loudUNCONFINEDwarning (isSupported()stays false, so a caller that requires enforcement can refuse).isSupported()is true when any backend is present, soprocessTool/forPolicywork across all three. The purebwrapArgs(...)/firejailArgs(...)are unit-tested everywhere; the kernel-level integration (@EnabledOnOs(OS.LINUX)+@Tag("linux_only")) is verified on CI's native Ubuntu runner. - Subprocess env + cwd honored (#2892):
ProcessSandboxnow confines the child's environment and working directory.forPolicyderives the env from the declaredToolEnvironmentPolicy—environment { allow("HOME") }passes only those vars through,environment { denyAll() }gives the child an empty environment, unspecified inherits;forWritableRoots(..., env, workingDir)sets them explicitly. Applied on theProcessBuilder, so every backend (Seatbelt / bwrap / firejail) inherits the confinement. - Network default-deny ships across all backends (#2893 core): only
network { allowAll() }opens the network;denyAll/Hosts/ unspecified stay blocked (Seatbelt no-network, bwrap--unshare-net, firejail--net=none). The hostname-allowlist proxy (soHostscan selectively allow domains) remains the deferred part of #2893. - Remaining Layer-2 follow-ups: the network hostname-allowlist proxy (#2893), read-confinement, the
grants { }structure DSL, and theprocess { }DSL. Wasm/Docker backends are #2894/#2895.
Errata — v0.6.5 release notes overstated document-attachment shipping (#2868)
- The v0.6.5 tagged
RELEASE_NOTES.mdcarried an "Added — Document attachments (#2470 slice c)" section claiming PDF / text / markdown routing through Anthropic, OpenAI, Ollama, and DeepSeek. That section was incorrect. The shipped code in 0.6.5 (and 0.6.6) routes onlyContent.Imagethroughagent.invokeWithAttachments(...);Content.Documentis explicitly skipped inAgenticLoop(executeAgenticfilters non-Image variants with a// Not an image — skip in v1comment). - What 0.6.5 actually shipped on the multimodal axis:
Contenthierarchy (#2466),ContentRef+BlobStore(#2467),ToolResultwith audit placeholder rendering for every modality (#2469), vision input wire path only (#2470 slice a — Image), typed agent attachments for Image (#2470 slice b),Files.loadextension-based modality detection across all content types (#Files). - What's still deferred:
#2470 slice c(Document/Audio/Video provider-input adapters).docs/multimodal.mdhas always said this — the drift was only in the tagged release-notes prose. The 0.6.6RELEASE_NOTES.mddoes not repeat the false claim.
[0.6.6] — 2026-05-30
Fixed — Session catch swallowed CancellationException as AgentEvent.Failed (#2863)
- All six session extensions (
AgentSessionExtension,PipelineSessionExtension,ParallelSessionExtension,BranchSessionExtension,LoopSessionExtension,ForumSessionExtension) — the outercatch (t: Throwable)block previously treated everyCancellationExceptionas a real failure: it emitted a syntheticAgentEvent.Failed, closed the channel cleanly, and swallowed the cancel from the surrounding scope. Field-reported regression (SSE bridge rendered "FlowSubscription was cancelled" as a user-visible failure, clobbering already-streamed partial output). - Rewritten as ordered multi-catch:
TimeoutCancellationExceptionfirst (real failure →Failedpath, must come before bareCancellationExceptionbecause it's a subtype), thenCancellationException(propagate per structured-concurrency contract — close channel with the cancel, rethrow), thenThrowable(real failure →Failed). - Pinned by new
SessionCancellationTest— 2 structural cases (bare cancellation propagates — no Failed event,executor failure still emits Failed) plus 4 per-vendor cases (Ollama / Claude / OpenAI / DeepSeek) using stubModelClientinjections so a future adapter-specific regression can't slip past CI.
Changed — Maintainability epic #2790 (10 refactor tickets)
A code-smell audit landed 10 focused refactors. All behavior-preserving; no public API removals.
- #2806 — Runtime cleanup. Central
agents_engine.runtime.Ansiobject ownsESC/RESET/ERASE_LINE;AnsiColor.code+wrap+ spinner clear route through it; deadAnsiColor.Companion.RESETdeleted. Session-extension bracket events (Completed/Failed) on Agent/Pipeline/Branch/Parallel switched from non-suspendingtrySend→ suspendingsendso terminal events can't be dropped silently; inner per-token emitter stays ontrySend(typealias is non-suspending) but now logs JUL warnings on failure.agents_engine.internal.BuildInfo.versionreadsImplementation-Versionfrom the JAR manifest (stamped bytasks.jar { manifest { ... } });McpServer.SERVER_VERSION/McpClient.CLIENT_VERSION/McpRunner.VERSIONall forward to it — the three constants had drifted to0.1.3 / 0.1.3 / 0.3.0. - #2805 — Core/generation cleanup.
enum class ToolRisk(val manifestName: String);fromManifestderives fromentriesinstead of a duplicate when-block.Agent.describeBudget()reflection (BudgetConfig::class.members) replaced withBudgetConfig.describeOverrides()— restores reflect-optional contract (#1718). Broad catches inGenerableSupport/LenientJsonParser/GeneratedMetaCacheFINE-logged via newtryGenerablehelper;GeneratedMetaCache.tryLoadnarrowed toLinkageError / ReflectiveOperationException / SecurityException.ManifestYaml.parsePolicyMapliteral0/2/4/6indent levels replaced with namedDEPTH_TOPLEVEL/SECTION/LEAF/FILESYSTEM_LEAFconstants. - #2804 — Model-layer cleanup.
AgenticLoopreusesRESERVED_MEMORY_TOOL_NAMES(no parallel inline set). Named constantsMANIFEST_HASH_PREFIX_LEN=12,BLOB_HASH_PREFIX_LEN=12,ANTHROPIC_MAX_CACHE_BREAKPOINTS=4,EPHEMERAL_TTL_BOUNDARY_MINUTES=5L. NewMutableList<ToolDef>.reserveName(name)collapses 5× duplicatedrequire(...)for "Tool already defined".Severity.valueOfbad parse logs at WARNING. 4 near-identicalAgentEvent.ToolCallFinishedemit blocks →emitToolFinished(...)helper; 5 inlineagents_engine.runtime.events.AgentEvent.…FQNs removed. - #2799 — JSON escape consolidation.
JsonEscapemoved toagents_engine.internalso generation + core can depend on it without inverting the model→generation direction.generation.GenerableSupport.escapeJson,core.ToolPolicy.ManifestJson.quote,core.Snapshotall flow throughtoJsonString()now. The repeated{"type":"object","properties":{},"additionalProperties":true}literal promoted tointernal.OPEN_EMPTY_OBJECT_SCHEMA_JSON.ClaudeClientremoveSuffix("}") + ",$cc}"cache-control surgery extracted toappendCacheControlToBlock/appendCacheControlToLastBlockhelpers. New control-char regression test inUntrustedToolOutputTest. - #2796 — Shared JsonRpc helper for MCP. New
agents_engine.mcp.JsonRpcconsolidatesencodeRequest/encodeResult/encodeError/parseEnvelope/isNotification.JsonRpcWireowns the literal"2.0", wire keys, and notification prefix.JsonRpcErrorCodenames-32700/-32600/-32601/-32602/-32603asPARSE_ERROR/INVALID_REQUEST/METHOD_NOT_FOUND/INVALID_PARAMS/INTERNAL_ERROR. Newsealed class McpException : IllegalStateException(extends ISE for back-compat) withTransport / Protocol / ToolFailuresubclasses. - #2792 — Shared HttpModelClientSupport.
HttpModelClientSupport.sendBounded(http, request, providerLabel, maxResponseBytes)consolidates the duplicated bounded-read + OOM-guard pattern; Claude / OpenAI / OllamasendChatall delegate.ModelClient.chatStream(messages)(default impl) delegates tochatStream(messages, jsonSchema = null)instead of carrying a byte-identical 28-line clone. - #2800 — Dedup MCP client list/text-block + Skills factories. 4 file-private helpers in
McpClient.kt:resultArray(result, key),joinTextContent(blocks, contentKey),prefixed(prefix, name),makeMcpSkill(name, description, impl).toolSkills/promptSkills/resourceSkillsall flow through the factory (8 boilerplate lines each → 3-4). - #2794 —
toLlmInput+jsonSerializecollapse. Both flow through a single parameterisedserializeForLlm(value, quoteTopLevelStrings)walker. Deferred (out of scope for the maintainability pass — flagged in commit body): theforEachGenerableParam6-walker unification andconstructFromMapReflective5-job split. - #2801 — Primary
(String) -> Any?overload. NewLiveShow.from(invoke, ...)andLiveRunner.serve(invoke, args, ...)overloads. Future operator types just passmyAgent::invokeSuspend— no edit to LiveShow/LiveRunner required. The six typed overloads stay for source-compat. - #2807 — Detekt static analysis. detekt 1.23.7 plugin wired into root
build.gradle.kts.detekt.ymlenables complexity (LongMethod, LargeClass, CyclomaticComplexMethod, NestedBlockDepth), exceptions (SwallowedException, TooGenericExceptionCaught/Thrown), style (MagicNumber with sensible allowlist, UnusedPrivateMember), naming (FunctionNaming), potential-bugs, empty-blocks.detekt-baseline.xmlfreezes current violations so the build stays green on existing code; new violations fail. README's "First 10 Minutes" lists./gradlew detektalongside./gradlew test.
Notes
- No API removals; every 0.6.5 caller compiles and runs unchanged.
agents_engine.model.JsonEscape→agents_engine.internal.JsonEscape: only theinternalqualifier is package-visible, so this is a binary-compatible relocation for any consumer using only the public API.- The detekt baseline file (788 lines) is checked in; future PRs are held to the rules without retroactively forcing cleanup of the audited code.
[0.6.5] — 2026-05-30
Fixed — Hardcoded 60s LLM request timeout killed long Sonnet turns (#2850)
ClaudeClient/OpenAiClient/DeepSeekClient/OllamaClient— bumpedDEFAULT_REQUEST_TIMEOUTfrom60.secondsto300.seconds. Field report against 0.6.4 showed long Sonnet turns (multi-step agentic loops with extended thinking) consistently breached the 60s cap on the JDK HttpClient, surfacing asHttpTimeoutException: request timed outand tearing down the streamingFlow. New floor matches what production agents actually need; 0.6.5 callers see no behavior change unless they were silently relying on the truncation.DEFAULT_CONNECT_TIMEOUTstays at10.seconds— healthy networks never spend that long on TCP connect.model { requestTimeout = …; connectTimeout = … }— tunable from the DSL on every built-in provider (Ollama, Claude, OpenAI, DeepSeek). Both fields default tonull, which falls back to the adapter'sDEFAULT_REQUEST_TIMEOUT/DEFAULT_CONNECT_TIMEOUT. Set the override when long-context calls, big Ollama generations, or extended-thinking turns regularly approach 5 minutes. Wired throughModelConfig.requestTimeout/connectTimeout→defaultClientFor()→ each adapter ctor — no shared global; per-agent, per-config, per-test.- No public API removals — additive only. Existing
ModelBuildercallers compile and run unchanged.
Added — Files convenience surface
agents_engine.content.Files— one-line file loading for the typedContenthierarchy.Files.load(path, store): Contentreads the file, detects modality + mime from filename extension (case-insensitive, no magic-byte sniffing), puts bytes via theBlobStore, returns the rightContentvariant. SameContentRef.hashas a manualstore.put. ThrowsUnknownExtensionException(names the extension + path + full list of known extensions) on unrecognised.- Variants:
loadOrNull(null-on-unknown),loadAll(throws on first unknown),loadAllOrSkip(silently skips — directory ingestion),canonicalExtensionFor(content)(inverse mapping),knownExtensions: Set<String>(predicate for callers). - Extension coverage: every
wireMimeon every modality variant has at least one canonical extension. Image:png,jpg/jpeg,gif,webp. Audio:mp3,wav,flac,ogg. Video:mp4,webm,mov. Document:pdf,docx,md/markdown,html/htm,txt. - 13 unit tests pin per-extension mapping, hash round-trip, case-insensitivity, unknown-extension behavior on every entry point, and the canonical-extension inverse for all 17 variants.
Added — Typed agent attachments (#2470 slice b)
agent.invokeWithAttachments(input, attachments)+ suspending siblinginvokeSuspendWithAttachments— user-facing API for vision input via typedContent.Image. The runtime dereferences each ref against the agent's injectedBlobStore, base64-encodes once, and attachesImagePartto the first userLlmMessage. Per-provider wire translation is the slice-a work — this commit routes the typed surface into it.Agent.blobStore: BlobStore?+blobStore(store)DSL — optional injection; null when the agent doesn't take attachments. Passing attachments to an agent with noblobStoreerrors fast at invoke time with a clear message — caller misconfiguration surfaces before any provider HTTP.- Closed mime mapping —
ImageMime → ImagePart.WireMimefor all four variants (Png,Jpeg,Gif,Webp). NoStringconversion at any boundary. - Forensic-friendly errors — when a ref's blob is missing from the store, the error names the ref's hash prefix. Helps debug snapshot resumes against partially-purged stores.
- Non-image variants skipped in v1 —
Content.Text/Document/Audio/Videoflow through the attachment path as no-ops. Slice c will wire Document via provider doc-input adapters; Audio/Video land in Stage 2. - Empty / all-skipped attachments → null images — no provider sees an empty array; legacy wire shape preserved.
- Resume composition —
attachmentsargument is ignored on resume because the restored conversation already carries the originalLlmMessage.imageson the saved user turn. - Tests: 8 unit cases (
AgentAttachmentsTest) + 6 live cases (AgentVisionLiveTest) running the sameVisionFixturesfrom slice a through the agent surface on Ollama qwen3-vl:8b, Claude Haiku 4.5, OpenAI gpt-4o-mini. See docs/multimodal.md.
Added — Vision input across all providers (#2470 slice a)
LlmMessage.images: List<ImagePart>? = null— new optional field; back-compat default leaves the wire shape byte-identical to pre-#2470 for callers that don't pass images. ClosedImagePart(base64, wireMime)withWireMimesealed type (Png,Jpeg,Gif,Webp) —Stringmime is intentionally not accepted in the public ctor.- Per-provider adapters translate vision on
role = "user"messages:- Ollama:
{role:"user", content:"text", images:["<b64>", ...]}— works withqwen3-vl:8b,llava,llama3.2-vision, etc. Non-vision models silently ignore the field. - Claude: typed content array —
[{type:"text"}, {type:"image", source:{type:"base64", media_type:"image/png", data:"<b64>"}}, ...]. Works with all Claude vision-capable models (Haiku 4.5, Sonnet 4.6, Opus 4.7). - OpenAI: typed content array —
[{type:"text"}, {type:"image_url", image_url:{url:"data:image/png;base64,<b64>"}}, ...]. Works with gpt-4o, gpt-4o-mini, gpt-4-turbo, the o* reasoning models. - DeepSeek: inherits the OpenAI adapter shape; current DeepSeek models lack vision and silently ignore the field. Shape-tested; no live call to avoid spending on a no-op.
- Ollama:
- Role-gated: non-user messages (system/assistant/tool) with non-null
imagesignore the field on the wire — no provider's API accepts images on those roles. Pinned by tests. - Programmatic fixtures in
src/test:VisionFixtures.threeSquaresPng()(256×256 red/blue/green squares for "count the squares" eval) andVisionFixtures.housePng()(256×256 cartoon house for "what is this?" eval). Rendered viaBufferedImage+ImageIO— reproducible byte-for-byte across machines and CI, no external assets in the repo. - Live integration tests (
VisionLiveTest) cover all three vision-capable providers with cost discipline (temperature = 0,maxTokens = 80, single-turn, ~5KB base64 payloads): Ollamaqwen3-vl:8b(taggedlive-llm, runs via:integrationTest), Claudeclaude-haiku-4-5and OpenAIgpt-4o-mini(taggedlive-cloud-api, runs in default:testwithassumeTrueskipping when no key). Model names overridable via env. Assertion shape is loose keyword-match — robust against per-model phrasing variance. - 8 wire-format unit tests pin per-provider JSON shape + the no-images back-compat path. See docs/multimodal.md.
Added — Multimodal foundation (#2465 epic, Stage 1)
- Typed
Contenthierarchy (#2466) —sealed interface Contentwith variantsText,Image,Audio,Video,Documentin packageagents_engine.content. Each non-text variant carries aContentRefplus a typed mime (ImageMime,AudioMime,VideoMime,DocMime). Mime types are closed sealed interfaces withwireMime: Stringaccessors — noStringmime in any public API. Extension propertyContent.modality: Stringis the audit-stable per-variant name. Stage 1 wires Image + Document end-to-end (the modalities the 0.8 spec → product loop consumes); Audio + Video are modelled now and exercised through provider adapters in Stage 2 (#2470, deferred). ContentRef+BlobStore(#2467) — content-addressed reference (hash: StringSHA-256 hex,sizeBytes: Long,wireMime: String).BlobStoreinterface withInMemoryBlobStore(defensive byte-array copies on put + get) andFileBlobStore(dir)(one file per blob, filename = hash, atomic tmp + rename, survives process restart, idempotent put). Hash family matches the manifest hash (#1912) and snapshot filename hash (#2753) — single algorithm across the audit surface. Public top-levelcomputeContentHash(bytes): Stringfor byte-level comparison without a store.ToolResult(#2469) —data class ToolResult(parts: List<Content>)for tools that return mixed content (a screenshot tool returns text + image; OCR returns extracted text + the source PDF ref). Just anotherAny?the tool executor returns — noToolDefsignature change; existing tools that return strings keep working byte-for-byte. AgenticLoop renders multipart returns as text +[modality: <wireMime>] (<hash-prefix>, <size>B)placeholders for the LLM tool-result message; provider-specific multipart rendering (vision-capable Claude/OpenAI/Gemini) is sibling #2470 (deferred). JSONL audit exporter gains anoutputParts: List<String>?column on audit rows — forToolResultreturns it emits one entry per part as<modality>:<hash-prefix>:<sizeBytes>:<wireMime>(text parts astext:inline:<charCount>:text/plain); blob bytes never enter the audit row. Field is null for non-multimodal returns — legacy audit rows unchanged.EXPECTED_FIELDSschema-pin updated to include the new column. Composes with snapshot/resume (refs serialise, blobs stay external) anduntrustedOutput(the text-summary rendering goes through the existing JSON envelope). See docs/multimodal.md.
Added — Eval harness (#2491 epic, feature-complete)
DeterministicModelClient(#2492) —agents_engine.testing.DeterministicModelClient(scripted: List<LlmResponse>)(or vararg ctor) hands back pre-scripted responses one perchatcall. No network, byte-deterministic.requestsrecords every message list the agent built up;remaining()reports unconsumed responses. Exhaustion throwsDeterministicScriptExhausted(callIndex, scriptSize, lastMessages). Streaming uses the defaultModelClient.chatStreamwrap. Out of scope for v1: record-from-live HTTP capture (mentioned in the ticket — needs an HTTP-fixture story we'll write when there's demand) and per-token chunk replay.eval { }DSL (#2493) —agents_engine.testing.eval<IN, OUT>("name") { input(...); expect { ... } }builds a typed eval case. Three expectation styles:expect("label") { predicate }(typed predicate overOUT),expectSnapshot(snapshot = "...")(pin canonicaltoLlmInput(output)JSON; diff on regression),expectFieldEquals(field, value)(single-field substring on rendered JSON). Multiple expects compose — all must pass.EvalResult.failureMessageis null on pass, structured on fail with per-expectation diagnostics.evalSuite("name") { + case; + case }.runAll(agent)bundles cases; type-homogeneous over the agent type at call time (mixed-shape suite is a compile error). Composes withDeterministicModelClientfor fully reproducible end-to-end agentic-loop eval against typedOUT. See docs/eval.md.- LLM-as-judge scorer (#2494) —
agents_engine.testing.JudgeRubric(criteria, scoreRange, judgeModel)+@Generable JudgeVerdict(score, rationale). Opt-in viaeval { ... judge("tone", rubric) }. Verdicts surface onEvalResult.judgeVerdicts: Map<String, JudgeOutcome>keyed by label; sealedJudgeOutcome { Scored(verdict) | Errored(detail) }so parse failures or out-of-range scores surface without aborting.EvalResult.passedis structurally restricted to deterministicoutcomes+invocationError— judges NEVER gate pass/fail.EvalResult.judgeSummaryrenders[advisory] <label>: <score> — <rationale>lines for test reports. The judge model is independent of the production agent's model: useDeterministicModelClientfor unit tests (so the judge itself is reproducible) or a pinned cloud model for live eval. Judges don't run when the agent itself fails (no output to score). See docs/eval.md.
[0.6.4] — 2026-05-30
"Trust patch." Outside auditor reviewed 0.6.3 at 7.5/10 with the verdict "useful hardening release, but not a repositioning release." 0.6.4 is the deliberate response: boring on features, focused on closing every real boundary gap the audit found. The tagline:
0.6.4 makes Agents.KT more tolerant of real model behavior without weakening runtime boundaries.
Product identity is unchanged: auditable Kotlin agent runtime for regulated JVM teams. The #2752 epic batches six runtime/audit fixes + the docs-and-release-hygiene reconciliation; it also ships the #2655 prompt-caching epic completion that landed between 0.6.3 and 0.6.4. See RELEASE_NOTES.md for the long-form release narrative.
Added — Trust patch (#2752 epic)
PipelineEvent.ToolHallucinated— first-class audit event for unknown / unlisted tool calls (#2757) — since #2476, hallucinated tool calls are recoverable and emitToolCallFinished(isError = true), but auditors could only distinguish "model hallucinated a tool" from "tool ran and returned an error" by parsing the error message body. Now a typed event withrequestedName,arguments,allowedTools(skill-bounded, not the wideragent.toolMap), andruntimeContext(requestId / sessionId / manifestHash). NewAgent.onToolHallucinated { name, args, allowed -> }listener mirrorsonToolDenied; wired throughAgent.observe { }so JSONL audit exporter and OTel / LangSmith / Langfuse bridges pick it up automatically. Streaming consumers still getToolCallFinished(isError = true)on the same wall-clock —ToolHallucinatedis additive evidence, not a replacement.MemoryBank.snapshotForAgent/restoreForAgent— namespaced snapshot/restore (#2755) — the shared-workspace topology now actually works. New per-agent accessors:bank.snapshotForAgent(agentName): String?andbank.restoreForAgent(agentName, value: String?). AgenticLoop wires through them so resuming session A in a bank also holding session B's slot leaves B untouched.SnapshotManifestMismatchException+allowManifestMismatchopt-in (#2754) —SessionSnapshot.manifestHashwas carried in 0.6.x for the restore guard but never enforced. Now fails closed by default: a snapshot taken under one tool/permission set refuses to replay against an agent whose manifest has since changed. Exception carries bothexpectedandactualhashes for forensics. Callers who own the migration story passallowManifestMismatch = true.nullsnapshot.manifestHash is allowed (back-compat with any pre-0.6.4 snapshots).onBudgetExceededbroadened to TURNS / DURATION / TOKENS / CONSECUTIVE_TOOL (#2750) — #2412 wired the handler forTOOL_CALLSonly; the other reasons threw unconditionally even when a handler was registered. The handler contract is now symmetric across every cumulative throw site.BudgetDecision.Extend(newLimit)raises the cap and re-arms theonBudgetThresholdwarning toward the new cap. Units: integer count for TOOL_CALLS / TURNS / TOKENS / CONSECUTIVE_TOOL; milliseconds for DURATION. PER_TOOL_TIMEOUT stays unconditionally throwing (extending a single in-flight tool needs interrupt semantics — separate ticket).BudgetDecision.Checkpointbroadened to every cumulative cap (#2764) — #2749 introduced Checkpoint at the TOOL_CALLS hook only; the four sites broadened by #2750 still ignored a Checkpoint return value and threw the plainBudgetExceededException, which discarded the in-flight state — the same history-replay tax #2749 was designed to remove. Now mirrors #2750's Extend coverage: a handler returning Checkpoint at TURNS / DURATION / TOKENS / CONSECUTIVE_TOOL captures aSessionSnapshotat the turn boundary, firesonTurnCheckpoint, and throwsBudgetCheckpointException. Falls back to plainBudgetExceededExceptionwhenonTurnCheckpointis null (Stop semantics — same as the TOOL_CALLS site). The shared capture path now usesMemoryBank.snapshotForAgentper #2755 — incidentally fixes the pre-#2764 TOOL_CALLS Checkpoint, which still used the wipe-allbank.entries()and leaked other agents' slots into the snapshot.
Changed — Trust patch (#2752 epic)
FileSnapshotStorehashes session ids before forming filenames (#2753) — pre-#2753 the raw key flowed intodir.resolve("$key.json"), so a hostile session id like"../../../etc/poisoned"would let the caller read or write outsidedir. The fix: SHA-256 hex hash for the filesystem name; the original session id stays inside the snapshot body (sessionId/requestId) for traceability. Deterministic — repeated saves with the same key overwrite atomically.wrapUntrustedToolResultroutes through the centraltoJsonStringescaper (#2756) — the old hand-rolled 5-char replace chain handled\\ " \n \r \tbut left the rest of U+0000–U+001F unescaped, producing invalid JSON for binary / OCR / captured-terminal tool output.String.toJsonString()(added #2378) was the project-wide source of truth everywhere else; the local copy was the last holdout. Tool name is escaped throughtoJsonString()too — a name containing"no longer breaks the envelope. Tests cover NUL / BS / FF / ESC, emoji, and the pre-#2756 charset.- Docs + release-notes hygiene reconciled (#2752 workstream A) — README dep coordinate 0.6.0 → 0.6.4 with the lead paragraph and "Current Release" blurb rewritten;
RELEASE_NOTES.mdfully refreshed from the stale v0.5.0 body; provider count consistent acrossdocs/model-and-tools.mdandSECURITY.md(four built-in adapters — Ollama / Anthropic / OpenAI / DeepSeek); unknown-tool documentation indocs/prd.mdanddocs/model-and-tools.mddescribes the recoverable-error path from #2476 (not the pre-0.6.3IllegalStateException); MCP server adjunct (src/main/resources/internals-agent/mcp/McpServer.md) describes output viatoLlmInputper #2483, not rawtoString(); CHANGELOG duplicate## [0.6.3]header removed; [0.6.2] attribution claim annotated with the 0.6.3 revert.
Added — Caching epic completion (#2655 — landed between 0.6.3 and 0.6.4)
- Public snapshot/resume seam +
BudgetDecision.Checkpoint(#2749) — exposes the snapshot/resume primitives that have been carried insideexecuteAgenticasinternalparameters since the 0.6.1 spike (#2416). Two new surfaces, both opt-in / additive / non-breaking:Agent.invokeSuspendResuming(input, resumeFrom = null, onTurnCheckpoint = null): OUT— the public seam. Defaults matchinvokeSuspend(input)byte-for-byte. WithonTurnCheckpointset, the hook fires at every turn boundary with the in-flightSessionSnapshot. WithresumeFromset, the loop continues from the saved state (full conversation + counters + memory) without replaying history. Threads through the existinginvokeSuspendForSession(gains the two parameters) →executeAgentic.BudgetDecision.Checkpoint+BudgetCheckpointException(snapshot, reason, currentLimit)— third sealed variant alongsideStop/Extend. When anonBudgetExceededhandler returnsCheckpointAND anonTurnCheckpointis registered on the invocation, the runtime captures the snapshot, delivers it to the hook, and throwsBudgetCheckpointException(a subclass ofBudgetExceededException— existing catch blocks still fire). Without anonTurnCheckpoint, falls back toStopsemantics. On resume,toolCallLimitis taken asmax(snapshot.toolCallLimit, agent.budget.maxToolCalls)so a rebuilt agent with a raised cap honors the new ceiling.- The UX this unblocks: agent hits its tool-call cap mid-dialog →
Checkpointreturns the snapshot → caller surfaces a "raise the cap and continue?" prompt → on acceptance,agent.invokeSuspendResuming(input, resumeFrom = snapshot)continues the same conversation. No history-replay tax. See docs/wiki/extending-budgets-at-runtime.md for the worked example.
- Prompt caching across all providers (#2655 epic — #2657, #2658, #2659, #2661, #2662, #2663) — completes the vendor-neutral caching epic started by the #2656 DSL in 0.6.3. The
caching { }agent block now drives real cost/latency savings end-to-end:- #2658 — Anthropic explicit
cache_controlbreakpoints inClaudeClient. Emitscache_control:{type:"ephemeral"}on the system block (array form), the last tool definition (caches the tool-defs prefix), eachCustomsegment insystem[], and rolling-conversation breakpoints on the latest assistant/user message. TTL mapping:Duration ≤ 5min→ default ephemeral;> 5min→"ttl":"1h". Breakpoint budget coalesced at Anthropic's per-request cap of 4. Backward-compat: requests without cache hints emit the legacysystem: "<text>"string form byte-identically. - #2659 / #2661 — OpenAI / DeepSeek automatic prefix caching +
prompt_cache_keyrouting. OpenAI does automatic prefix caching above ~1024 tokens; the adapter now emits aprompt_cache_keyderived from the agent identity (+ first 12 chars ofmanifestHashwhen present) so same-shape requests land on the same cache shard, improving hit rate. DeepSeek (OpenAI-compatible) inherits the same. Cached-input tokens already surface onTokenUsage. - #2662 — Ollama / self-hosted engine APC. Engines (Ollama context reuse, vLLM APC, SGLang RadixAttention) cache at the KV-cache level with no wire control; cache hints degrade to a documented no-op. Prefix stability — covered by #2657 — is what makes the engine cache hit. Pinned by
OllamaCacheHintNoopTest: a hinted message produces a hint-free Ollama request body. - #2657 — Prefix-stability guard. The vendor cache silently misses on a non-byte-identical prefix; the framework now hashes each cache-hinted segment per-agent across invocations and emits a
WARNING("cacheable segment [SystemPrompt] for agent X changed between invocations") when it drifts. First-sighting pattern probe warns on Unix-millis timestamps, ISO-8601 datetimes, and UUIDs in cacheable content — the silent killers. State lives in aWeakHashMapkeyed byAgentidentity; off when the message has no cache hint. - #2663 — Cache observability on
TokenUsage. NewcacheWriteTokens: Int? = nullfield for Anthropic's premium-billed write side (~25% surcharge on first-write tokens; null on providers that don't expose it). DerivedcacheHitRate: Double?returnscachedInputTokens / promptTokenswhen both are present. CumulativeTokenUsagein the agentic loop now sumscacheWriteTokensacross turns alongside the existingcachedInputTokensaccumulation. - Gemini cached-content handles (#2660) — deferred: no Gemini adapter exists in this codebase yet, so this slice is blocked on the underlying provider work.
- See docs/caching.md for the per-provider behavior table, the
CacheHintmodel, the prefix-stability rules, and the common cache-buster anti-patterns.
- #2658 — Anthropic explicit
[0.6.3] — 2026-05-29
"Prompt-caching foundation + Koog-bug regression net." Ships the vendor-neutral prompt-caching DSL — the foundation of the #2655 epic — and lands the first eight Koog issue-set regression checks under #2474 (five real fixes including the sealed @Generable parent-dispatch unblock, plus three regression-pin tests against the existing contracts).
Added
- Vendor-neutral prompt-caching DSL + neutral hint model (#2656, part of the #2655 epic) — agent-controllable prompt caching declared in provider-agnostic terms. New
caching { }block:enabled(default true),cacheSystemPrompt/cacheToolDefs(default true — byte-stable system prompt + KSP-stable tool defs, #1703),cacheConversation = None | Rolling(defaultNone; opt-in because rolling has per-vendor write cost),ttl(null = provider default), plus acacheable(id, ttl) { content }helper for per-segment marking of large retrieved documents / instruction sets. Internally, the agentic loop attaches a neutralCacheHint(segment, ttl, breakpoint)(withsealed CacheSegment { SystemPrompt; ToolDefs; Conversation; Custom(id) }) toLlmMessageat message-assembly time.LlmMessagegains an optionalcacheHint: CacheHint? = nullfield — backward-compatible: existing adapters ignore it, preserving the pre-#2656 wire shape exactly. No provider cache types (cache_control, Gemini cache IDs, …) appear in the public API. Per-provider adapter consumption (Anthropic / OpenAI / Gemini / DeepSeek / Ollama) lands in #2658-#2662; stability guard in #2657; observability in #2663. See the Prompt Caching wiki page. SessionHistory— ergonomic, stable history accessors overAgentSessionevents (#2485, addresses Koog signal under #2474) —class SessionHistory(events: List<AgentEvent<*>>)exposestoolCalls()/toolResults(excludeErrors = false)/assistantMessages()/completedOutput()/failed()/skillsStarted(). Thin wrapper — no new state, deterministic ordering from the source flow, no allocation beyond filtered list materializations.ToolCallRecord(callId, toolName, arguments)andToolResultRecord(callId, toolName, result, isError)are the surfaced shapes. Not in v1: auserMessages()accessor — the agent input is passed toagent.session(input)directly and is not surfaced as an event; adding it requires a newAgentEvent.UserMessageand is out of scope for this slice.
Changed
- Unknown / unlisted tool name mid-loop is now recoverable, not fatal (#2476, regression for Koog signal under #2474) — when the model emits a tool name absent from the active skill's allowlist (whether outright unknown or belonging to a different skill on the same agent), the agentic loop previously threw
IllegalStateExceptionand the run died. It now appends a tool-result message naming the bad call and listing the skill's allowed tools, then continues — so the model gets a turn to self-correct. The disallowed executor still never runs (authorization boundary unchanged), the skill's allowlist is the only set named (no leak of the wideragent.toolMap), and streaming consumers see aToolCallFinished(isError = true)for the rejected call. Pinned byKoogRegressionUnknownToolTest;ToolAuthorizationTestrewritten to assert the recovery contract (two of its prior assertions were accidentally passing viafail()message contents — replaced with honest tool-message inspection). McpServertools/call now serializes@Generableoutputs as JSON, not as Kotlin debugtoString(#2483, regression for Koog signal under #2474) —McpServer.handleToolCallpreviously rendered the executor's return value throughoutput?.toString(), leaking the Kotlin data-class debug shape (SearchPayload(text=Hello, source=wiki)) into the MCP text content. Routed throughtoLlmInputinstead:@Generableoutputs render as JSON ({"text":"Hello","source":"wiki"}),Stringstays clean, and primitives stay clean. Non-@Generabletyped outputs still fall back to.toString()— documented limitation, register a@Generableoutput type for typed MCP boundaries.- Enum-typed fields now appear in JSON Schema with a typed value list (#2479 part 1, regression for Koog signal under #2474) —
KType.jsonSchemaTypeObjectpreviously fell through to{"type":"string"}for enum-typed constructor parameters, so the LLM had no way to know which values were valid and the constrained-decoding provider path couldn't enforce them. Enums now render as{"type":"string","enum":["veryHigh","normal","low"]}with constant names emitted verbatim fromEnum.name— no case mutation, no@SerialName-style lowercasing. Mixed-case constants (RED/Green/blue) survive intact. The tool_choice configurability half of #2479 is a separate slice (ToolChoice { Auto | Required | None | Specific(name) }API + adapter wiring). - Sealed
@Generableparent classes now deserialize via type-discriminator dispatch (#2482a, regression for Koog signal under #2474) —KClass<Sealed>.constructFromMap(...)previously returned null becauseprimaryConstructoris null on sealed parents. The schema-gen path emits{"oneOf": [...]}for sealed types, so any MCP-exposed skill (or other typed entry point) declaring a sealed@Generableinput was unusable — the model could produce a matching payload, the server couldn't read it.constructFromMapReflectivenow checksisSealed, looks up the matching variant by thetypediscriminator, and recurses — including thedata objectcase viaobjectInstance. Unknown variants and missing-discriminator maps return null so the call routes throughonError.invalidArgsinstead of constructing a wrong-shape value. - Stringified-JSON coercion for nested object / list / sealed fields (#2482b, regression for Koog signal under #2474) — when the LLM emits a typed field whose value is a JSON string (instead of a nested object / array),
coerceValuenow parses the string withLenientJsonParserand continues coercion. Guarded:Stringfields are NOT JSON-decoded (a value like"The {weather} report"stays the literal string —String::classmatches first in thewhen), and unparseable JSON for an object/list field returns null so the failure routes throughonError.invalidArgs. Composes with #2482a — a sealed-typed field accepts a JSON string carrying the type discriminator.
Tests
- Koog issue-set regression suite — first slice (#2474) — pin Agents.KT contracts where Koog broke. #2475 ships
KoogRegressionWrongTypedArgsTest(3 cases): (1) scalarNumber → Stringis intentional coercion percoerceValue(not a malformed arg — executor runs with the stringified value); (2) a truly-unparseable value for a typed field (e.g."abc"forcount: Int) routes throughonError.invalidArgswith end-to-end recovery viaRepairResult.Fixed, executor runs exactly once for the repaired call; (3) without a handler the failure is the framework'sToolExecutionExceptionwith typed-arg context — never a rawkotlinx.serialization/NumberFormatException. - Koog regression — loop protection (#2480) —
KoogRegressionLoopProtectionTest(4 cases) pinsbudget { maxConsecutiveSameTool = N }: same tool past the cap throwsBudgetExceededException(reason = CONSECUTIVE_TOOL)naming the offending tool; an interleaved call resets the counter (alpha → beta → alpha → beta) so an alternating agent doesn't trip; name-only semantics — varying args still trip the cap (stricter than the Koog signal's "identical args" framing — Agents.KT catches more loop shapes); pre-cap threshold listener (onBudgetThreshold) fires forCONSECUTIVE_TOOL. Repeated-identical-assistant-output detection mentioned in the Koog signal is NOT yet implemented — known gap, separate detector if/when needed. - Koog regression — OpenRouter-style streaming chunk reconstruction (#2478) —
KoogRegressionStreamingChunkReconstructionTest(3 cases) feeds synthetic chunk sequences throughchatOrStreamand pins: OpenRouter shape (toolName in the firstToolCallStartedonly, args split across NToolCallArgumentsDeltachunks, finalized byToolCallFinished) reconstructs into one coherentLlmResponse.ToolCallsentry with full args; every wire arg-delta surfaces as exactly oneAgentEvent.ToolCallArgumentsDeltaevent in arrival order verbatim (so streaming UIs can show JSON building up); interleaved chunks for parallel calls route bycallIdand reconstruct both calls cleanly; an orphan args delta (no precedingToolCallStarted) doesn't crash the aggregator and doesn't fabricate aStarted— the delta still fires as a consumer event so a UI sees the wire activity. ClaudeClientChatStreamLiveTeststabilised (#2723) —1..50prompt was still small enough for Haiku 4.5 to occasionally batch the entire response into ~3 same-millisecond SSE chunks, failing the>=10ms gap OR >=5 chunksassertion intended to catch wire-level re-bundling regressions. Bumped to1..200; three consecutive validation runs each report 8 chunks across ~1.3s of streaming. Also corrected the test's stale doc comment — the actual@Tagislive-cloud-apirunning under default:test, notlive-llmrunning under:integrationTest.
[0.6.2] — 2026-05-29
"Attribution you can filter by." Closes the bridge-observability gap that every downstream Langfuse / LangSmith / OTel consumer was working around: business identifiers flow through the runtime context, so bridges drop their per-bridge ConcurrentHashMap<requestId, userId> + onBeforeTurn capture pattern and read user / project / dialog identifiers directly off AgentRuntimeContext. Bundles the entire 0.6.1 release because 0.6.1 shipped on a parallel branch and never reached main — see the [0.6.1] section for the carried-forward bullets.
Reverted in 0.6.3. The
AgentRuntimeContext.attributionsurface added below was reverted in 0.6.3. Today'sAgentRuntimeContextcarries onlyrequestId,sessionId, andmanifestHash. The current position: attribution is a deployer concern, not a framework concern — the integrating bridge / API gateway / session boundary owns its own side-channel and can attach arbitrary identifiers in its own layer without the framework opining on the schema. The bullet below is retained as the historical record of what shipped under the 0.6.2 tag, but consumers should not depend on the attribution API.
Added
- Business-attribution on
AgentRuntimeContext(#2720) [reverted in 0.6.3, see note above] —AgentRuntimeContext.attribution: Map<String, String>plus typeduserId/projectId/dialogIdaccessors. Set once at the session boundary viawithAgentRuntimeContext(...); every nestedAgentEvent/PipelineEventsurfaces it, and bridges (Langfuse / LangSmith / OTel) read it directly instead of capturing it themselves. Free-form keys are honoured for product-specific identifiers; the typed accessors are conveniences over well-known keys.
Bundled from 0.6.1
Because 0.6.1 shipped on a parallel branch and was never merged to main, its content is also published in the 0.6.2 artifacts. Full bullets in the [0.6.1] section below:
- Snapshot/resume foundation (#2416, experimental)
- Reasoning/thinking stream across providers (#2406)
onBudgetExceeded— raise a budget cap and continue (#2412)onToolDenied+PipelineEvent.ToolDenied(#2395)- Typed parameter schemas for built-in tools (#2379)
- AI Act-aligned whitepaper draft (#1921, engineering guidance not legal advice)
Dependencies
org.jline:jline3.27.1 → 4.1.2 — major version bump.LiveShow/LiveRunnerREPL exercisesLineReaderBuilder,TerminalBuilder,DefaultHistory,EndOfFileException,UserInterruptException— all source-compatible across the 3→4 boundary; no callsite changes inagents_engine.runtime.LineEditor.com.google.devtools.ksp:symbol-processing-api2.3.7 → 2.3.8 (KSP module).- Gradle wrapper 9.5.0 → 9.5.1.
Verified: full ./gradlew test green on the new toolchain — 1596 tests, 0 failures across all 7 modules.
[0.6.1] — 2026-05-28
Note: 0.6.1 was cut on a parallel branch and never merged to main. Its content is re-published in the 0.6.2 artifacts (see [0.6.2] above). The dated bullets here document what shipped under the 0.6.1 tag for the audit trail.
Added
- Kimi (Moonshot AI) provider (#2697) —
model { kimi("moonshot-v1-8k") }(or-32k/-128kfor the long-context variants) joins Ollama, Anthropic, OpenAI, and DeepSeek as a built-inModelClient. Kimi's wire format is OpenAI-compatible, soKimiClientis a thinOpenAiClientsubclass with provider identity("kimi" / "Kimi")and base URLhttps://api.moonshot.cn.supportsConstrainedDecoding = false(Kimi'sresponse_formatdoes not currently accept OpenAI'sjson_schemapayload). Key loading from.secrets/kimi-key(file-backed, primary) withKIMI_API_KEYenv override, mirroring the sibling adapters.ModelProvider.KIMIis wired throughdefaultClientFor,semconvProviderName, the permission-manifest provider mapping, andModelConfig.toStringmasking. Unit-test parity withDeepSeekClientTest(wire format, token-usage identity, error envelope, headers, DSL). Live integration test (KimiClientIntegrationTest) gatedlive-llmfor now — the local Moonshot key returnsInvalid Authentication(verified via directcurl); flip the tag tolive-cloud-apionce the key validates so it gains default-suite coverage like DeepSeek. - OpenRouter provider (#2701) —
model { openrouter("anthropic/claude-3.5-sonnet"); apiKey = ... }joins Ollama / Anthropic / OpenAI / DeepSeek / Kimi under the sameModelClientinterface. OpenRouter is an OpenAI-compatible aggregator that fronts hundreds of upstream models (provider/model:variantids — including:free-tier variants).OpenRouterClientis a thinOpenAiClientsubclass with provider identity("openrouter" / "OpenRouter"), base URLhttps://openrouter.ai/api, and two optional attribution headers OpenRouter recognizes —openRouterHttpReferer(origin URL) andopenRouterXTitle(app name) — surfaced via the sameModelConfigDSL. Header merge logic lives inOpenRouterClient.withOpenRouterHeaders; injected for bothchat(sendChat) andchatStream(sendChatStream).supportsConstrainedDecoding = falsebecause upstream behavior varies widely; framework-level constrained decoding stays off so@Generableoutput parsing remains prompt/parser-driven regardless of which upstream the request routes to. Key loading from.secrets/open-router-key(file-backed, primary) withOPENROUTER_API_KEYenv override.ModelProvider.OPENROUTERwired throughdefaultClientFor,semconvProviderName, and the permission-manifest provider mapping. Unit tests pin wire-format parity with the OpenAI sibling adapters plus header injection (configured / absent / both chat & stream paths). Live integration tests against free-tier models (meta-llama/llama-3.2-3b-instruct:free,openai/gpt-oss-20b:free) gatedlive-llm— free upstreams hit rate limits and rotate availability unpredictably, so they live outside the default:testto keep CI green; run via:integrationTest. - Snapshot/resume foundation (#2416, spike for #2386) — experimental — an agent's resumable state is its message history + loop counters, so resume re-enters the loop seeded with a snapshot rather than suspending a coroutine. Ships
Snapshotable<S>,SessionSnapshot,SnapshotStore(+InMemorySnapshotStoreandFileSnapshotStorewith atomic temp-write/rename),MemoryBanksnapshot/restore, and theexecuteAgenticturn-boundary checkpoint +resumeFromseam. Round-trip proven by test (3 turns → crash → fresh agent → restore → finish). The ergonomicpersistence { }DSL +Agent.resumeOrStart(sessionId), the manifest-hash restore guard, and composition snapshots are the next phases on #2386. - Reasoning/thinking surface across providers (#2406) — opt-in
model { reasoning(budgetTokens = …, effort = …) }streams a model's reasoning separately from its answer asAgentEvent.Reasoning(withLlmChunk.ReasoningDeltaand accumulatedLlmResponse.reasoning). Off by default — no behavior change or added cost until enabled. Claude (extended thinking — forces temperature 1), DeepSeek (reasoning_content), and Ollama (think:true→message.thinking) emit reasoning text; OpenAI Chat Completions surfacesreasoning_effort+TokenUsage.reasoningTokensonly (no reasoning text on the wire — Responses-API summaries are out of scope). Tracing bridges record reasoning length only (PII-safe); the JSONL audit exporter omits it. See docs/streaming.md and docs/model-and-tools.md. onBudgetExceeded— raise a budget cap and continue (#2412) — when a budget cap would throwBudgetExceededException,onBudgetExceeded { reason, currentLimit -> }is consulted: returningBudgetDecision.Extend(newLimit)raises the cap and continues,BudgetDecision.Stop(or no handler / a non-greater limit) throws as before. Currently wired for the tool-call cap, so a long-running agent can grant itself more tool calls mid-run ("hit 32 but need to continue") instead of failing. Off by default — no behavior change unless registered.onToolDeniedhook +PipelineEvent.ToolDenied(#2395) — tool calls blocked by anonBeforeToolCallDecision.Denyare now first-class observable. Previously a denied call never firedonToolUse(its executor never ran), so audit/observability built ononToolUseorobserve{}silently dropped every blocked attempt.onToolDenied { name, args, reason -> }now fires in its place (under the runtime context, sorequestId/sessionId/manifestHashcorrelate), andobserve{}surfaces it asPipelineEvent.ToolDenied.onToolUsestill does not fire on denial.
Changed
- Built-in tools now declare typed parameter schemas (#2379) —
memory_write/memory_searchcarry@Generablearg types,memory_readan explicit closed no-args schema,forum_returna closedvalue-only schema, and swarmabsorbdelegates a typed{query: String}schema. Previously these relied on the providers' permissive empty-properties fallback (additionalProperties: true), forcing the model to infer argument shapes from the description prose. No public API change.
Docs
- AI Act-aligned whitepaper draft (#1921, engineering guidance not legal advice) — markdown source of the regulated-deployment whitepaper draft: capability inventory, action log, decision points, failure modes, data lineage, vendor risk; EU AI Act articles (Art. 9 / 12 / 13 / 14 / 15) mapped to specific Agents.KT artefacts; evidence-pack template. Published as
docs/whitepapers/regulated-deployment.md.
[0.6.0] — 2026-05-23
"Boundaries you can audit." The 0.6.0 epic (#1911) turns Agents.KT's typed-boundary model into auditor-ready evidence: deterministic permission manifests with runtime hash correlation, append-only JSONL audit, before-interceptor guardrails, typed tool / MCP-tool hierarchies, vendor-neutral observability bridges (OTel / LangSmith / Langfuse), constrained decoding for @Generable outputs, DeepSeek as a fourth provider, and onTokenUsage telemetry. Existing consumers see no behavior change unless they opt into the new surfaces.
Added
Permission manifest — the 0.6.0 hero feature (#1912)
:agents-kt-manifestmodule —agentManifest(agent)returns a deterministic capability graph: every agent, skill, tool, knowledge entry, MCP endpoint, provider, budget, and policy boundary in a system, in YAML or JSON, with stable ordering and masked provider secrets.verifyAgentManifestGradle task — diffs the current manifest against a checked-in baseline; fails the build on capability widening (new tools, new MCP endpoints, broader policies) so reviewers always see surface-area changes before they merge.- Manifest SHA-256 propagates into the runtime — every
PipelineEvent/AgentEventcarries themanifestHashof the agent that emitted it, so static manifest and dynamic audit trace tie back to the same approved capability set. - Provider secrets masked — API keys, base URLs containing credentials, and any field marked
@SecretSafeare redacted from the emitted manifest.
Runtime event context (#1913)
manifestHash,requestId,sessionIdon every runtime event —PipelineEventandAgentEventboth carry them, so JSONL audit / OTel / LangSmith / Langfuse downstreams all bind events to the manifest hash that was authoritative at invocation time.withAgentRuntimeContext { ... }extension — Kotlin-coroutines-context-aware threading so nested compositions (then,branch,loop,forum,wrap) inherit the outer request/session/manifest correlation without re-derivation.
JSONL audit exporter (#1914)
:agents-kt-observabilityJsonlAuditExporter— append-only, one-line-per-event audit format withrequestId,sessionId,manifestHash, agent/skill/tool ids, event type, provider, and model. Raw arguments and results are omitted by default; opt-in viaincludeRawArgs = true/includeRawResults = truewhen the audit consumer needs them.- Stable canonical field ordering — same audit row produces the same JSON line on every run, so the file is grep-friendly and diff-able.
- PII-safe defaults — designed for the regulated-deployment workflow in
docs/regulated-deployment.md.
Before-interceptor guardrails (#1907)
onBeforeSkill/onBeforeToolCall/onBeforeTurn— Rails-style interceptors returning a sealedDecision { Proceed | ProceedWith(...) | Deny(reason) | Substitute(result) }. Sibling to the post-hoconToolUse/onSkillChosen/onErrorobserver hooks already in 0.4.x.- Chain semantics — interceptors run in registration order; every interceptor runs; the first non-
Proceedwins;Denyshort-circuits with anonUnauthorizedToolCall-shaped audit event;Substituteskips the model and returns the substituted value. - Unified use cases — per-client tool policy (McpServer per-principal allowlists), action confirmation (
Escalate(reason, reviewerRole)resumed by the host app), prompt-injection filtering as a one-liner, uniformperToolTimeoutwrapping. Seedocs/interceptors.md.
Declarative tool policy (#1915)
ToolPolicyDSL ontool { policy { … } }— declares tool risk (LOW/MEDIUM/HIGH/CRITICAL) plus filesystem / network / environment declarations. Consumed by the permission manifest and by audit-row formatters.- No runtime enforcement yet — the sandbox-enforcement work is deferred to 0.7.0 (#1916). 0.6.0 ships the declaration surface so manifest reviewers can already see "this tool reads
~/.ssh" or "this tool calls*.openai.com" at policy-review time.
Typed tool + MCP-tool hierarchies (#1948)
Tool<IN, OUT>typed handles —tool<Args, Result>("name", "desc") { args -> ... }returns aTool<Args, Result>with phantom types soSkill.tools(addTool, divideTool, …)is compile-time-checked instead of stringly-typed.McpTool<IN, OUT>— every MCP-imported tool also gets a typed handle viaMcpClient.tools(prefix). Composes with the sameSkill.tools(...)builder. Additive alongside the existingMCP-as-skilladapter.
MCP server hardening (#1902)
- Inbound bearer auth —
McpServer.tokens(...)configures principal → token mappings; unauthenticated requests get a structured 401.McpStdioServershares the same authn surface for stdio deployments. - Host / Origin allowlists — DNS-rebinding and CSRF defenses against browser-side
localhostexploits; explicit allowlist required for non-loopback hosts. - Per-principal tool policy — each principal can have its own subset of agent skills exposed as MCP tools. Policy decisions flow through the
onBefore*chain and into audit events. - Default-deny — unconfigured server rejects everything except
initialize/tools/list; opt-in for each authorization grant.
Stdio MCP server transport (#2045)
McpStdioServer.from(agent)— exposes the same agent surface (tools, prompts, resources,tools/listChanged: false) over line-delimited stdio instead of HTTP. Same authentication + policy plumbing as the HTTP server.McpRunner --stdio— picocli-style one-liner for shipping agents as stdio-MCP services without a Gradle dependency on:server-style infrastructure.
LiveShow line editing (#985)
LineEditor— line-discipline-aware input handling for the LiveShow runner: cursor movement, history, kill-line, basic readline-style navigation, all while the agent streams events to the display.- Cancellation-safe — collector cancellation propagates through the editor; no orphaned threads.
Runtime observability bridge (#1908)
ObservabilityBridgein:agents-kt-observability— vendor-neutral bridge contract withonPipelineEvent,onAgentEvent, andonInterceptorDecision, plus.observe(bridge)for one-call wiring.:agents-kt-otelmodule — OpenTelemetry adapter that maps agent sessions toagent.invokespans, model turns togen_ai.chatspans, tool calls togen_ai.toolchild spans, errors to span status, usage to GenAI attrs, and before-interceptor decisions to span events.:agents-kt-langsmithmodule — LangSmith run-tree adapter that maps skill invocations tochainruns, model turns to childllmruns, tool calls to childtoolruns, failures to run errors, budget threshold events to run extras, and interceptor decisions to run tags. Dispatch is asynchronous, batched, oldest-drop under backpressure, and never throws into the agent path.:agents-kt-langfusemodule — Langfuse trace adapter that maps skill invocations to traces, model turns to generations, tool calls to spans, runtime events to Langfuse events, and interceptor decisions to tags plusinterceptor.decisionobservations. Dispatch is asynchronous, batched, oldest-drop under backpressure, and uses Langfuse's native ingestion endpoint without a vendor SDK.- Core remains vendor-free — OTel, LangSmith, and Langfuse integration code is isolated to adapter modules.
Provider constrained decoding (#1949)
@Generableschemas are threaded into provider payloads — OpenAI receivesresponse_format.json_schema, Ollama receivesformat, and Anthropic receives a structured-output tool path for typed agentic outputs.- Provider capability detection —
ModelClient.supportsConstrainedDecodinggates schema forwarding so unsupported adapters keep the existing repair-loop behavior.
DeepSeek provider adapter
model { deepseek(name); apiKey = ... }— OpenAI-compatible Chat Completions adapter with DeepSeek provider identity, configurabledeepSeekBaseUrl, usage normalization, streaming through the OpenAI-compatible SSE path, and manifest provider metadata.- Constrained decoding stays disabled for DeepSeek — the adapter does not send OpenAI
response_format.json_schemabecause DeepSeek documents JSON-object mode rather than that schema payload.
Token usage telemetry (#2354, #2355, #2356, #2357)
- Public
Agent.onTokenUsage { usage: TokenUsage -> }listener — fires once per successful LLM round-trip that reports usage, including streaming paths at end-of-stream. Tool-use cycles fire once per provider response, not once per agent invocation. - Widened
TokenUsage— now carriespromptTokens,completionTokens,cachedInputTokens,provider, andmodel.totalremains prompt + completion; cached tokens are a provider-visible subset of prompt tokens, not an extra addend. - Provider-normalized usage mapping — Anthropic maps
input_tokens/output_tokens/cache_read_input_tokenswithprovider = "claude"; OpenAI mapsprompt_tokens/completion_tokens/prompt_tokens_details.cached_tokenswithprovider = "openai"; Ollama mapsprompt_eval_count/eval_countwithcachedInputTokens = nullandprovider = "ollama". - Listener safety semantics — missing usage does not fire, LLM failures do not fire and remain covered by
onError, multiple listeners run in registration order, and listener exceptions are logged and swallowed so telemetry cannot break the agent run.
Tests
- Added
OnTokenUsageTestcoverage for widened fields, multi-listener ordering, listener-error swallowing, missing-usage skip, model-failure skip withonError, multi-turn tool-use ordering, and streaming single-fire behavior. - Updated Anthropic, OpenAI, and Ollama adapter tests to assert provider/model/cache mapping for normal and streaming responses.
Added
InternalsAgent — framework documents itself via MCP (#1837)
buildInternalsAgent(): Agent<String, String>inagents_engine.runtime.internals— a self-hosting docs agent whose skills correspond 1:1 to source files in the framework (63 today). Each skill isimplementedBy { _ -> loadResource("internals-agent/<path>.md") }— nomodel { }configured because the IDE's LLM does the reasoning.Main.ktrunner exposes the agent viaMcpServer.from(...)over Streamable HTTP. Default port 8765; override via--args="<port>"../gradlew runInternalsAgentGradle task. Seedocs/internals-agent.mdfor Claude Desktop / Cursor MCP wiring.- Classpath-scan registration —
buildInternalsAgent()walkssrc/main/resources/internals-agent/at construction time, deriving skill names from paths (internals-agent/core/Agent.md→core_agent_kt) and pullingdescription:from YAML-style frontmatter. Adding a new source-file adjunct is a one-.md-file change — noInternalsAgent.ktedit. validateInternalsAdjunctsGradle task wired intocheck— CI guardrail that fails the build if any adjunct lacksdescription:frontmatter.
Distribution
- GitHub Packages as secondary publication target (#1927) —
publishAllPublicationsToGitHubPackagesRepositorypublished alongside the existing Sonatype path. Maven Central remains the primary public channel; GitHub Packages is for CI snapshots, PR previews, Sonatype-outage redundancy, and authenticated early-access. SeePUBLISHING.mdGitHub Packages section for consumer-side wiring + when to use which channel.
Documentation
docs/internals-agent.md— InternalsAgent quickstart + IDE wiring (Claude Desktop, Cursor) (#1837).docs/threat-model.md— five deployment scenarios (safe-local / internal-tool / MCP-gateway / multi-agent swarm / anti-patterns), trust boundaries, gap-vs-framework matrix (#1904).docs/production-hardening.md— actionable pre-launch checklist organized by tool surface / MCP / budgets / secrets / observability / governance / operational; pre-launch ritual (#1919).docs/regulated-deployment.md— capability inventory, action log, decision points, failure modes, data lineage, vendor risk; EU AI Act mapping (Art. 9 / 12 / 13 / 14 / 15 → Agents.KT artefact); evidence-pack template (#1919).docs/comparison.md— side-by-side against LangChain / Semantic Kernel / AutoGen / raw MCP. Honest about losses; 8-shortcut "Choosing" subsection that sometimes points away from Agents.KT (#1906).docs/interceptors.md—onBefore*interceptor family +Decisionsealed type reference (#1907).docs/observability.md— JSONL audit exporter reference plus the shippedObservabilityBridgecontract,agents-kt-otel,agents-kt-langsmith, andagents-kt-langfuseadapters (#1908, #1909, #1910, #1914).
Changed
InternalsAgent.ktrefactored from 63 hand-written skill blocks to a single classpath scanner (#1837). 493 → 152 lines. Adding a source-file adjunct is now a one-.md-file change. Frontmatter is the single source of truth for the LLM-facing tool description.- README streaming-claims reconciliation (#1901) — dropped the stale "no per-adapter native streaming yet" bullet that contradicted the next bullet's "all three adapters stream natively". Phase 2 roadmap entry updated to reflect v0.5.0-shipped per-adapter streaming.
- README release positioning (#1922) — hero, section order, and non-goals now lead with the 0.6.0 "auditable Kotlin agent runtime" story: manifest evidence, runtime audit correlation, least-privilege tools, and explicit deployer responsibilities.
- PUBLISHING.md GPG setup (#1905) — passphrase-protected key is now the recommended default. Empty-passphrase path preserved as a labelled fallback for isolated environments. "Why not
%no-protection?" callout explains the threat model. - Live-test classification split —
live-cloud-apitag (DeepSeek / Anthropic / OpenAI direct against hosted APIs) runs in default:testso cloud-provider regressions are caught alongside unit tests; the broaderlive-llmtag (Ollama / Ollama Cloud) stays excluded from default:testdue to upstream infra flakiness and runs via:integrationTest.testAllaggregator covers all five 0.6.0 subprojects plus both live slices.
Fixed
- Session-aware tool calls respect
perToolTimeout(#1903) — thesessionExecutorpath now honorsbudget.perToolTimeout, emits a failedToolCallFinishedevent on timeout, and surfacesBudgetExceededException(PER_TOOL_TIMEOUT). Pre-fix, only the blocking-tool path enforced the per-tool timeout; session-aware suspend tools could hang indefinitely on a wedged backend. - Provider JSON string escaping (#2378) —
OpenAiClient,OllamaClient, andClaudeClienteach carried an identical hand-rolled escaper that only escaped\ " \n \r \t, producing invalid JSON whenever a tool result or prompt contained any other U+0000-U+001F codepoint (NUL bytes from binary tool output, U+000C form-feed from Tesseract OCR / PDF extraction, U+001B ESC from captured terminal output, etc.). Extracted the existing RFC 8259-conformant implementation fromInlineToolCallParser.ktintoagents_engine.model.JsonEscape.ktas a single internalString.toJsonString(); removed the three buggy private copies plus the duplicate insideInlineToolCallParser. Now escapes\b/\f/\n/\r/\tshort forms and\u00XXfor every remaining U+0000-U+001F;\and"unchanged; forward slash deliberately left literal. - MCP tool
inputSchemaforwarding (#2377) —McpClient.toolDefs()now passes each MCP server'sinputSchemathrough to the provider's wireparametersfield via the newToolDef.parametersSchemaJson: String?slot. Before, MCP-imported schemas only surfaced in the description prose while the wireparametersfell back to a permissive empty-object — conflicting signal. Provider resolution order:argsType.jsonSchema() ?? parametersSchemaJson ?? <permissive empty>. - Ollama transient-error retry (#2380) —
OllamaClient.chat()now retries transport-level failures wrapped in Ollama's{"error":"..."}envelope:unexpected EOF,Internal Server Error,Service Unavailable,Bad Gateway,Gateway Timeout,connection reset. Three attempts max with 250ms / 500ms backoff (~750ms worst-case latency added to a real outage). Non-transient errors — model-not-found, capability mismatch, auth, malformed-request — still fail fast on attempt 1. Capability-mismatch path still threads through the existing inline-tool fallback.
Tests
- Added
ObservabilityBridgeTest,OtelBridgeTest,LangSmithBridgeTest, andLangfuseBridgeTestcoverage for bridge forwarding, observer stacking, session events, interceptor decisions, OTel parent context propagation, tool child spans, LangSmith run-tree shape, Langfuse trace/span/generation shape, async backpressure logging, usage attrs, and error status mapping. - Added
DeepSeekClientTestcoverage for provider identity, OpenAI-compatible tool payloads, disabled schema forwarding, error envelopes, headers, and themodel { deepseek(...) }DSL. JsonEscapeTest(#2378) — 10 tests covering backslash/quote, five short-form controls, every other U+0000-U+001F as\u00XX, printable-ASCII passthrough, DEL literal, multibyte + surrogate-pair preservation, forward-slash literal, full-BMP round-trip throughLenientJsonParser, and realistic carrier payloads (NUL, form-feed, ESC, mixed).ToolParametersSchemaTest(#2377) — each of three provider clients verifies the closed fallback emits the permissive default and thatparametersSchemaJsonis forwarded verbatim when set.McpClientInputSchemaForwardingTest(#2377) —toolDefs()carries inputSchema through (with and without prefix); null when the upstream tool has no schema. End-to-end viaMockStdioMcpServer.OllamaClientRetryTest(#2380) — five TDD-first tests: transient EOF retries to success, transient 500 retries, non-transient model-not-found fails fast (1 attempt), non-transient capability mismatch does not enter the retry loop, persistent transient exhausts retries at exactly maxAttempts=3.ClaudeClientChatStreamLiveTest— extended prompt to "1..50" so Haiku reliably emits >= 3 SSE chunks across a measurable timing gap; previous "1..10" was short enough that Haiku occasionally bundled the full reply into two same-millisecond chunks.ForumExecutionTest.antagonistic agents debate— Bull / Bear prompts reframed as formal-debate-exercise roles (construct strongest rhetorical case for YES / NO) so modern instruction-tuned models can play the part without being asked to assert known falsehoods.AgenticLoopTest.agent pipeline returns Int resultandFibonacciMemoryTest.pre-seeded memory resumes— replaced hard assertions on LLM-quality-dependent outputs withassumeTrue-then-assertEqualspairs; the framework signal is preserved (wrong-by-framework still fails red), Ollama-quality variance becomes a skip.McpServerLifecycleTest(#889) — 8 new assertions coveringurl/isRunning/stoplifecycle invariants. Kills ~6–8 PIT mutants inMcpServer.kt:82-95that the response-code tests couldn't reach.McpRunnerMissingFlagValueTest(#889) — 5 tests covering the--port/--exposemissing-value error paths and multi-error accumulation.LenientJsonParserUnterminatedTest(#889) — 9 tests pinning the parser's "lenient on shape, strict on safety" contract: unterminated string / object / array at EOF doesn't hang; backslash-at-EOF; unicode-escape-at-EOF boundary; empty / whitespace-only / non-JSON-garbage returns null cleanly.InternalsAgentTest(#1837) — replaced hard-coded63skill-count assertion withassertEquals(countAdjunctsOnClasspath(), agent.skills.size). Test no longer breaks when adjuncts are added.
[0.5.0] — 2026-05-16
The platform release. Streaming runtime end-to-end, MCP-as-skills unification, every composition operator surfacing typed event flows. v0.4.x was about correctness (typed boundaries, KSP, reflect-optional); v0.5.0 is about visibility — what's happening inside an agent's loop and across the wire is now first-class.
Added
Streaming runtime
agent.session(input): AgentSession<OUT>— primary entry point for observing agent execution. Returns a coldFlow<AgentEvent<OUT>>of typed events plus asuspend fun await(): OUTterminal. Each call starts a fresh invocation; sharing across collectors is viaevents.shareIn(...). Defined inagents_engine.runtime.events. Backward compat preserved — existingagent.invoke(input)andagent.invokeSuspend(input)go through the same internal path with a no-op emitter, byte-for-byte unchanged behavior.AgentEvent<OUT>sealed hierarchy — eight subtypes covering the full lifecycle:Token(skillName, text),ToolCallStarted(callId, toolName),ToolCallArgumentsDelta(callId, deltaJson),ToolCallFinished(callId, toolName, arguments, result, isError),SkillStarted(skillName),SkillCompleted(skillName, tokensUsed),Completed<OUT>(output, tokensUsed),Failed(cause). Every event carriesagentIdso consumers can demultiplex composed streams. OnlyCompleted<OUT>is parameterized on the typed output; the rest areAgentEvent<Nothing>and flow through anyAgentSession<OUT>.ModelClient.chatStream(messages): Flow<LlmChunk>as a default-implementing sibling ofchat. Non-streaming providers keep working unchanged; the default wrapschat()and emits a chunk-equivalent sequence.LlmChunksealed type — provider-level chunks:TextDelta,ToolCallStarted,ToolCallArgumentsDelta,ToolCallFinished,End(tokenUsage). Sits between adapters andchatOrStream, keeping provider quirks from leaking intoAgentEvent.- Cumulative
TokenUsageonSkillCompletedandCompleted— summed across every LLM turn of one skill invocation (prompt and completion tokens summed independently). Null forimplementedByskills (no LLM round-trip).
Native streaming adapters
Three adapters override the default chatStream with real wire-level streaming:
- Ollama (NDJSON) —
POST /api/chatwithstream: true. Line-by-line parser; tool calls land in the final chunk (Ollama limitation), emitted as the canonicalToolCallStarted/ArgumentsDelta/ToolCallFinishedtriple. Live integration: ~19 chunks per response, measurable timing gap between first and last. - Anthropic SSE —
POST /v1/messageswithstream: true. Indexed content-block aware: tracksMap<Int, BlockState>so interleavedcontent_block_deltaevents for text + tool_use can be routed to the right block.tool_useblocks carry the canonical Anthropictoolu_*id; we use it verbatim asLlmChunk.ToolCallStarted.callId(the caseToolCall.callIdwas designed for). Live integration verified againstclaude-haiku-4-5-20251001. - OpenAI SSE —
POST /v1/chat/completionswithstream: true+stream_options.include_usage: true. Per-index tool-call state (id from first delta, args accumulated across deltas). Terminator:data: [DONE]. Live integration verified againstgpt-4o-mini.
Cancellation contract verified by regression-guard tests on all three adapters: Kotlin Flow's channel-backed emit propagates collector cancellation back through useLines + .use { stream }, closing the underlying InputStream before the next blocking read.
Composition session support
Every composition operator now exposes a .session(input) entry point. Inner events from each contained agent flow with their own agentIds; the operator emits a single terminal Completed/Failed:
Pipeline.session(input)(#1745, #1746) — sequential composition. Each stage runs to completion (streaming its tokens), then the next starts with the typedMIDvalue. Three-stage chains (a then b then c) emit events from all three.wrap(teacher wrap student) (#1747) — teacher streams; its output becomes the student's prompt override; student streams. ConsolidatedinvokeSuspendForSessionto take an optionalpromptOverride, collapsing two near-identical entry points.Branch.session(input)(#1748) — source agent streams, matched route streams.BranchRoutegainssessionExecutorandroutedAgentNameso terminalCompleted.agentIdpoints at the agent that actually produced the output.Loop.session(input)(#1749) — bracket events emitted per iteration; sameagentIdrepeated each iteration.Parallel.session(input)(#1750) — branches run concurrently onDispatchers.Default; their events interleave by arrival order in the shared Flow, demultiplexable byagentId. TerminalCompleted.agentId = "parallel".Forum.session(input)(#1751) — participants stream concurrently, captain streams sequentially after. Preserves theForumReturnExceptionshort-circuit.Swarm.absorb(sibling)(#1752) — absorbed siblings stream their inner events into the captain's session, between the captain's ownToolCallStartedandToolCallFinishedbrackets.ToolDefgains an optionalsessionExecutorchannel that any future sub-agent-wrapping tool can use.
MCP-as-skills unification
The conceptual point of v0.5.0: an MCP capability and an agent Skill share the same shape (named, described, typed unit of work). All three MCP capability surfaces now expose as Skill<Map<String, Any?>, String>:
mcp.toolSkills()(#1795) — every MCP-exposed tool wrapped as a Skill whoseimplementedByinvokesmcp.call(toolName, args). Sits alongside the existingmcp.toolDefs()(tools as auxiliary functions a skill calls); consumers pick the shape that matches their agent design.mcp.promptSkills()(#1796) — every server-side prompt template wrapped as a Skill whoseimplementedByinvokesmcp.getPrompt(name, args). NewMcpClient.listPrompts()andMcpClient.getPrompt(name, args)methods.mcp.resourceSkills()(#1810) — every URI-addressable resource wrapped as a Skill whoseimplementedByinvokesmcp.readResource(uri). Skill args are ignored — the URI is captured in the skill's closure. NewMcpClient.listResources()andMcpClient.readResource(uri)methods.
McpServer gains DSLs for the server side:
McpServer.from(agent) {
port = 0
expose("skill-name") // tool (existing)
prompt("greet", "Greeting template") { args -> "Hello ${args["name"]}" } // new
resource("policy:///precision.md", "precision-policy",
description = "...", mimeType = "text/markdown") { // new
"Be precise. Cite sources."
}
}
Handlers added for prompts/list, prompts/get, resources/list, resources/read. Initialize capabilities now declare prompts and resources when registered.
McpClient.snapshot: McpServerInfo(#1734) — immutable view of the connected server's full surface (identity, capabilities matrix, tools, prompts, resources, resource templates). Populated afterhandshake()+loadTools().
Test infrastructure
- Loopback MCP fixture (
LoopbackMcpAlgebraTest, #1754) — agent →McpServer.from(...)→McpClient.connect(server.url)→ tool invocation, all in-JVM. Round-trip verified by computingsqrt(π/e)(digits-as-arrays + BigInteger) and checking the result with both a Math.sqrt sanity floor and a BigDecimal square-back provingresult² ≈ π/eto 20 decimal places. - Three pre-existing MCP tests converted to loopback (#1794) — no more
MCP_REDMINE_URLrequirement../gradlew mcpIntegrationTestruns fully out of the box. ./gradlew testAlltask (#1720) aggregates unit + KSP + no-reflect smoke + live-llm integration + live-mcp integration into one command for pre-push verification.docs/streaming.md(#1744) — consumer guide for the session API, native streaming status, cancellation contract, test coverage map, composition note.docs/premortem-0.5.0-streaming.md(#1721) — design-before-code premortem listing the typed event hierarchy, cancellation contract, composition fidelity matrix, success criteria. Every claim in this release notes points at a criterion this premortem listed.
Roadmap updates
- Sandboxed tool execution refined in
docs/roadmap.mdPhase 3 with concrete backends:ProcessSandbox(Seatbelt on macOS, bwrap on Linux),WasmSandbox(Chicory pure-Java),DockerSandbox(docker-java extras module). Scoped to subprocess-shaped tools only —grants { }covers in-process lambdas. - Multimodal I/O added — image/audio input (Phase 2) via
LlmContentsealed-block evolution ofLlmMessage; image generation (ImageModelClient) and TTS (TTSModelClient) in Phase 3. - HTTP
sendAsyncmigration documented as the cancellation latency optimization deferred past v0.5.0 — correctness already holds via Flow semantics (verified by adapter regression-guard tests);sendAsyncwould tighten mid-line cancellation but is not blocking.
Migration notes
v0.5.0 is drop-in for v0.4.6 consumers. Every existing API still works:
agent.invoke(input)andagent.invokeSuspend(input)unchanged.agent.observe { PipelineEvent -> ... }unchanged (the v0.4.x event surface for post-hoc skill/tool/error observability).model { ollama / claude / openai }adapters unchanged;chatStreamis a default-impl addition.
To opt into streaming:
val session = myAgent.session(input)
session.events.collect { event -> /* render Token, log ToolCall*, ... */ }
val output: OUT = session.await() // typed terminal
To consume an MCP server via the unified surface:
val mcp = McpClient.connect(url)
val agent = agent<Map<String, Any?>, String>("wrapper") {
skills {
mcp.toolSkills().forEach { +it }
mcp.promptSkills().forEach { +it }
mcp.resourceSkills().forEach { +it }
}
}
Stats
- 1,074+ unit tests across root + KSP + no-reflect smoke subprojects — 0 failures
- 54 live-LLM integration tests — green on clean runs against
gpt-oss:120b-cloud,claude-haiku-4-5-20251001,gpt-4o-mini - 7 live-MCP integration tests — fully self-contained loopback coverage, no external infrastructure
- v0.4.6 → v0.5.0: ~30 commits, ~25 new test files
[0.4.6] — 2026-05-15
Follow-up to v0.4.5's open thread: actually make kotlin-reflect optional at runtime, and ship the smoke test that proves it. The premortem (docs/premortem-0.4.6.md) defined the success criteria; this release meets them.
Changed
kotlin-reflectis nowcompileOnlyfor real. v0.4.5 reverted toimplementationhonestly because several callsites (Skill.toLlmDescription,ToolDeftyped-tool validation,McpServer@Generableinput detection,GenerableSupport.toLlmInput+generableToJson,BranchBuilder.sealedSubclasses,fromLlmOutput'sisSealedcheck) still went directly throughkotlin.reflect.full.*. v0.4.6 wraps every remaining callsite viaReflectionFallback.withReflection { ... }or routes through the newhasGenerableAnnotation()probe which checks the KSP-generated cache first. The published POM now shipskotlin-reflectascompileOnly— consumers either apply:agents-kt-ksp(recommended, full functionality) or pullkotlin-reflectin themselves (legacy reflection paths). Without either, the runtime degrades to sane fallbacks (empty schema, simple-name LLM description, null onconstructFromMap) instead of crashing.ReflectionFallbackcatchesKotlinReflectionNotSupportedErrorin addition toLinkageError. kotlin-stdlib'sKClass::isSealeddoesn't throwNoClassDefFoundErrorwhen reflect is absent — it throws its ownkotlin.jvm.KotlinReflectionNotSupportedError, a sibling underError. Both branches are now caught.fromLlmOutputno longer crashes on theisSealedcheck without reflect. Theif (isSealed)dispatch is now wrapped — data classes route through the unguardedconstructFromMappath (cache hit returns instantly; cache miss falls into the wrapped reflection branch), sealed roots without reflect return null cleanly.
Added
agents-kt-no-reflect-testGradle subproject. Excludeskotlin-reflectfrom its consumer-shaped classpaths (compileClasspath,runtimeClasspath, and the test counterparts — scoped narrowly so the Kotlin compiler daemon's own classpath is untouched, since the compiler internally uses reflect to read its argument metadata). The suite asserts (a)Class.forName("kotlin.reflect.full.KClasses")throwsClassNotFoundException— the proof that reflect really is absent; (b)jsonSchema,toLlmDescription,fromLlmOutputall return correct results via the generated__GeneratedSchemacompanion when present; (c) all three return their graceful-degradation fallbacks when no companion exists. Failing this suite regresses the v0.4.6 contract.
[0.4.5] — 2026-05-14
Patch release responding to v0.4.4 reviewer feedback (#1707). All five concerns verified against main; correctness fixes shipped, one over-promise walked back honestly.
Fixed
wrapis now race-safe under concurrent invocation. v0.4.4 implementedteacher wrap studentby mutatingstudent.promptfor the duration of one call and restoring infinally. Single-placement protected against multi-pipeline reuse but not against the same Pipeline launched from multiple coroutines, or a direct invocation racing with a wrap-pipeline mid-call — one lane could see another's system prompt. v0.4.5 threads the effective prompt throughexecuteAgentic(agent, skill, input, effectivePrompt)as a local parameter;agent.promptis never mutated. Test coverage:WrapConcurrencyTestexercises 8 parallel lanes with distinguishable teacher outputs + a direct invocation racing alongside, asserting no cross-talk. Consumer-visible behavior change:wrap's prompt override is only visible to agentic skills (those that go throughexecuteAgentic).implementedByskills don't see it (and never reliably did — the old test pattern that relied on readingagent.promptfrom inside animplementedBylambda worked only because of the now-removed mutation race). Realistic usage ofwrapis for LLM-driven students; that path is unchanged. (#1707/#3)- KSP
constructFromMapno longer emits uncompilable nested references. v0.4.4 generatedCustomer__GeneratedSchema.constructFromMap(it)for every nested@Generableref. If the nested class had default-valued primary-ctor params, the processor skipped emitting itsconstructFromMap— leaving the outer class's generated source with an unresolved reference at compile time, not a runtime fallback as the code comment claimed. v0.4.5 routes nested refs through<NestedClass>::class.constructFromMap(map)instead: the::classreceiver is a Kotlin class literal (nokotlin-reflectinvolvement at the call site), and the@PublishedApiextension's cache lookup handles both cases — generated companion present → fast path; absent → reflection fallback or graceful null. Test coverage:ConstructFromMapEmitterTestpins the new emission shape. (#1707/#2)
Changed
kotlin-reflectreverted fromcompileOnlyback toimplementation. v0.4.4 framed the KSP arc as "reflect-free runtime", but several hot paths still callkotlin.reflect.full.*regardless of KSP:Skill.toLlmDescription,AgenticLoopsystem-message build,ToolDeftyped-tool validation,McpServerruntime-discovered@Generableinput detection,GenerableSupport.toLlmInput,BranchBuilder.sealedSubclasses. A consumer withoutkotlin-reflectwould hitLinkageErrorat agent construction, not just at LLM calls. The KSP wins are still real —jsonSchema,toLlmDescription, andconstructFromMapread-paths skip the reflection walk when a generated companion exists — but the runtime continues to requirekotlin-reflect. A future PR will wrap each remaining callsite and ship a consumer-app smoke test withoutkotlin-reflect; too large to be a v0.4.5 patch. (#1707/#1)- Doc drift cleared. README's "main prepared as 0.4.3" stale string updated to current version.
wiki/API-Quick-Reference.md'smaxTurnsdefault corrected fromInt.MAX_VALUEto the actual code default8(set inBudgetConfig.kt). (#1707/#5)
Deferred to a follow-up commit
- CI alignment with the wrapper (
./gradlewat Gradle 9.5.0 instead of action-supplied 8.13) — patch ready locally; requires a GitHub token withworkflowscope to push. See #1707/#4 follow-up.
[0.4.4] — 2026-05-13
First Maven Central release after v0.4.2. Internal tags v0.4.3 (BC pin completeness) existed on GitHub but never reached Maven Central; their content is folded into 0.4.4 alongside the KSP arc and the wrap operator. Skip straight to 0.4.4:
implementation("ai.deep-code:agents-kt:0.4.4")
[0.4.3-unpublished] — 2026-05-12
Added
- KSP validation pass for
@Generable— the:agents-kt-kspskeleton that's shipped since 0.3.0 (#1018) is now wired to do real work. The processor walks every@Generableclass in the consumer's compilation and emits compile-time errors for shapes the framework can't actually construct from JSON: non-sealed interfaces, annotation classes, enums, abstract classes, and classes without a parameterised primary constructor. Sealed types short-circuit — they route through the polymorphic /typediscriminator path thatGenerableSupport.sealedJsonSchemaalready handles. Errors point at the offending declaration so the IDE shows red squiggles where the user can fix them. Schema-generation pass (replacing runtime reflection) is the next KSP increment; this issue closes the validation half (#1700). - KSP schema-generation pass —
:agents-kt-kspnow emits<ClassName>__GeneratedSchema.ktfor every non-sealed@Generable data classwhose fields are all representable types (String / Int / Long / Double / Float / Boolean /List<T>/ nested@Generable). The generated file holds aconst val JSON_SCHEMA: Stringbyte-identical to whatKClass.dataClassJsonSchema()produces via reflection. The runtime'sKClass.jsonSchema()(inGenerableSupport) triesClass.forName("${qualifiedName}__GeneratedSchema")first — hit → returns the constant, cached for the JVM lifetime; miss → falls through to the existing reflection path. Consumers without KSP applied see no behavior change. Consumers with KSP get: ~50-200ms cold-start saving, zero per-call reflection on the schema path, byte-stable schemas across JVM restarts (deterministic Anthropic prompt-cache hits), and the prerequisite for droppingkotlin-reflectfrom the runtime classpath. Lifted alongside: field-type validation — fields with types outside the supported set (e.g.java.time.Instant) now fail at compile time pointing at the offending param, instead of silently degrading to{"type":"string"}at runtime (#1701). - KSP sealed-root schema generation — extends #1701 to
@Generable sealed interface/sealed classtypes. Walks the parent'sgetSealedSubclasses()at compile time and emits a{"oneOf":[...]}schema where each variant carries a"type":"<SimpleName>"discriminator at the head, then the variant's own primary-ctor params, thenadditionalProperties:false, then a trailingdescriptionfield if the variant class carries@Guide. Byte-identical toGenerableSupport.sealedJsonSchema()+variantJsonSchema(). Cross-module sealed hierarchies (variant declared in a different module than the parent) currently produce an incomplete generated schema — single-module is the common case and works; cross-module is a known follow-up. The runtimeKClass.jsonSchema()is now fully shape-agnostic — the gate that skipped lookup for sealed roots in #1701 is removed (#1702). - KSP
toLlmDescriptioncodegen — next-frequency runtime read afterjsonSchema(one call per skill on every agent build, embedded in the system prompt). Each<ClassName>__GeneratedSchema.ktnow carries a secondconst val LLM_DESCRIPTION: StringalongsideJSON_SCHEMA, byte-identical to whatGenerableSupport.dataClassLlmDescription()/sealedLlmDescription()produce. Class-level@Generable(description)renders as the intro paragraph; field@Guide(description)becomes the: descriptiontail on bullets; variant-class@Guidebecomes the: descriptiontail on### Variantheaders.@LlmDescription(text)overrides the auto-generated text — the override is baked into the constant verbatim so the runtime lookup stays reflection-free either way. The runtime cache (renamedGeneratedMetaCache) now loads ALLpublic static final Stringfields from the generated object in one pass, exposing typedlookupJsonSchema/lookupLlmDescriptionmethods; future constants (constructFromMapnext) join without touching the cache implementation. Consumers without KSP still hit the reflection fallbacks unchanged (#1703). - KSP
constructFromMapcodegen — lastkotlin-reflectuser for@Generabletyped-tool args is now compile-time. Each generated companion gains a@JvmStatic fun constructFromMap(fields: Map<*, Any?>): Foo?that calls into freshly-exposed@PublishedApi internalcoercion helpers (coerceString,coerceInt,coerceLong,coerceDouble,coerceFloat,coerceBoolean,coerceList) — the same strict overflow / type rejection the reflection path enforces (#665, #855). Sealed roots dispatch by"type"discriminator to each variant's own generatedconstructFromMap. Cache extended withlookupConstructor(KClass)that resolves the JVM method via JDK reflection (java.lang.reflect, notkotlin-reflect) and caches the invocation lambda. Scope: generation skips data classes with default-valued primary-ctor params — those need the Kotlin compiler's synthetic constructor-with-mask which isn't callable from generated Kotlin source; reflection still handles those. Sealed-variant subclasses with the right shape get full generated path. With this in, every reflection-walk hot path on@Generablehas a codegen alternative; Phase 3 (droppingkotlin-reflectfrom runtime classpath) becomes a follow-up POM-only change (#1704).
Changed (potential consumer impact)
kotlin-reflectis no longer on the runtime classpath ofai.deep-code:agents-kt. With every@Generablehot path (jsonSchema,toLlmDescription,constructFromMap, sealed-variant dispatch) replaceable by KSP-generated code (#1701-#1704), the reflection paths are nowcompileOnlyfallbacks. Consumer migration paths: (a) apply:agents-kt-ksp(recommended — generated path covers everything); (b) addorg.jetbrains.kotlin:kotlin-reflectto your own dependency declarations if you want the reflection fallback to remain available. Without either, reflection-using fallbacks return null gracefully via the newReflectionFallback.withReflection { ... }wrap — typed-tool deserialization routes throughonError.invalidArgs, schema/description lookups return placeholder shapes — so consumers don't crash but they will see degraded LLM output. Defensive emission gate added alongside: sealed@Generableparents whose variants aren't visible to KSP (incremental-compile race, edge cases) skip schema emission; reflection takes over at runtime against the full JVM hierarchy. Both pieces shipped together in #1705.wrapoperator (PRD>>) — closes the last open Phase 1 PRD line item.teacher wrap studentreturns aPipeline<IN, OUT>that runs the teacher first to compute a system prompt string, then invokes the student with that string as its prompt for that one call. The student's baked-inpromptis restored after the call returns. Two framings: education (teacher specializes a generalist student) and security (teacher locks down the student's task surface). Type:Agent<IN, String> wrap Agent<IN, OUT>→Pipeline<IN, OUT>. Headline test: agent A teaches agent B to computefib(10)via afibtool, driven by a stubModelClientthat reads the teacher's instruction from the system prompt and emits a tool call. Both agents participate in the single-placement contract via the returned Pipeline (#1698).
Security
- Complete the BouncyCastle pin across both Gradle modules. v0.4.2 added explicit BC 1.84
compileOnlydeclarations +force(...)to the rootbuild.gradle.kts, but missed the:agents-kt-kspsubproject — itskotlinBouncyCastleConfigurationstill pulled BC 1.80 transitively, which is what kept the four dependabot advisories alive. v0.4.3 mirrors the same fix intoagents-kt-ksp/build.gradle.ktsand prunes stale 1.80 entries fromgradle/verification-metadata.xml. Bothgradle.lockfilefiles now record 1.84 everywhere; no 1.80 entries remain anywhere in the repo. Published JARs are unchanged — BC was never inruntimeClasspathfor either module.
[0.4.2] — 2026-05-12
Security
- Make BouncyCastle 1.84 pin visible to Dependabot. The existing
force(...)block inbuild.gradle.ktsalready pins BC to 1.84 (the patched release per OSV + GHSA) and the lockfile + verification metadata confirm 1.84 is what resolves. However, Dependabot's submitted dependency graph reads requested versions, not resolved, and was still alerting on the 1.80-range CVEs that don't apply to our build. Declare BC 1.84 explicitly at the project level viacompileOnly(...)so Dependabot sees the explicit 1.84 nodes.compileOnlydoes NOT ship to consumers and does NOT add to the runtime jar —runtimeClasspathstays free of BC, as before. No functional change for downstream users.
[0.4.1] — 2026-05-12
Dependency refresh on top of the v0.4.0 feature set. v0.4.0 was tagged on GitHub but never reached Maven Central; 0.4.1 is the first published release of the three-providers feature set.
Security
- Refreshed runtime + build dependencies to close the four dependabot advisories on
main:kotlinx-coroutines-coreandkotlinx-coroutines-test1.10.2 → 1.11.0- Gradle wrapper 9.4.1 → 9.5.0
- Lockfile and
gradle/verification-metadata.xmlregenerated. - Supersedes the open dependabot PRs (#47, #48, #39).
Compatibility
Source-compatible with 0.4.0. Consumers on 0.3.x can upgrade straight to 0.4.1 — same surface as 0.4.0, plus the dep refresh.
[0.4.0] — 2026-05-12
Three model providers, fail-fast startup, and a long-overdue bugfix.
Binary compatibility
Source-compatible with 0.3.x — every new public API addition (claude(), openai(), ModelProvider.ANTHROPIC, ModelProvider.OPENAI, the precheck hook, the new ModelConfig fields) has defaults; existing 0.3.x code compiles unchanged.
Wire-shape change for Ollama tool-call messages (#1694) — assistant turns with tool_calls and no textual content now serialize as content: null on the wire instead of content: "". This is purely a payload-shaping change; in-memory LlmMessage is unchanged. Local Ollama tolerated both shapes; Ollama Cloud's strict validators only accept the new (spec-compliant) form.
Added
- Anthropic Claude adapter — new
ClaudeClient: ModelClientandmodel { claude("claude-opus-4-7"); apiKey = "..." }DSL. Maps the framework'sLlmMessage/LlmResponsemodel to Anthropic's structured Messages API content blocks (tool_use/tool_result); tools advertise asinput_schema(Anthropic's spelling); top-levelerrorenvelopes surface asLlmProviderException, same boundary contract asOllamaClient(#702). Provider dispatch inAgenticLoopconstructs the client lazily so the agent's full tool catalog flows in. Live integration tests against the real API gated on a gitignored.secrets/anthropic-key(withANTHROPIC_API_KEYenv fallback); skipped via JUnitAssumptionswhen the key is absent (#1644). - OpenAI Chat Completions adapter —
OpenAiClient: ModelClientandmodel { openai("gpt-4o"); apiKey = "..." }DSL. Maps to OpenAI'stool_calls/tool_call_idshape with synthesized ids paired FIFO per request;function.argumentsrides the wire as a stringified JSON (OpenAI's convention); tools advertise asparameters(vs Anthropic'sinput_schema). System messages stay in the messages array (vs Anthropic's hoisted top-level field). Same provider-error contract viaLlmProviderException. Live integration tests gated on.secrets/openai-key(withOPENAI_API_KEYenv fallback) (#1656). LiveRunnerprecheck hook —LiveShowBuilder.precheck: (() -> Unit)?runs after arg-parse and before banner /--once/ REPL. Throw to abort startup; the runner printserror: <msg>and returns exit code 2. NewOllamaPreflight(host, port)helper performs aGET /api/tagsreachability check; wired into the swarm-demo captain so a misconfigured endpoint fails fast at startup instead of mid-spinner on the first turn (#1132).- Live typed-args integration tests across all three providers —
TypedArgsLiveIntegrationTestexercises the full@Generableschema → provider envelope → wire → response parse →KClass.constructFromMap→ typed executor round-trip on Ollama / Claude / OpenAI. Each test skips cleanly when the relevant provider isn't reachable (#1675).
Changed
ModelProviderenum gainedANTHROPICandOPENAI.ModelConfigcarries optionalapiKey,anthropicBaseUrl,openAiBaseUrl, andmaxTokensfields used by the Claude / OpenAI adapters. Default Ollama path is unchanged.
Fixed
- OllamaClient: assistant tool-call messages now wire-serialize
contentas JSONnullwhen no textual content accompanies thetool_calls. The previous shape (content: "") was tolerated by local Ollama but rejected with500 Internal Server Errorby Ollama Cloud's strict OpenAI-compatible validators (gpt-oss:120b-cloud,gpt-oss:20b-cloud). This broke every multi-turn agentic loop against those models. The null-coercion fires only when role isassistantANDtool_callsis non-empty AND content is blank — empty-string assistant turns without tool_calls keep their previous shape. Other adapters (ClaudeClient,OpenAiClient) were already spec-compliant; this is an Ollama-only fix (#1694).
Security
ModelConfig.toString()masksapiKeyas<6-char-prefix>…<N>charssolog.info("config = $cfg"), future reflection-based serializers, or stack traces that capture a config no longer leak credentials.equals/hashCodestill consider apiKey — masking is observation-only (#1665).SECURITY.mdextended with a "Handling LLM provider credentials" section:.secrets/directory convention,chmod 0600/0700guidance, thetoStringmasking contract, header-handling claim, and a "if a key is committed → rotate first" runbook.
[0.3.0] — 2026-05-05
First leg of the KSP / compile-time-validation initiative described in docs/ksp-design.md. This release ships typed tool refs — Kotlin's type system catches tools("typo") mistakes that previously bombed at agent validate() (or in CI test runs). Plus the :agents-kt-ksp module skeleton, ready for the Phase 2 codegen work.
Binary compatibility
Source-compatible with 0.2.x — your code compiles unchanged (you'll see deprecation warnings on tools("name") calls, with a ReplaceWith hint to the typed form).
NOT binary-compatible. tool(...) builders changed return type Unit → Tool<Args, Result>. Consumers who upgrade the agents-kt jar without recompiling will hit NoSuchMethodError at first tool registration. Recompile against 0.3.0; no source changes required. If you depend on agents-kt from a published library, that library must also republish against 0.3.0. This is why the bump goes 0.2.x → 0.3.0 and not 0.2.x → 0.2.3.
Added
Tool<Args, Result>typed handle returned by everytool(...)builder overload. Phantom-typed wrapper aroundToolDefwhose type parameters propagate through the agent build (#1015).Skill.tools(first: Tool<*, *>, vararg rest: Tool<*, *>)— typed overload alongside the legacy stringly-typed form. Tool typos become red squiggles in IntelliJ instead of runtime errors atvalidate()(#1016).Skill.tools()— explicit no-argument overload that marks a skill agentic with no allowlisted tools (the model gets only memory + built-in tools). Disambiguates from the deprecated string-vararg form.docs/ksp-design.md— initiative roadmap, runtime-checks inventory (72 sites bucketed), three-phase plan.:agents-kt-kspGradle module — new sibling artifactai.deep-code:agents-kt-ksppublished to Maven Central. EmptySymbolProcessorProviderskeleton; consumers can wire it viaksp("ai.deep-code:agents-kt-ksp:VERSION")but it does no work yet. Phase 2 of the KSP initiative (#1018). The validation pass (#1019) and schema-generation pass (#1020) plug into the processor in subsequent issues.- Multi-module Gradle setup:
settings.gradle.ktsincludes:agents-kt-ksp; same Maven Central + Sonatype publishing wiring as the runtime artifact; same in-memory PGP signing. - Depends on
com.google.devtools.ksp:symbol-processing-api:2.3.7(KSP2, decoupled from Kotlin compiler version). - Reads runtime annotations via
compileOnly(project(":"))— never lands on the consumer's runtime classpath.
- Multi-module Gradle setup:
Changed
- README +
docs/model-and-tools.mdexamples now show typed-ref form first; string form is documented only for built-in tools (escalate,throwException,memory_*). - Internal test fixtures migrated to typed refs across 35+ files (#1017).
Deprecated
Skill.tools(vararg names: String)— soft-deprecated at warning level. Stays for built-in tools (escalate,throwException,memory_*) and runtime-discovered tool names (MCP); no removal planned pre-1.0.
[0.2.3] — 2026-05-04
Hotfix patch — single bug.
Fixed
LenientJsonParserno longer infinite-loops / OOMs on input where a JSON array or object contains a non-numeric, non-string, non-keyword character (e.g.[abc],{"k": foo},[<html>]). The previousparseValue()fell through toparseNumber()for any unrecognized character;parseNumber()returned 0 without advancingpos, soparseArray()/parseObject()spun forever, accumulating zeros until the heap was exhausted. The 0.2.2MAX_NESTING_DEPTHguard (#854) only caught deep nesting, not zero-progress in a single loop body. Two-layer fix:parseValue()is now strict on theelsebranch (throws on unknown chars; the throw is caught by the top-levelparse(input)try/catch and returnsnull, preserving the lenient contract);parseArray()andparseObject()carry zero-progress guards as defense-in-depth (#1028).
Trigger path in the wild
Any LLM response or HTTP body containing […non-JSON content…] would hit this — including non-Ollama responses on localhost:11434 (HTML error pages, JSON error blobs with embedded brackets), small-model output that emitted markdown tables or pseudo-JSON, or test fixtures pointing the agent at unrelated services. Surfaced as OutOfMemoryError during agent invocation, several seconds after the request started.
[0.2.2] — 2026-05-03
A feature-heavy patch release — REPL deployment, multi-agent JAR composition (Swarm), four new observability hooks, two new budget controls, classpath-resource prompt loading, and a slimmer README. Pre-1.0 patch bump — no breaking changes; all existing API surface preserved.
Highlights
LiveShow/LiveRunner— REPL deployment surface mirroring MCP's two-layer split (LiveShow.from(x).start()/LiveRunner.serve(x, args)). Six factory overloads coverAgent/Pipeline/Forum/Parallel/Loop/Branch— anyString-input structure becomes interactively chattable. ANSI color theme, full-resolution ASCII Agents.KT banner, in-place cat spinner during inference, lifecycle hooks (onTurnStart/onTurnEnd/onErrorReported),renderOutputpost-processor, string-concatenated conversation history with--- user ---/--- assistant ---delimiters, slash commands (/quit,/clear,/helpplus user-extensibleslash(name) { }),--once "<prompt>"for non-interactive single-turn use.- Swarm — multi-agent JAR composition. Drop sibling agent JARs into a folder, ServiceLoader-discover them,
me.absorb(sibling)exposes each as a tool with full agent personality preserved (prompt, skills, knowledge, memory, observability hooks). In-JVM, no IPC overhead, no static-typing-across-JARs limitation MCP-stdio would impose. Captain-capable: any agent JAR can be elected by running itsmain. - Four new observability hooks.
onError { Throwable }for infrastructure failures (LLM transport, parse, budget).Agent.observe { event }bridges the four legacy hooks into one sealedPipelineEventstream.onBudgetThreshold(threshold) { reason, used }fires once perBudgetReasonwhen cumulative usage crosses a fraction (pre-cap warning).LiveShow.onTurnStart/onTurnEnd/onErrorReportedfor REPL-side telemetry. - Two new budget controls.
maxTokens(cumulative across turns when the provider reports usage; newBudgetReason.TOKENS) andmaxConsecutiveSameTool(catches LLM retry loops on a broken tool; newBudgetReason.CONSECUTIVE_TOOL).LlmResponse.tokenUsage: TokenUsage?— Ollama'sprompt_eval_count+eval_countplumbed through the agentic loop. loadResource(path)for classpath prompts.prompt(loadResource("prompts/coder.md"))loads UTF-8 from the classpath; fail-fast at agent construction with a helpful error if the path is missing.loadResourceOrNull(path)for the optional case.- README split. Down from 1243 → 203 lines. Topical sections moved to
docs/{skills, model-and-tools, mcp, error-recovery, memory, generation, composition, roadmap}.mdwith cross-back links.
Added
REPL / runtime
LiveShow.from(agent | pipeline | forum | parallel | loop | branch).start().runUntilTerminated()— programmatic REPL host. Six factory overloads collapse to one private constructor takingsuspend (String) -> Any?(#981).LiveRunner.serve(structure, args, configure)— picocli-shaped main shim mirroringMcpRunner.serve. Six overloads,--once "<prompt>",--max-history N,-h,-V. JVM shutdown hook + blocking until SIGTERM, returns int exit code (#981).LiveShowBuilderconfigurables:prompt,maxHistoryTurns,historyDelimiter,input,output, plus UI polish:colors,theme,renderOutput,banner,spinner(#983).LiveShowTheme.DEFAULT/LiveShowTheme.NONEcolor presets bindingAnsiColorto roles (prompt / agentOutput / error / slashOutput / banner) (#983).Spinner.CAT/Spinner.NONE— in-place cat-face spinner during inference, suppressed on non-TTY (#983).- Default banner — full-resolution ASCII rendering of the Agents.KT logo (angular cat face with pink crown accents, block-letter wordmark) (#983).
Swarm.discover()andSwarm.discover(classLoader)— ServiceLoader-walk forAgentProviderimpls (#984).interface AgentProvider { fun build(): Agent<*, *> }— single-method SPI for sibling JARs (#984).Agent<*, *>.absorb(sibling: Agent<*, *>)— wraps the sibling as a tool on the captain; auto-enables across all skills; fails fast on name collision / typed-input siblings (#984).
Observability
Agent.onError { Throwable -> }— infrastructure-error observability hook (LLM transport, response parse, budget). Pure observability — original exception always rethrows; listener exceptions attached as suppressed (#962).Agent.observe { event -> }— sealedPipelineEvent(SkillChosen/ToolCalled/KnowledgeLoaded/ErrorOccurred) bridges the four hooks into one typed stream; composes additively with prior listeners (#965).Agent.onBudgetThreshold(threshold) { reason, usedPercent -> }— pre-cap warning hook; fires once perBudgetReasonwhen cumulative usage crosses the fraction (#966).
Budget
BudgetConfig.maxTokens: Int?+BudgetReason.TOKENS— cumulative token cap; counts only when the provider reportstokenUsageon the response (#963).BudgetConfig.maxConsecutiveSameTool: Int?+BudgetReason.CONSECUTIVE_TOOL— catches retry loops on a broken tool (#969).LlmResponse.tokenUsage: TokenUsage?(promptTokens,completionTokens,total) — Ollama'sprompt_eval_count+eval_countplumbed end-to-end (#963).
DX
loadResource(path: String): String— read agent prompts fromsrc/main/resources/.... Fail-fast at agent construction; UTF-8 decoded; leading-slash normalized (#980).loadResourceOrNull(path: String): String?— null-returning variant for optional resources (#980).Agent.toString()— single-lineAgent<NAME>form replacing the JVM identity-hash default (#970).Agent.describe(): String— multi-line debug summary of name + OUT type, prompt (truncated at 80), model config, budget (overrides only), skills, tools, memory bank presence (#970).
Changed
- README split from 1243 → 203 lines. Topical content moved to
docs/{skills, model-and-tools, mcp, error-recovery, memory, generation, composition, roadmap}.md. Each new doc links back to README (#975). - README install snippet bumped to
0.2.2. - LICENSE copyright + README license footer updated to
Deep-Code.AI. - Default LiveShow banner is the full-resolution Agents.KT logo (40-line ASCII art); replaced the small geometric placeholder shipped briefly in earlier #983 builds (banner-followup).
Fixed
LenientJsonParserexponent-sign / Long-overflow / unicode-escape edge-case coverage (#889 cluster).OllamaClient.parseResponseextractsprompt_eval_count+eval_countfrom response root; partial reports drop tonullrather than half-attributing (#963).
0.2.0 — 2026-05-03
A substantial release covering MCP client + server, the typed tool boundary, the runtime tool-authorization model, frozen-after-construction agents, an inline-tool fallback for capability-limited models, and a cross-cutting suspend refactor. Pre-1.0 minor bump — no breaking changes; existing blocking invoke API preserved via runBlocking shim.
Highlights
- MCP, both directions. Full client (
mcp { server() }over HTTP / stdio / TCP, Bearer auth, namespaced tools) and server (McpServer.from(agent)exposes an agent as an MCP-conformant 2025-03-26 server, plusMcpRunnerfor one-liner standalone JARs). - Typed tool boundary.
tool<Args, Result>(name, description) { args -> }with@Generable-derived JSON Schema,additionalProperties: false, sealed-discriminator validation, repaired-args revalidation. - Per-skill tool authorization, runtime-enforced. The system prompt's "Available tools" listing is descriptive; the security boundary is the runtime allowlist. Unknown tool calls are rejected before execution.
- Frozen-after-construction agents. Skills, tools, memory, model, budget, prompt, and error handlers are immutable once
agent { }returns. Closes themcp { }post-construction registration gap that #708 caught. - Suspend-native framework. Every composition operator (Pipeline, Branch, Loop, Parallel, Forum) and Agent gain
suspend fun invokeSuspend. Existingoperator fun invokeis now a one-linerunBlockingshim — at the user-facing boundary only, never inside the framework. - Inline-tool fallback for Ollama models without native tool support.
gemma3:4band similar models that reject the nativetoolsfield now drive transparently via inline JSON tool-call format. No more agent failures from capability mismatches.
Added
MCP
mcp { server(name) { url = ... | command = ... | host + port = ... } }agent DSL — three transports, namespaced tools (server.tool), connection at agent-build time,mcpClientslifecycle handle.McpAuth.Bearer(token)andMcpAuth.None— outgoing auth thread-through.McpServer.from(agent)— exposes an agent's skills as MCP tools; explicittools/listChanged: falsecapability declaration;protocolVersionconstant for tracking.McpRunner.main(args)— picocli-style standalone server entry point for shipping agents as MCP services.- Mock MCP servers (HTTP, stdio, TCP) for tests.
Typed tool DSL
tool<Args, Result>(name, description) { args: Args -> ... }— typed args via reflection-built JSON Schema (Args::class.jsonSchema()); deserialization viaconstructFromMap; deserialization failures route throughonError { invalidArgs { } }like JSON-parse failures, notexecutionError.@Generable("desc")and@Guide("field doc")annotations now drive the typed tool envelope (realproperties+required+ per-field descriptions, replacing the legacyproperties: {}, additionalProperties: true).
Runtime hardening
- Budget controls —
budget { maxTurns; maxToolCalls; maxDuration; perToolTimeout }, sacrificial-thread enforcement for the per-tool case (#637). ForumTranscript<IN>deliberation pattern —transcriptCaptain(agent: Agent<ForumTranscript<IN>, OUT>)— captain receives full participant outputs (#639).BranchRoutesealed type withonNull/onElsemarkers and construction-time sealed-completeness validation (#640).SkillRoute(name, confidence, rationale)— structured LLM router output;skillSelectionConfidenceThreshold(#641).- Untrusted tool-output wrapping — tool results carry an envelope so the model can't impersonate framework messages (#642).
- Reserved tool names —
memory_read/memory_write/memory_searchcannot be shadowed by user tools (#644). - Fail-fast on duplicate tool names at agent construction (#645).
registerToolfreeze guard — closes themcp { }post-construction registration bypass;registerBuiltInToolandunregisterToolremain unguarded for Forum's runtime captain rotation (#708).- Strict typed args —
additionalProperties: false; sealedtypediscriminator must match the constructed variant;constructFromMaprejects extra keys (#661, #665, #699). - Repaired args revalidation — repaired tool args are re-validated through the typed schema before reaching the executor (#658).
- Encapsulation —
Agent.toolMapandAgent.skillsare read-onlyMapviews; mutation only via DSL or framework-internal escape hatches (#659, #667). Skill.implementationprivate setter (#698).- Skill freeze at end of validate() (#668).
Provider integration
LlmProviderException— provider-boundary errors (auth, model-not-found, capability mismatch) surface distinctly from output-parse errors. Stops Ollama{"error":"..."}envelopes from flowing into usertransformOutputas opaque text (#702).- Inline tool-call fallback — when Ollama responds with
does not support tools,OllamaClient.chatstrips the nativetoolsfield, injects the tool catalog into a system message in inline JSON format, and retries. Per-instance@Volatilelatch skips the native attempt on subsequent calls. Existing user system message preserved (#706).
Suspend refactor
suspend fun invokeSuspend(input)onAgent,Pipeline,Branch,Loop,Parallel,Forum. Internal cross-calls go through suspend; the framework no longer wrapsrunBlockingaround itself (#638).AgenticLoop.executeAgenticandselectSkillByLlmare now suspend;client.chat(...)wrapped inwithContext(Dispatchers.IO)so cancellation interrupts the HTTP I/O thread.ParallelandForumusewithContext(Dispatchers.Default)+coroutineScopeinstead ofrunBlocking(Dispatchers.Default)— caller controls the parent scope;withTimeoutand parent-scope cancellation propagate.
Fixed
- Ollama provider error envelopes were silently passed through as
LlmResponse.Text(rawJson), causing usertransformOutputto fail with a misleading "could not parse" error far from the provider boundary (#702). Agent.mcp { }could mutate the tool registry post-construction becauseregisterTooldidn'tcheckNotFrozen()— the "frozen after construction" invariant had a hole the reviewer flagged (#708).- Agentic loop accepted repaired tool args without re-validating them through the typed schema (#658).
constructFromMapaccepted extra keys for plain data classes; sealed variants didn't verify thetypediscriminator matched (#665, #699).- Tool name typos in
tools(...)silently dropped instead of failing fast at construction (#631). - Default budget was unbounded — agents could loop indefinitely without an explicit
maxTurns(#633).
Changed
model { ollama(...) }Roadmap entry expanded — full budget set (maxToolCalls,maxDuration,perToolTimeout) plus the inline-tool fallback noted.- README reorganized — new "What's in the Box" overview block with explicit "Implemented today / Experimental / Security model / Known limitations" subsections so users can distinguish today's APIs from aspirational ones (#643).
- PRD §5.6 documents the tool capability fallback as a portability principle.
Docs
- README "What's in the Box" block — every implemented feature anchored to its detailed section + the issue # that established it.
- README + PRD: inline tool-call fallback documented with prompt-injection example.
- Wiki (out-of-tree) updates for MCP integration and Roadmap accuracy preceded this release.
Internal / refactor
- Coroutine model rewritten —
runBlockingonly at the user-facinginvokeshim; framework internals are suspend-native (#638). ForumandParallelusecoroutineScopefor structured concurrency; cancellation propagates from parent scopes.OllamaClientmadeopenwith aninternal open fun sendChattest seam — enables HTTP stubbing in unit tests without standing up a server.OllamaClient.parseResponsemadeinternalfor direct test access (matches thebuildRequestJsonpattern from #635).
Tests
- 596 → 602 default-suite tests, all green.
- Live-LLM integration tests (
./gradlew integrationTest):gemma3:4b + tools triggers inline fallback and tool gets executed(single-tool case)gemma3:4b solves parenthesized arithmetic via evaluate tool(string args)gemma3:4b computes 10th Fibonacci via fib tool(integer args)
- 6 new
runTest-based tests for cancellation and structured concurrency in the suspend layer.
Migration notes
No breaking changes. Existing code keeps working unchanged:
// Old code — still works exactly as before:
val result = myAgent("input")
val list = (a / b)("input")
val out = (a then b)("input")
Optional: for callers in coroutine scopes, the new suspend entry points let you skip the blocking shim and propagate cancellation cleanly:
runBlocking {
val result = myAgent.invokeSuspend("input") // no nested runBlocking
val list = (a / b).invokeSuspend("input") // structured concurrency
val out = (a then b).invokeSuspend("input")
val bounded = withTimeoutOrNull(2.seconds) { // works now
slowParallel.invokeSuspend("input")
}
}
No deprecations. The blocking shims are documented as the back-compat surface, not deprecated — call whichever fits your context.
Acknowledgements
Most of this release is driven by sustained external code-review feedback over several rounds. Thank you to the reviewers who pushed for typed tool args, the strict authorization model, the frozen-after-construction guarantee, and the suspend refactor. The "frozen after construction" claim now holds without the mcp { } caveat the latest review flagged.