Repository navigation
Changelog
Breaking changes and notable additions, newest first. The API reports
(packages/agboss/api/*.api.md) hold the exact public surface of each entry.
-
Fixed —
app.cancel(threadId)on any instance cancels the thread's execution running on another (Several instances → Cancelling from any instance): before, it returned{ cancelled: false }unless it reached the instance moving the tree. The cancel leaves a request under the reserved tree key@agboss/cancel-request; the holder stops withExecutionCancelledErrorwithin one lease renewal (10 s, or its next checkpoint write), and a request made while the holder is down ends the execution at recovery — on an instance of any config version — instead of continuing it. No public API change. -
Changed — tree keys starting with
@agboss/are reserved for the framework. - A cancel from another instance before the execution's
runningrecord exists (its budgets, its thread's compaction) returns{ cancelled: false }.
-
Added —
@Application({ maxConcurrentSteps })(Several instances → An instance's cap on steps): how many agent, router and action steps (launch branches too) one application instance runs at once, over every tree and tenant — a whole number from 1 ([limits.max-concurrent-steps-invalid]); unset → no cap (no change). A step without a free slot waits in the instance's queue in the tree queue's order (closing a branch first, then the deepest, aged every 10 s on theClock); a step in a provider's rate-limit queue keeps its slot; waiters atapp.close()are interrupted at the shutdown timeout and recovered elsewhere. -
Added —
HealthComponentKind.StepSlots(Health checks): the readiness componentstep-slots(only with a cap) —Degradedwhile every slot is busy,reason"32 of 32 step slots busy, 7 steps waiting"; liveness unchanged. - Changed — the recovery sweep of a capped instance starts at most its free step slots' worth of recoveries per look (none when full) and goes on where a cut-short look stopped.
-
agboss describeprintsmaxConcurrentSteps;describe --jsonhasapplication.maxConcurrentSteps(nullwhen unset).
-
Added —
maxResultSize(Tools → Result size): what a model reads of one tool result at most, in bytes of UTF-8 — on@Tool/@McpTooland indefaults.toolsof@Application/@Workflow(the most specific wins), default 128 KB. A longer rendered result (an error's too, a knowledge base's search tool too) is cut before PII masking, at the last whitespace within the final 256 bytes, and ends with\n[result truncated: N bytes, the first M kept]; a context-mode knowledge block is bounded by the agent's value.[tools.max-result-size-invalid]at assembly. -
Added —
kilobytes()andBytesinagboss/units(1 KB = 1,024 bytes). -
Breaking —
ToolEndEvent.isResultTruncated: booleanis required: an object built as aToolEndEvent(an observer test) sets it. -
Changed — every workflow's
configHashonce: the truncation marker is a framework text of the agent loop; a paused execution resumed on the new version isIncompatibleResumeErrorunlessonIncompatibleResumeaccepts it. -
Added —
agboss describeprints each tool'smaxResultSizeand where it came from;describe --jsonhasschemas.tools[name].maxResultSizeand each agent'smaxResultSize.
-
Added —
withdraw(withdrawal)on channels (Pauses → Taking a question back): the pair ofsend— a no-op onChannelOf(a role's implementation writesoverride), optional onChannelHandler;ChannelWithdrawal<TRole>(root entry) is{ pauseId, value, resolution: PauseCancellation }. Called once when a pause the channel sent closes without an answer —WithdrawnorCancelled— in the pause's frame, under its tool'stimeout; never for a pause decided at once or never sent. A throw or a timeout is the warningchannel.withdraw-failed. -
Fixed — a decided quorum withdraws its waiting branches' pauses: an execution whose
.joinQuorum(…)/.joinQuorumGroups(…)was decided while a branch waited on a channel rested paused (the join never ran); the pending pauses are now withdrawn (PauseResolution.Withdrawn) and the join runs in the same call. A deadline passing while the tree rests is acted on by its next pass (Parallel). -
Fixed —
FailFastwithdraws a paused sibling child's pause under.toParallelWorkflows(…)(and inside a child's own launch): the execution failed to fail, resting paused. A pause record hasbranchKeys(the launch branches it waits under;SqlitePausedExecutionRepositorykeeps them in its JSON column, a record stored before reads with its own step's key). -
Changed —
mockOf(Implementation)also replaces what a contract that resolves to that class gets (a channel role, anyextends): before, only a dependency on the class itself got the mock. -
PauseResolution.Withdrawnreads "aFailFastsibling failed, or its launch's quorum was decided".
-
Fixed — a root's
configHashcovers its child workflows (Terns and evaluation → What the config hash covers): the snapshot addschildren— every child reachable from the flow, recursively, each once by name,ThisWorkflowexcluded. A run paused or running inside a child that a deploy changed is nowIncompatibleResumeError/recovery.incompatible, decided by the root'sonIncompatibleResume, instead of continuing silently on the changed child (Several instances → Recovery). -
Breaking — hashes of workflows with children change once: every workflow that launches a
child gets a new
configHash(records, events,describe --json; on durable records the first start after the upgrade warns once that its version changed without a bump); runs of such a workflow paused or running before the upgrade are incompatible with it unless itsonIncompatibleResumesays"resume". Workflows without children keep their hash. -
Breaking —
IncompatibleResumeErroris anAGBossErrorwithstatic code = "resume.incompatible"(the same string; its instancecodeis typedstring), so.onError(AGBossError, …)anderror instanceof AGBossErrorinclude it.
-
Added —
agboss describe --jsonis each workflow's declarative snapshot (CLI → The declarative snapshot): next to what the text prints, every entry ofworkflows[]hasconfigHash(the full hash;configstays its first 8 characters),starts,finishes,nodes(kind, hashes,input/outputby DTO hash, an agent's model, provider, reasoning, limits, memory, tools, knowledge bases, the guardrails and PII policies in effect, judges and instructions; a router'sdecidedBy, model and routes withspawn/failure),edges, theonErrortable,launches(quorum, arbiters with their hashes) andschemas— every DTO by class and every tool by name with itsschemaHashand JSON Schema. -
Breaking —
ComponentRecordis{ name, kind, promptHash, schemaHash }(Observability → Join keys):kindis the newComponentKindenum (root entry);promptHashhashes what the component's model is told,schemaHashwhat it reads and writes (each DTO by its JSON Schema), eachundefinedwhen it has none — the valuesdescribe --jsongives. It is every record that names a component:event.step.component,executionContext.step.component,ExecutionOutput.finish,ExecutionEndEvent.finish, child records'start/finish,handledErrors'at/by,StepFailedError.component,VisitsLimitExceededError.component(its constructor takes the record) andBudgetExceededError.budget(kind: ComponentKind.Budget;BudgetBreach.budgetnames it bynameonly). A test comparing one withtoEqual({ name })compares withtoMatchObject. -
Breaking —
WorkflowRecordhasconfigHash: the workflow's fingerprint, the one join key — in every execution event,executionContext.workflow, child records and limit errors; there is nographHash. -
Breaking —
StepRecordhastoolLoopIterationandvalidationAttempts: an agent's model call in the visit (from 1, during the call and its move's tool calls;undefinedelsewhere) and its answers and tool arguments that failed validation so far (0outside an agent's loop). The idempotency key's format is unchanged (#335). A pause keeps both and its agent's record:record.pauses[].stepnames the iteration that asked (a pause stored before reads as an agent's record without hashes, iteration unknown). -
Not built — a branch's
{ verdict, status }onStepRecord(S93): its status and, under a quorum, its verdict are known only after the step ends; they stay inresult.launchesandonQuorumDecided, joined bynodeId,copyNumberanditemIndex(open question on #268).
-
Fixed — one value, one token across instances (Several instances → PII
tokens): without
@Application({ pseudonymKey })the tokens ofsensitivefields were made with a key of each instance, so after a pause resumed, a failover or a recovery on another instance one value got a second token in the same conversation. Now each root execution makes a key of its own at its start, keeps it in its checkpoint next topiiTokensand hands it to its child workflows; every instance that continues it gives one value the same token. WithpseudonymKey(or undertestWith, its fixed test key) nothing changes and no key is checkpointed; a checkpoint from before keeps the instance's key. No API change. -
Changed —
[application.pii-instance-key]'s text:no @Application({ pseudonymKey }): sensitive fields make PII tokens with a key of each execution — one value gets another token in each execution (in logs and traces: on each instance), so tokens compare across executions only with pseudonymKey(Environments → The PII key).
Unreleased — generators and naming: full names, tool names, timeouts with units, Execution… (#268, PR 4)
-
Breaking —
agboss generatetakes full names (CLI): the positional name ends with its kind (billing-agent,escalation-router,refund-tool,policies-rag,valid-score-arbiter,onboarding-workflow),--workflowis a workflow's full name (desk-workflow, found in the application ofpackage.jsonor--application, its children included) — no more--workflow src/x/x.workflow.ts— and--agentan agent's (billing-agent). Nothing is completed: a short name is exit 2 withDid you mean "billing-agent"?, an unknown workflow or agent too, and nothing is written.generatetakes--application <path>. -
Breaking —
agboss generate workflow <name>-workflowwrites the workflow beside the application file (src/onboarding/, with its test) and lists it in@Application({ workflows })— it no longer makes an application of its own; its scripts arechat:<name>/run:<name>by its start's full name. A name the application already has is a conflict (exit 4). -
Breaking —
agboss create <project>writes the application atsrc/<project>.application.ts(its path inpackage.json) with, beside it,environments/,config/budget.config.ts,model-providers/,guardrails/prompt-injection.guardrail.ts(now the application's,@Application({ guardrails, defaults: { guardrail } })) andservices/api.service.ts; the first workflow<project>-workflowinsrc/<project>/.--agents/--toolsand the questionnaire take full names ("triage-agent:Sorts requests","answerer-agent:search-orders-tool"); the default agent is<project>-assistant-agent, the knowledge base<project>-notes-rag. The scripts run a start by its full name ("chat": "agboss chat --start <project>-text-workflow-start"). -
Changed — generated code: tools
name: "<name>-tool", knowledge basesname: "<name>-rag"(class<Name>Rag, the index in the project's git-ignored.agboss/rag/, not the home folder), MCP tools<server>-<tool>-toolwith the server injected as<server>Server, MCP servers kebab-case, the OpenAPI client<Name>ApiServiceinjected as<name>ApiService, OpenAPI tools<operation>-tool; nomodels.ts. -
Breaking — tool names (Tools, Naming): a
@Tool/@McpToolname is kebab-case ending with-tool([name.kind-suffix]at assembly, with the name to use); every name a model sees fits[a-zA-Z0-9_-], at most 64 characters ([tool.name-too-long], new[tool.name-invalid]). A knowledge base's tool issearch-<rag name>-tool(wassearch_<name>). Every tool of job-scout, flow-patterns and the docs is renamed (order_status→order-status-tool,greenhouse_jobs→greenhouse-jobs-tool, …); job-scout's knowledge base iscompany-notes-rag. -
Breaking — timeouts have units:
@Tool({ timeoutMs: 5000 })/@McpTool({ timeoutMs })→timeout: seconds(5)(aMillisecondsvalue; default 30 s); a tool's timeout error readstimed out after 5 s. Internal:DEFAULT_TOOL_TIMEOUT,JEV_TIMEOUT. -
Breaking —
Execution…, notrun…(Naming):ExecutionOutput.runId→executionId;IncompatibleResume/IncompatibleResumeError{ thread, runId }→{ threadId, executionId };MemoryTurn.runId→executionId;RunId→ExecutionId,PausedRun→PausedExecution(PausedExecutionRepository),runAgent→executeAgent,RunDeps→AgentExecutionDeps,RunOptions→AgentExecutionOptions,runVersions→executionVersions,RunStreamEvent→ExecutionStreamEvent,RunTracing→ExecutionTracing. -
New — the Naming page: kind suffixes, tool names,
Workflow…vsExecution…, units, full names in the CLI.
-
Fixed — parallel branches overspending a
@Budget(FinOps → At run time): every call of a tree is now counted the moment it is made — a live tally of the pass on this instance; the ledger is still written once per superstep and nothing is counted twice — so a branch of.toParallelAgents,.toParallelActions,.toParallelWorkflowsor a quorum sees its siblings' spend while they run. The call that exhausts an execution, tenant or application budget aborts the in-flight model and tool calls of the other branches throughexecutionContext.signal; they fail with thatBudgetExceededError(their spend so far kept), and the launch fails with it into.onJoinError(BudgetExceededError, toAction(…)… | toFinish(…)), else the execution fails. Before, every branch checked only its own view (the spend at the launch plus the siblings that had ended), so N branches could each spend the whole remainder. The promise is now "a budget is exceeded by at most the calls in flight when it was exhausted"; an aborted call's partial cost is unknown and not recorded. A call that exhausts a budget also runs none of the tools its move asks for (an answer it gave is kept). Other instances still see the spend through the ledger only. No public API change.
-
Changed — contracts are function-typed properties, checked on the decorator line
(Components → Component rules):
RagConnector,PiiPolicyHandler,PiiRefusal,GuardrailRefusal,BudgetHandler,HealthCheckHandler,ModelProviderHandler,ChannelHandler,ReplyInterpreterHandler,ActionHandler,RouterHandler,FinishHandlerdeclarereadonly method: (…) => …instead of a method signature (ToolHandlerandJudgeHandleralready did), so a method that takes a narrower parameter than the contract no longer compiles — on@Rag,@PiiPolicy(a detector'sfindMatches, a declarative policy'srefusal(kind)) and@ModelProviderwhether or not the classimplementsit. Classes keep writing ordinary methods; nothing to migrate unless one relied on a narrower parameter. -
Changed — prompts are normalised at load (Components → File conventions):
no byte order mark,
\nline breaks, no blank lines at the edges, Unicode NFC; inside kept as written;promptUrlsparts joined with one blank line. Covers guardrails, compactions, reply interpreters, an arbiter's prompt and criteria and profile overrides. A prompt file's trailing newline is no longer sent. -
Changed — the config hash covers what an element's model reads outside its decorator
(Terns and evaluation): per agent and
model router the PII policies in effect,
uncompactedTurnsand the compaction, an agent's judges; and the framework's own texts. Every workflow'sconfigHashchanges once with this release, so a pause taken before it resumes only whereonIncompatibleResumeaccepts it. -
New —
agboss describeshows a computed guardrail refusal asClassName.refusal()(tested; printed since #120 PR 2).
-
Fixed — a lease store down past the lease (Several instances → The execution lease):
the holder trusts its lease until the
Clockread before its last confirmed renewal plus 30 seconds; a store error before then lets the checkpoint write go on, at that moment the execution's signal aborts withExecutionLeaseLostError— the agent's tool loop starts no further model or tool call, no.onError, the record staysrunningand another instance recovers the tree. Before, the background renewal retried forever and the holder kept calling tools after its lease had expired in the store. The error's message names the case (expired without takeover); no new field. -
Fixed — a provider repeating a tool-call id within one move
(Tools → Agents with tools): each repeated id gets its position (
${id}:${index}), so two calls no longer share a result slot and an idempotency key. The key format is unchanged; ids the provider gives once are kept as they are.
-
New —
agboss describedescribes the application (CLI): first its tree — the listed workflows with their starts and the child workflows they launch (by which step, one or per item withmaxItems/maxConcurrency;ThisWorkflowmarked withmaxDepth) —, each contract's implementation and why, the model providers and why, each channel (a role's question → answer, who answers, its reply interpreter, its implementation and why), the budgets by scope and each workflow's execution budget, the observers, the warnings and the environment once; then each workflow, now with its PII policies and their refusals (refusal: "…"orCardPiiPolicy.refusal()) andmaxDepthamong its limits, and each agent's compaction with where it came from (compaction conversation-compaction (job-scout-workflow):(own), the workflow or the application).describe --json'sapplicationcarries the same as data (tree,implementations,modelProviders,channels,budgets,executionBudgets, …); a workflow's data no longer repeats the application, and gainspiiPolicies. -
New — the check report (CLI):
agboss checkprints, per start, the worst case of one call — workflow executions, at the same time, agent and router steps of the whole tree, nesting depth — each with how it is made up, from the decorators only;--json:result.report.describeprints the same report under each workflow's limits (it was one line). -
Changed —
agboss check --env <name>lists the variables asprod environment needs 3 process variables:with alignedNAME value | secret[, optional | , has a default]lines — the field name is no longer printed, never a value (Environments). -
Breaking —
describeWorkflowis removed from the public API (it was a root export): describing is the CLI's — useagboss describe [--json]. It stays internal (the CLI and the framework's tests); the examples' tests that printed it now rely on the CLI's job-scout and flow-patterns describe tests. The root import loads nodescribe/module any more. -
Internal (
agboss/internal):describeApplication(assembled, { source, profile, environment }),describedApplicationOf(assembled)(takes the assembled application;executionBudgetsper workflow,modelProviders),describedTreeOf,describedChannelsOf,describedPiiPoliciesOf,worstCaseReportLines/worstCaseRowsOf(WorstCaseMeasure),worstCaseLimitsOf; aWorstCasecarries itsworkflow,isStepsCapped,treeStepsandchildren(each edge into a child:count,atOnce,maxItems,maxConcurrency,isRecursive, the child's worst case). -
job-scout: a
prodenvironment (agboss check --env prod).
-
New — the instance's probes (Health checks):
app.checkLiveness()(no I/O: the application —Unhealthy,stopping, fromapp.close()on — and the recovery loop —Unhealthywhen its last sweep ended more than three sweep intervals ago) andapp.checkReadiness()(liveness's components, every store'sping(), every model provider's circuit breaker — open →Degraded, never a model call — and the project's checks), each →HealthReport { status, components }withHealthStatusandHealthComponentKind. Checks run at once, each under its timeout on theClock; one that throws or times out is that component'sUnhealthywith itsreason— neither call rejects.testWith'sapphas both, also during and afterclose(). No HTTP server: the project's exposes them. -
New —
@HealthCheck({ name, timeout, deps? })+implements HealthCheckHandler(check(): Promise<HealthCheckResult>, built only withHealthCheckResult.healthy()/.degraded({ reason })/.unhealthy({ reason })), listed in@Application({ healthChecks }); rules[health.not-a-health-check],[health.duplicate-name],[name.kind-suffix](-health-check). -
Breaking — every #265 store contract has
abstract ping(): Promise<void>(CostLedgerRepository,ExecutionRecordRepository,PausedExecutionRepository,ExecutionLeaseRepository,ExecutionTreeRepository,RateLimitRepository): the in-memory defaults resolve, the SQLite ones runSELECT 1. Migrating: a project's own implementation addsping()— one fast round trip that rejects when the store is unreachable (Several instances → Your own implementation). A subclass of a framework implementation inherits it.
-
New —
IdGenerator(root export, App → Infrastructure): an infrastructure contract like theClock—newThreadId(),newExecutionId(); every thread id the framework mints and every execution id (roots, children, recursion levels) comes from it. The built-inRandomIdGenerator(root export) keeps the formats (random UUIDs); a class in@Application({ providers })thatextends IdGeneratorreplaces it. ApauseIdstays a random 128-bit token outside it. -
New —
SequentialIdGenerator(agboss/testing):testWithruns on it unless the application lists its own generator —thread-1, … andexecution-1, … per test (Testing → Ids). -
Breaking — the records repositories'
deps:SqliteExecutionRecordRepositorytakes[SqliteConnection, Clock, ApplicationContext, IdGenerator](was[SqliteConnection, Clock, NEW_THREAD_ID], an internal token), the built-in in-memory one[Clock, IdGenerator];NEW_THREAD_IDis removed. A project now subclassesSqliteExecutionRecordRepositorythrough public tokens only (Several instances → Extending a SQLite implementation). -
Breaking —
createApp/createAppDepsoptions: the undocumentednewThreadIdandnewRunIdoptions are removed — list anIdGeneratorinstead. -
Changed — execution ids under
testWith:execution-1,execution-2, … (wererun-1, …).
-
New —
agboss dev [--strict] [--env <name>](CLI): watch mode over the application'ssrc/— every change runsagboss checkin a short-lived child process (debounced, 200 ms; never two at once), so.agboss/types.d.tsis regenerated and the assembly problems are printed as they appear. -
New —
agboss studio [--port N] [--env <name>]: every workflow of the application (the children too) in LangGraph Studio; the CLI writes.agboss/studio.tsand.agboss/langgraph.json(git-ignored, rewritten on every run) and startslanggraphjs devon them. A child is a subgraph inside its parent,.toParallelWorkflows(…)included; an assembly problem or a missing variable stops it before the server starts (exit 3). -
Breaking —
studioGraphOfis removed fromagboss(and from the deprecatedagboss/core), and the examples'src/studio.tsand the rootlanggraph.jsonare gone. Migrating: delete the project'sstudio.tsandlanggraph.json, runagboss studio(job-scout:npm run studio). The graphs come fromstudioOf(Application, { env })inagboss/internal, the CLI's. -
Changed —
@langchain/langgraph-cliis a dependency ofagboss-cli(it was the monorepo root's dev dependency), soagboss studioworks in any project.
-
New —
examples/flow-patterns/src/approvals/(Example):offsite-booking-workflow— two buyers in one.toParallelAgents(…)launch, eachplace_purchasecall waiting for the thread's owner through thePurchaseApprovalChannelrole, answered in the terminal (TerminalPurchaseApprovalChannel); scripted tests: both pauses pending, both answers taken, a second answerNotPausedError, each question asked once. -
Changed — flow-patterns runs with a real model: its
devenvironment readsOPENROUTER_API_KEY(openRouterApiKey, aSecret) and, optionally,OPENROUTER_BASE_URL(openRouterBaseUrl) for the built-in providers;testis unchanged. Every store of its application is SQLite now (records, pauses, checkpoints, leases, tree keys, besides the ledger).npm run checkalso runsagboss check --models --env test(no key);npm run answeranswers waiting pauses from another process. ItsREADME.mdsays how to run the manual checks #263 M1, #264 M1 and #267 M1.
-
New —
@Compaction({ name, description, prompt?, promptUrls?, model, strategy, turnsBetweenCompactions, summaryQueueLength?, summaryMaxTokens })(Memory Management):compactions/*.compaction.ts, a-compactionname;CompactionStrategy.SummaryQueue(a queue of self-contained summaries, the latestsummaryQueueLengthkept),SingleSummary(one summary, rewritten),None(no model, no prompt, no parameters — the window only). A strategy that writes summaries without amodel, orNonewith one, does not compile. Root exports:Compaction,CompactionStrategy,TurnLimit,CompactionError, the option types,UncompactedTurns,SummaryMaxTokens,TokenLimit. -
New —
compactionanduncompactedTurns: one setting each on@Application,@Workflow,@Agentand@Router— the lowest level that sets one wins (AC4);uncompactedTurnsdefaults to 5,TurnLimit.ALLis every turn; on a code router neither compiles. A compaction summarises everything older than the smallestuncompactedTurnsamong the agents and routers that use it; a reader sees its window, every turn its compaction has not summarised yet, and only the summaries its turns do not cover. -
New —
summaryMaxTokensis the summarising call'smax_tokens(TokenLimit.MODEL_MAXallowed, thinking off): cut at it → one "shorter" retry, thenCompactionError(codecompaction;compaction,summaryMaxTokens; a failed model call is one too, as itscause) — never a cut summary. -
Changed — compaction runs lazily, before a new execution's first read of its thread (S132),
never after a turn: a finished turn's answer is always kept, a thread that ends pays for none, and
a
CompactionErrorfails the new execution before any of its work (no model call, no step, no turn kept). Its spend is the new execution's;result.compactedsays what ran before it. -
Breaking —
TokenLimit.MODEL_MAXreplaces theMODEL_MAXconstant (S132), an enum next toTurnLimit.ALL(both from the root). Migrating:maxTokens: MODEL_MAX→maxTokens: TokenLimit.MODEL_MAX,import { MODEL_MAX }→import { TokenLimit }; the value is still"max". - New — compaction behind the PII boundary (S84 i): its model reads the turns through the application's and the workflow's PII policies; the summary is revealed before it is stored, and each reader's own policies mask it at its boundary.
-
New — assembly rules
[compaction.not-a-compaction],[compaction.invalid-setting],[compaction.decision-model],[compaction.uncompacted-turns-invalid]; compaction models are checked againstservesascompactions.<name>. -
Breaking — removed:
defaults.history(limit,summaries),@Agent({ historyLimit, historySummaries }),@Workflow({ compaction: { every, keep, model }, compactionPrompt }), themockCompactionfixture and the typesHistoryDefaults,CompactionOptions(the old one),ModelOptions. Migrating:defaults: { history: { limit: 8 } }→uncompactedTurns: 8on the same decorator (outsidedefaults);historyLimit: 3→uncompactedTurns: 3;compaction: { every: 5, keep: 10, model: { model } }→ a@Compactionclass withstrategy: CompactionStrategy.SummaryQueue, turnsBetweenCompactions: 5, summaryQueueLength: 10, model, summaryMaxTokensnamed bycompaction: ConversationCompaction;mockCompaction()→mockLlm(ConversationCompaction). A profile'scompaction:iscompactions: { <name>: … },defaults.history.limitisdefaults.uncompactedTurns. -
Breaking — summaries are kept per compaction:
ExecutionRecordRepository.uncovered,latestSummariesandsummaryCounttake the compaction's name after(tenantId, threadId), aSummaryhascompaction(SQLite migration 13; a summary written before it belongs to no compaction and is not shown again).result.compactedis a list ({ compaction, fromTurn, toTurn, summaries, summaryQueueLength }, one per compaction that ran); spend iscompaction:<name>(categorycompaction). -
Changed — a compaction's model failure used to be swallowed (the next turn retried); it is now
a
CompactionErrorlike a summary that does not fit (#77 AC5 replaced, ADR-0007 amended).
-
Breaking — the CLI works on one application, set once (CLI):
"agboss": { "application": "src/job-scout.application.ts" }in the project'spackage.json(relative to its folder), or--application <path>, which wins; neither → exit 3 saying where to set it.--workflow <path>is gone fromchat,run,describe,check,eval,replay,golden,compareandrag(an unknown option, exit 2); the generators keep theirs until PR 4. Migrating: add theagbossfield topackage.jsonand drop--workflow …from the scripts ("chat": "agboss chat");agboss createwrites both. -
New —
--start <full name>onchat,run,eval,replay,golden add,compare: the workflow is chosen by its start (--start screening-workflow-start); optional with one start in the application; with several and none given, exit 2 listing each start's name, description and workflow; an unknown name, exit 2 withDid you mean "…"?and the list — the lookup is exact, nothing runs under a guessed name. -
Breaking —
agboss run --inputis JSON:--input '<json>'/--input-file <file>give the start's input, checked against itsInputOf(one DTO, alistOf(…), a map of fields) before the app starts — every problem at once by field (company.name: …; request.criteria: …), exit 2; the task's words are the text of a start takingWorkflowStartTextDto(--input <text>was that before).agboss chatneeds a start takingWorkflowStartTextDto. -
Breaking —
agboss rag index [--rag <name>](wasrag:index --kb): every knowledge base of the application, each once;--ragtakes the full name, an unknown one is exit 2 with a hint. -
Changed —
describeandcheckcover every workflow of the application:describe --jsonis{ application: { name, version }, workflows: [ … ] };checkassembles the application ascreateAppdoes (its rules across workflows too) and its problems name the application's path (ok: src/job-scout.application.ts — …);result.applicationreplacesresult.workflow. -
Internal (
agboss/internal):loadApplicationClass(path)loads the one exported@Applicationof a module (ApplicationLoadError, CLI codeapplication.load) — the--workflowbridgesenvironmentOwnerOfFile,textStartOfWorkflowandworkflowsOfApplicationare gone; newstartsOfApplication(App)(ApplicationStart: name, description, workflow, class, input — from the decorators),startInputIssuesOf(Start, payload),applicationOf,environmentOwnerOf;applicationWarningsOftakes the assembled application.
-
Breaking —
JudgeInput<TReply>carriesvariant(S125, Judges → The variant of aoneOfreply): the class the answer validated as, from the answer tool the agent used — never guessed from the answer's shape, never compared by name. For aoneOf(A, B)judgeJudgeInput<A | B>is a discriminated union ({ variant: typeof A; reply: A; task; attempt } | { variant: typeof B; … }), narrowed byisVariant(input, A); a single DTO,listOf(XDto)orPlainTextDtojudge hasvariantas that one class. Migrating: a judge that told variants apart by their fields ("salary" in reply) usesisVariant(input, OfferDto). -
Breaking — a
listOf(oneOf(A, B))judge readsreply: readonly VariantOf<A | B>[]: each item{ variant, value }, as the answer's items are kept, narrowed byisVariant(item, A); it has novariantof its own. Migrating:implements JudgeHandler<readonly (A | B)[]>becomesJudgeHandler<readonly VariantOf<A | B>[]>(the old form does not compile), each item read asitem.value. -
New —
VariantOf<T>(agboss/dto, the root),JudgedValueOf<TShape>(what a judge of areplyshape reads) andJudgeInputOf<TJudge>;isVariant(x, XDto)also narrows anything that carriesvariant(a judge's input, aVariantOfitem). -
Breaking —
testJudge(J, …)({ variant, reply, task, attempt })takesvarianttoo, typed the same way: avariantwith another class's value does not compile.
-
New — a decision model may read an approval's reply (S124 a, Pauses):
@ReplyInterpreter({ decidedBy: DecidedBy.Model, model: "typesafe/jev-1.13" })on an approval role (approves) makes oneDecision.noul("approved")about the question and the reply — approved when P(yes) > 0.5, the answer{ approved }, never areasonoroverrideArguments; counted underreply-interpreter:<name>, a model use of purpose decision (decisions-model-provider). A question role on a decision model stays[reply-interpreter.decision-model], its message saying why (the answer DTO needs more than a yes / no). Tests:mockLlm(X).thenReturn(decideWith({ approved: 0.9 })). -
Breaking — a code interpreter returns its outcome (S124 b):
ReplyInterpreterHandler<Self>.interpretreturnsReplyInterpretation.understood(decision)orReplyInterpretation.notUnderstood({ reason })(sync or a promise;ReplyInterpretationKind,UnderstoodReply,NotUnderstoodReply); a plain object does not compile.notUnderstood→UninterpretableReplyErrorwith thatreason, the pause pending; athrowis a crash only (unreadable too, its error thecause). Migrating: wrap each returned decision inReplyInterpretation.understood(…), and replace athrowthat declines withreturn ReplyInterpretation.notUnderstood({ reason }); a test'smockOf(I).interpret.mockResolvedValue(decision)becomesmockReturnValue(ReplyInterpretation.understood(decision)). -
New —
UninterpretableReplyError.reason(every unreadable reply: the interpreter's reason, or its error's message); its constructor takes{ reason, cause? }(UninterpretableReplyErrorOptions). -
New —
ChildExecutionRecord.pauses(S124 c, Pauses → Who answered): each child's record inresult.childrenandresult.record.childrenlists the resolved pauses its own steps asked; the root'srecord.pauseskeeps the whole tree's.PauseAnswerRecord.executionIdnames the execution a pause waited in (stored with each answer: apause_answersrow written before this change has none and fails to parse). The state's records are the internalEndedChildRecord(exported as a type; it isChildExecutionRecordwithoutpauses). -
Rule — new runtime subsystems load lazily (S124 d, #120 S123 c,
QUALITY.md→ Design): the decision-model and code reading are behind the lazily loaded reply reader; the root import stays under its 1,400-module budget (1,397).
-
New —
@InputGuardrail/@OutputGuardrail({ name, description, prompt | promptUrls, model?, threshold, refusal?, deps? })(Guards → Guardrails): a yes / no question on a decision model (Decision.noul) or a chat model (the answer toolanswer-guardrail);modelelsedefaults.guardrail.model(never the router's,[guardrail.no-model]). An input guardrail reads what an agent's or a model router's model reads — its input message, masked by that element's PII policies (S84 i) — before the call; an output guardrail reads an agent's answer. At P(yes) ≥thresholdthe execution fails withGuardrailBlockedError(guardrail.blocked;refusal,key: guardrails.<name>.threshold,probability,threshold); a model that does not answer fails closed (GuardFailedError,step.guard). -
New — the refusal: a text in the decorator, or
refusal(): string | Promise<string>of a class thatimplements GuardrailRefusal(its services and the execution throughdeps); neither does not compile (the decorator's overloads); at assembly[guardrail.missing-refusal],[guardrail.refusal-twice]. -
New — levels:
guardrailson@Application,@Workflow,@Agent,@Routeradd up,excludeGuardrailson an agent or a router removes an inherited one; a model router takes the input guardrails, a code router none. New rules:[guardrail.not-a-guardrail],[guardrail.exclude-not-inherited],[guardrail.output-on-router],[guardrail.no-model-call],[guardrail.no-prompt],[guardrail.threshold-invalid]; names end with-guardrail. -
New —
[application.pii-instance-key](S94, Environments → The PII key): no@Application({ pseudonymKey })and no policy that needs it → sensitive fields and keyless policies use a key of the instance; the start goes on with this warning inapp.warningsand the start log (an error underagboss check --strict, silent in tests). -
Breaking —
@Workflow({ guards })is removed with the framework's guard texts (prompt_injection,pii),GuardOptions,GuardsOptions. Migrating: one@InputGuardrail/@OutputGuardrailclass per guard (guardrails/*.guardrail.ts), listed inguardrails. The guard that ran on the execution's input now runs on what each model reads (the router's first); the one on the output on each agent's answer. -
Breaking — the
@Guardrailhook classes are removed (beforeToolCall,afterToolCall,onChannelDecision,beforeAgentAnswer,GuardrailContext,overrideGuardrails,disableGuardrails,guardrailson@Tool); checks of tool calls come back as tool-point judges (#123).testGuardrail(G, options)(text, { element })now checks a text and gives{ isBlocked, probability, refusal? }(GuardrailCheck);GuardrailDecision/GuardrailHookResultare removed. -
Changed — observer events:
GuardrailStartEvent.inputis{ side: GuardrailSide, element, text }(the masked text),GuardrailEndEvent.updateisGuardrailOutcome { isBlocked, probability }. Spend:guardrail:<name>(categoryguards; wasrouter:guard:<name>); tests script it withmockLlm(G).thenReturn(decideWith({ blocks: 0.9 }))— an unscripted guardrail passes. A VCR cassette records it asdecide guardrail:<name>. -
agboss describeprints each guardrail (side, threshold, model and where it came from, refusal — the text orClass.refusal()—, the elements it guards);agboss createwritesguardrails/prompt-injection.guardrail.ts.
-
Free-text replies (Pauses → free text):
app.resume({ pauseId, reply }, options)—ResumeRequestis nowDecisionResumeRequest | ReplyResumeRequest, exactly one ofdecision/ a non-emptyreply(elseDtoValidationError). The role'sreplyInterpreter— a@ReplyInterpreterclass (-reply-interpreter):DecidedBy.Model(a chat model, one answer tool, staticprompt/promptUrls) orDecidedBy.Code(implements ReplyInterpreterHandler<Self>,interpret({ ask, reply }),deps, created by the container) — reads the reply with the pause's question; its answer is validated like a decision. A model interpreter's call resolves its provider byserves, is checked against the tree's budgets first, counted in their ledger and in the execution'sspend(once) and traced underreply-interpreter:<name>. Either kind reads in the pause's execution frame (itsExecutionContext: tenant, owner, the waiting step); a model one through the application's and the workflow's PII policies, its answer revealed before validation (an unknown token is a decline). -
UninterpretableReplyError(pause.uninterpretable-reply): a reply nobody can read — the interpreter declined, or its answer is invalid (the reason ascause); the pause staysPending.ReplyInterpreterMissingError(pause.no-reply-interpreter): areplyfor a role without an interpreter, the pause untouched. - Assembly:
[reply-interpreter.not-an-interpreter],[reply-interpreter.decision-model],[reply-interpreter.no-interpret-method],[name.kind-suffix]for-reply-interpreter; model interpreters are model uses ([model.unserved],agboss check --models:replyInterpreters.<name>). - Tests:
mockLlm(Interpreter).when({ reply })scripts a model interpreter (Testing → Reply interpreters);mockOf(Interpreter)a code one. -
Who answered each pause (Pauses → who answered, S107):
ExecutionRecord.pauses: PauseAnswerRecord[]— every resolved pause of the tree in the order asked (pauseId— none for aPauseAnswerer.Channelpause —,step,role,answeredBy,responder,resolution,askedAt,resolvedAt,decisionPII-masked, none when withdrawn or cancelled);enum PauseResolution { Decided, Replied, Withdrawn, Cancelled }. Written when a pause is resolved into the execution records — the permanent history, untouched by the retention: new abstract methods onExecutionRecordRepository(a project's own implementation adds them)savePauseAnswer(tenantId, rootExecutionId, answer)andfindPauseAnswersByTenantIdAndRootExecutionId(tenantId, rootExecutionId); the built-in and SQLite ones keep them in apause_answerstable (migration 12 of the record store). -
Retention:
@Application({ pauseRecordRetention }), defaulthours(24)— resolved records (Taken,Cancelled) are removed by the recovery sweep throughPausedExecutionRepository.deleteResolvedBefore(cutoff); within the window a late answer isNotPausedError, after itUnknownPauseError;Pendingis never removed. -
Breaking —
PausedExecutionRepository(a project's own implementation changes):PauseRecord.resolution(the decision and who answered) is nowanswer: PauseAnswer(decision,answerer,usage?) — the interfacePauseResolutionbecamePauseAnswer, andPauseResolutionis the enum above; new record fieldscopyNumber,askedAt,resolution,resolvedAt.resolveByTenantIdAndPauseId(tenantId, pauseId, answer, at),cancelByTenantIdAndPauseId(tenantId, pauseId, resolution: PauseCancellation, at); newdecideByTenantIdAndPauseId(a channel'sChannelOutcome.decided(…)closes its record asTaken/Decidedinstead of deleting it) anddeleteResolvedBefore(cutoff);deleteByTenantIdAndPauseIdis removed.SqlitePausedExecutionRepositorymigrates a PR 2 file.
-
Breaking —
@Judge({ name, model, reply, deps })+JudgeHandler<TReply>(Judges and Quality Gates): a judge declares what it judges —replyis any shape of an agent'soutput— a DTO class,oneOf(A, B)(typedA | B),listOf(XDto)(readonly XDto[],listOf(oneOf(…))the items' values) orPlainTextDtofor a text agent — andjudge(input: JudgeInput<TReply>)gets the agent's validated answer object ({ text }for a text agent), never a serialized string.JudgeHandlerandJudgeInputtake the reply's type. -
Breaking —
JudgeVerdict.feedbackisreason, and verdicts are built only withJudgeVerdict.passed({ metrics? })/JudgeVerdict.failed({ reason, metrics? })— a plain{ passed, feedback }object no longer compiles. Thereasongoes back to the agent for its retry.QualityGateError.feedbackisrejections: [{ judge, reason }](JudgeFeedback→JudgeRejection). -
New rule
[judge.wrong-reply]: an agent whoseoutputdoes not fit a listed judge'sreplyfails assembly (alsoagboss check) — a text agent with a DTO judge, a DTO agent with another class, aoneOfvariant the judge does not cover, a list agent with a judge of one value or the other way round. -
testJudge(Judge, …)({ reply, task, attempt })is typed by the judge:replyis a plain object of its DTO; another shape does not compile. - Migrating: add
replyto every@Judge, writeimplements JudgeHandler<XDto>andJudgeInput<XDto>, readreply.textin a text agent's judge, and returnJudgeVerdict.passed()/JudgeVerdict.failed({ reason }).
-
ThisWorkflowis typed per workflow (Workflow as a step → Recursion):agboss checkwrites aThisWorkflowOutputsentry per recursive workflow (input,output,join), keyed by itsname;[types.this-workflow-outputs]is removed — recursive workflows with different finishes live in one project. No project code changes. -
The
.returns(…)after aThisWorkflowlaunch (.toWorkflow,route,workflow,toWorkflowas a side path, the join of.toParallelWorkflows) is checked by@Workflow({ name, flow }): a wrong class fails onflow, no longer at the.returns(…). -
Builder types (API reports): every path head (
SourceHead,RouteHead,CaseHead,SideHead,GroupHead) takes aclaimstype parameter;.switch,.chooseOne,.onError,.onJoinErrorand.joinQuorumGroupsare generic over their sub-paths;WorkflowOptions<TName, TFlow>andWorkflow(…)are generic; newThisWorkflowOutputs,ThisWorkflowCheck,ThisWorkflowStepClaim,ThisWorkflowJoinClaim,ClaimsOf,WithClaims,ChildLaunched. The generatedProducerOutputs,InputsandJoinOutputsno longer hold athis-workflowentry.
-
@Workflow({ maxConcurrentExecutions })works (FinOps → Concurrency of a tree): the root's value (default 10) caps how many executions of its tree work at once; a step without a slot waits in the tree's queue — no error. A slot is held only while an agent, a router or an action runs (an execution's parallel branches share one); at a join, while its child runs or paused an execution holds none. -
The queue's order: a parent closing its branch, then the deepest;
seconds(10)of waiting counts as one level deeper (theClock). A child's own smaller cap limits its subtree. -
A quorum's
deadlineexcludes the time its branches wait in the tree's queue; aQuorumNotReachedErrorpast it names the effective deadline (the deadline passed (2026-01-05T09:01:10.000Z: the ticket's deadline plus 40 s in the tree's queue)). -
New rules:
[limits.max-concurrent-executions-invalid](not a whole number from 1, an error),[limits.child-concurrency-above-parent](a child's cap above its parent's, a warning). -
agboss describeprintsmaxConcurrentExecutions N (set | default)for a workflow that launches children;describe --jsonlimits carrymaxConcurrentExecutionsandisMaxConcurrentExecutionsSet.
-
Parallel branches in tests (Testing → Parallel branches):
testWith(App, { concurrency })—TestConcurrency.Sequential, the new default: the branches of a launch run one at a time, in listed / item order;TestConcurrency.Realruns them as production does (withinmaxConcurrency);TestConcurrency.inOrder([step(A), step(B, partial)])makes them complete in that order through completion gates (step(Node, partial?)takes the matcher ofwhen(…)). A step waiting at its gate is cancelled at once by aFailFast, a decided quorum orapp.cancel; when no branch can move and the next step is not waiting, the execution fails at once withtest.unreachable-step; a listed step never reached fails the test at its end. Breaking for tests: a test that relied on branches running at once (a sliding window, a surplus answer, an agent waiting for its siblings) setsconcurrency: TestConcurrency.Real. -
Unit runners (Testing → Unit runners):
testTool,testAction,testRouter,testArbiter,testRag,testJudge,testGuardrailandtestPii— one component on its own container (overrides: [override(Class, Replacement)],environment), every call in its step's frame (executionContext.signalwith atimeouton the runner'sVirtualClock,run.clock;SpendRecorderand its model calls intospend), its model by script (model: decideWith(…),replyWith(…),routeTo(…)); a call gives{ output, spend }and rejects as the component threw. The calls run for the tenant and execution values given —{ tenantContext, tenant, executionContext, execution }, astestExecutionContexttakes them, validated the same way.testPii([Policies])checks the policies' own rules first (-pii-policy) and givesmask(text)/reveal(text). Theagboss create/agboss generate tool/agboss generate openapitool tests usetestTool. -
testWith(App, { instances: 2 })is the documented way to test across instances: one store set, one clock, a container each — a lease, a pause resumed on the other instance, a rate-limit slot (Several instances). -
testWith(App, { overrides: [override(Class, Replacement)] })(#267 S104): a class replaced by a class in the application's container, over any listed one — a channel role answered byoverride(RefundApprovalRole, AutoApproveChannel)(the replacement'ssendmust fit the role).AutoApproveChannel/AutoRejectChannel'ssendnow givesDecidedOutcome<AutoDecision>(approved,reason), so they fit any role that approves a tool. -
GuardrailContextis exported fromagboss(the contexttestGuardrail's hooks take). - New page The test split: unit tests with scripts → evaluation and golden sets →
make smoke; no test calls a real model.
-
ThisWorkflow(Workflow as a step → Recursion): a workflow launches itself —.toWorkflow(ThisWorkflow),route(ThisWorkflow),.toParallelWorkflows(ThisWorkflow, …); the nodethis-workflow, run from the registry (Studio:recursive: maxDepth N). -
@Workflow({ maxDepth })required on a recursive workflow ([graph.recursion-without-depth]); the root is depth 1,ChildExecutionRecord.depthand each child's trace metadata carry it. -
The soft stop on the last level: an agent's recursive variants leave its schema, a model
router's recursive routes are hidden, a code router returning one is
RouterDecisionError; every such branch keeps an exit ([graph.recursion-without-exit]). -
DepthLimitExceededError(limit.depth;workflow,maxDepth,depth): a launch by code pastmaxDepthdoes not start; the level above gets it as thecauseofChildExecutionFailedError. -
[graph.mutual-recursion]: mutual recursion, a flow naming its own class, a class undefined while the flow is built.[types.this-workflow-outputs]: recursive workflows of one project with different finish classes (removed in PR 3). -
agboss describe: therecursive:line, per-item child results counted (this-workflow ×0–5); the worst case counts the levels down tomaxDepth.
-
Children run (Workflow as a step):
flow.child-not-runnableis removed. A child is a node of its parent whose subgraph is the child's compiled graph (Studio draws it inside), on the parent's checkpoint thread under its task'scheckpoint_ns; its ownexecutionId, kept in its state across a pause, a restart and a crash (no secondonExecutionStart); its own path, steps andmaxSteps; no thread history; the parent's thread,ownerId, tenant and execution fields inherited; the root's budgets spent (a child workflow's own execution budget applies only as a root). A child need not be listed in@Application({ workflows }): collected transitively, it goes through every application rule a listed one does (names, models, DI and infrastructure, channel roles), budget coverage aside. - PII tokens (#120): a child starts with its parent's tokens and gives its own back — with its value, or with its error (into the parent's state, never on the error) to the side path that takes it, a per-item child's too.
-
ChildExecutionFailedError(step.child, aStepFailedError:cause,childExecutionId,itemIndex,workflow): an uncaught error of the child, its ownmaxSteps/maxVisitsincluded; budgets and cancellation pass through unwrapped (S25).PaidStepErrorstays the base ofStepFailedError(step.failed, #273 S71). -
toWorkflow(W)side-path starter (S85):.onError(E, toWorkflow(W)…), also in.onJoinError. -
Records:
result.children/result.record.children(ChildExecutionRecord,ChildExecutionStatus),result.handledErrors(HandledErrorRecord); observer events carryparentExecutionId/rootExecutionId;executionContext.parent,root,depth,itemIndex,executionOf(W)read the tree;ExecutionTreeRepositorykeys are the root's. -
Pauses inside a child (#267): the root's — in
result.pendingwithchildExecutionIdand, per item,itemIndex; several children may wait at once; resumed bypauseIdlike any other. -
Testing:
toHaveChildExecutions(W, [...]),toHaveHandledError(E, { at, by }). -
The worst case of a start counts its child executions (
maxItems× the child's, at mostmaxConcurrencyat once) and their depth. -
Example:
examples/flow-patterns/src/vendor/— a child workflow per vendor.
-
Several pauses at once (Pauses): a paused branch of a parallel launch does not stop
its siblings; the execution rests when nothing else can move. Breaking:
ExecutionOutput.pause→pending: readonly PendingPause[](pauseId?,answeredBy,agent,channel,itemIndex,childExecutionId,ask— theToolCallApprovalAskDto), empty unless paused;ExecutionPauseEvent.pause→pending, fired only when the execution rests (again after a partial resume). -
Breaking — resume by pause id:
app.resume(threadId, decision, { tenantId, ownerId })→app.resume({ pauseId, decision }, { tenantId, ownerId | answeredBy })(ResumeRequest,ResumeOptions = ThreadOwnerResumeOptions | ChannelResumeOptions). The pause is found by(tenantId, pauseId)anywhere in the tree; unknown or another tenant's →UnknownPauseError(pause.unknown); with others left the execution pauses again with the rest. APauseAnswerer.ThreadOwnerpause lists its id and takesownerId; aPauseAnswerer.Channelpause takes a non-emptyansweredBy(recorded on the pause and inonExecutionResume, new fieldansweredBy) and its id is only in the channel'sask.pauseId; the other credential →PauseAnswererError(pause.wrong-answerer). A concrete@Channelnamed as is answers as the thread's owner. The CLI, the MCPresume_workflowtool ({ pauseId, decision }) and the A2A adapter (resume({ pauseId, decision }),POST /resume { pauseId, decision }, the response'spauseIds) follow. -
Breaking —
ToolCallApprovalDecisionDtois{ approved, reason?, overrideArguments? }:byis gone (who answered comes from the resume),feedback→reason;overrideArgumentsonly withapproved: trueand a valid input of the tool — onresume(the pause keeps waiting) and onChannelOutcome.decided(…)(the step fails). -
Breaking for your own
PausedExecutionRepository(#267 S35):PausedRunkeeps the run and its versions only; each pause is aPauseRecordof its own, keyed by its step's idempotency key —findOrCreateByStepKey,findByTenantIdAndPauseId,findAllByTenantIdAndRootExecutionId,markSentByTenantIdAndPauseId,resolveByTenantIdAndPauseId(atomicPending → Resolved),cancelByTenantIdAndPauseId,markTakenByTenantIdAndPauseId,deleteByTenantIdAndPauseId;resolveByTenantIdAndThreadIdis removed.PauseStatusgainsTakenandCancelled.SqlitePausedExecutionRepositoryadds apausestable and drops the oldstatus/decisioncolumns ofpaused_executions(a pause written before cannot be answered by id). -
Concurrent answers (Q58): one
Command({ resume })per pass maps every answered pause; the holder drains decisions recorded meanwhile; a double click →NotPausedError; a reply that lands whilesendis returning is taken at once.FailFastwithdraws paused siblings' pauses (Cancelled; a late answer →NotPausedError); so doesapp.canceland the end of a tree. The join after a withdrawal gets the branch's own error object (.onJoinError(E, …)by class and cause chain); a channel'ssendthat throws fails the agent's step (AgentFailedError, the channel's error ascause). - Spend while several loops are paused: the execution's spend and the ledger hold every paused loop's spend, summed, each once; a resume records nothing twice.
-
resumeAgent(paused, resume, deps)(internal wiring) takes the tree's answers (TreeResume) instead of one decision.
-
when(…)onmockLlm(X)(Testing): turns bound to the node's input values,PartialValues<InputOf<X>>— values only,undefinedfor an absent field, lists by length and position,variant(XDto, { … })for aoneOfelement (varianttakes a partial value there). A call no script matches fails naming its input (test.no-matching-script). -
Scripts are expectations: a
thenReturnturn no call took, awhen(…)nothing matched or a scripted tool failure never caused fails a test whose body passed (test.unused-script;thenReturnAlwaysnever does). Breaking for tests that scripted turns their run never reaches: drop them, or usethenReturnAlways. -
Typed scripts:
replyWith(XDto, fields)must be a class of the agent's output androuteTo(...targets)followRoute<R>— one target after.chooseOne(…), theOnDemandtargets (or none, with anAlwaysroute) before.toParallelAgents(…), whichrouteTo(…)now scripts too. The generatedRouterAnswershas an entry for every router, model routers included. -
waitFor(duration): the next turn answers only after that long on the test's clock. -
A caller's own thread id (App):
app.execute(Start, input, { threadId })with an id the tenant does not have yet creates that thread — keyed bytenantId+threadId(the same id in another tenant is another thread), bound toownerId, created only once the budgets pass. Before, it wasUnknownThreadError; that stays forcancelof an unknown thread. A bad id (empty, over 200 characters, a control character) is the newInvalidThreadIdError(run.invalid-thread-id). Breaking for your ownExecutionRecordRepository:createThread({ workflow, tenantId, ownerId?, threadId? }: NewThread)takes one object and returns the thread's id; every read by thread names both values —lastTerns(tenantId, threadId, limit),uncovered(tenantId, threadId),before(tenantId, threadId, ternId, limit),turnNumber(tenantId, threadId, ternId),latestSummaries(tenantId, threadId, limit),summaryCount(tenantId, threadId); aSummaryand aMemoryTurncarrytenantIdnext tothreadId. The SQLite thread store's migration 11 keys threads by(tenant_id, id)(owners, Terns and summaries by(tenant_id, thread_id)); existing threads aredefault's and keep their ids. -
mockAction(A)(the action still runs) andmockTool(T).thenFail(ToolFailure.Timeout); the call matcherstoHaveBeenCalledWith,toHaveBeenCalledTimes,toHaveReturnedonmockLlm(X)/mockAction(A)(Vitest's own on anything else) andmockLlm(X).inputs. -
Breaking —
toFailWith(ErrorClass, { …fields, cause })replacestoFailWith({ code, node })(ExpectedFailureremoved):toFailWith({ code: "step.action" })→toFailWith(ActionFailedError),{ node: MainRouter }→toFailWith(RouterFailedError, { nodeId: nameOf(MainRouter) }), a budget by its name →toFailWith(BudgetExceededError, { budget: { name: "tenant-daily-budget" } }). The rootErrorClasstakes the error's type (ErrorClass<TError>).
-
env.optional("X"),env.integer.optional("X"),env.flag.optional("X")— a variable that may be unset: typedT | undefined, unset →undefined(not a missing variable), a set value still checked;agboss check --envmarks it(optional)(Environments). New public typeEnvironmentValueReader<T>;EnvironmentVariableReaderextends it. -
The base-URL field repeats no default:
openRouterBaseUrl: env.optional("OPENROUTER_BASE_URL")— unset → the decorator'sbaseUrl(Model providers); job-scout andagboss createdeclare it this way.
-
@PiiPolicyis rebuilt (Guards → PII policies): declarative{ name, description, rules: [pii(PiiKind | PiiGroup, PiiAction.…)], detectWithModel?, pseudonymKey?: secret("…") }, or a project's own detector{ name, action, deps }+implements PiiPolicyHandler(findMatches(text)→PiiMatch { start, end }). Breaking: thePiiPolicyinterface (mask/maskJson) is removed; names end with-pii-policy. -
Actions
PiiAction.replaceWithKind(),replacePart({ keep… } | { pattern }),replaceWithToken()(HMAC-SHA256),blockWithRefusal({ refusal } | ())+PiiRefusal;PiiRefusedError(pii.refused),PiiDetectionFailedError(pii.detection-failed). -
Levels:
@Application({ piiPolicies })(new) →@Workflow→@Agent/@Router; the lists add up,excludePiiPoliciesremoves. Breaking:overridePiiPolicies/disablePiiPoliciesare removed, and so are the PII policies of@Tool(a tool sees real values; its agent's policies close its result). Assembly rules[pii.conflicting-rules],[pii.exclude-not-inherited],[pii.missing-refusal],[pii.unused-refusal],[pii.kind-needs-model],[pii.not-a-policy]. -
The model boundary masks everything a model reads and reveals its moves before validation;
the token map is the execution state's
piiTokenschannel (S38, replaces #121's store). -
Observability: events, the log, Langfuse spans and
result.recordare masked by the application's and the workflow's lists (Observability); breaking:onPiiPolicyStart/onPiiPolicyEnd,OnPiiPolicyStart/OnPiiPolicyEnd,PiiPolicyStartEvent/PiiPolicyEndEventandPiiPolicyPayloadare removed — an observer that implements one of the hooks fails assembly with[observer.unknown-hook]; delete the method (there is nothing to migrate to: masking happens at the model boundary, not in a hook). -
Breaking —
sensitive: true | { kind }is replaced bysensitive: PiiAction.…(masking actions) on DTO and context fields;WorkflowStartTextDto.authorisreplaceWithToken(); a channel shows such a field through its action instead of***. -
@Application({ pseudonymKey: "piiPseudonymKey" })names aSecretfield of the environment (Environments): the HMAC key ofsensitivefields, project detectors and every policy without its own key (now optional) — one token per value on every instance. A policy's own key wins; a policy that needs the application's when none is set fails the start; a field the contract lacks is[environment.missing-field].
-
Channels are found through the tools that name them (Tools → Approval channels):
breaking —
@Workflow({ channelClasses })is removed; one channel instance per application. -
@ChannelRole— a project's abstract class (extends ChannelOf<Self>) withapproves: XDtoorask/answer, a requiredansweredBy: PauseAnswerer.ThreadOwner | PauseAnswerer.Channeland an optionalreplyInterpreter; its one implementation (@Channel,extendsthe role) is listed in@Application({ providers }). New assembly codes:[channel.wrong-tool],[channel.shared-interpreter],[channel.role-form],[di.missing-implementation],[di.ambiguous-implementation]; a@Channelin a workflow'sprovidersis[di.infrastructure-in-workflow]. -
Breaking — the channel contract: one method,
send(ask: ChannelAsk<Role>): Promise<ChannelOutcome<Role>>(ask = { pauseId, value }, typed by the generatedChannelRoles), returningChannelOutcome.awaitReply()(pause) orChannelOutcome.decided(decision)(no pause).requestApproval,ChannelRequestandChannelDecisionare removed. -
Breaking —
InboundChannelAdapterandSemanticInboundChannelAdapterare removed (and@Channel({ inboundAdapter })):app.resume(threadId, decision)takes theToolCallApprovalDecisionDtoitself until free-text replies come with #267 PR 3. -
AutoApproveChannel/AutoRejectChannelnow decide at once (decided) — no pause.
-
rateLimitPolicyon@ModelProvider(Model providers):RateLimitPolicy.of({ maxConcurrentCalls?, perMinute?: { calls?, tokens? }, balancing? })— one limit for the whole application, every execution, tenant and instance, its state inRateLimitRepository. A call without room waits (its signal ends the wait —app.cancelwithExecutionCancelledError; the attempt'stimeoutstarts with the room); waiting tenants share free slots byQueueBalancingStrategy(TenantRoundRobindefault,TenantLeastInFlight,TenantWeightedbytenant.weight,Fifo); a 429 pauses the provider's whole queue forRetry-After(else the retry delay) on every instance, then halves the instance's concurrency, back by one a minute (AIMD). No policy → no limit. -
retryPolicyis the only backoff; a wait between attempts holds no slot. -
RateLimitRepositorygets windows and pauses:addToWindow,clearWindow,pauseUntil,pausedUntil— abstract, so breaking for your own implementation (in memory andSqliteRateLimitRepositoryimplement them; a SQLite file gets two new tables on open) (Several instances). -
Breaking — the circuit breaker's state is in
RateLimitRepository(an open breaker holds on every instance) and on the application'sClock:CircuitBreakertakes(provider, policy, { rateLimits, clock })and its methods are async;CircuitBreakersand the process-wide breakers are removed. -
Breaking — provider keys are
Secrets of the environment:apiKeynames the field (apiKey: "openRouterApiKey"), set withopenRouterApiKey: secret("OPENROUTER_API_KEY"); a base URL may come from a text field too (baseUrlField: "openRouterBaseUrl",env.optional("OPENROUTER_BASE_URL")since S90).EnvironmentVariable,MissingEnvironmentVariableError,SettingValue,OPENROUTER_API_KEYandOPENROUTER_BASE_URL_SETTINGare removed —env()/secret()are the one way to read a process variable (Environments). -
agboss checkwithout--modelsneeds no secret values (it reads the contract's shape);--modelsconnects and fails on a missing secret, naming its variable. -
Breaking — provider names end with
-model-provider([name.kind-suffix]): the built-ins areopenrouter-model-provideranddecisions-model-provider(indescribe, the startup log and model problems).
-
Quorums run (Parallel → At run time): decided in the launch's
superstep as answers arrive — each branch's arbiter chain in its slot and frame, the answer
recorded at once (
BranchVerdict.CountedwithacceptedSequence,RejectedwithrejectedBy,Surplus), the rest cancelled once the quorum is fixed; the join decides from the records (counted answers in counting order, the winning group's own path).flow.quorum-not-runnableis removed. -
New errors:
QuorumNotReachedError(step.quorum) andArbiterError(step.arbiter), bothStepFailedErrors, taken by.onJoinError(E, …);QuorumShortfall. -
Deadline on the application's
Clock:deadlineAtat dispatch, kept in the launch's ticket; a pause counts. After a resume or a crash the quorum is rebuilt from the finished branches' records; an expired deadline cancels the rest without running it. -
Under a quorum a branch's own step failure is
Skipped(its place stays empty) whatever the route's failure policy; anArbiterErroror a limit fails the launch. -
Model arbiters: one decision per answer under
arbiter:<name>(answer-arbiteron a chat model), budgets checked before each call; their prompts and criteria in the prompt version, their model a model use (arbiters.<name>). Code arbiters are created by the container at start. -
Shown:
LaunchRecord.isQuorumReached,LaunchRecord.group(QuorumGroupRecord),BranchRecord.verdict/acceptedSequence/rejectedBy; trace entries; the hookonQuorumDecided(OnQuorumDecided,QuorumDecidedEvent— a node event,stepthe launching step). -
Testing:
mockLlm(ModelArbiter).thenReturn(decideWith({ counts: 0.9 })). -
CLI:
agboss generate arbiter <name> --workflow … [--agent …]— a code arbiter with one@OnAnswermethod per agent of a launch.
-
Breaking: observers are registered only in
@Application({ observers })(Observability);@Workflow({ observers })is removed (does not compile). One manager per application instance, for every execution of every workflow. -
Breaking: hooks are named after executions —
onExecutionStart,onExecutionEnd,onExecutionPause,onExecutionResume,onExecutionError,onExecutionRejectedreplaceonWorkflowStart/End/Pause/Resume,onErrorandonRunRejected(interfacesOnExecution…; eventsExecutionStartEvent,ExecutionEndEvent—finish,spend, once per execution at a finish —ExecutionPauseEvent,ExecutionResumeEvent,ExecutionErrorEvent,ExecutionRejectedEvent). -
Breaking: one event object per hook, no
AppState(removed): every execution event extendsExecutionEvent(executionId,parentExecutionId,rootExecutionId,threadId,tenantId,workflow); node events extendNodeEventand addstep(StepRecord).ValuesOutdatedEventandLaunchItemsTruncatedEventgaintenantId. -
Breaking (#269):
ExecutionOperation.Execute | Resume | CancelinExecutionRejectedEvent.operationreplaces the strings; the event carriestenantId. -
Breaking: the untyped
executionContext/metadataexecute options are removed, withChannelRequest.metadata/.executionContext,GuardrailContext.metadata/.executionContext(no longer generic) andRunContext.metadata: components read the typed contexts through an injectedExecutionContext;McpService.connect(transport, session?)/handleSse(res, endpoint, session?)takeMcpSessionOptions(tenant,execution,ownerId);A2ACallOptionsissignal,ownerId,tenant,execution. -
New: the
Loggercontract (infrastructure;ConsoleLoggerby default,LogLevel,LogEntry,formatLogEntry) replacesprocess.emitWarning(execution.recovered,recovery.incompatible,recovery.failed,app.close-failed,observer.failed); in tests an unlisted logger is silent.ExecutionObserverManager's default failure listener no longer warns. -
Breaking: renames to executions —
WorkflowObserver→ExecutionObserver,ObserverManager→ExecutionObserverManager,WorkflowCancelledError→ExecutionCancelledError(codeworkflow.cancelled→execution.cancelled);agboss migrate importsrewrites the imports. -
New:
nameOf(C)— thenameof any AGBoss-decorated class (its literal type where the generated types brand it), for filtering serialized events and records. -
New: the start log — what started and the worst case of one call of each start; no
maxSpend()at start, nothing per tenant;testWithlogs none of it. -
New: assembly warnings
[application.in-memory-cost-ledger],[application.in-memory-pauses]— inapp.warningsand the start log, errors underagboss check --strict;agboss checkruns a newapplicationcheck. The CLI'srun/chatno longer printapp.warningsthemselves (the start log has them). -
New in
agboss describe: the application — each contract's implementation and why, budgets by scope with their periods, the workflow's execution budget, observers, warnings — and alimitssection (maxStepsset or default,maxVisits, worst cases);--jsonaddsapplicationand a richerlimits. - flow-patterns lists
SqliteCostLedgerRepository(environmentsqlitePath) so its strict check passes.
-
New: quorums (Parallel): after
.toParallelAgents(…),.joinQuorum({ min, max, deadline })(options always an object;maxdefaults tomin) and.joinQuorumGroups(group(A, B).priority(n).returns(…).to…(…), …, { deadline })— every group continues its own path, nothing follows the groups but.onJoinError(…). New builder names:group,anyOf; typesQuorums,QuorumJoined,GroupsJoined,GroupStart,GroupValues,GroupPath,GroupHead,AgentsLaunched,QuorumOptions,QuorumGroupsOptions,AnyOfArbiters,ArbiterChoice; ASTQuorumKind,CountQuorum,GroupsQuorum,Quorum,QuorumGroup,AcceptedBy,ArbiterClass(LaunchStep.acceptedBy,JoinStep.quorum). -
New:
@Arbiterand@OnAnswer(Agent), attached with.acceptedBy(X)(a chain is an AND,anyOf(X, Y)the first that accepts, S57): by model (criteriaper agent) or by code (one method per agent class, typed by the decorator). Newagboss/units:hours(). -
What
.returns(…)may promise: after.joinQuorum({ min })withminbelow the agents launched a single answer isoptional(XDto)(compile error and[flow.quorum-return-not-optional]otherwise); a repeated class or a sharedoutputislistOf(XDto)in every form; a group's agents are required in its.returns(…)(JoinOutputs["<launch> group(<agents>)"]). -
New rules:
[flow.quorum-impossible],[flow.quorum-return-not-optional],[arbiter.not-an-arbiter],[arbiter.unhandled-agent],[arbiter.unknown-agent],[arbiter.duplicate-agent],[arbiter.answer-method-on-model],[arbiter.method-name];[input.missing-on-path]names the agent a quorum may leave out and the fix. -
agboss describeprints quorums and counts a quorum list's producers (critic-agent ×1–2). - A workflow with a quorum did not assemble until #279 PR 2 (
flow.quorum-not-runnable, removed there). -
@QuorumRouter,QuorumStrategy,QuorumManagerand.joinQuorum(n)stay removed (AC11).
-
Budgets are checked (FinOps): every execution's budgets — its workflow's
own execution budget (else the default), every tenant budget, every application budget — have
their
maxSpend()read once, at its start, in itsExecutionContext, and kept in its state for the whole execution (a resume and a recovery count against the same amounts). Before the first step an exhausted one rejects the execution (BudgetExceededError,onRunRejected) leaving no trace — no thread (a new thread is now created after the check), no record, no call; every model call's cost is then counted in all of them, and before every step and model call the first exhausted stops the execution. Nothing is reserved (#202's holds are gone): a small overrun on a race, or by one call, is accepted. -
New assembly rules:
[budget.execution-budget-required],[budget.duplicate-execution-budget],[budget.duplicate-default-execution-budget],[budget.tenant-budget-required],[budget.application-budget-required]— every application lists an execution budget for every workflow (own or the default), a tenant and an application budget. -
Breaking —
settings().limitsis removed:WorkflowSettings.builder().limits(…),WorkflowLimits,PerRunLimits,PerDayLimits,PerRunLimitRequiredError([limits.per-run-required]),WorkflowSettingsError,LimitKey,SpendAccountandRunOptions.accountare gone; money is@Budgets only.settings()keepsonIncompatibleResume. -
Breaking — the limit errors (FinOps):
StepsLimitExceededError(limit.steps:workflow,maxSteps,steps;support-workflow took 41 steps; its maxSteps is 40) formaxSteps;BudgetExceededErrornames its budget —budget({ name }),scope,key(the application's name, thetenantId, the execution's id),maxSpend,spent, a sentence (tenant acme spent $40.05 today; tenant-daily-budget allows $40) — instead of the keyslimits.perRun.cost/limits.perDay.cost.toFailWith({ code })matches a budget by its name. -
Breaking —
CostLedgerRepositorycounts per budget, scope key and period:findSpentByCounter(counter),saveAll(counters, records),findMaxSpendByAccount(account),saveMaxSpend(account, maxSpend)(new typesBudgetAccount,CostCounter; werefindSpentToday(key)/saveAll(key, records)). The ledgers read no time any more — the runtime names each counter's period from the application'sClock— soInMemoryCostLedgerRepository()andSqliteCostLedgerRepository(connection)take no clock.SqliteCostLedgerRepositorydrops the old per-workflow day table of a file written before (#265 S69 e). -
maxStepsand launches (#273 S68 f): the default (agents + routers) × 3 must hold the worst case of a per-item launch (maxItemsruns) — else[limits.max-steps-too-low]; set@Workflow({ maxSteps })..onError(StepsLimitExceededError, …)(or a parent class) counts as a way out for the steps rules. -
Eval / replay / compare run within the application's budgets; the judge's calls count in the
application budgets, held to their last known
maxSpend()(the eval account<workflow>:evalis gone). -
agboss describe:limits steps 2–2 (maxSteps 12)(no money there; budgets by scope come with PR 6).
-
Clockis public (rootagboss, an infrastructure contract):now()andsleep(delay, signal?). Every time the framework reads and every wait it makes goes through the application's one clock — the ledger's day, records, leases and their renewals, the recovery sweep, the shutdown timeout, a tool'stimeoutMs, a model call's timeout, retry backoff andRetry-Afterpauses. The system's clock unless@Application({ providers })lists a class thatextends Clock(created first; it may not depend on the contexts); in a workflow'sprovidersit is[di.infrastructure-in-workflow]. A component that needs the time injects it (deps: [Clock]). Testing → The clock. -
VirtualClockis public (agboss/testing): theclockfixture andapp.clockare one per test, the application's clock in every test (also when it lists its own);new VirtualClock(start?)in unit tests. Breaking: theTestClocktype is gone —VirtualClockin its place (agboss migrate importsrewrites the import); itsnowis a method, soclock.now()reads as before butclock.nowis no longer a function to pass around. -
Lint
agboss/no-direct-timefailsDate.now(),new Date()/Date()without arguments,performance.now(),setTimeout,setInterval,AbortSignal.timeoutand thenode:timersmodules in the framework's sources (SystemClockexcepted). -
SqliteConnectionis a value export (#265 S70): the DI token a subclass of a SQLite implementation names. Breaking for a project that constructs one of them itself: the constructors take(connection, clock: Clock, applicationContext: ApplicationContext)(the leases and rate-limit slots),(connection, clock)(the paused executions; the ledger takes(connection)since #261 PR 5) — Several instances → Extending a SQLite implementation. -
Breaking: the
clockoption ofcreateApp/createAppDeps(AppOptions,AppDepsOptions) is removed — one way to set the clock: list aClocksubclass in@Application({ providers }); tests get theVirtualClockfromtestWith.
-
New: a workflow is a step (Workflow as a step):
.toWorkflow(W),route(W),workflow(W)(the own path of a child reached from two places) and.toParallelWorkflows(W, { maxConcurrency, maxItems })(one child per item,maxItemsrequired, joined like any launch;WorkflowLaunchOptions). Checked and typed byagboss check; a workflow with a child did not assemble until #263 PR 2 (flow.child-not-runnable, removed there). -
The start is chosen per edge, by class: one start → it; per item → the start taking the item;
else the start taking the chain's current value, of several the one whose other required fields
are on the path —
[graph.no-matching-start]/[graph.ambiguous-start]otherwise, also compile errors through the child'sInputsentry.agboss describeprints each edge's start (children;--json:children). -
The child's result is its finishes' output classes (
ProducerOutputs):.returns(XDto), or.returns(oneOf(…)).switch(…)with one case per class; after a per-item launch the join giveslistOf(oneOf(…))(JoinOutputskeytoParallelWorkflows(<child>) per item). The child's scope starts with its start input only. -
New rules:
[graph.child-without-failure-finish](a warning, an error under--strict) and[graph.limit-handler-uses-model]— breaking for flows that catch a limit: an.onError/.onJoinErrorside path that can catch aLimitExceededError(its class, a subclass or a parent such asAGBossError/Error) now fails assembly when it reaches an agent, a router or a child. - A workflow class mentioned as another kind (
toAgent(SomeWorkflow)) is[graph.wrong-kind], no longer[graph.not-a-node]. -
New:
agboss check --strict— the analyser's warnings ([flow.path-too-deep],[graph.child-without-failure-finish]) are errors (exit 3); flow-patterns'npm run checkuses it.
-
The context levels (Context and Authorization):
ApplicationContext(name,version,instanceId,startedAt,environmentName,environment,host—HostContext) →TenantContext(tenantId,weight, #266 S60) →ExecutionContext(new:tenant), each its type and its DI token. New public names:ApplicationContext,HostContext,TenantContext,ContextValues<T>,TenantValues,TenantContextClass,ExecutionContextClass,CallOptions,ResumeOptions,ExecutionOptionsOf,ExecutionOptionsArgs;agboss/testing:TestContextOptions,TestApplicationOptions. -
The project's subclasses:
@Application({ tenantContext, executionContext }); values per execution,app.execute(Start, input, { tenant, execution }), typed per start by the generated types (theWorkflowStartsentry names the start'stenant/executionclasses, and a declared execution subclass'stenantis typed as the tenant subclass), validated before the first step (dto.invalid,onRunRejected), frozen, kept in the checkpoint. New rule codes:[context.prompt-unused],[context.not-declared]. -
Breaking —
ENVis removed: read the environment asapplicationContext.environment(deps: [ApplicationContext]);[di.missing-environment]is gone (an application without a contract has an empty environment). -
Breaking — the options are named like the context fields:
app.execute(…, { threadId, ownerId })(wasthread,owner),app.resume(threadId, decision, { tenantId, ownerId }),app.cancel(threadId, { tenantId, ownerId });ExecutionOutput.threadId(wasthread) and the newExecutionOutput.tenantId. -
Breaking — a thread belongs to its tenant: its key is
tenantId+threadId;app.cancelof a thread the tenant does not have throwsUnknownThreadError(it answered{ cancelled: false }). The storage contracts take the tenant:ExecutionRecordRepository.createThread(workflow, tenantId, owner?),hasThread(workflow, tenantId, threadId),threadOwner(workflow, tenantId, threadId); every record carriestenantId(Tern store migration 10);PausedExecutionRepository.findByTenantIdAndThreadId,resolveByTenantIdAndThreadId,deleteByTenantIdAndThreadId(were…ByThreadId), keyed by the paused run'stenantId. Leases and tree keys use the execution's tenant (was always"default");holderInstanceIdisApplicationContext.instanceId. -
SQLite (#265 S69 e):
paused_executionsis keyed by(tenant_id, thread_id)and thecheckpoints/checkpoint_writesrows carrytenant_id(the run'sconfigurable.tenant_id); a file written before is migrated on first use, its rows in the tenant"default".SqliteConnectionreadssqlitePathfromapplicationContext.environment. -
Traces carry the tenant: a run's LangGraph
metadata(tenantId,executionId,rootExecutionId,threadId,workflowName,workflowVersion) andtags(tenant:<id>), the Langfuse trace's tagtenant:<id>and metadatatenantId;TraceContext.tenantId. -
testExecutionContext({ tenantContext, tenant, executionContext, execution, application }).
-
Model providers are the application's (Model providers):
@ModelProviderclasses are listed only in@Application({ providers })— none listed → the built-inOpenRouterModelProvider+DecisionsModelProvider; one listed → the list is complete, the built-ins are not added next to it. A@ModelProviderin a workflow'sproviders→[di.infrastructure-in-workflow]. -
Breaking — no provider setting on a workflow:
WorkflowSettings.builder().modelProviders(…),.defaultModelProvider(…)andWorkflowSettings.modelsare removed; move the list to the application'sproviders.AssembledWorkflow.models→modelProviders(the application's list). -
Every model resolves to exactly one provider by
serves, at assembly — agents, model routers, the default router, guards, compaction, judges: new rule codes[model.unserved]and[model.ambiguous-provider](all at once, before any model call). No default breaks a tie: one model through two providers is two model strings. -
Breaking —
ModelProblemCodemodel.no-provider→model.unserved;ModelProviderDirectory.of(classes)takes no default provider (byName,defaultProvidergone). -
agboss describeprintsmodel: … → <provider>for each model (--json:models);agboss check --modelschecks every model of every workflow of the application.
-
Launches run (Workflow → Parallel launches at run time):
each branch is one LangGraph
Sendof one superstep,maxConcurrencya sliding window (a paused branch frees its slot), the join a node of its own reading only its launch's outcome records (launch id${executionId}:${nodeId}:${visitNumber}); the join's lists keep listed / item order.flow.launch-not-runnableis gone. -
New:
StepFailedError(step.failed, #273 S22; lineage e.g.[step.agent, step.failed, step]) — a step's own failure, naming it:component,nodeId(critic-agent#2for a copy),visitNumber,itemIndex;StepCoordinates. Breaking (class hierarchy) —AgentFailedError,GuardFailedError,RouterFailedError(withRouterDecisionError) andActionFailedErrorextend it instead ofPaidStepErrordirectly (stillPaidStepErrors);AgentAnswerInvalidError's andActionFailedError'scomponent/nodeIdcome from it, and every step error's recorddetailsnow carriesvisitNumber(anditemIndexin a per-item run). -
FailFastandSkip(S21): a failed branch aborts its siblings (BranchStatus.Cancelled, their spend counted) and the join takes the most specific.onJoinError(E, …)by class and cause chain, else the execution fails with the branch's error;FailurePolicy.Skipdrops only aStepFailedError— a limit in aSkipbranch fails the launch;app.cancelduring a launch cancels the whole execution (WorkflowCancelledError), never routed by the join. -
One visit per launch: every copy and item of a launch is the same visit of its node (the
launch's
visitNumber, told apart bycopyNumber/itemIndex);maxVisitscounts launches. -
Router launches (S23, #280 S47 f): a model router decides its
OnDemandroutes in one call — aDecision.noulper route on a decision model, one answer toolanswer-launchwith a boolean per route on a chat model; all no without anAlwaysroute →RouterDecisionError. A code router'sroute()list is launched beside itsAlwaystargets. Tests script both withdecideWith({ "<target>": P(yes) })(Testing → Parallel launches). -
New:
result.launches(ExecutionOutput.launches: LaunchRecord[]—launch,items,taken,branches: BranchRecord[]),BranchStatus, and the hookonLaunchItemsTruncated(OnLaunchItemsTruncated,LaunchItemsTruncatedEvent) when a per-item launch dropped items pastmaxItems(Observability → Parallel launches); a trace entry per ended branch (critic-agent#2,company-check-agent[3]), "launch items truncated" and "launch decision". Agent and action events name a copy by its copy id;toFollowPathtakes the class for a copy. A list a join gathered from several agents names each element's producer (ScopeValueRecord.elementProducers). -
Example:
examples/flow-patterns/src/review/— a section reviewer per section, a mapping action, then a style critic beside two fact checker copies.
-
Every storage contract has a ready SQLite implementation, enabled only by listing it in
@Application({ providers }):SqliteExecutionRecordRepository,SqliteCostLedgerRepository,SqlitePausedExecutionRepository,SqliteCheckpointSaver(our ownBaseCheckpointSaveronnode:sqlite),SqliteExecutionLeaseRepository,SqliteExecutionTreeRepository,SqliteRateLimitRepository(Several instances → SQLite). One connection per application instance on the environment'ssqlitePath— required only when one is listed ([environment.missing-field]) — WAL,busy_timeout, atomic operations inBEGIN IMMEDIATE, time from the framework's clock. -
Breaking for your own
ExecutionRecordRepository: a new abstract methodcompleteIfStatus(ternId, expected, outcome): Promise<boolean>— a compare-and-set status write. A resume marks its paused recordrunningwith it, so a write that lands after the holder finished the tree no longer leaves a finished recordrunning(#265 S66 h). -
Cross-process persistence is back for
--thread,agboss eval/replay/goldenwhen the application lists the SQLite implementations (#261 S49 b): a workflow run on its own (createAppDeps) now uses the infrastructure of the one application that lists it, not always the in-memory defaults. job-scout lists all seven (dev:.agboss/job-scout.sqlite,AGBOSS_SQLITE_PATH; tests::memory:).
-
Launches (Workflow → Parallel launches):
.toParallelAgents(A, B, B, { maxConcurrency })and.toParallelActions(…), a per-item launch (one target right after.returns(listOf(XDto)),{ maxItems }required), the mandatory.join(),.onJoinError(ErrorClass, side path)and.join().returns(…)— checked by the compiler against the new generated tableJoinOutputs(JoinOutputOf<Key>) and by the analyser. New public types:LaunchOptions,Launched,Joined,JoinErrorHandling,Launches,RouterLaunches,ValuesSource/StepValues/JoinValues, the AST'sLaunchStep,JoinStep,JoinErrorHandler,LaunchTargetKind;StepKind.Launch,StepKind.Join. -
Breaking (types only) —
Returning<TSource, …>takes a values source (StepValues<X>,JoinValues<Key>) instead of a component instance;AfterValueandRouterStephave a new optional type parameter. -
New rule codes:
[launch.join-required],[launch.max-items-required],[launch.max-items-without-list],[launch.item-not-read],[launch.branch-continued],[launch.branch-outdates-sibling](Path scope → Parallel branches, #271 AC9),[router.spawn-required],[router.prompt-on-always];[router.no-prompt]for anOnDemandroute without prompt;[router.all-routes-always]now applies to router launches. -
A model router states
spawnon every route before a launch (#273 S50, Routing patterns → 6); a code router'sRouterAnswerOf<Self>is a list when a launch follows it. -
agboss describeprints launches and joins; the steps checks count the copies that always run; the worst case of one call of each start is computed with the analysis. -
Not run yet: a workflow with a launch passed
agboss check, butcreateAppstopped withflow.launch-not-runnableuntil #273 PR 2 (above).
-
Breaking — environments are typed object literals: the contract is a plain class with declared
fields only (
environments/app.environment.ts), declared once in@Application({ environment: AppEnvironment }); each environment isexport const environment: AppEnvironment = { … }inenvironments/<name>.environment.ts.defineEnvironment,fromEnv,FromEnv,FromEnvOptions,EnvironmentValues,EnvironmentDefinitionand thedeclare module "agboss" { interface Environment … }contract are removed;agboss checkwritesinterface Environment extends AppEnvironment {}into.agboss/types.d.ts, soENVis typed by the contract (Environments). -
New:
env("X"),env.integer("X"),env.flag("X")(each with{ default }),secret("X")andSecret(prints as***everywhere,.reveal()gives the value; a literal in aSecretfield does not compile);EnvironmentErrorCode(environment.not-found,environment.missing-variables,environment.invalid-value,environment.no-contract). A missing variable fails the start asDATABASE_URL is not set (AppEnvironment.databaseUrl, prod);agboss check --env <name>lists the variables it needs, marked value / secret. -
Breaking — tests run on
test:testWith(App)loadstest.environment.tsand fails without it (no fallback todev);testWith(App, { environment })andcreateApp(App, { environment })are removed — name an environment withenv. Breaking —@Application({ environment })is required: an application with no settings declaresenvironment: EmptyEnvironment(new, a contract without fields), which needs no environment file (its environments are{}). -
New assembly rules:
[environment.contract-value](a value in the contract),[environment.missing-field](a listed framework implementation's field missing from the contract),[guardrail.no-model]. -
The
defaultscascade:@Application({ defaults })→@Workflow({ defaults })→ the component's own field, merged field by field, the most specific level wins (Configuration). Breaking: guards takedefaults.guardrail.model(new), neverdefaults.router.model— a guard with no model anywhere fails with[guardrail.no-model].[router.no-model]now reads… uses DecidedBy.Model but has no model — set model or defaults.router.model.agboss describeshows each router's model and each agent's temperature / maxTokens / history with where it came from ((own),(<application or workflow>),(built-in)). -
loadWorkflow,studioGraphOf,compareProfilesandworkflowOffromagboss/testingassemble a workflow in the application that lists it (its*.application.tsnext to it or above): its providers anddefaults.
-
Recovery (Several instances → Recovery): an execution's record
is
ExecutionRecordStatus.Runningwhile its tree moves (new public enum, root entry:Running,Answered,Guarded,Failed,Paused); every instance looks forrunningroots at its start and every 30 s, takes the lease of one whose holder died or stopped and continues it from its last checkpoint (a resolved pause first) — only the interrupted step runs again. A root of another config is left to a compatible instance and logged once. -
Breaking —
ExecutionRecordRepository: a new abstractfindByStatus(status, limit, after?)(a page of the records with that status, oldest first). A record is now written when the run starts (running) and completed when it ends, instead of appended at the end. -
Breaking —
WorkflowResumeEventhas areason: ResumeReason(new public enum:Answered—app.resume;Recovered— recovery,decisiononly when it took one). -
The graceful stop:
app.close(options?)stops the sweep, runsonStop, waits for the running executions up to the shutdown timeout —@Application({ shutdownTimeout }),app.close({ timeout })(CloseOptions), defaultseconds(25)— then interrupts them: the call rejects withApplicationStoppingError, noonError, the lease released at once, the record leftrunningfor recovery (App → app.close()). -
SIGTERM / SIGINT:
createAppcallsapp.close()on either (a second one interrupts at once); the listeners are removed whenclose()finishes, and the signal is raised again when nothing else listens.testWithnever listens.
-
Breaking —
.returns(value, options?)names one value (#272 S54): a DTO class,optional(…),listOf(…)oroneOf(…). Several values (a start's map of fields) are a chain,.returns(CompanyDto).returns(optional(SearchRequestDto)), in any order;.returns(A, B)does not compile (untyped code:FlowSyntaxError).ReturnsStepholds onevalue. -
Breaking — a
oneOfoutput is written before its switch:.returns(oneOf(A, B)).switch(…); a.switchwithout it, or with anotheroneOf, is[flow.returns-mismatch]. -
Breaking —
about(TargetDto, shape)is removed (withAboutWrapper,DeclaredOutput): components declare a plainoutput; the flow says what a value is about —.returns(CritiqueDto, { about: PlanDto }),{ about: [PlanDto, BudgetEstimateDto] }, and for aoneOfon the case,caseOf(ChangesRequestedDto, { about: OfferDraftDto })(never on.returns(oneOf(…))). A value'saboutslots are kept onScopeValueRecord.about.{ about }on a start's.returns(…)does not compile ([io.about-on-start]at assembly).[io.ambiguous-cycle]hints name the.returns/caseOfto write it on. -
Breaking — no string tags (#272 S56):
{ as: "before" }, the tag slots, their labels (DraftDto[before]#1) and[flow.unused-tag]are removed. NewhistoryOf(Dto, { limit })ininput(root entry andagboss/dto;HistoryOf<T>,HistoryWrapper,HistoryOptions): the field is{ current, previous }— the current value and the earlier versions oldest first, at mostlimit— typedHistoryOf<Produced<Dto, P>>inInputOf, shown in the input message (### draft, earlier versions (oldest first)) and bydescribe. It needs a currentDtoon every path ([input.missing-on-path]); the replaced versions are never[input.outdated-on-path]; alimitthat is not a positive integer →[input.history-limit]. -
[io.ambiguous-about]now: a producer whose values are{ about: T }readingTin two or more fields (withhistoryOf(T)the answers are about its current value);[flow.ambiguous-field]: two fields of one node that read one slot. -
Breaking —
listOf(oneOf(A, B))is one slot (#272 S53): the filteredlist:ADto/list:BDtoslots are gone; alistOf(ADto)field after such a step is[input.missing-on-path]. - Path scope, Workflow, Components, Standard DTOs.
-
New infrastructure contracts (root entry), each with a built-in in-memory default, listed only
in
@Application({ providers })and found by inheritance:ExecutionLeaseRepository(acquire(tenantId, rootExecutionId, lease),renew,release;ExecutionLease { tenantId, rootExecutionId, holderInstanceId, leaseEpoch, expiresAt }),ExecutionTreeRepository(saveIfAbsent(key, value)/findById(key)inside an execution; an implementation providessaveIfAbsentInTree,findByIdInTree,deleteAllInTree) andRateLimitRepository(acquire(key, maxSlots, lease),renew,release;RateLimitLease). There is noExecutionSlotRepository(Several instances). -
The execution lease (Q58):
app.executeandapp.resumemove a tree only while holding its lease (seconds(30), renewed every 10 s and before every checkpoint write, released when the pass ends). An instance that lost it stops, and its call rejects withExecutionLeaseLostError(execution.lease-lost) — no.onError, noonError, no failed record: the new holder continues. -
Breaking —
PausedExecutionRepository: a paused run has astatus(PauseStatus.Pending/Resolved) and, once resolved, itsdecision; a new abstractresolveByThreadId(threadId, decision)turns a pending pause to resolved atomically. A resume records its decision first (a second resume of the same pause →NotPausedError), then waits for the tree's lease; the holder takes decisions resolved meanwhile and the waiting call returns the tree's current output. -
Tree keys of
ExecutionTreeRepositoryare removed when the tree finishes, fails or is cancelled; a pause keeps them. -
Testing:
testWith(App, { instances: 2 })with the fixturesappsandclock;clock.advancetakesseconds(…)too and returns a promise —awaitit (the framework's waits sleep on the test clock) (Testing).
-
Breaking —
@Applicationis the one entry:@Application({ name, version, workflows, providers, budgets })(nameends with-application, the class withApplication), andcreateApp(App, { env })/testWith(App)take it.createApp(Workflow)andtestWith(Workflow)are removed — they do not compile (the generated.agboss/types.d.tsbrands every workflow) and fail at run time naming the fix.app.execute(Start, …)runs the workflow of that start (any workflow of the application);resume/cancelfind the workflow of the thread;app.name/app.versionare the application's. Environments are found next to the application file (App, Testing). -
New assembly rules (
GraphRuleError, all at once):[application.no-workflows],[application.not-a-workflow],[application.duplicate-workflow],[application.ambiguous-start](one start in two workflows),[graph.duplicate-node-name]across workflows,[budget.not-a-budget],[di.infrastructure-in-workflow],[name.kind-suffix]on the application and on budgets. -
Infrastructure:
CostLedgerRepository,ExecutionRecordRepository,PausedExecutionRepository(new, root entry) and LangGraph'sBaseCheckpointSaver— listed only in@Application({ providers }), found by inheritance, each with a built-in in-memory default. Breaking:createApp({ stores, pausedRuns }),AppStores,PausedRunRepositoryandcreateSqlitePausedRunRepositoryare removed, and nothing is written to the home folder any more: execution records (Terns) and the spend ledger were SQLite / JSONL files under~/.langgraph-agents/(TERN_DB,SPEND_LEDGER_DIR) and are now in memory — gone on restart, soagboss eval/replayonly see records of their own process until the SQLite implementations of #265 are listed (App → Infrastructure). -
Budgets, declarative:
@Budget({ name, scope, period?, workflow?, deps? })on a class thatimplements BudgetHandler(maxSpend(): Usd | Promise<Usd>),BudgetScope.Application | Tenant | Execution,BudgetPeriod.Day | Week | Month, all listed in@Application({ budgets })(config/budget.config.ts). They are components (injectable,mockOf-able); the checks at run time come with #261 PR 5 — until thensettings().limitscaps the money (FinOps). -
Tests: every framework fixture and test runs through an
@Application;app.ready()builds the test's application for a test that executes nothing; each test runs on the infrastructure the application lists (else the in-memory defaults), new per test. -
CLI (until #268):
agboss run/chat --workflow <file>run the application that lists the workflow — exported by its module, or by a*.application.tsin its folder or a folder above it — at that workflow's text start.agboss create/generate workflowwrite the workflow's*.application.tsandconfig/budget.config.ts.
-
Breaking — no scopes: one container per application instance, one instance per class (a class
listed by two workflows is one instance). The
scopeoption of every DI decorator, theScopetype, the run-scoped container and[di.scope-mismatch]/[di.run-scope-outside-run]are gone (against #184). Components keep no state: read the execution fromExecutionContext, keep data that outlive a step in the graph state, caches in your own service over external storage (Components → Components keep no state). -
Breaking — lint:
agboss/no-component-statereplacesagboss/no-run-state-in-singleton—this.x = …outside the constructor of any component is an error. -
Lifecycle hooks:
OnInit(new in the root entry),OnStart,OnStop,OnDestroy—onInit(dependencies first) →onStartat start,app.close()→onStop→ the running executions awaited →onDestroy(dependants first). A failingonInit/onStartstops the start ([di.hook-failed] <Component>.<hook> failed: …); failing stop hooks are collected into oneAggregateError. Afterapp.close()execute/resumereject with the newApplicationStoppingError(app.stopping, anonRunRejected);cancelstill works. Breaking:AfterAssembleis removed,OnDestroynow runs once at shutdown, not per run (App → Lifecycle hooks). -
Implementation by inheritance: a dependency on a contract gets the one listed class that extends
it; two →
[di.ambiguous-implementation]. One token with different values →[di.conflicting-values];provide(Class, …)→[di.instance-provider](nonew: the container creates every instance) (Components). -
createAppDeps(…).close(executionsDone?)takes what to wait for betweenonStopandonDestroy.
-
DecidedBy.Code: a router decided by its class —implements RouterHandler<Self>,route(input: InputOf<Self>)returns one of its targets (Route<Self>, generated; a target outsideroutesdoes not compile);depsin the constructor like an action, the execution throughdeps: [ExecutionContext]. No model call, cost 0, the same flow as a model router (Routers → Routers decided by code, Routing patterns). -
@Routeroptions are a union bydecidedBy(ModelRouterOptions/CodeRouterOptions):model,prompt,promptUrls,promptVariablesand routeprompts only on a model router;depsonly on a code router. -
spawn/failureon routes (SpawnPolicy.Always | OnDemand,FailurePolicy.FailFast | Skip), both kinds — the options of a parallel launch (#273). DefaultsAlways+FailFast; a code router's routeOnDemand. With.chooseOne(…)an explicit one →[router.policy-with-choose-one]andRoute<R>is every target; before a launchRoute<R>leavesAlwaystargets out and every routeAlways→[router.all-routes-always]. The generatedRouterAnswerstable types whatroute()returns. -
Breaking — router errors (S19):
RouterFailedError(step.router, the original ascause: a model call orroute()failed) andRouterDecisionError(step.router.invalid-decision, now aRouterFailedError: an answer outside the routes, a list after.chooseOne(…), an unreadable answer — never retried). Both carrycomponent,nodeId,visitNumber. The instance codesrouter.failed/router.unknown-route, the typeRouterFailureCodeand the fieldrouterare gone (router→nodeId); an unreadable model answer is nowRouterDecisionError, not a failure..onError(RouterFailedError, …)takes both;.onError(DomainError, …)the error inside. -
agboss describelists code routers ascode;mockLlm(CodeRouter)throws (a code router calls no model —mockOf()its services).
-
Style: a flow chain has one call per line —
start(A)/.returns(B)/.toFinish(C), androute(X)/.toFinish(F)inside achooseOne(…)(Workflows → The grammar). In the repo,*.workflow.tsmodules and the flow fixtures are left out of Prettier (which joins short chains), formatted bymake fmtand checked by ESLint's@stylistic/newline-per-chained-call; the docs' and Wiki's ts snippets that build paths are checked bynpm run check:docs(doc:line: … (@stylistic/newline-per-chained-call)).agboss create/agboss generatewrite workflow modules one call per line.
-
scopeholds current values only: a write moves the older value of its slot and everything built from it tooutdatedValues, each withoutdated: { byValueId, reason }—byValueIdthe root trigger,reasonanOutdatedReason(Replaced,AboutReplaced,BuiltFromReplaced) (Path scope → At run time).OutdatedValueRecord.outdatedgainsreason. -
onValuesOutdated(OnValuesOutdated,ValuesOutdatedEvent): one event per cascade, never empty, no DTO values; withExecutionEvent's fields (executionId,parentExecutionId,rootExecutionId,threadId,workflowas aWorkflowRecord) andstep(aStepRecord) (Observability); one "values outdated" trace entry per cascade. -
Records:
WorkflowRecord,StepRecord,ScopeValueReference,OutdatedValueReference,OutdatedMark;WorkflowDescriptor,StepDescriptorandComponentDescriptorextend them. -
agboss describeprints eachabout(…)output (CLI).
-
ExecutionContext: the one context of an execution, injected through the constructor —deps: [ExecutionContext], a parameter namedexecutionContext— into tools, actions, judges and services (Context and Authorization). Read-only data:executionId,threadId,ownerId,signal,workflow,step(idempotencyKey=${executionId}:${nodeId}:${visitNumber}[:${itemIndex}][:${callId}],callId,visitNumber, …) and the tree (parent,root,depth,executionOf(W)). A read outside an execution or after its step ended throwsExecutionContextUnavailableError(context.unavailable). -
No
ctxparameter:ToolHandler.run(input),@McpToolalike;ToolContextandRunContextare gone (ctx.run.runId→executionContext.executionId,ctx.run.owner→.ownerId,ctx.run.signal/ctx.signal→.signal,ctx.callId→.step.callId, side effects key on.step.idempotencyKey).ctx.run.metadataandctx.run.inputhave no replacement in the context: domain data come through a node'sinput.ctx.pause(question)is removed until #267 (questions as part of the graph; S44) — approval of a tool call through@Tool({ channel })keeps working.ctx.reportCost(usd)→deps: [SpendRecorder],spendRecorder.record({ service, cost })(S43), counted in the money limits; a tool'sROUTER_FACTORYdecisions are accounted as its spend by the framework. -
A tool call's signal (S45): in a tool call
executionContext.signalaborts on the execution's cancel and at the tool's owntimeoutMs. -
Judges:
judge(input: JudgeInput)(reply,task,attempt) instead ofjudge(reply, ctx: JudgeContext); the judge's model throughdeps: [JudgeModel](judgeModel.invoke/judgeModel.decide). -
Testing:
testExecutionContext({ … })replacestestRunContext();toolOf(tool).invoke(input)takes no context;app.tool(T).invoke(input)runs the tool in an execution of its own. - An exported tool called over MCP runs in an execution of its own; the client's
_meta.callIdis itsstep.callId.
-
Outdating (#271, Path scope): a field reads only current values — a new value outdates everything built from the older one, and assembly proves no node reads an outdated value (
[input.outdated-on-path]). In a cycle of classes built from each other the component that answers says what about:output: about(DraftDto, oneOf(ApprovalDto, DraftChangesDto))([io.ambiguous-cycle],[io.ambiguous-about]). A review loop written withoutaboutno longer assembles; the hint names the output to wrap. -
The flow is a list of paths (Workflow, ADR-0019):
start(S).returns(Dto).toRouter(R).chooseOne(route(A)…),.toAgent/.toAction/.toFinish,.switch(caseOf(…)),.onError(E, <side path>); own pathsagent(X)…/router(R)…/action(A)…for shared nodes. Removed:from(…).next(…),from(R).routes(),chain,node,catchError/compensateWith,nextParallel/join,batchParallel,joinQuorum,@QuorumRouter,parallel(…)/optional(…)route targets,Self, nested@Workflownodes, sagas andcompensate, theagboss/concurrencyentry. Parallel launches return in #273, child workflows in #263, code routers in #280. -
Generated types:
agboss checkwrites.agboss/types.d.ts(git-ignored) — what types the paths,InputOf<X>/OutputOf<X>andapp.execute(Start, input). A generated project runs it on install (npm run types);declare readonly inputon a start is no longer needed. -
Typed data on paths (Path scope):
@Agent/@Action/@Router/@WorkflowFinishtakeinputfields andoutputshapes; wrapperslistOf,oneOf,optional,variant,isVariant(agboss/dto). Agents read an input message after static instructions and answer a structuredoutputthrough answer tools;{{item}}and{{input.…}}in instructions are gone ([prompt.input-in-instructions]). -
Names: every component
nameends with its kind (main-router,scout-agent,chat-workflow-start,charge-action,job-scout-workflow) and is unique in the application; DTO classes on paths end withDto— the standard DTOs are renamed (WorkflowStartText→WorkflowStartTextDto,Money→MoneyDto, …, Standard DTOs). LangGraph node ids are the names (no<kind>.prefix). -
@WorkflowAction→@Action,execute(input)with its fields from the path's scope (ActionHandler<Self>).ctx.run,ctx.idempotencyKeyandctx.executionContextare gone from actions — since #280 the execution comes throughdeps: [ExecutionContext](above). -
Limits:
@Workflow({ maxSteps })replaceslimits.perRun.steps;maxVisitson routers and agents (VisitsLimitExceededError, catchable with.onError); money stays insettings(). -
Results:
ExecutionStatus.Finished | Paused;result.finishis{ name };{ record: true }adds the execution record. The thread's history keeps finished executions asstart <name>: <input>/finish <name>: <output>. -
Enums instead of strings (#269):
Reasoning.…instead ofthinkingstrings on components,RagMode.Tool | Context,ModelKind,ProviderSort,ImageDetail,ExecutionStatus. -
defaults.routeris{ model }(nokind); a@RoutertakesdecidedBy: DecidedBy.Modeland a requiredinput. -
job-scout: one path per turn, names with their kinds, profiles and golden sets under
job-scout-workflow/(Example).
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Naming
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)