Repository navigation
Releases: jwcarman/nessy
Release list
0.7.0
Breaking and upgrade notes
Read these before upgrading from 0.6.0.
nessy_agent_turnhas a newlabel VARCHAR(1000) NOT NULLcolumn. The schema never alters
an existing table, so a database created by 0.6.0 must drop and recreatenessy_agent_turn(or
be recreated) before 0.7.0 starts; otherwise every turn's ending fails to commit. An application
withnessy.initialize-schema=falsemust add the column itself.AgentTurnhas a newlabelcomponent aftertrajectoryJson. A customAgentTurns
implementation must store it and return it.- A label is a category.
inputLabelnames the kind of work an input starts, from a small set
of values, and is stored plain, unencrypted. An application whose label carries input content
must change it before upgrading: from 0.7.0 the label is written in plain text to
nessy_agent_turn. A label containing NUL, or an unpaired surrogate, is stored with U+FFFD in that
character's place.
Added
- The task label on every turn row. The label an application gives an input is stored in
nessy_agent_turn.label, so trajectories group by the kind of work.
0.6.0
Breaking and upgrade notes
DirectBackendandQueuedBackendhave a new method,turns(), which returns the
AgentTurnsstore. An application with its own backend implementation must add it; the JDBC and
in-memory backends already do.- The database must use UTF8 encoding (the PostgreSQL default).
nessy_agent_turn.trajectory
stores tool names as text, so a name outside the database's character set would stop a turn's
ending from committing. - An application with
nessy.initialize-schema=falsemust createnessy_agent_turn, the
table of completed turns, with the rest of its schema; the DDL is innessy-schema.sqlin the
JDBC backend jar. - An application with
nessy.initialize-schema=falsemust createnessy_noteand
nessy_plan_task, the notebook's and the plan's tables, with the rest of its schema; the DDL is
nessy-schema.sqlin the notebook jar and in the planning jar. - The notebook index is present when it is empty, and the
remembertool's description says
what a note is for.
Added
- Trajectory fingerprints. Every completed turn gets a deterministic, versioned digest of its
behavior: which tools ran in which rounds, what each came to, and how the turn ended, with
inputs, arguments, results, timing and cost left out. It is computed in the fold and written to
the newnessy_agent_turntable in the same transaction as the turn-ending event, with the
turn's round, tool and inference counts beside it; the same values are tagged on the turn's span.
TurnOutcomeandTrajectoryare the new public types;AgentTurnsis the new backend store.
Seedocs/concepts/trajectories.md. - The readable trajectory. Each
nessy_agent_turnrow now carries its trajectory as JSONB
beside the hash: the rounds of tool calls with their outcomes, then how the turn ended. A query
reads behavior directly, and@>finds turns by what they did. - The notebook installs itself. With
nessy-memory-notebookandnessy-backend-jdbcon a Spring Boot application's classpath,
every agent of both doors gets the notebook index andremember,revise,recalland
forget.nessy.notebook.enabled=falseturns it off, and an application's ownNotebookbean
keeps the starter's out. - The plan store installs itself. With
nessy-planningandnessy-backend-jdbcon a Spring Boot application's classpath,
every agent of both doors gets the ambient current plan andupdate_plan.
nessy.plan.enabled=falseturns it off, and an application's ownPlansbean keeps the
starter's out. QueuedHarnessFactoryConfig.feature(...)adds a feature to the queued factory, as the direct
factory's configuration already did.- Both harness auto-configurations collect every
Customizer<HarnessConfig<?>>bean and apply
it as a feature to each harness they create. NotebookTools.feature(Notebook)installs the index and the four tools in one call.PlanTools.feature(Plans)installs the ambient plan andupdate_planin one call.
0.5.0
Breaking changes
OutcomeisAskOutcome.DirectHarness.askreturnsAskOutcome<O>, with the same arms
Answered,Refused,FailedandBusy, and a fifth,Terminated. Asking a terminated agent
returnsAskOutcome.Terminated, notRefusedwith the categoryterminated: no turn runs,
nothing is appended, and the outcome carries noTurnStats. Aswitchover the outcome must
handleTerminated.QueuedHarness.tellreturns aTellOutcome.Acceptedmeans the agent took the input: it
was handed to the backlog policy, whatever the policy then did with it, and a turn started at
once when the agent was idle and the policy left something waiting.Terminatedmeans the agent has been terminated, including one whose last turn is still in
progress: the input was dropped, nothing was stored and the dispatcher was not nudged. It used to
return nothing and drop the input without a word. An implementation ofQueuedHarnessmust
return one.- A terminated agent is called terminated.
TerminationOutcome.Endedis
TerminationOutcome.TerminatedandTerminationOutcome.AlreadyEndedis
TerminationOutcome.AlreadyTerminated. Code that switches on the outcome must use the new
names. - The reply token is removed.
ReplyToken,ApprovalRequest.replyToken()and its record
component,ToolCallRequest.replyToken(), the engine'sReplyTokens,
QueuedHarnessFactoryConfig.replyTokens(...)and thenessy.reply-token-encryption-keys
property are gone.ApprovalRequest's constructors lose thereplyTokenargument. Nessy does
not check who is answering: an application guards the endpoint that callsReplies. - A late answer is addressed by agent type, agent id and the call's key.
Replies.approveand
Replies.completetake anAgentType, anAgentIdand anIdempotencyKey, all required,
where they took aReplyToken.ReplyOutcomeisAppliedorIgnored:Appliedmeans the
answer changed the agent's state, andIgnoredmeans it did not, whatever the reason.Settled,
NotAwaitingandUnreadableare gone. An approval request and a tool call request already
carry the three values; a desk that stored a token stores them instead. - An approval's facts are stored on its events.
ToolApproved,ToolDeniedandToolFailed
gain afactsfield, a JSON object, beforeidempotencyKey, and the stored
failure response on an effect row gains it too. Stored events and failure responses change
shape; recreate the database. A customStoryContentmust implementapprovalFacts. - Stored events and stored effect attempts changed shape. The five model-call events gain a
manifestfield, and a failed attempt kept on an effect row gainsmanifest. Recreate the
database. nessy_agent_effectgained a nullableparked_atcolumn, andEffectsgains
park(effectId, attemptsMade, at), so a custom implementation must add it. Recreate the
database.- A custom
Effectsmust addliveForandparkedNow.Effects.liveFor(type, agent)lists
one agent's live effect rows andEffects.parkedNow(type, now, after, limit)pages the rows that
are waiting on an answer atnow, both as the newLiveEffect. A row that is parked, claimed
and not yet at its deadline is the oneLiveEffect.parkedNowcalls waiting. - A custom
QueuedBackendmust addqueued(type, agent), the number of inputs waiting in the
agent's queue. - A custom
QueuedHarnessFactoryandDirectHarnessFactorymust addwork(), which returns
the factory'sAgentWork. - A model response that repeats a call id is a failed inference. Two tool calls in one
response with the same call id make the response invalid. The turn fails as it does for any
failed inference, with a permanent failure that names the repeated id, and the response's usage
is on the record. Nothing of the response is stored, narrated or requested, and no approver is
asked and no tool runs. The engine logs one error naming the agent, the turn and the id. nessy_payloadgained akindcolumn. A payload holds message blocks or a JSON document,
andkindsays which:BLOCKSorDOCUMENT.Payloadsgains two abstract methods, so a
custom implementation must add them. Recreate the database.- Narration is reshaped into the agent's story.
Narrationhas two groups,Story(stored
events) andLive(signals heard only as they happen).TurnEndedis removed: a turn ends in
exactly oneTurnEndingevent (Answered,TurnRefused,TurnFailedorTurnStopped), and
NarrationListenerConfig.onTurnEndinghears all four. A turn a policy stopped isTurnStopped,
no longerTurnFailed.Answered,TurnRefused,TurnFailedandActionsRequestedcarry
their turn and the model call'sUsage;TurnFailedcarries aFailureKind; each requested
call carries itsIdempotencyKey. A retried model call is told asInferenceRetried. - A deferral is a stored event and a story event.
AgentEventgainsApprovalDeferred(seq, turn, callId, until, facts, idempotencyKey)andToolDeferred(seq, turn, callId, until, idempotencyKey), stored asapproval-deferredandtool-deferred; recreate the database. The
narrationsApprovalDeferredandCallDeferredareStoryevents, told from those stored
events, and have the shape(callId, idempotencyKey, until): they carry noactionor
toolName, and a watcher joins by key to theActionsRequested.Callthat has them. - Every call event carries the call's
IdempotencyKey.ToolApproved,ToolDenied,
ToolSucceededandToolFailedgain a trailingidempotencyKey, the key the call was
requested with; the stored shape changes, so recreate the database. The four narration records
CallApproved,CallDenied,CallFinishedandCallFailedgainidempotencyKeyafter
callId. - A failed call says why it failed.
AgentEvent.ToolFailed,EffectOutcome.ToolFailed,
AgentCommand.ToolOutcome.FailedandNarration.CallFailedgain aCallFailure kindbefore
message:FAILED,PAST_DEADLINEorNOT_AUTHORISED. The storedtool-failedevent has a
kindfield; recreate the database.PAST_DEADLINEis what a tool call that does not finish in
time reads on both doors: the queued door's expiry, and the direct door cutting a running call
off. - An answer says when it was cut off.
AgentEvent.InferenceAnswered,
EffectOutcome.InferenceAnswered,AgentCommand.InferenceOutcome.Answeredand
Narration.Answeredgain aboolean truncated, true when the model was cut off at its output
limit. The storedinference-answeredevent has atruncatedfield; recreate the database. The
caller ofaskreceives a cut-off reply exactly as before. - An approval records who decided, not a reference.
ApprovalResult.reference()is
decidedBy(), andapprovedByanddeniedBytake adecidedBy: who or what decided, as the
application says it. Nessy never interprets it. The same rename runs throughEffectOutcome.ToolApproved
andToolDenied,AgentCommand.ApprovalOutcome.ApprovedandDenied, and the stored
ToolApprovedandToolDeniedevents, whose JSON field is nowdecidedBy; recreate the
database. The narration recordsCallApprovedandCallDeniedgain a trailingdecidedBy. The
argument toapprovedByanddeniedByused to be a reference and is now who decided: code that
passed a ticket or decision id still compiles, and now records that id as the decider. NarrationListener.ontakes aNarratedenvelope: the agent, the event, and for a story
event its position (seqand time written).NarrationListenerConfig.Handleris
on(Narrated narrated, E event), andNarrator.narratetakes aNarrated. A story event's
time is the engine's clock, passed to the store:AgentEvents.appendtakes a trailing
Instant at, which a store writes as given rather than reading a clock of its own, so an event
heard live and read back later carry the same instant.InMemoryAgentEventsno longer takes a
Clock.AgentNarrator.narratetakes only aNarration.Livesignal.- The JSON and SSE names of the turn endings changed.
turn-endedis gone;turn-stoppedand
inference-retriedare new. A browser client that listened forturn-endedmust listen for the
four endings:answered,turn-refused,turn-failedandturn-stopped. - The stored event for a turn a policy stopped is
turn-stopped; it wasturn-failed. It is
AgentEvent.TurnStopped, no longerAgentEvent.TurnFailed. Recreate the database. NarrationListenerConfig.on(Class, handler)hears every member of a group. Given a group
type (Narration.TurnEnding,Narration.Story,Narration.LiveorNarration), it now hears
each event of that group, not only an event of exactly that class.- A stored event's
written_atis the engine's clock reading for its step, to the microsecond,
not the database'snow(). - A turn's start says what started it and when its input arrived.
AgentEvent.TurnStarted
gainslabelandarrivedAtbeforestartedAt;AgentCommand.StartTurngainslabeland
arrivedAtbeforeat;Narration.TurnStartedgainslabelandarrivedAt. The stored
turn-startedevent haslabelandarrivedAtfields; recreate the database. On the queued
doorarrivedAtis whentellwas called, and on the direct door whenaskread its clock.
Added
AgentWorksays what an agent is doing and what waits on a person.status(type, id)
returns anAgentStatus: anActivity(IDLE,WORKING,WAITINGorTERMINATED), the number
queued, the current turn, the approvals waiting, and the number of waiting tool calls.
waitingApprovals()andwaitingApprovals(type)list the approvals parked on people, each as
theApprovalRequestits approver...
0.4.0
Breaking changes
ApprovalRequest.callKey()is replaced byidempotencyKey(), a typed
IdempotencyKeywrapping a UUID.callKey()wasturn/callId: unique only
within one agent, and not even there when a model repeats a call id within
a turn. The new key is made once per call when the model's request is
recorded, stays the same across retries and restarts, and is unique across
every agent.ToolCallRequest.idempotencyKey()carries the same key, so a
tool can deduplicate on it and find its own approval. Stored events and
effects change shape; recreate the database.DirectHarness.askrefuses to run inside a caller's transaction. It
throwsIllegalStateExceptionbefore writing anything. A turn makes a model
call, and a transaction should not stay open across a network call; on JDBC
the call failed anyway, with "no event at 1 for agent ...". Suspend the
transaction for the call (PROPAGATION_NOT_SUPPORTED). The queued door's
tellstill joins the caller's transaction.nessy-enginenow depends on
spring-txfor this check, as it already does onspring-context.- Anthropic prompt caching is on by default. With
anthropic.cache_control.ttlabsent, a request now carriesFIVE_MINUTES
cache markers; before, it carried none. A one-off request still carries
none, and neither does a request that asks for an answer
(ToolChoice.Answer), which is sent without its tools. Narration.ActionsRequestedcarries oneCallper requested call
(callId,toolName,action) in place of tool names alone, so a watcher
can join each later call event, which names only the id, to its tool.Turnhas notokensfield. It was always 0 and nothing read it.
Usage is reported as a whole, throughUsageReports.
Added
AnthropicCacheTtl.DISABLEDturns Anthropic prompt caching off. Set it on
the provider, or on one agent type to override a provider that caches.UsageReportsreads an agent's usage over its whole history, by model:
of(type, id)returns oneModelUsageper model, never added across
models. It is projected from the agent's stored events, so it counts every
inference, retries and failures included, and is fit for cost accounting,
which narration is not. The starter offers aUsageReportsbean.
Changed
- The OPA adapter and the policy example are tested against OPA 1.21.1. Every
response shape the adapter relies on is the same as on 0.68.0.
Fixed
- The watchman example no longer hides a repeated call id. Its approvals
board was keyed on the model's call id, so once an agent'scall_1was
answered, a latercall_1from the same agent was never shown and timed
out. The board is now keyed on the call'sIdempotencyKey, and its
approve and deny URLs carry that key. Dropwatchman_pending_approval(or
recreate the watchman's database) before running it. - Gemini no longer returns half a reply as the answer. A stream that
stopped before its finish reason was read as a complete answer. It is now a
fault. - A stream cut short is
Failure.Unknown, notPermanent, on Anthropic,
OpenAI Chat, OpenAI Responses, Bedrock and Gemini. A server that closes the
stream mid-answer is a dropped connection, so a retry policy can now try
again. A stream that delivers no answer at all is stillPermanent. - A dropped connection to the model can be retried. Every adapter
reports a dropped connection asFailure.Unknown, and the dispatcher sent
onlyFailure.Transientto the retry policy, so a dropped connection ended
the turn whatever the policy said. AnUnknownmodel failure now reaches
the policy too: a model call that runs twice changes nothing but the bill.
The default policy is stillNever; set one with
InferenceConfig.retryPolicyfor the queued door to try again. - The Boot starter closes every inference provider's client at shutdown.
Each provider bean is anObservedInferenceProvider, which was not
AutoCloseable, so the container never closed the adapter inside it and
the adapter's HTTP client leaked. The wrapper now closes what it wraps, as
ObservedEmbedderalready did. - A generated answer type works on Anthropic. Anthropic refuses an answer
schema with an object that does not say"additionalProperties": false,
and the schema generator writes none, so every generated answer type failed
with a 400. The adapter now closes every object in the answer schema, and
leaves one that already says what it allows as written. - The queued dispatcher can no longer stop for good. A claim that returned
more rows than its batch made the dispatcher release a negative count of
permits; the semaphore threw and the agent type never dispatched again. The
dispatcher now runs at most its batch and puts any surplus straight back on
the queue, and the JDBC claim picks and locks its rows in aMATERIALIZED
CTE, evaluated exactly once.
Known issues
- 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.
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
Sonne...
0.2.0
Breaking changes
InferenceProvider.providerName()is nowvendor(), and so are
Embedder.providerName()andEmbeddingProvider.providerName(). The value
is unchanged: the OpenTelemetrygen_ai.provider.name(openai,x_ai,
gcp.gemini, ...), which two providers can share.OpenAiProviderConfig.provider(String)
is nowvendor(String). Traces are byte-identical.- A harness factory holds its providers by name.
DirectHarnessFactoryConfig.provider(InferenceProvider)and
QueuedHarnessFactoryConfig.inference(InferenceProvider, InferenceOptions)
are replaced by a repeatableprovider(ProviderId, InferenceProvider)on
both configs, plusinference(ProviderId, InferenceOptions)for the
factory's defaults. Migrate
f.provider(p)tof.provider(ProviderId.of("openai"), p).inference(ProviderId.of("openai"), InferenceOptions.of("<model>")). - Every agent type needs a provider and a model, its own
(inference(in -> in.provider("openai").model("..."))) or the factory's
defaults. A missing or unregistered one fails when the harness is built,
naming the agent type and listing the registered providers. DirectHarnessFactory.providerName()is removed: a factory holding
several providers has no single answer.- Spring Boot:
nessy.providerandnessy.modelare a pair. Set both,
or neither and name them on each agent type; setting one fails at startup.
Several vendor keys set no longer means one provider picked by
auto-configuration order: every lit provider is registered. - Spring Boot: an application's own
InferenceProviderbeans no longer
switch the vendor presets off. They are registered beside them under
their bean names. A bean named like a lit preset fails startup. - Spring Boot:
OpenAiAutoConfiguration,AnthropicAutoConfigurationand
GeminiAutoConfigurationare replaced byInferenceProvidersAutoConfiguration.
Update anyspring.autoconfigure.excludethat names them.
Added
ProviderId, the name an application gives a provider;InferenceConfig.provider(ProviderId)
andprovider(String).- Spring Boot presets. A hosted preset is registered when its key is set:
openai(OPENAI_API_KEY),xai(XAI_API_KEY),anthropic
(ANTHROPIC_API_KEY),gemini(GEMINI_API_KEYorGOOGLE_API_KEY),
openrouter(OPENROUTER_API_KEY),nvidia(NVIDIA_API_KEY). A local one
when switched on:lmstudioandollama(nessy.providers.<id>.enabled: true).
Every preset was measured against its real endpoint before it shipped: a
tool call, reported usage, and a forced answer. nessy.providers.<id>.*(api-key,enabled,wire,base-url,
vendor) overrides any preset field; an id that is not a preset declares a
custom provider and must setwire(openai,anthropicorgemini) and
base-url.enabled: falseturns any provider off, key or no key.- The startup report lists every registered provider (id, wire, endpoint,
vendor, never the key), and each harness logs the provider and model its
agent type resolved to when it is built. PresetCandidatesLiveTest(@Tag("live")) measures OpenAI-shaped vendors
and the existing presets against their real endpoints from the keys in the
environment, and writestarget/preset-measurements.md.
Changed
- An agent type built from factory defaults that carry no max-tokens gets
4096 on both doors. - The console:
NESSY_PROVIDERandNESSY_MODELare required together; a
mistypedNESSY_PROVIDERis reported, not thrown./configshows the
provider only when the console chose it. - The examples name their provider: chat-web and watchman use the
lmstudio
preset by default; watchman's scripted mode isWATCHMAN_PROVIDER=scripted. nessy-bomkeeps its parent for the build and is flattened when published:
it manages only Nessy's own artifacts. Every module publishes the
repository's own URL and SCM connection.- Dependencies: openai-java 4.69.2, anthropic-java 2.65.0, google-genai
1.73.0, AWS SDK 2.55.5, Jackson 3.2.3 and 2.22.3, Netty 4.2.18, Tomcat
11.0.26, HikariCP 7.1.0, SLF4J 2.0.20.
0.1.1
Fixed
-
A turn ended by a
TurnPolicyreturningFailTurncould not be replayed,
which left the agent unusable rather than merely ended: every later
ask,terminate, or any other read reconstitutes from the event stream
and threwTurnFailed cannot happen in Inferring. The default policy is
TurnPolicy.calls(20, 25), so this reached any turn that made
twenty-five model calls without finishing.AgentState.Inferringnow acceptsAgentEvent.TurnFailedand returns to
Idle. The event is emitted byAwaitingActionsbut arrives in
Inferring, because the discharge that freed the last outstanding call is
applied first.
0.1.0
Nessy is an agent harness framework for Java. This is the first release.
Added
- Two doors.
DirectHarness<I, O>.ask(agentId, input)runs a turn on the
calling thread and returns anOutcome<O>—Answered,Refused,
Failed, orBusyif another turn is already running for that agent.
QueuedHarness<I>.tell(agentId, input)always accepts and returns nothing;
the turn runs later. Both doors exclude each other per agent with a lock
(Locks.TURN), so only one turn ever runs for a given agent at a time. - A decide/accept fold.
AgentStateis a sealed type —Idle,
Inferring,AwaitingActions,Terminal— that decides a command and
folds the resulting events. State is rebuilt by replaying events, so
recovery after a crash is the same code path as normal operation. - Storage backends.
DirectBackendandQueuedBackendare implemented
bynessy-backend-jdbc(PostgreSQL, viaSchemas.initialize) and
nessy-backend-inmemory. On the queued door, effects are rows performed
by a dispatcher; on the direct door, the calling thread performs them. - Turn policy and cost.
TurnPolicy.calls(20, 25)is the default: the
model is asked to answer at twenty calls and the turn fails at
twenty-five, communicated to providers asToolChoice.Answer. Every
Outcome.Answered/Refused/Failedcarries aTurnStatstally built
fromUsage(one model call) andTokens, which is eitherCountedor
Uncounteddepending on what the vendor reports. - Retries. Every retry default is
RetryPolicy.Never. A model call
reaches a configured retry policy only when its adapter classifies the
failure asFailure.Transient. - Structured output.
OutputReader<O>reads a model's answer into a
typed result;JsonSchemaandJsonSchemaGeneratorderive the schema
sent to the provider. - Model providers. Inference adapters for Anthropic, OpenAI (and any
OpenAI-compatible endpoint), Gemini, and Bedrock. Embedding adapters for
OpenAI, Gemini, Voyage, and Bedrock. - Tools and approvals.
Tool<I>binds a typed input and executes
against aToolCallRequest<I>.Awaitedlets a call answer immediately
or defer, and every call goes through anApprover.nessy-approval-risk
scores a call with NIST SP 800-30's likelihood/impact matrix and routes
it to auto-approve, auto-deny, or a human desk.nessy-approval-policy
delegates the decision to aPolicyEngine, withnessy-approval-policy-opa
speaking Rego to Open Policy Agent.nessy-approval-intentis a claim
channel for a model to declare what it is about to do before it does it. - Memory.
nessy-memory-episodiccuts a story into named episodes and
summarizes each as it closes.nessy-memory-notebookgives an agent notes
it recalls by heading.nessy-memory-summarizingwrites a background
summary of the head of a long conversation. - Planning.
nessy-planninggives an agent a plan it writes, holds
across turns, and works through, resent wholesale on every turn. - MCP tools.
nessy-tool-mcpimports a remote MCP server's tools as
ordinaryTools, over the official MCP Java SDK. - Observability.
NarrationandNarrationListenerreport what an
agent is doing as it happens;nessy-narration-odysseyjournals events
to an Odyssey stream so a page can subscribe, resume, and replay them as
Server-Sent Events. Async listeners run outside a turn's trace by design. - Spring Boot.
nessy-spring-boot-starteris a single dependency that
assembles a harness fromnessy.*properties and beans. Each door,
backend, provider, and embedder has its own@ConditionalOnMissingBean
auto-configuration innessy-spring-boot-autoconfigure, so an
application can override any bean without the starter's opinions. - Console.
nessy-consolebuilds a working terminal agent from a main
method in one call.
Requirements
- Java 25.
- Spring Boot 4.1 (optional — only needed for
nessy-spring-boot-starter).