Repository navigation
0.3.0
Upgrading from 0.2.0: recreate the database (the schema changed and old data cannot be read), give JdbcNotebook, JdbcPlans and JdbcChapters a CodecFactory, replace any ActionRenderer with a Stringifier on ToolConfig.action, and add a Truncated arm to any switch over InferenceResult. Chapters are on by default: an agent gets a chapter every 20 turns, summarised by its own model, unless withoutChapters() is set. Each breaking change is listed below.
Breaking changes
-
A database written by an earlier build cannot be read and must be
recreated. -
Every piece of content Nessy stores goes through the storage codec.
A chapter's summary (nessy_chapter.summary), a note's hook and body
(nessy_note.hook,nessy_note.body) and a plan task's title
(nessy_plan_task.title) wereTEXT; they are nowBYTEA, encoded with
the application'sCodecFactory, so aStorageCodecConfigurer's compression
or encryption covers them as it covers the events and the payloads.
JdbcNotebookandJdbcPlanstake aCodecFactoryas a third constructor
argument,JdbcChapterstakes one as its second, andInMemoryChapters
takes one, so each holds the encoded bytes of a summary as the in-memory
payload store holds content. The constructors without one are removed. A
database written by an earlier build must be recreated. -
A custom
InferenceProvider, or aswitchoverInferenceResult, needs
the new arm. A provider returnsTruncatedwhen its vendor says the
output limit was reached and the reply holds text. -
ToolConfig.actiontakes aStringifierof the tool's input. A line is
one line of at most 1,000 characters; with none named it is cut at 255,
keeping its start. -
Exchange,ActionRequest.ToolCallandToolSucceededeach gained
components:actionsandresults,action, andrendered. -
ActionRendereris removed. AStringifier<I>takes its place. -
nessy-approval-intentis removed, withIntent,Intents,
JdbcIntents,IntentTool,IntentEnricher,IntentPolicyand the
nessy_intenttable. -
Embedder.dimension()returnsOptional<Dimension>, notint(0 for
not learned yet): empty until the first reply when no width was asked for.
Embedding.dimension(), a vector's length, staysint. -
EmbeddingOptions.dimensionisOptional<Dimension>, notOptionalInt. -
InferenceOptionsandEmbeddingOptionsgain aproperties
component. Their existing constructors andof(...)still work;
a record pattern over either (InferenceOptions(var model, var max))
needs the third component. -
The
openaiwire is nowopenai-chat. A custom provider with
nessy.providers.<id>.wire: openaifails at startup; writeopenai-chat.
Presets are unaffected, and the startup report now printsopenai-chat. -
OpenAiInferenceProvideris nowOpenAiChatInferenceProvider, and
OpenAiProviderConfigis nowOpenAiChatProviderConfig. Same factories,
same setters, same behaviour. -
OpenAiRequestsis no longer public. It was the chat adapter's
internal projection and is now the package-privateOpenAiChatRequests. -
DefaultEmbedderFactory's three constructors are gone; build it with
DefaultEmbedderFactory.of(f -> f.provider(id, provider).embedding(id, EmbeddingOptions.of(model))). Every embedder it mints is now an
ObservedEmbedder. -
An embedding provider holds nothing about a model.
model(...)and
dimension(...)are gone fromOpenAiEmbedderConfig,
GeminiEmbedderConfig,BedrockEmbedderConfigandVoyageEmbedderConfig,
anddefaultModel()/defaultDimension()from the four providers. The
DEFAULT_MODELconstants stay, to cite:EmbeddingOptions.of( OpenAiEmbedderConfig.DEFAULT_MODEL). -
An embedder that asked for a width and got another fails, naming both
(asked for 256 coordinates, the model returned 768), on every call. -
A Bedrock embedder over a model of neither family fails when it is
made, not at its first call. -
nessy.embedding.*is replaced bynessy.embedders.*, with no
aliases:old new nessy.embedding.openai.modelnessy.embedder: openai+nessy.embedding-model: <model>nessy.embedding.openai.dimensionnessy.embedding-dimension: <n>openai.api-key+openai.base-url+nessy.embedding.openai.modela custom embedder ( nessy.embedders.local.wire: openai,.base-url,.api-key,.vendor) plus the pair naming it; or theopenaipreset withopenai.base-url, plus the pairnessy.embedding.gemini.modelnessy.embedder: gemini+nessy.embedding-model: <model>nessy.embedding.gemini.dimensionnessy.embedding-dimension: <n>nessy.embedding.voyage.api-keyvoyage.api-key(VOYAGE_API_KEY), ornessy.embedders.voyage.api-keynessy.embedding.voyage.modelnessy.embedder: voyage+nessy.embedding-model: <model>nessy.embedding.voyage.dimensionnessy.embedding-dimension: <n>a key alone, with the vendor's default model a key registers the provider; minting an embedder needs a model, from the store or the pair -
VoyageEmbeddingAutoConfiguration,OpenAiEmbeddingAutoConfiguration
andGeminiEmbeddingAutoConfigurationare replaced by
EmbeddingProvidersAutoConfiguration; update any
spring.autoconfigure.excludenaming them. Several keys set now register
several embedders instead of the first in auto-configuration order. -
The starter's
EmbedderFactorybean is always present. A store that
decided "no embeddings" by the factory's absence now asks whether
nessy.embedderis set. An applicationEmbedderFactorybean still
replaces the starter's factory, and no longer switches the presets off. -
Anthropic's typed setters are removed.
AnthropicProviderConfig
thinking(boolean),thinkingBudget(int)andpromptCaching(PromptCaching)
are gone, as is thePromptCachingenum; so areAnthropicRequests.Features
andAnthropicRequests.toParams(InferenceRequest, Features, JsonMapper). Use
property(AnthropicProperties.THINKING_TYPE, AnthropicThinkingType.ENABLED),
THINKING_BUDGETandCACHE_TTL. Thinking and caching stay off unless a
property turns them on. -
nessy-memory-summarizingis removed, withHeadSummarizer,
JdbcSummariesand the tablenessy_summary. Chapters replace it, and are on
by default. Existingnessy_summaryrows are no longer read. -
nessy-memory-episodicis removed, withEpisodeSummarizer,
EpisodeTools,JdbcEpisodes, the toolsbegin_episodeand
recall_episode, and the tablenessy_episode. Chapters replace it; to let
the model draw the boundaries, useDeclaredChapters.feature(histories)and
itsbegin_chapter. Existingnessy_episoderows are no longer read. -
Summarizeris a different interface. The read-sideSummarizer
(forAgent,summarizedThrough) is gone;org.jwcarman.nessy.api.Summarizer
now has one method,String summarize(Chapter), and writes a chapter's
summary. A source that supplied recollections to a turn is aMemorySource. -
summaries(...)is removed fromHarnessConfigandContextConfig. Use
chapterPolicy(...)andsummarizer(...)to decide what stands in for old
history, ormemory(...)to offer recalled text. -
Block.SummaryContentis removed. ASummaryis now
Summary(Chapter chapter, String text). -
InferenceContexthas new components:(summaries, tail, memory, state, activeTurn, ambient). A provider adapter that reads its parts must place
the new strata; see the providers guide. -
Ambient background is no longer in the system prompt. An adapter written
against the old context, or an application that relied on ambient text
being part of the system field, sees a different request. -
SystemPromptSourceis removed, withsystemPrompt(SystemPromptSource)
on both doors and onReplConfig. UsesystemPrompt(String), adding
sections withinstructions(String); anything that varied per agent or per
call becomes aStateSourceor anAmbientSource. -
PromptVariableSourceis removed. Its factories are onPromptVariables
(of,supplied,firstOf,none), which no longer takes an agent.
EnvironmentVariables.of(...)returns aPromptVariables. -
TemplatedSystemPrompt.of(...)is replaced byrender(...), which
renders once and returns aSystemPrompt. -
The starter's
SystemPromptSourcebean is now aSystemPromptbean,
andPromptVariableSourcebeans are no longer consulted: declare
PromptVariablesbeans. -
A backend implementation must provide
chapters()andleases()on
DirectBackendandQueuedBackend.
Added
InferencePurpose, what a model call is for, with the supplied values
ANSWERandSUMMARY.InferenceRequestgainspurpose(ANSWERunless
it says otherwise) andwithPurpose, andProseSummarizersendsSUMMARY.
Thenessy.inference.purposekey is on the model-call span and on the
gen_ai.client.token.usageandgen_ai.client.operation.durationmetrics,
so spend and cache use can be split by purpose. It is never sent to the
vendor.InferenceResult.Truncated, a reply the vendor cut off at the output
limit. It holds the blocks written before it stopped, at least one of them
text. All five adapters return it (Anthropicmax_tokens, OpenAI chat
length, OpenAI Responsesmax_output_tokens, GeminiMAX_TOKENS,
Bedrockmax_tokens). An agent's turn delivers it as the answer, logs a
WARN and reportslengthas the finish reason.AnthropicThinkingType.BETWEEN_TOOLS(anthropic.thinking.type: between_tools), sent as the vendor names it. It is how thinking is turned
off on a model that thinks with no thinking field and refusesdisabled, as
Sonnet 5.5 does: the model does not think before it responds.InferenceRequest.oneOff, set withasOneOff(): a request that
belongs to no series, so nothing it sends will be sent again. The Anthropic
adapter sends no cache markers for one, whatever
anthropic.cache_control.ttlsays, andProseSummarizersends one-offs, so
a chapter's summary no longer pays for a cache write nobody reads.JsonSchemaGenerator.generate(Type), which keeps type arguments:
List<Item>is described with typed items. TheClassform stays.- Spring Boot: strict tools by default on the measured chat-wire presets.
xai,groq,mistral,openrouter,nvidiaandcerebrasnow send
strict tools by default, overridable with
nessy.providers.<id>.properties.openai.tools.strict: false. OpenAiResponsesInferenceProvider, an adapter for OpenAI's Responses
API, innessy-inference-openaibeside the Chat Completions one. It is
stateless (store: false, the whole context on every call), keeps each
encrypted reasoning item as aBlock.Providerblock, and sends function
tools in strict mode, falling back per tool with a warning when a schema
cannot be expressed strictly. Reasoning models such as GPT-6 call tools
through it.- Spring Boot: a
cerebraspreset, lit bycerebras.api-key, at
https://api.cerebras.ai/v1on theopenai-chatwire, sending strict tools
by default (openai.tools.strict=true). - Spring Boot: the
openai-responseswire. A custom provider, or the
openaipreset withnessy.providers.openai.wire: openai-responses,
builds the Responses adapter. - Vendor properties.
InferenceConfig.property(name, value)sets a
vendor-prefixed setting on an agent type (openai.reasoning.effort,
anthropic.thinking.budget_tokens,
gemini.generationConfig.thinkingConfig.thinkingBudget,
bedrock.inferenceConfig.temperature); each adapter supports the names it
lists, parsed into the SDK's typed fields with a bad value refused when the
harness is built. Any other name under the adapter's own prefix is ignored
and logged atWARN, once, naming the property and the supported names;
another adapter's prefix is logged atDEBUG. Every provider config and
EmbedderConfigtake properties too, though no embedding adapter supports
one yet. - Typed vendor properties.
VendorProperty<T>innessy-apideclares a
property's name and type once (ofInteger,ofBoolean,ofFloat,
ofEnum), andproperty(VendorProperty<T>, T)on
InferenceConfig,EmbedderConfigand each provider config sets it in
code, stored as the same text the by-name form carries. Each inference
adapter publishes its constants (OpenAiProperties,AnthropicProperties,GeminiProperties,
BedrockProperties), and every fixed value set is an enum:
OpenAiReasoningEffort,OpenAiReasoningSummary,OpenAiServiceTier,
AnthropicThinkingType,AnthropicCacheTtl,AnthropicServiceTierand
GeminiThinkingLevel. A value outside an enum fails at build listing the
spellings;openai.service_tier=ultrafastis carried by the Responses
wire only, and the chat wire refuses it at build. Boot binding is
unchanged: YAML values are still text. InferenceProvider.validate(InferenceOptions)and
EmbeddingProvider.validate(EmbeddingOptions), default no-ops, which
the factories call when a harness or embedder is built.VendorPropertiesinnessy-api, which adapters read properties
through. The BOM now also listsnessy-inference-spiand
nessy-embedding-spi.openai.tools.stricton the chat adapter: function tools go out in
strict mode over a rewritten schema.- Spring Boot:
nessy.providers.<id>.properties.*. - Named embedders.
DefaultEmbedderFactory.of(...)over
EmbedderFactoryConfig:provider(ProviderId, EmbeddingProvider)
registers an embedding provider by name,embedding(ProviderId, EmbeddingOptions)sets the default,observations(...)gives every
embedder it mints its spans.EmbedderConfig.provider(...)names the
provider a store wants; an unknown or missing one fails when the embedder
is made, listing what is registered. - Spring Boot:
nessy.embedders.<id>. Presetsopenai,geminiand
voyagelight from a vendor key when the vendor's embedding module is on
the classpath; custom embedders statewire(openai,gemini,
voyage),base-urlandapi-key; applicationEmbeddingProvider
beans join under their bean names;nessy.embedder+
nessy.embedding-model(+ optionalnessy.embedding-dimension) name the
default;EmbeddingReportlogs what lit.VOYAGE_API_KEYlights the
voyagepreset. OpenAiEmbedderConfig.vendor(String), for a customopenai-wire
embedder that is somebody else.Dimension, inorg.jwcarman.nessy.api.embedding: how many
coordinates a vector has, at least 1.EmbedderConfig.dimension(Dimension)
takes it;dimension(int)remains as the convenience.- Chapters. History is cut into chapters when a turn ends, and a closed
chapter is replaced in the context by a summary.ChapterPolicysays where
a chapter ends (ends(OpenTurns), withChapterPolicy.every(int)) and
Summarizerwrites the text that stands in for one
(summarize(Chapter));ChapterandSummaryare in
org.jwcarman.nessy.api.turn. Both run off the agent's thread, under a
nessy.chapterslease, driven by the engine'sChapterKeeper. Closed
chapters and their summaries are kept by the backend, asChaptersin
nessy-backend-spi(reached asbackend.chapters()) and, for JDBC, the
tablenessy_chapter, whichSchemas.initializecreates. A chapter that is
closed but not yet summarised is still shown as turns, and a failed summary
is tried again at a later turn end. - Chapter settings.
chapterPolicy(...)andsummarizer(...)on the
harness config;maxChapterLength(int)(default 30),
chapterLeaseTtl(Duration)(default two minutes),maxTail(int)and
withoutChapters()on the context config. A harness refuses to build when
maxTailis not greater thanmaxChapterLengthwith chapters on. ProseSummarizer, the default summariser: a prose record of the
chapter, written by the agent's own provider and model.DeclaredChapters, which lets the model say where chapters begin:
DeclaredChapters.feature(histories)installs the toolbegin_chapterand
a policy that closes the chapter before any turn that called it.- The context has six strata, in a fixed order: instructions; history
(summaries of closed chapters, then the tail of completed turns); memory;
state; the active turn; ambient.InferenceContextis now
(summaries, tail, memory, state, activeTurn, ambient). MemorySourceandStateSource, besideAmbientSource, with the
recordsMemoryandStateandmemory(...)andstate(...)on the
harness and context configs. Both are asked with
forAgent(AgentId, Turn current), once per call, and nothing holds an
earlier answer for them. Two sources of the same stratum may not offer the
same kind.instructions(String)onHarnessConfig, which adds a section after the
system prompt, fixed when the harness is built.PromptVariables.supplied(...),firstOf(...)andnone(), and
TemplatedSystemPrompt.render(...), which renders a template once.- Spans for chapters and sources:
nessy.summaryfor each summary
written,nessy.chapter.policyfor each question put to the policy, and
nessy.context memory <kind>andnessy.context state <kind>beside the
ambient ones. nessy-examples/chapter-lab, a command-line lab that replays a long
recorded conversation under different chapter policies and summarisers.Stringifier<T>,String stringify(T value), withdropTail,
dropHeadanddropMiddleto bound what it writes,truncatedto bound it
with aTruncatorof your own,Stringifier.byToString()and
Stringifier.json(mapper).Truncatorcuts a string to a limit and
supplies the three droppers.ToolConfig.result, aStringifier<ToolResult.Success>that says what a
call returned, withToolConfig.resultText()as the default, plus
ToolConfig.LINE_CAP(1,000) andToolConfig.DEFAULT_LINE_LIMIT(255).- Two lines stored for each tool call:
ActionRequest.ToolCall.action,
written when the model asks, andToolSucceeded.rendered, written when the
call succeeds. Each is at most 1,000 characters and is never worked out
again. Exchange.actionOf(CallId)andExchange.resultOf(CallId), the two
lines for a call in a turn's history.- chat-web:
chat.summary-model(CHAT_SUMMARY_MODEL_ID) names a second
model on the same provider to write chapter summaries. The local default
model of chat-web, watchman and chat-cli isqwen/qwen3-coder-30b.
Changed
- Listeners hear an agent's events after the step that wrote them commits.
Both doors write an agent's events in short locked steps, which on a
database are transactions, and used to tell listeners inside the step. What a
step narrates is now held until its lock returns and delivered then, in the
order the steps committed in for that agent; narration from outside any
step, such as streamed deltas, keeps its place behind a step reserved
before it. Another agent's narration is never held up. The chapter keeper
no longer waits for the turn it was told about to become visible, because
it already is. - A reply cut off at the output limit is
Truncated. It was anAnswer. - A tool call cut off at the output limit is a
Fault. It wasActions,
carrying arguments that could parse as{}and run. - Gemini's
MALFORMED_FUNCTION_CALLis aFaultthat says a tool call was
malformed and was not run. It was the empty-answer fault. Gemini reports a
tool call cut off at the output limit this way. - A chapter summary cut off at the output limit is refused. It used to be
stored. The chapter stays unsummarised and is tried again when a later turn
ends, so an agent type'smaxTokenshas to leave room for the summary.
While a summary does not fit, that chapter and every chapter after it stay
unsummarised, and once more thanmaxTailturns have completed since the
last written summary, the oldest of them are sent neither as a summary nor
in the tail. - The
openaipreset sends strict function tools
(openai.tools.strict=trueby default). Set
nessy.providers.openai.properties.openai.tools.strict: "false"to turn
it off, for instance whenopenai.base-urlpoints at a server that
rejects strict mode. - Anthropic
enabledthinking with no budget sends 1024 tokens instead of
refusing at build; the budget must still be belowmaxTokens. - Ambient no longer travels in the system prompt, on any provider. The
system field carries the instructions alone. Memory and state are text at the
head of the active turn's first message, tagged<memory kind="...">and
<state kind="...">, and ambient is text at the very end of the request. On
Anthropic the cache markers are chosen before the ambient text is appended,
so none sits on it. A consequence on Anthropic: with any ambient background
present, the vendor drops replayed thinking blocks on later calls. - Chapters are on by default. An agent that sets nothing gets a chapter
every 20 turns, summarised by its own provider and model, so it spends
tokens on summaries unlesswithoutChapters()is set. - The default
maxTailis 40 on both doors. It was 50 on the direct door
and 20 on the queued one, so a queued harness now shows more. It counts
completed turns; the turn being answered is sent besides. - The system prompt is fixed when a harness is built. It was resolved on
every call. What varies by agent belongs in aStateSource, and what
varies by the moment in anAmbientSource. - Spring Boot renders
nessy.system-promptonce, at startup, from every
PromptVariablesbean and then theEnvironment, and publishes a
SystemPromptbean. A hole nothing fills fails startup. - A chapter is summarised from text.
ProseSummarizerwrites the chapter's
turns out as lines, each call asassistant did: <action> -- succeeded: <result>, and sends them as one user message. Nothing changes for the turn
being answered: the adapters still receive every call and result whole. - A failed call's message is at most 1,000 characters, its middle dropped
and...in the gap. That is the text the model reads back for the call. - A refused turn reads as one line in a chapter's transcript,
(a message was withdrawn), and a line that runs onto more than one is
indented four spaces after its first, so the summariser can tell where each
line ends.
Fixed
- A listener could be told about a step that then rolled back, or that failed
to commit. A step that does not commit is now never heard, unless the harness
was called inside an application's own transaction, which its steps then join. - A chapter that was due at a turn's end could be left open, or unsummarised,
until a later turn when two turns ended close together. The keeper that
found the agent's lease held walked away, and the keeper holding it had read
the history before the later turn, or had already listed the chapters to
summarise. The keeper that held the lease now reads the history again when it
lets go and goes round again for anything that became due meanwhile, and
re-reads the unsummarised chapters after each summary. Nothing waits on a
lease. - A chapter whose turns called a tool could not be summarised on Anthropic.
The summary request carried tool history and offered no tools, and the model
answered nothing. - Anthropic: a changed prefix no longer rejects a request that replays
thinking. With thinking on, the adapter now sends
thinking.block_binding.prefix_mismatch_behavior: drop_blockand the
thinking-binding-controls-2026-08-01beta header. Claude Fable 5.1,
Opus 5.5 and Sonnet 5.5 bind a thinking block to everything before it, and
accounts created since 2026-08-31 got a 400 when a request replayed
thinking and the tail had moved or the background had changed. The
request now asks the vendor to drop a thinking block whose prefix has
changed, which is what makes a moved tail or changed background an answer
and not a refusal. On older accounts, where such blocks used to reach the model
unchanged, they are dropped too. The adapter logs how many blocks were
dropped, at DEBUG. A request that does not think, a summariser's for
instance, no longer replays thinking at all. - Anthropic: prompt caching no longer fails a turn that uses tools. With
anthropic.cache_control.ttlset, a turn's third model call (two tool
rounds in), or any request after a turn that used tools twice or that
failed, threwArrayIndexOutOfBoundsExceptionwhile the request was being
built. The conversation's two cache markers now sit on the last block of
the request, which in a tool loop is the newest tool result, and on the last
block of the user-side message before it, which is where the previous
request ended. - Answers whose type is a list, enum, string or sealed type work on OpenAI
and Azure. A schema whose root is not an object travels wrapped as
{"value": ...}and is unwrapped before the caller sees it, and generic
answer types keep their generics. - Episode ranking no longer fails on a stored summary of another width.
A summary embedded under the same model name at a different width ranks
last, like another model's, instead of failing the question. - A width of zero or less is refused where it is set.
dimension(0)and
nessy.embedding-dimension: 0fail at once; the property's failure names it. - A late answer to a call could be recorded as the answer to a later call
with the same id. A call id can repeat across two requests of one turn,
and the Gemini adapter mints ids by position when the vendor sends none. A
tool that finished after its deadline had been given up on, or an approval
that came back late, was taken for the second request's call. An answer to
a tool call or an approval now names the request it answers, and one for
another request is ignored.
Documentation
- Context, a concepts page for the six strata, chapters, and how to keep
a conversation cacheable: what caching costs and saves, each Claude model's
minimum cacheable size, and how to choose a chapter size, with measured runs. - 12-Factor Agents, a factor-by-factor check of Nessy against the
12-Factor Agents principles. - Every example on the site compiles, and every command runs.
Known issues
- The Spring Boot starter does not close inference clients at shutdown. Fixed for 0.4.0.
- LM Studio returns an empty answer when a reasoning model is asked for a structured answer (lmstudio-bug-tracker #1773). Set
openai.reasoning.effort: noneon the provider; see the providers guide.