Migrate to 0.18
This page holds the breaking changes of each 0.18.x release in full, and what to change for each. The Changelog lists every change in one line and links here for the detail. Read every section from the release after the one you are on, up to the one you move to, newest first. Each change is marked as in the changelog: BREAKING fails to compile or link, and a marker such as BREAKING (behavior only) changes what running code does without failing to compile.
0.18.7
Case-insensitive knowledge selectors
BREAKING (behavior only): @Knowledge include and exclude selectors now match paths ignoring case, so both widen: **/*.pdf now also picks B.PDF and c.Pdf, and drafts/** now also drops Drafts/x.md and DRAFTS/y.md. Pattern and path are lower-cased one code point at a time with Character.toLowerCase(int), and the final ς folds to σ, so a Turkish default locale changes nothing: I, i and İ match one another, the dotless ı matches only itself, and ς, σ and Σ match one another. Every path a selector matched before still matches it. A source whose include picked only files of one case now ingests their other-case siblings too, and one whose exclude dropped only one case now drops the others. IngestionPathSelector.matches(...) ignores case the same way. Lines in .gitignore and .tnsignore still match case-sensitively, so secret.md there does not drop SECRET.md.
0.18.6
The japicmp gate is lifted for 0.18.6 alone, so some of these changes fail to compile. Run dev.tnsai.rewrite.UpgradeTnsAI_0_18_6 from tnsai-rewrite first: it marks (/*~~>*/) every call to the removed send methods and rewrites nothing.
Relative FILE paths follow the base directory
BREAKING (behavior only): a relative FILE @Knowledge path written in an annotation, and one a @Config file entry keeps without giving path, now resolves against the base directory ClassConfig.useBaseDirectory set, when one is set, instead of always against the working directory. Without a base directory such a path still resolves against the working directory, and absolute paths and URLs are kept as written. A relative path a @Config file or a run file gives already resolved against the base directory and still does. The path resolves when the source is read, not when the annotation is declared, so a base directory set later applies from the next run. The loader, the setup-time readiness check (KnowledgePreflight), the startup presence check and tnsai-server's FileIndexer resolve it the same way. So does any other relative FILE path a source is given: one built in code with KnowledgeSourceConfig.file, or a relative location passed to RoleRagBinding.forRole, while FileIndexer.index(Path, …) still resolves its Path against the working directory. Under a base directory, a @Config file that restates the annotation's relative path, or names its absolute path under the base, gives the same source as the annotation, so a source with presence WHEN_CONFIGURED named that way is not configured and is skipped. An absolute path under the working directory that it named before is now a different source and is configured. A source skipped because a @Config entry gives the annotation's own path logs a WARN naming the resolved path. A FILE location passed to RoleRagBinding.forRole or given at dispatch that resolves to the source's declared path, written relative or absolute and with or without a base directory, now gives the declared source, so it shares the host's own binding and SEQUENTIAL cursor. Before, any such location passed to forRole, and an absolute one given at dispatch, had a binding of its own. A relative location now reaches the source as an absolute path, so a stored ingestion fingerprint of such a source no longer matches and the source is ingested once more. A base directory switch reaches the process's cached bindings through the reset useBaseDirectory runs, not through the binding key. Bindings already built in an open KnowledgeScope keep the base they were built under until the scope closes. Migration: with @Knowledge(name = "kb", path = "kb"), a corpus in /srv/app/kb and the working directory /srv/app, a host that calls ClassConfig.useBaseDirectory(Path.of("/runs/r1")) now reads /runs/r1/kb. To keep reading /srv/app/kb, give path: /srv/app/kb in a @Config entry or the run file, or move the corpus under the base.
AgentSpec is inherited
BREAKING (behavior only): @AgentSpec is now @Inherited, as @RoleSpec already is. An agent class without its own @AgentSpec takes its nearest superclass's whole, and every reader of the annotation sees it as the subclass's own. Fields are not merged: a subclass that declares its own @AgentSpec replaces the superclass's entirely, and an @AgentSpec on an interface is not inherited. A subclass that must not take the superclass's settings declares an @AgentSpec of its own.
- What a subclass takes. An existing subclass of an annotated agent, where before it had none of these, now takes the superclass's knowledge and knowledge policies, its
@LLMSpec, its@Configfile, its DID settings, including its public key and service endpoint, its groups, which it joins on start whenautoJoinis set, itsroles, which it plays when it is given no roles in code, and itsautoStart,idleTimeoutMs,toolCallFilter,memory,communication,maxContextTokens, description and version. The common case is an annotated abstract base whose concrete agents declare no@AgentSpec: before, they ran as if unannotated, and now they take the base's annotation, so DIRECT knowledge it declares is given on every chat turn of each of them and needs a retrieval runtime such as tnsai-rag at start, and amemoryit declares withFILEpersistence makes each of them write its conversation history to disk, or withREDISorDATABASEmakes each of them open a connection to itsconnectionUrl. Each concrete agent binds and indexes the inherited knowledge on its own, as two annotated classes declaring the same source do, so the sources load once for each concrete agent that uses them. A playground class thatSCOPBridgeresolves likewise gives its superclass'sllmas the playground tier. - The subclass's own code still wins over an inherited memory or communication. A
getMemoryStore()orgetCommunication()override in any class below the annotated superclass, or a@CommunicationStyleon the agent's own class, wins over the inheritedmemorystore orcommunication. A class that declares its own@AgentSpeckeeps its annotation'smemoryandcommunicationfirst, so an override of either method on the annotated superclass itself, which its subclasses got before, now gives way in them to the annotation they inherit. The override replaces the inherited store or style rather than wrapping it:super.getMemoryStore()andsuper.getCommunication()return the superclass's implementation,Agent's default unless a class in between overrides it, not the annotation's. Bridge and synthetic methods do not count as an override, and a generated subclass, such as a proxy, whose override of either method is neither counts as the subclass's own code. So a proxy that intercepts either method, even only to callsuper, gives the agent the superclass's implementation of the method instead of the annotation's. A proxy that must keep the annotation's leaves both methods out of what it intercepts. A generator that copies the class's annotations onto its generated class, such as Mockito's subclass mock maker, makes that class declare the@AgentSpec, so there the subclass's own override gives way to the annotation as it does in a class that declares its own. The inheritedmemory'smaxContextTokensstill applies unless the subclass overridesgetMaxContextTokens(). - A fixed DID is not inherited. A class that takes its
@AgentSpecfrom a superclass whosedidnames a fixedagentIdnow fails at start, even when it is the only subclass (… inherits @AgentSpec from …, whose did names the fixed agentId '<id>'; a fixed DID is not inherited, so declare it on <Class>'s own @AgentSpec), and a builder-given DID does not lift this. A class that wants a fixed DID declares it on its own@AgentSpec. An inheriteddidwithout anagentIdstill gives each agent a DID of its own id. - Roles sections. An inherited
@Configfile'srolessections now bind the subclass's roles, so a subclass that plays fewer roles than they name fails at start (roles [<name>] are not played by this agent; …). A run fileroles.<Role>section keyed by the superclass is not the subclass's, so when the superclass and a subclass that plays that role through the inheritedrolesstart in one run, the second fails (Role … is configured by … and differently by …) unless the subclass's key repeats the section. - A subclass that declares only
@RoleSpecis now a class with both annotations rather than a role class. Its knowledge is its role's and the inherited agent's together, and its role knowledge now reaches its chat as well as the actions that name it, so role DIRECT knowledge is given on every chat turn and needs a retrieval runtime such as tnsai-rag at start. The rules over a class's whole knowledge now run over both together, so it fails at start when, for example, its@RoleSpecand the inherited@AgentSpecboth name a@Configfile (… names a @Config file in both @AgentSpec and @RoleSpec; keep one) or both declare knowledge of one name (… declares knowledge '<name>' twice). A run file sectionAgents.<Subclass>is now read as its agent section. Before, the run'sClassConfigrefused it as naming a role class when keyed by the binary name or when the host gave the run's agent types, and ignored it otherwise. A simple-name key it shares with another agent class of the run is now ambiguous and fails, where before the role class gave way. - Knowledge given in code or by a run file. An unannotated subclass's own
getKnowledge()entries now layer by name over the inherited knowledge instead of standing alone, and a run file section may now give it knowledge, where before that was refused for a class without@AgentSpec. A run file section keyed by the superclass still configures only the superclass, not its subclasses. Messages and run file sections name the subclass.
A single-owner turn follows the multi-owner rules
BREAKING (behavior only): a SCOPBridge.sendToLLM(ChatTurn) turn with one owner gets the knowledge, checks, rendering and fail-open rules of a turn with several. An owner whose class carries @RoleSpec without @AgentSpec now gives its @RoleSpec knowledge as the turn's one owner, as it already did on the 0.18.5 list call: its enabled DIRECT knowledge is given on every chat turn, which needs a retrieval runtime such as tnsai-rag, and a binding passed for it is checked against its @RoleSpec SEARCH declaration, so one whose source or topK does not match is refused with IllegalArgumentException and nothing is dispatched. Before, the 0.18.5 single-owner sendToLLM read only @AgentSpec knowledge for chat, so such an owner gave no knowledge and its binding was searched after an ownership check alone. A binding that belongs to another owner is now refused with IllegalArgumentException when the turn is built, before anything is sent, and its message still names the owner's class. An owner whose class carries @AgentSpec gets what it got before.
The principal moves to the invocation
BREAKING: SCOPBridge.principal(AgentPrincipal) and SCOPBridge.getInstance(AgentPrincipal, LiabilitySink, AuthorityScope) are removed, a binary and source break. The principal no longer sits on the bridge, where every later call of every agent shared it: pass it on the InvocationContext of executeAction(target, action, params, resolvedPaths, InvocationContext), for example InvocationContext.autonomous(executingAgent, principal.id(), scope, principal), and set the sink and scope with liabilitySink(...) and authorityScope(...). A dispatch whose invocation carries no principal, and a call with no invocation, now fail the wiring check (principal=missing) before retrieval or the action body.
New components on ChatRetrievalContext and RetrievalRequest
BREAKING: ChatRetrievalContext gains the components turnMessage, conversationId and sourcePaths, and RetrievalRequest the same three, a binary and source break for a call of either canonical constructor and for a record pattern over either record. Their builders are unchanged and leave the new components empty. turnMessage(String), conversationId(String) and sourcePaths(Map) set them, and the canonical constructors refuse a null Optional or map and a blank message, conversation or source name. ChatKnowledgeBinding.retrieve copies the three from the context into the request as components, not as metadata filters, and searches the turn message, when there is one, instead of the message the provider renders. A turn's sourcePaths move the chat's DIRECT sources to the locations it names, as an action dispatch's locations do (see Where a FILE source is read). A cached result is kept apart per turn message, conversation and source location.
PathRedactor moves package
BREAKING: PathRedactor moved from com.tnsai.integration.activity in tnsai-integration to com.tnsai.redaction in tnsai-core, a binary and source break: change the import. It now implements Masker, which is a UnaryOperator<String>, so it is still accepted wherever a UnaryOperator<String> is, such as the AgentActivity constructor, and its constructor and apply are unchanged.
Source locations are given per call
BREAKING: SCOPBridge.resolvedSourcePaths(Map) is removed, a binary and source break. Source locations are given per call through executeAction's resolvedPaths argument and the requireLoaded/requireReady overloads that take one. null there now means no locations, where before it fell back to the map the setter kept.
Agent record lines carry a conversation field
BREAKING (format and behavior): the .history and .log lines that AgentRecordFormat gives a host carry a conversation field after the episode state and before the writer's name: [<conversation>] from the conversation-id MDC key, or [-] on a line written outside any conversation, as in 2026-10-21 18:00:00 [running] [conv-1] Alice has started episode 1.. A host that writes its agents' files with these patterns, such as SCOP's logger factory, writes the new field, and a reader that splits these lines by position, such as a tail view, must skip it. A message of several lines is now written with two spaces before each line after its first, so a line in a message, such as a forged record of another conversation, continues the record it was written in. RecordParser parses a line only from its start, so an indented line never starts a record. Known limit: only a message's own lines are indented. Lines written before 0.18.6, and the stack trace logback appends after a message, are not, so a line in them that looks like a record, such as one of another conversation, is read as a record of that conversation. RecordParser reads lines without the field too. For IncrementalTextFilesRetriever, a line without the field, such as every line of a run written before the field, is a fieldless record: a fieldless line in the current episode does not enter evidence and is reported with one WARN per file, while a fieldless line of another episode enters, and a line before a file's first record counts as fieldless. This applies only to a request with both a current episode and a conversation id. A request without either is given no .history or .log record at all.
PatternRedactor hides more
BREAKING (behavior only): tnsai-quality's default PatternRedactor catalog now takes its credential patterns from CredentialPatterns, and two of them hide more. jwt also matches a token whose signature segment is empty or shorter than 10 characters, such as an unsigned (alg: none) token. ssh_private_key_block also matches a PKCS #8 PRIVATE KEY block, a PGP PRIVATE KEY BLOCK and a key type with digits, and a block cut off before its END line is hidden to the end of the text. Text these now match is redacted where before it passed through, and the pattern names and severities are unchanged.
The old sendToLLM overloads are removed
BREAKING: SCOPBridge.sendToLLM(LLMConfiguration, JSONArray, JSONArray), sendToLLM(LLMConfiguration, JSONArray, JSONArray, Object, ChatKnowledgeBinding, ChatRetrievalContext) and both sendToLLMForOwners overloads are removed, a binary and source break. Each call becomes sendToLLM(ChatTurn.builder(config, conversation)…build()), setting only what the call used: owners(owner) and bindings(binding) for the six-argument call, owners(list) and bindings(list) for sendToLLMForOwners. A call that sent a conversation from prepareConversationForDispatch(conversation, owners…) becomes a turn of the stored conversation with owners(owners…), leaving out null and repeated entries, which prepareConversationForDispatch skipped and build() refuses: the bridge prepares every turn itself, and a prepared copy in a turn without its owners goes out with their state removed. A null binding, or a null bindings list for sendToLLMForOwners, meant none. Leave bindings out instead, since build() refuses a null binding and bindings refuses a null list with NullPointerException. These behaviors change with them. The three-argument call sent the conversation as it was given. Every turn now sends a copy with any @State the bridge marked for an earlier owner removed, and a system message that held only that state left out, so state an earlier owner's turn left in a conversation no longer reaches a turn without owners. An empty owner list was refused by sendToLLMForOwners. A turn without owners is now a plain dispatch, and only a retrieval context with no owner is refused, when the turn is built. A null configuration came back as an error LLMResponse, and the three-argument call sent a null conversation to the provider with no messages. ChatTurn.builder now refuses a null configuration or conversation with NullPointerException. A null, Collection or repeated owner and a binding of no listed owner are refused by build() instead of by the send. The messages for a Collection owner and for bindings or a context given without owners name ChatTurn.Builder.owners. prepareConversationForDispatch is unchanged. Run dev.tnsai.rewrite.UpgradeTnsAI_0_18_6 from tnsai-rewrite to mark (/*~~>*/) every call to the removed methods. It rewrites nothing.
The removed send overloads map onto the builder:
| Removed in 0.18.6 | Turn |
|---|---|
sendToLLM(config, conversation, tools) | ChatTurn.builder(config, conversation).tools(tools).build(), plus .owners(..) when the conversation carries their state |
sendToLLM(config, conversation, tools, owner, binding, context) | .owners(owner).bindings(binding).context(context) |
sendToLLMForOwners(config, conversation, tools, owners, bindings, context) | .owners(owners).bindings(bindings).context(context) |
sendToLLMForOwners(..., requestSection) | the same, plus .requestSection(section) |
A null binding or binding list used to mean none. Now leave bindings
out. A call that sent a conversation from
prepareConversationForDispatch(conversation, owners...) becomes a turn of
the stored conversation with .owners(owners...), leaving out null and
repeated entries. The three-argument call sent the conversation as given.
A turn without owners now goes out with any state an earlier owner's turn
left in it removed. The dev.tnsai.rewrite.UpgradeTnsAI_0_18_6 recipe marks calls to the
removed methods with /*~~>*/ and rewrites nothing.
0.18.5
0.18.5 is additive: nothing to change.
0.18.4
Nothing fails to compile: these changes are behavior only.
Failed action bodies are narrated
BREAKING (behavior only): a failed action body now tells the model why it failed. When the body itself throws, ActionExecutor raises an ActionExecutionException of category INVOCATION whose cause is the body's own exception, an Error included, and whose message is the root cause's simple type name and message on one line, for example IOException: disk full. In that message, Unicode format characters such as a zero-width space or a bidirectional override are removed, a run of whitespace or control characters becomes one space, e-mail addresses, phone numbers, payment cards, IBANs and national identifiers are replaced by the tokens the audit parameters use, and the message is cut to 200 characters and marked with .... Inside an agent context, the message also ends with the suffix every TnsAI exception's message carries, such as [agent=…, role=…, session=…, tenant=…] with whichever of the agent, role, session, tenant and group are set, added after the cut and not redacted. When the root cause is itself a TnsAI exception, its own suffix is part of the narrated text and is redacted and cut with it. Nothing else is removed from the message: a file path or any other detail it holds reaches the model and the record as the body wrote it. A cause chain holding a java.lang.SecurityException or a TnsAI SecurityException says only Failed to execute action: <name>, and a cyclic chain is read to its first repeat. A VirtualMachineError from the body, such as an OutOfMemoryError or a StackOverflowError, is not narrated: it propagates unchanged. From a reflective body, a @Resilience retry, and on the Role path a @Fallback method, can still handle it first, as they do any other body failure. From the body of an action added through ActionConfig, it propagates before either sees it. Otherwise the body of an action added through ActionConfig fails the same way as a reflective one. Before, it depended on the path. On plain-object dispatch, the path SCOPBridge takes for a target that is not a Role, a body failure became category UNKNOWN with the message Failed to execute action: <name> and the reflective InvocationTargetException as its cause. On the Role path without tnsai-quality it was category INVOCATION with the message Action invocation failed: <message>, and an Error raised a ClassCastException instead. An Error from the body of an action added through ActionConfig escaped as itself on both paths, except on a Role action with a tnsai-quality security timeout, where it became the GENERAL violation. On the Role path with tnsai-quality's SecurityEnforcer it was not an ActionExecutionException at all but a SecurityException of type GENERAL (see the next entry). A caller that reads the category, the cause or the message of this exception sees the new value. The liability record outcome and the SCOP action narration (Action <name> failed: <message>) change with them, and the new text reaches the model in the agent tool loop. An UNAUTHORIZED failure and a retrieval failure narrate as before. This applies to a failure of the action body, on the Role path and on plain-object dispatch alike, with or without tnsai-quality. A failure raised outside the body, such as an LLM, web-service or MCP executor error, is not narrated this way: it reaches the caller as the executor raised it, except that an IllegalArgumentException is a PARAMETER failure reading Parameter error: <message>, as it was before without tnsai-quality.
SecurityEnforcer no longer masks failures
BREAKING (behavior only): tnsai-quality's SecurityEnforcer.executeWithTimeout(ActionMetadata, Role, Callable) no longer reports a failed task as a security violation. An unchecked exception or an Error from the task passes through unchanged and a checked one becomes a TnsAIException whose cause it is, as without tnsai-quality. Only the security timeout and an interrupt of the waiting thread are a SecurityException of type GENERAL. Before, every exception from the task, and with a security timeout an Error too, became SecurityException(GENERAL, "Action execution failed"). On the Role path the task is the whole dispatch, not only the body, so a failure raised outside the body, such as an LLM, web-service or MCP executor error, now reaches the caller as it does without tnsai-quality, where before tnsai-quality masked it as that GENERAL violation. Such a failure is not narrated: an exception's text reaches the caller, the SCOP action narration, the agent tool loop and the liability record with no masking, personal-data redaction or cut, and an Error propagates past the SCOP action narration. A checked one reaches the caller as Action execution failed: <message>. The SCOP action narration shows an ActionExecutionException's own message and, for any other exception, the message of its cause when it has one. A caller that catches the GENERAL violation for a failed Role action sees the action's own failure instead. The parts that read the failure see what they see without tnsai-quality, not the GENERAL violation: a @Fallback method's failure parameter, @Resilience retryOn and noRetryOn, and the agent retry policy's retryOn and abortOn and the agent's error-recovery scenarios, which match by exception type. For a body failure, @Fallback and @Resilience see a TnsAIException whose cause is an InvocationTargetException holding the body's own exception, and the agent's retry policy and error-recovery scenarios see the INVOCATION ActionExecutionException.
sendToLLM refuses a Collection owner
BREAKING (behavior only): the single-owner SCOPBridge.sendToLLM(LLMConfiguration, JSONArray, JSONArray, Object, ChatKnowledgeBinding, ChatRetrievalContext) refuses a Collection as its conversation owner with an IllegalArgumentException that names sendToLLMForOwners, with or without a retrieval context, before anything is dispatched. Before, a collection that declares no knowledge or @State of its own was taken as the one owner, so the knowledge and @State of the objects it held were never read: the turn was dispatched without them and without an error, and a supplied binding failed unless it was made for the collection itself. A class that implements Collection is refused too, even one that declares its own knowledge or @State. Several owners go to sendToLLMForOwners. An array or a Map passed as the owner is not refused.
The single-owner sendToLLM is removed in 0.18.6, whose ChatTurn build() refuses a Collection owner the same way.
prepareConversationForDispatch refuses a Collection
BREAKING (behavior only): SCOPBridge.prepareConversationForDispatch(JSONArray, Object...) refuses a Collection among its conversation owners with an IllegalArgumentException that names the entry's index and type and asks for the owners it holds, one by one, before the conversation is copied. Before, a collection passed as an owner, as in prepareConversationForDispatch(conversation, List.of(agent, role)), where the list is one varargs entry, was ignored as an object without annotations, so the @State of the owners it held was left out of the request without an error. Pass the owners themselves, one argument each or as owners.toArray(). A class that implements Collection is refused too, even one that declares its own @State. An array or a Map entry is not refused.
0.18.3
Nothing fails to compile: the changes are behavior only, and one resource leaves the tnsai-core jar.
Security features read every declaration
BREAKING (behavior only): @Security encryption, timeout and audit now read every @Security the access gate already resolves for the action: on the target's class and each of its superclasses and interfaces, on the action method, and on each method it overrides. This applies on the Role path and on plain-object dispatch alike. Before, they read the dispatched method's @Security, or, when it had none, only the one declared directly on the target's class. A declaration on a superclass, an interface or an interface method, an overridden method that does not repeat it, and the host a proxy subclass extends now take effect. No declaration replaces another: an encryptParams, encryptResult or sensitive set on any of them applies, the shortest positive securityTimeout applies, the highest audit level applies, and the mask fields are the union. A method's @Security therefore no longer hides its class's: a class audit level, for example, now applies to a method that declares only encryptParams. No declaration opts out of another: a nearer false, 0 or NONE does not undo an ancestor's setting, so a method's longer securityTimeout, for example, no longer lifts its class's shorter one. The access checks (allowedCallers, allowedRoles, requiredPermissions) already used this resolution and are unchanged. @AuditLog is resolved over the same methods: the action method's own, else the first found on a method it overrides, walking the target's class and each superclass up the chain, then the interfaces of those classes from the topmost superclass's down to the target class's own, each class's interfaces in declaration order, depth-first through the interfaces each extends, skipping an interface already walked. An override that does not repeat it therefore no longer drops it. One @AuditLog applies, and the action is audited once. On the Role path, interrupting the dispatching thread while it waits for a body under a securityTimeout now also interrupts the body.
Plain-object dispatch applies Security features
BREAKING (behavior only): plain-object dispatch, ActionExecutor.execute(ActionMetadata, Object, Map, Map) on a target that is not a Role, now applies a declared @Security encryptParams, encryptResult, securityTimeout or audit level, and @AuditLog, at the points the Role path applies them. Before, these declarations were ignored on this path. With SecurityEnforcer from tnsai-quality:
- The body receives its
String-valued parameters encrypted, and values of other types pass unchanged. Encryption runs after BeforeAction and the input guardrail, which see the caller's values, and before parameter validation,@Contract, knowledge retrieval and the body, which see the ciphertext, as does the dispatch's liability record. AStringthat the body's parameter type converts, such as"42"for anint, no longer converts. - The caller receives a
Stringresult encrypted, after AfterAction, which sees the plaintext. - The key is generated for each
SecurityEnforcerinstance, so only the enforcer that encrypted a value decrypts it, and nothing encrypted survives a restart. - Audit entries are written after a dispatch that succeeds, and a body that fails writes none.
- A body with a
securityTimeoutruns on asecurity-timeoutworker thread and is refused withSecurityExceptionwhen it exceeds the timeout. The worker carries the agent and invocation contexts and the dispatch's knowledge lease but, as with a@Resiliencetimeout, no other state of the calling thread. It is interrupted, not stopped, when the timeout fires: a body that ignores the interrupt runs on to its end, holding its knowledge lease, after the dispatch has failed. The timeout runs inside any@Resilienceretry, which retries the refusal: each attempt gets its own worker and its own timeout, and an attempt that ignored the interrupt can still be running when the next one starts. A failure of the body under asecurityTimeoutreaches the caller as it would without one, not wrapped inSecurityExceptionas on theRolepath. - A handle that does not override the new methods refuses an action that declares any of these features with
SecurityExceptionUNAUTHORIZEDinrequireHonored, beforeprocessParameters. BeforeAction and the input guardrail have run by then, but the body has not, and the refusal is recorded as a failure. This includes the no-op handle used when tnsai-quality is absent: it already refused an action that declares@Securityat the entry authorization check, and now also refuses one that declares only@AuditLog. On aRolethe no-op handle still runs such an action and writes no audit entry.
Plain-object dispatch needs a wired executor
BREAKING (behavior only): ActionExecutor.execute(ActionMetadata, Object, Map, Map) on a non-Role target now refuses with AccountabilityWiringException before the body runs when the executor has no principal or authority scope, as the Role path already did. On such an executor an action that declares an execution policy is refused earlier, at the entry authorization check, with SecurityException, and neither refusal runs the body or writes a record. A direct caller of this overload must wire the executor first. SCOPBridge dispatch is unaffected when the bridge is wired. See the migration.
Wire the executor before a direct call of ActionExecutor.execute(ActionMetadata, Object, Map, Map) on a target that is not a Role: executor.setPrincipal(principal) with the AgentPrincipal the calls are attributed to, and executor.setDefaultScope(scope) with the AuthorityScope recorded on each dispatch, AuthorityScope.unrestricted(...) when not narrowing is the deliberate choice. An executor taken from an agent built with AgentBuilder.principal(...) and .authorityScope(...) already carries both. A SCOPBridge whose executor is wired needs no change.
Resilience on plain objects
BREAKING (behavior only): a @Resilience declared on a non-Role host, on the method or the class, now takes effect on plain-object dispatch, as it does on a Role. A retry re-invokes the action body, so a body with side effects repeats them on each attempt. The executor's circuit breaker for that action name wraps the retries, so it counts a dispatch once, whatever its attempts. It judges the last 20 dispatches that reached it since it last closed, or all of them while fewer than 20 have, once at least 10 have: it opens when half of them failed or three quarters of them took longer than 10 seconds, even if they succeeded. After 15 seconds open it lets 5 trial dispatches through, and their outcomes decide whether it closes or opens again. While it is open, a dispatch fails with CallNotPermittedException without running the body and is recorded as a failure. A timeout wraps both and bounds how long the caller waits for all attempts together, not each attempt, and not the attempts themselves. With a timeout the body runs on a tnsai-resilience-timeout-* worker thread, which carries the agent and invocation contexts and the dispatch's knowledge lease but no other state of the calling thread: the executor's circular-dependency check does not see the calling dispatch, and a body confined to a UI thread is no longer on it. When the timeout fires the dispatch fails with com.tnsai.exceptions.TimeoutException and is recorded as a failure, and the running attempt is interrupted. The worker does not stop with the dispatch: a body that ignores the interrupt runs on to its end, and when the body stops on the interrupt with the InterruptedException of a blocking call, which clears the interrupt flag, the remaining retry attempts can still run after the dispatch has failed, and an attempt that fails with the interrupt still set ends the retries. These late attempts run under the dispatch's single approval, their outcomes are not recorded, and while an attempt runs the worker holds a lease on the dispatch's knowledge scope. The circuit breaker runs on the worker too, so it counts what the worker ends with, not the timeout: a dispatch that timed out is a failure in its record, but if its worker later succeeds within 10 seconds the breaker counts a success.
SCOP continuations run through the executor
BREAKING (behavior only): when an action that SCOPBridge.executeAction dispatches returns a SCOP action, the bridge now runs that action's execute() through ActionExecutor's plain-object dispatch, as a dispatch of its own named execute, whatever the host is, a TnsAI Role included. It still runs inside the authority of the action that returned it, after the same re-check of that action's authorization binding. The executor authorizes the continuation by its own declarations, such as an @ExecutionPolicy on its class. The host action's declarations do not extend to it. The executor's plain-object pipeline applies to it, and it gets its own liability record, without an outcome on success, after the record of the action that returned it. A continuation refused at the executor's entry authorization does not run and is not recorded, and executeAction returns a failed result denied for authorization, with the message This action is not authorized.. A continuation that throws, other than with an authorization denial, which stays a failed result denied for authorization, now makes executeAction return a failed ActionExecutionResult whose error message starts with Action <name> failed: and whose exception carries the failure. Before, the bridge returned success with the narration Action <name> failed: …. The action that returned the continuation has already run and stays recorded as a success. The post-narration is read inside the continuation's own authorization, as before, so a dispatch it makes cannot exceed what the continuation was authorized for. An undeclared continuation has no authorization of its own, and its post-narration runs under the authority of the action that returned it. A post-narration that cannot be read after a successful continuation falls back to the host's state or Action <name> executed successfully., including one the bridge's module cannot access, which before failed the dispatch. An authorization denial raised while reading it still returns a failed result denied for authorization. The executor's circular-dependency check on the plain-object path now keys on the target instance instead of its class: a dispatch is refused only while the same action on the same instance is already on the thread's call stack, so continuations of one class, such as those scop-core's Role.newAction builds, nest across hosts, and so do two instances of one plain host class. A plain-object action without a @Resilience timeout or a @Security securityTimeout that dispatches itself again on a fresh instance of its class, without end, is no longer refused at the second level and now recurses until the thread's stack overflows (StackOverflowError). Under a @Resilience retry each level can retry that failure. Since the plain-object path applies @Resilience and, under SecurityEnforcer, a securityTimeout, both earlier in this release, an action with a @Resilience timeout, or with a securityTimeout under SecurityEnforcer, runs its body on a worker thread where the circular-dependency check does not see the calling dispatch, so that case is not refused either, where 0.18.2 refused it at the second level. The Role path still keys on the role's class.
The portable role schema is removed
BREAKING (resource): schema/portable-role.schema.json, a JSON Schema that nothing in TnsAI read, is no longer in the tnsai-core jar, so a classpath lookup of it now returns null.
0.18.2
TnsAI moves to its own Maven repository
BREAKING: TnsAI is published to its own Maven repository, https://repo.tnsai.dev/maven2, instead of Maven Central. 0.18.1 and every earlier version stay on Central and keep resolving from there, but nothing from 0.18.2 on is published to Central, so a build resolves 0.18.2 or later only once it adds the repository, as shown below. The coordinates stay dev.tnsai, and every file is signed and carries MD5, SHA-1, SHA-256 and SHA-512 checksums. A library that is itself published to Maven Central cannot depend on TnsAI 0.18.2 or later, because Central's publishing rules require a published artifact's dependencies to resolve from Central.
Add the repository to every build that resolves TnsAI 0.18.2 or later. Maven, in the pom.xml or in a profile of settings.xml:
<repositories>
<repository>
<id>tnsai</id>
<url>https://repo.tnsai.dev/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>Gradle (Kotlin DSL), in settings.gradle.kts under dependencyResolutionManagement or in build.gradle.kts:
repositories {
mavenCentral()
maven {
url = uri("https://repo.tnsai.dev/maven2")
content { includeGroup("dev.tnsai") }
}
}Keep Maven Central in the list: TnsAI's own dependencies still resolve from there. The Gradle includeGroup keeps every other artifact from being looked up on repo.tnsai.dev. A Maven mirror declared with <mirrorOf>*</mirrorOf>, such as a company proxy, must proxy https://repo.tnsai.dev/maven2 too, or be narrowed to <mirrorOf>*,!tnsai</mirrorOf>.
Artifacts are GPG-signed with the same key as before, long key ID D60CFDA975403254 (fingerprint on Installation).
Installation carries the same blocks for a new project.
Run file Agents entries take the Config shape
BREAKING: an entry of the SCOP run file's Agents section takes the shape of an agent's @Config file, {"llm": {...}, "knowledge": {...}, "roles": {...}}, each field optional. The flat entry, "QuestionerAgent": {"temperature": 0.3}, now fails naming the agent type and the setting. Move its LLM fields under llm, as shown below. AgentSettings.fromJson decodes only {"llm": {...}}: a flat LLM setting fails, and so do knowledge and roles, since they configure the agent type. The top-level LLM section is unchanged. AgentConfiguration.parse checks an Agents entry and reads its llm, and a host passes the whole section to the new ClassConfig.useBaseDirectory(ClassConfig.RunFile), which checks the section as an agent's file is checked and applies its knowledge and roles to the agent type it names.
In a SCOP run file, move each Agents entry's LLM fields under llm:
{"Agents": {"QuestionerAgent": {"temperature": 0.3}}}becomes
{"Agents": {"QuestionerAgent": {"llm": {"temperature": 0.3}}}}An entry passed to AgentSettings.fromJson moves its LLM fields under llm the same way: {"temperature": 0.2} becomes {"llm": {"temperature": 0.2}}.
The run file wins over Config files
BREAKING: the run file's agent section, once a host passes it to ClassConfig.useBaseDirectory(ClassConfig.RunFile), wins over the class's @Config files. For the LLM, lowest first, the chain is: the annotations, the shared LLM section, the agent's file, its roles section, the role's own file, the run file's agent section, that section's roles. The run file's type llm applies once, at the run level: a host registers AgentConfiguration.shared() with SCOPBridge.llmConfigurationSource, which sits below the class files, not resolve(...).llm(). Until 0.18.1 a class @Config file won over the run file's LLM fields, and a host that overlaid its own instance, file or explicit run layers on resolve(...).llm() had them win over the type's entry. A source registered with llmConfigurationSource sits below the class files and the run file's type section, so such layers no longer win over the type's llm, and a setting that must win goes in the run file's section. The section is read once per run, when the host starts it, so an edit applies at the next run. A role class takes one run file roles section per run, as it takes one roles section from the agent files.
A value that a class @Config file used to override now overrides that file. Drop it from the run file to keep the class's value.
A host that reads the run file
A host that reads the run file:
- Register the shared section on the bridge,
bridge.llmConfigurationSource(name -> Optional.of(configuration.shared()))withbridgetheSCOPBridgeinstance, notresolve(...).llm(). - Start each run with
ClassConfig.useBaseDirectory(ClassConfig.RunFile.of(base, runFile, agents).agentTypes(registeredTypes)), whereagentsis theAgentssection's text,sections.has("Agents") ? sections.getJSONObject("Agents").toString() : null, andRunFile.oftakesnullfor a run file without the section. - Before saving edited sections, call
AgentConfiguration.validate(sections, registeredTypes)with them, and refuse the edit when it throws.
useBaseDirectory(null) needs a cast
BREAKING (source only): ClassConfig.useBaseDirectory(null) no longer compiles, since useBaseDirectory now takes a Path or a ClassConfig.RunFile, so write ClassConfig.useBaseDirectory((Path) null). Compiled code is unaffected.
0.18.1
Nothing fails to compile: 0.18.1 is additive, and 0.18.0 code and configuration keep working unchanged. Its patched dependencies reach your build only if it lets them.
- Jackson: the TnsAI BOM now imports the patched
jackson-bomlines (2.22.3 and 3.2.3). Maven applies the first import that manages an artifact, so you get them through the BOM only if you importtnsai-bombefore anyjackson-bomor Spring Boot BOM. Otherwise, pin Jackson yourself. The one exception istnsai-payments, which now declares Jackson 3jackson-databinddirectly, so it resolves 3.2.3 even without the BOM. - PostgreSQL driver: the BOM manages
org.postgresql:postgresql42.7.14. TnsAI uses it only in tests, but the same import-order rule decides whether your application resolves 42.7.14. - Logback:
logback-classicmoves to 1.6.5, which fixes a path traversal through an MDC value in aSiftingAppender. TnsAI configures noSiftingAppender. If your application does, make sure 1.6.5 or later is what resolves. - New, opt-in:
@RoleSpec(knowledgePolicy = @KnowledgePolicy(source = "x", presence = KnowledgePolicy.Presence.WHEN_CONFIGURED))(or the same member on@AgentSpec) lets aFILEsource load as no content until a configuration points it at a file.SCOPBridge.requireReady(...)checks before a run that every configured source of that kind is ready. Nothing changes unless you declare them.
0.18.0
Highlights: Knowledge is declared with one annotation, @Knowledge, inside @AgentSpec, @RoleSpec or @ActionSpec. Each entry says how its documents reach the model: use = SEARCH (the default), DIRECT (every document whole, inside a token budget) or SEQUENTIAL (one unit per call). An action sees only the knowledge it names.
Run the upgrade recipe first
Run dev.tnsai.rewrite.UpgradeTnsAI_0_18_0 from tnsai-rewrite before you bump the version. It marks (/*~~>*/) every removed knowledge annotation, every use of the four moved enums and every removed config method, and rewrites nothing. Its description says what each site needs. Remove each marker by hand once the site is migrated.
Your project is still on 0.17.x at that point, so name the 0.18.0 recipe artifact explicitly:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=dev.tnsai:tnsai-rewrite:0.18.0 \
-Drewrite.activeRecipes=dev.tnsai.rewrite.UpgradeTnsAI_0_18_0Rename the four moved enums by hand, with the same constants: KnowledgeSource.KnowledgeType becomes Knowledge.Type, and Retrieval.Strategy, Retrieval.QueryExpansion and Retrieval.FallbackAction become Search.Strategy, Search.QueryExpansion and Search.FallbackAction. A recipe would import Type by its simple name, which collides with java.lang.reflect.Type.
One knowledge annotation
BREAKING: @KnowledgeSource, @KnowledgeSources, @Retrieval, @Sequential and @ChatKnowledge are deleted, with AgentSpec.retrieval(), Retrieval.directContextTokens() and AgentSpecExtractor.getChatKnowledge(). AgentSpec.knowledge() now takes @Knowledge[], RoleSpec.knowledge() and ActionSpec.knowledge() are new, and the four nested enums move to Knowledge.Type and Search.*. A name declared twice on one class, a name an action names twice, and action knowledge on an action that is not LLM or LOCAL all fail. KnowledgeSourceConfig gains a 13th component, select, after chunkContextModel: the 12-argument constructor remains and loads whole documents, but a record pattern over the 12 components no longer compiles, and equals, hashCode and toString include select. UpgradeTnsAI_0_18_0 does not mark record patterns.
Move each @KnowledgeSource into @RoleSpec(knowledge = @Knowledge(...)) on a role or @AgentSpec(knowledge = @Knowledge(...)) on an agent, and the attributes keep their names. @Retrieval(sources = {"a", "b"}, ...) on an action becomes @ActionSpec(knowledge = {@Knowledge(name = "a", search = @Search(...)), @Knowledge(name = "b")}), and the @Search attributes keep the @Retrieval names. directContextTokens = n becomes use = DIRECT, maxTokens = n on that knowledge. @Sequential(source = "x") becomes @ActionSpec(knowledge = @Knowledge(name = "x", use = Knowledge.Use.SEQUENTIAL)). @ChatKnowledge(source = "x", topK = n) becomes @AgentSpec(knowledge = @Knowledge(name = "x", search = @Search(topK = n))).
| 0.17.x | 0.18.0 |
|---|---|
@KnowledgeSource(...) on a role or agent | @RoleSpec(knowledge = @Knowledge(...)) or @AgentSpec(knowledge = @Knowledge(...)), and the attributes keep their names |
@Retrieval(sources = {"a", "b"}, ...) on an action | @ActionSpec(knowledge = {@Knowledge(name = "a", search = @Search(...)), @Knowledge(name = "b")}), and the @Search attributes keep the @Retrieval names |
class-level @Retrieval(...) or @AgentSpec(retrieval = ...) | search = @Search(...) on the declared knowledge, named by each action that used it |
@KnowledgeSource on an action method | declare it on the role or agent, and name it from the action's @ActionSpec(knowledge = ...) |
directContextTokens = n | use = Knowledge.Use.DIRECT, maxTokens = n on that knowledge |
@Sequential(source = "x") | @ActionSpec(knowledge = @Knowledge(name = "x", use = Knowledge.Use.SEQUENTIAL)) |
@ChatKnowledge(source = "x", topK = n) | @AgentSpec(knowledge = @Knowledge(name = "x", search = @Search(topK = n))) |
KnowledgeSourceConfig.from(KnowledgeSource) becomes KnowledgeSourceConfig.from(Knowledge). A record pattern over KnowledgeSourceConfig takes a 13th component, select. The recipe does not mark record patterns, so find them by hand. SequentialConfig.from(Sequential) becomes new SequentialConfig(name). RetrievalConfig.from(Retrieval) has no replacement, so build the config with RetrievalConfig.builder(). RetrievalConfig.directContextTokens(), its builder method and the constructor that took it have no replacement: a builder RetrievalConfig only searches, and DIRECT is declared on @Knowledge.
An action sees only the knowledge it names
BREAKING: an action sees only the knowledge it names in @ActionSpec(knowledge = ...), resolved against the declaring role or agent. Class-level @Retrieval and @AgentSpec(retrieval = ...) used to reach every LLM action of the class. Nothing replaces that, so an action that names no knowledge retrieves nothing.
Each LLM action that used class-level @Retrieval or @AgentSpec(retrieval = ...) must now name its knowledge in @ActionSpec(knowledge = ...), or, on a builder agent, in actionKnowledge(action, names...).
Chat grounds without an opt-in
On Agent, chat knowledge is on by default, with no opt-in: a chat turn sees every enabled @AgentSpec knowledge entry with use = SEARCH or DIRECT, and enabled = false is the only opt-out. On SCOPBridge, SEARCH chat stays host opt-in: it reaches a chat turn only through a binding the host attaches.
BREAKING: a chat turn sees the enabled @AgentSpec knowledge with use = SEARCH or DIRECT, with no opt-in. SEQUENTIAL entries are ignored by chat, GRAPH entries stay out of it, and @RoleSpec knowledge reaches only the actions that name it, except on a class that also carries @AgentSpec, whose chat sees it too. One validator checks every runtime ChatKnowledgeBinding, on Agent and on SCOPBridge alike: the binding must belong to the agent, name one of its enabled SEARCH entries and carry the same topK as that entry's merged @Search. Each refusal names the mismatched field and both values. SCOPBridge has no agent startup, so a bridge chat call without a binding gives the conversation owner's DIRECT entries only, and SEARCH reaches a bridge chat through a binding the host attaches. An agent whose enabled @AgentSpec knowledge includes use = SEARCH builds its chat retrieval engine at startup, unless a runtime binding was attached first, and fails there without an installed RetrievalEngineProvider such as tnsai-rag. Before, only an agent that opted in with @ChatKnowledge did.
DIRECT fails instead of falling back
BREAKING: DIRECT knowledge is loaded at agent startup and checked against its budget there and on every call. Over the budget, or not loadable as whole documents, it fails. It never falls back to search, as directContextTokens did. The engine-side automatic DIRECT selection is removed with it.
Run file knowledge sources are removed
BREAKING: an Agents.<Type> run-file entry with knowledgeSources fails with knowledgeSources moved to the class's knowledge declaration. AgentConfiguration.parseSourceOverrides(JSONObject), AgentConfiguration.SourceOverride, AgentConfiguration.KnowledgeSourceSelection and AgentSettings.knowledgeSources() are removed, as are KnowledgeSourceOverrides, the context keys RetrievalSpi.RESOLVED_SOURCE_CONFIGS_KEY and RetrievalSpi.SOURCE_OVERRIDE_ROOTS_KEY, SCOPBridge.sourceOverrideRoots(Collection) and the 6-argument SCOPBridge.executeAction(Object, String, Map, Map, List, InvocationContext), and executeAction(Object, String, Map, Map<String, Path>, InvocationContext) replaces it and still rejects a null invocation. Resolved runtime paths still locate declared sources.
Move each Agents.<Type>.knowledgeSources row of a run file onto the class's knowledge declaration. A 5-argument SCOPBridge.executeAction call that passed null as the removed source-override list now binds to the InvocationContext overload and returns an authorization error result instead of executing. Drop the null argument to reach the 4-argument overload.
DeclarativeAgentBuilder is removed
BREAKING: DeclarativeAgentBuilder is removed. Its constructor was package-private, so code outside com.tnsai.agents reached it only through fromYaml(String) and fromSpec(Map<String, Object>), and neither could return an agent: neither set a model, so build() threw a NullPointerException. AgentBuilder builds agents.
Replace each DeclarativeAgentBuilder.fromYaml(...) or fromSpec(...) call with AgentBuilder. UpgradeTnsAI_0_18_0 marks every use of the class.
A FILE entry with no path fails
BREAKING: a FILE knowledge entry with no path, which nothing locates, fails as CONFIGURATION whatever its onFailure. On Agent this happens at startup, for every action that names the entry, whether it uses SEARCH, SEQUENTIAL or DIRECT. On SCOPBridge it happens at dispatch, when the run's path map gives the entry no path, and the action does not run. Before, a SEARCH action read such an entry as an empty source and succeeded with no context, a SEQUENTIAL action ran with no unit, and a DIRECT action failed as SOURCE_LOADING. A FILE path that is set but missing passes startup and is a source that does not load, as the entry above describes.
Give each enabled FILE knowledge entry a path, in @Knowledge, the @Config file or the run's path map, or set enabled = false on it.
A denied source fails as a RetrievalException
BREAKING: a knowledge source whose load a SecurityException of either type denies reaches the caller as a RetrievalException with reason CONFIGURATION, the text Knowledge source access was denied and the SecurityException in its cause chain. On 0.17.5 the SecurityException itself was thrown, so a catch (SecurityException e) no longer catches it. A loader that wrapped the denial in its own RetrievalException kept that exception's reason on 0.17.5, so a SOURCE_LOADING one was skipped by a SEARCH action with onFailure = CONTINUE. It is now CONFIGURATION and fails every action that names the source, whatever its onFailure. On SCOPBridge the failed action's message is Action <name> failed: Knowledge source access was denied, with no source, role or cause detail.
Where code caught the SecurityException of a denied knowledge source, catch RetrievalException and look for the SecurityException in its cause chain. The reason alone does not tell a denial apart, since invalid configuration is CONFIGURATION too. Where a loader wraps a denial in its own RetrievalException, its reason is no longer kept: an action that skipped such a SOURCE_LOADING failure under onFailure = CONTINUE now fails, so grant the access or stop naming the source.
MEMORY is chat knowledge the application supplies
BREAKING: Knowledge.Type.MEMORY is chat knowledge the application supplies at runtime through a ChatKnowledgeBinding. Before, it was a loader extension point that no bundled loader served. Initialization loads nothing for it and needs no RetrievalEngineProvider on its account: the agent starts ungrounded, and setChatKnowledgeBinding grounds it under the binding rule of the chat-scope entry above, with the chat default topK of 5 when the entry sets none. A MEMORY entry is declared on @AgentSpec only and uses SEARCH. It takes no maxTokens, no location or loader member and no @Search member but topK. A chat scope holds at most one MEMORY entry and no other SEARCH entry beside it, counting the @RoleSpec knowledge of a class that carries both annotations, while DIRECT entries may sit beside it, and an action that names a MEMORY entry fails. Each rule fails at agent init, naming the entry and, for @Search, the member. SCOPBridge has no agent init: there the rules fire from a class's first action dispatch when a RetrievalSpi such as tnsai-rag is installed, and at every sendToLLM call that carries a ChatRetrievalContext, while a sendToLLM call with a null context checks only the binding's owner. A SourceLoader whose handles() is MEMORY or GRAPH fails discovery, and KnowledgePreflight.requireLoaded refuses a MEMORY name. AgentBuilder.liveChatKnowledge(String), public since 0.16.0, is removed: a builder agent gives knowledge(KnowledgeEntry.search(KnowledgeSourceConfig.memory(name))), and the rules above run on its layered knowledge, builder entries included. In tnsai-server, RagService.attachChatTo(Agent, int) becomes attachChatTo(Agent), which binds RagService.CHAT_TOP_K, 5, and every agent AgentFactory builds declares RagService.CHAT_SOURCE as its MEMORY chat knowledge.
Replace AgentBuilder.liveChatKnowledge(name) with knowledge(KnowledgeEntry.search(KnowledgeSourceConfig.memory(name))), and the runtime binding must then carry topK 5, the chat default. Replace RagService.attachChatTo(agent, topK) with attachChatTo(agent), which binds RagService.CHAT_TOP_K, 5. Move a MEMORY @Knowledge off @RoleSpec and out of @ActionSpec(knowledge = ...) onto the agent's @AgentSpec, keep at most one per agent with no other SEARCH entry beside it, drop its location and loader members, maxTokens and every @Search member but topK, and remove a SourceLoader that handles MEMORY. UpgradeTnsAI_0_18_0 marks the two removed methods. Find the declarations and loaders by hand.
Builder knowledge is given as entries
BREAKING: builder knowledge is given as the same KnowledgeEntry model the annotations produce. AgentBuilder.addKnowledgeSource(KnowledgeSourceConfig), knowledgeSources(List), retrieval(RetrievalConfig) and chatKnowledge(String) are removed, with the Agent hooks getKnowledgeSources(), getRetrieval() and getKnowledgeSourcesTemplate() and with AgentToolExecutor.AgentContext.getAgentRetrievalConfig(). Before, a builder agent's sources and its one RetrievalConfig sat beside the declared knowledge: chat grounded on the one source chatKnowledge named, and an action that named no knowledge, or only SEARCH knowledge, searched every builder source with that config. Now AgentBuilder.knowledge(KnowledgeEntry...) layers entries over the @AgentSpec knowledge by name: an entry with a declared name replaces that entry in place, so one replacing a @RoleSpec entry stays a role entry, and an entry with a new name joins the agent's chat scope. The knowledge rules run on the layered list, so a chat scope still holds one MEMORY entry and no other SEARCH entry beside it. A chat turn searches every enabled SEARCH entry of the chat scope, and an action uses only the knowledge it names, and AgentBuilder.actionKnowledge(action, names...) replaces the list an action's @ActionSpec names, and an action the agent does not have fails at init with the list of its actions. A subclass gives the same through the new protected hooks getKnowledge() and getActionKnowledge(). Agent.getRetrievalConfig() stays and returns the chat scope's merged search configuration.
Give builder knowledge as entries. addKnowledgeSource(source) and knowledgeSources(List.of(source)) become knowledge(KnowledgeEntry.search(source)), or a new KnowledgeEntry(source, use, maxTokens, search) for DIRECT or for a source with its own search settings. retrieval(RetrievalConfig.builder().topK(5).build()) becomes SearchSettings.builder().topK(5).build() on the SEARCH entries it applied to. The settings of a scope's SEARCH entries merge member by member, as @Search members do. Drop chatKnowledge(name): a chat turn searches every enabled SEARCH entry of the chat scope. An action that searched the builder's sources without naming them now names them with actionKnowledge(action, names...). A subclass override of getKnowledgeSources() or getRetrieval() becomes an override of getKnowledge(), and one of getActionKnowledge() for its actions. An AgentToolExecutor.AgentContext implementation drops getAgentRetrievalConfig(). UpgradeTnsAI_0_18_0 marks each removed call and each override of a removed hook.
Before 0.18
Bridge retries follow the error type (0.17.5)
BREAKING: this change is silent: nothing fails to compile, but retries
behave differently. Before 0.17.5, BridgeLLMClient.chat() reported every failure as
ErrorType.UNKNOWN, which is retryable. It now reports the classified type,
so a RetryPolicy keyed on error types behaves differently on the HTTP
fallback path:
- A policy keyed on
SERVER_ERRORorMODEL_OVERLOADEDnow retries 5xx and 429 failures. - 401/403 and other 4xx failures are no longer retryable.
- An empty 2xx body, or a 2xx body carrying an
errorobject, staysUNKNOWNand retryable, as before.
The bridge itself still sends each request once, and any retry comes from the
caller's RetryPolicy. If you relied on 4xx failures being retried, review
your policy before upgrading. See the
0.17.5 changelog entry.