Skip to content

Changelog

Claude edited this page Oct 22, 2026 · 64 revisions

Changelog

Breaking changes and notable additions, newest first. The API reports (packages/agboss/api/*.api.md) hold the exact public surface of each entry.

Unreleased — app.cancel from any instance stops an execution running on another (#360)

  • 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 with ExecutionCancelledError within 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 running record exists (its budgets, its thread's compaction) returns { cancelled: false }.

Unreleased — an instance's cap on steps, its queue and readiness (#332)

  • 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 the Clock); a step in a provider's rate-limit queue keeps its slot; waiters at app.close() are interrupted at the shutdown timeout and recovered elsewhere.
  • Added — HealthComponentKind.StepSlots (Health checks): the readiness component step-slots (only with a cap) — Degraded while 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 describe prints maxConcurrentSteps; describe --json has application.maxConcurrentSteps (null when unset).

Unreleased — tool results bounded by maxResultSize (#336, PR 2)

  • Added — maxResultSize (Tools → Result size): what a model reads of one tool result at most, in bytes of UTF-8 — on @Tool / @McpTool and in defaults.tools of @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() and Bytes in agboss/units (1 KB = 1,024 bytes).
  • Breaking — ToolEndEvent.isResultTruncated: boolean is required: an object built as a ToolEndEvent (an observer test) sets it.
  • Changed — every workflow's configHash once: the truncation marker is a framework text of the agent loop; a paused execution resumed on the new version is IncompatibleResumeError unless onIncompatibleResume accepts it.
  • Added — agboss describe prints each tool's maxResultSize and where it came from; describe --json has schemas.tools[name].maxResultSize and each agent's maxResultSize.

Unreleased — a decided quorum withdraws its waiting branches' pauses; channels may withdraw (#337)

  • Added — withdraw(withdrawal) on channels (Pauses → Taking a question back): the pair of send — a no-op on ChannelOf (a role's implementation writes override), optional on ChannelHandler; ChannelWithdrawal<TRole> (root entry) is { pauseId, value, resolution: PauseCancellation }. Called once when a pause the channel sent closes without an answer — Withdrawn or Cancelled — in the pause's frame, under its tool's timeout; never for a pause decided at once or never sent. A throw or a timeout is the warning channel.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 — FailFast withdraws 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 has branchKeys (the launch branches it waits under; SqlitePausedExecutionRepository keeps 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, any extends): before, only a dependency on the class itself got the mock.
  • PauseResolution.Withdrawn reads "a FailFast sibling failed, or its launch's quorum was decided".

Unreleased — a child workflow's change is part of its root's configHash (#336, PR 1)

  • Fixed — a root's configHash covers its child workflows (Terns and evaluation → What the config hash covers): the snapshot adds children — every child reachable from the flow, recursively, each once by name, ThisWorkflow excluded. A run paused or running inside a child that a deploy changed is now IncompatibleResumeError / recovery.incompatible, decided by the root's onIncompatibleResume, 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 its onIncompatibleResume says "resume". Workflows without children keep their hash.
  • Breaking — IncompatibleResumeError is an AGBossError with static code = "resume.incompatible" (the same string; its instance code is typed string), so .onError(AGBossError, …) and error instanceof AGBossError include it.

Unreleased — the declarative snapshot and join keys (#268, PR 5)

  • Added — agboss describe --json is each workflow's declarative snapshot (CLI → The declarative snapshot): next to what the text prints, every entry of workflows[] has configHash (the full hash; config stays its first 8 characters), starts, finishes, nodes (kind, hashes, input / output by 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's decidedBy, model and routes with spawn / failure), edges, the onError table, launches (quorum, arbiters with their hashes) and schemas — every DTO by class and every tool by name with its schemaHash and JSON Schema.
  • Breaking — ComponentRecord is { name, kind, promptHash, schemaHash } (Observability → Join keys): kind is the new ComponentKind enum (root entry); promptHash hashes what the component's model is told, schemaHash what it reads and writes (each DTO by its JSON Schema), each undefined when it has none — the values describe --json gives. 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) and BudgetExceededError.budget (kind: ComponentKind.Budget; BudgetBreach.budget names it by name only). A test comparing one with toEqual({ name }) compares with toMatchObject.
  • Breaking — WorkflowRecord has configHash: the workflow's fingerprint, the one join key — in every execution event, executionContext.workflow, child records and limit errors; there is no graphHash.
  • Breaking — StepRecord has toolLoopIteration and validationAttempts: an agent's model call in the visit (from 1, during the call and its move's tool calls; undefined elsewhere) and its answers and tool arguments that failed validation so far (0 outside an agent's loop). The idempotency key's format is unchanged (#335). A pause keeps both and its agent's record: record.pauses[].step names 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 } on StepRecord (S93): its status and, under a quorum, its verdict are known only after the step ends; they stay in result.launches and onQuorumDecided, joined by nodeId, copyNumber and itemIndex (open question on #268).

Unreleased — a PII key per root execution when the application sets none (#334)

  • Fixed — one value, one token across instances (Several instances → PII tokens): without @Application({ pseudonymKey }) the tokens of sensitive fields 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 to piiTokens and hands it to its child workflows; every instance that continues it gives one value the same token. With pseudonymKey (or under testWith, 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 generate takes full names (CLI): the positional name ends with its kind (billing-agent, escalation-router, refund-tool, policies-rag, valid-score-arbiter, onboarding-workflow), --workflow is a workflow's full name (desk-workflow, found in the application of package.json or --application, its children included) — no more --workflow src/x/x.workflow.ts — and --agent an agent's (billing-agent). Nothing is completed: a short name is exit 2 with Did you mean "billing-agent"?, an unknown workflow or agent too, and nothing is written. generate takes --application <path>.
  • Breaking — agboss generate workflow <name>-workflow writes 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 are chat:<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 at src/<project>.application.ts (its path in package.json) with, beside it, environments/, config/budget.config.ts, model-providers/, guardrails/prompt-injection.guardrail.ts (now the application's, @Application({ guardrails, defaults: { guardrail } })) and services/api.service.ts; the first workflow <project>-workflow in src/<project>/. --agents / --tools and 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 bases name: "<name>-rag" (class <Name>Rag, the index in the project's git-ignored .agboss/rag/, not the home folder), MCP tools <server>-<tool>-tool with the server injected as <server>Server, MCP servers kebab-case, the OpenAPI client <Name>ApiService injected as <name>ApiService, OpenAPI tools <operation>-tool; no models.ts.
  • Breaking — tool names (Tools, Naming): a @Tool / @McpTool name 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 is search-<rag name>-tool (was search_<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 is company-notes-rag.
  • Breaking — timeouts have units: @Tool({ timeoutMs: 5000 }) / @McpTool({ timeoutMs }) → timeout: seconds(5) (a Milliseconds value; default 30 s); a tool's timeout error reads timed out after 5 s. Internal: DEFAULT_TOOL_TIMEOUT, JEV_TIMEOUT.
  • Breaking — Execution…, not run… (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… vs Execution…, units, full names in the CLI.

Unreleased — a budget breach in a parallel launch stops its siblings at once (#338)

  • 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, .toParallelWorkflows or 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 through executionContext.signal; they fail with that BudgetExceededError (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.

Unreleased — checks and fingerprints (#120, PR 4)

  • 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, FinishHandler declare readonly method: (…) => … instead of a method signature (ToolHandler and JudgeHandler already did), so a method that takes a narrower parameter than the contract no longer compiles — on @Rag, @PiiPolicy (a detector's findMatches, a declarative policy's refusal(kind)) and @ModelProvider whether or not the class implements it. 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, \n line breaks, no blank lines at the edges, Unicode NFC; inside kept as written; promptUrls parts 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, uncompactedTurns and the compaction, an agent's judges; and the framework's own texts. Every workflow's configHash changes once with this release, so a pause taken before it resumes only where onIncompatibleResume accepts it.
  • New — agboss describe shows a computed guardrail refusal as ClassName.refusal() (tested; printed since #120 PR 2).

Unreleased — lease fencing at expiry, unique tool-call ids (#335)

  • Fixed — a lease store down past the lease (Several instances → The execution lease): the holder trusts its lease until the Clock read 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 with ExecutionLeaseLostError — the agent's tool loop starts no further model or tool call, no .onError, the record stays running and 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.

Unreleased — describe and the check report: the worst case per start, check --env (#268, PR 2)

  • New — agboss describe describes 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 with maxItems / maxConcurrency; ThisWorkflow marked with maxDepth) —, 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: "…" or CardPiiPolicy.refusal()) and maxDepth among 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's application carries the same as data (tree, implementations, modelProviders, channels, budgets, executionBudgets, …); a workflow's data no longer repeats the application, and gains piiPolicies.
  • New — the check report (CLI): agboss check prints, 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. describe prints the same report under each workflow's limits (it was one line).
  • Changed — agboss check --env <name> lists the variables as prod environment needs 3 process variables: with aligned NAME value | secret[, optional | , has a default] lines — the field name is no longer printed, never a value (Environments).
  • Breaking — describeWorkflow is removed from the public API (it was a root export): describing is the CLI's — use agboss 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 no describe/ module any more.
  • Internal (agboss/internal): describeApplication(assembled, { source, profile, environment }), describedApplicationOf(assembled) (takes the assembled application; executionBudgets per workflow, modelProviders), describedTreeOf, describedChannelsOf, describedPiiPoliciesOf, worstCaseReportLines / worstCaseRowsOf (WorstCaseMeasure), worstCaseLimitsOf; a WorstCase carries its workflow, isStepsCapped, treeSteps and children (each edge into a child: count, atOnce, maxItems, maxConcurrency, isRecursive, the child's worst case).
  • job-scout: a prod environment (agboss check --env prod).

Unreleased — health checks: @HealthCheck, app.checkLiveness(), app.checkReadiness() (#300, PR 1)

  • New — the instance's probes (Health checks): app.checkLiveness() (no I/O: the application — Unhealthy, stopping, from app.close() on — and the recovery loop — Unhealthy when its last sweep ended more than three sweep intervals ago) and app.checkReadiness() (liveness's components, every store's ping(), every model provider's circuit breaker — open → Degraded, never a model call — and the project's checks), each → HealthReport { status, components } with HealthStatus and HealthComponentKind. Checks run at once, each under its timeout on the Clock; one that throws or times out is that component's Unhealthy with its reason — neither call rejects. testWith's app has both, also during and after close(). No HTTP server: the project's exposes them.
  • New — @HealthCheck({ name, timeout, deps? }) + implements HealthCheckHandler (check(): Promise<HealthCheckResult>, built only with HealthCheckResult.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 run SELECT 1. Migrating: a project's own implementation adds ping() — one fast round trip that rejects when the store is unreachable (Several instances → Your own implementation). A subclass of a framework implementation inherits it.

Unreleased — the IdGenerator contract (#313)

  • New — IdGenerator (root export, App → Infrastructure): an infrastructure contract like the Clock — newThreadId(), newExecutionId(); every thread id the framework mints and every execution id (roots, children, recursion levels) comes from it. The built-in RandomIdGenerator (root export) keeps the formats (random UUIDs); a class in @Application({ providers }) that extends IdGenerator replaces it. A pauseId stays a random 128-bit token outside it.
  • New — SequentialIdGenerator (agboss/testing): testWith runs on it unless the application lists its own generator — thread-1, … and execution-1, … per test (Testing → Ids).
  • Breaking — the records repositories' deps: SqliteExecutionRecordRepository takes [SqliteConnection, Clock, ApplicationContext, IdGenerator] (was [SqliteConnection, Clock, NEW_THREAD_ID], an internal token), the built-in in-memory one [Clock, IdGenerator]; NEW_THREAD_ID is removed. A project now subclasses SqliteExecutionRecordRepository through public tokens only (Several instances → Extending a SQLite implementation).
  • Breaking — createApp / createAppDeps options: the undocumented newThreadId and newRunId options are removed — list an IdGenerator instead.
  • Changed — execution ids under testWith: execution-1, execution-2, … (were run-1, …).

Unreleased — agboss dev and agboss studio: no studio.ts, children as subgraphs (#268, PR 3)

  • New — agboss dev [--strict] [--env <name>] (CLI): watch mode over the application's src/ — every change runs agboss check in a short-lived child process (debounced, 200 ms; never two at once), so .agboss/types.d.ts is 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.ts and .agboss/langgraph.json (git-ignored, rewritten on every run) and starts langgraphjs dev on 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 — studioGraphOf is removed from agboss (and from the deprecated agboss/core), and the examples' src/studio.ts and the root langgraph.json are gone. Migrating: delete the project's studio.ts and langgraph.json, run agboss studio (job-scout: npm run studio). The graphs come from studioOf(Application, { env }) in agboss/internal, the CLI's.
  • Changed — @langchain/langgraph-cli is a dependency of agboss-cli (it was the monorepo root's dev dependency), so agboss studio works in any project.

Unreleased — flow-patterns runs with a real model, and an approvals pattern (#346)

  • New — examples/flow-patterns/src/approvals/ (Example): offsite-booking-workflow — two buyers in one .toParallelAgents(…) launch, each place_purchase call waiting for the thread's owner through the PurchaseApprovalChannel role, answered in the terminal (TerminalPurchaseApprovalChannel); scripted tests: both pauses pending, both answers taken, a second answer NotPausedError, each question asked once.
  • Changed — flow-patterns runs with a real model: its dev environment reads OPENROUTER_API_KEY (openRouterApiKey, a Secret) and, optionally, OPENROUTER_BASE_URL (openRouterBaseUrl) for the built-in providers; test is unchanged. Every store of its application is SQLite now (records, pauses, checkpoints, leases, tree keys, besides the ledger). npm run check also runs agboss check --models --env test (no key); npm run answer answers waiting pauses from another process. Its README.md says how to run the manual checks #263 M1, #264 M1 and #267 M1.

Unreleased — compaction: @Compaction, strategies, uncompactedTurns (#120, PR 3)

  • New — @Compaction({ name, description, prompt?, promptUrls?, model, strategy, turnsBetweenCompactions, summaryQueueLength?, summaryMaxTokens }) (Memory Management): compactions/*.compaction.ts, a -compaction name; CompactionStrategy.SummaryQueue (a queue of self-contained summaries, the latest summaryQueueLength kept), SingleSummary (one summary, rewritten), None (no model, no prompt, no parameters — the window only). A strategy that writes summaries without a model, or None with one, does not compile. Root exports: Compaction, CompactionStrategy, TurnLimit, CompactionError, the option types, UncompactedTurns, SummaryMaxTokens, TokenLimit.
  • New — compaction and uncompactedTurns: one setting each on @Application, @Workflow, @Agent and @Router — the lowest level that sets one wins (AC4); uncompactedTurns defaults to 5, TurnLimit.ALL is every turn; on a code router neither compiles. A compaction summarises everything older than the smallest uncompactedTurns among 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 — summaryMaxTokens is the summarising call's max_tokens (TokenLimit.MODEL_MAX allowed, thinking off): cut at it → one "shorter" retry, then CompactionError (code compaction; compaction, summaryMaxTokens; a failed model call is one too, as its cause) — 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 CompactionError fails the new execution before any of its work (no model call, no step, no turn kept). Its spend is the new execution's; result.compacted says what ran before it.
  • Breaking — TokenLimit.MODEL_MAX replaces the MODEL_MAX constant (S132), an enum next to TurnLimit.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 against serves as compactions.<name>.
  • Breaking — removed: defaults.history (limit, summaries), @Agent({ historyLimit, historySummaries }), @Workflow({ compaction: { every, keep, model }, compactionPrompt }), the mockCompaction fixture and the types HistoryDefaults, CompactionOptions (the old one), ModelOptions. Migrating: defaults: { history: { limit: 8 } } → uncompactedTurns: 8 on the same decorator (outside defaults); historyLimit: 3 → uncompactedTurns: 3; compaction: { every: 5, keep: 10, model: { model } } → a @Compaction class with strategy: CompactionStrategy.SummaryQueue, turnsBetweenCompactions: 5, summaryQueueLength: 10, model, summaryMaxTokens named by compaction: ConversationCompaction; mockCompaction() → mockLlm(ConversationCompaction). A profile's compaction: is compactions: { <name>: … }, defaults.history.limit is defaults.uncompactedTurns.
  • Breaking — summaries are kept per compaction: ExecutionRecordRepository.uncovered, latestSummaries and summaryCount take the compaction's name after (tenantId, threadId), a Summary has compaction (SQLite migration 13; a summary written before it belongs to no compaction and is not shown again). result.compacted is a list ({ compaction, fromTurn, toTurn, summaries, summaryQueueLength }, one per compaction that ran); spend is compaction:<name> (category compaction).
  • Changed — a compaction's model failure used to be swallowed (the next turn retried); it is now a CompactionError like a summary that does not fit (#77 AC5 replaced, ADR-0007 amended).

Unreleased — the application path and starts: --start, --input, rag index (#268, PR 1)

  • Breaking — the CLI works on one application, set once (CLI): "agboss": { "application": "src/job-scout.application.ts" } in the project's package.json (relative to its folder), or --application <path>, which wins; neither → exit 3 saying where to set it. --workflow <path> is gone from chat, run, describe, check, eval, replay, golden, compare and rag (an unknown option, exit 2); the generators keep theirs until PR 4. Migrating: add the agboss field to package.json and drop --workflow … from the scripts ("chat": "agboss chat"); agboss create writes both.
  • New — --start <full name> on chat, 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 with Did you mean "…"? and the list — the lookup is exact, nothing runs under a guessed name.
  • Breaking — agboss run --input is JSON: --input '<json>' / --input-file <file> give the start's input, checked against its InputOf (one DTO, a listOf(…), 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 taking WorkflowStartTextDto (--input <text> was that before). agboss chat needs a start taking WorkflowStartTextDto.
  • Breaking — agboss rag index [--rag <name>] (was rag:index --kb): every knowledge base of the application, each once; --rag takes the full name, an unknown one is exit 2 with a hint.
  • Changed — describe and check cover every workflow of the application: describe --json is { application: { name, version }, workflows: [ … ] }; check assembles the application as createApp does (its rules across workflows too) and its problems name the application's path (ok: src/job-scout.application.ts — …); result.application replaces result.workflow.
  • Internal (agboss/internal): loadApplicationClass(path) loads the one exported @Application of a module (ApplicationLoadError, CLI code application.load) — the --workflow bridges environmentOwnerOfFile, textStartOfWorkflow and workflowsOfApplication are gone; new startsOfApplication(App) (ApplicationStart: name, description, workflow, class, input — from the decorators), startInputIssuesOf(Start, payload), applicationOf, environmentOwnerOf; applicationWarningsOf takes the assembled application.

Unreleased — the variant of a oneOf reply (#262, PR 5)

  • Breaking — JudgeInput<TReply> carries variant (S125, Judges → The variant of a oneOf reply): 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 a oneOf(A, B) judge JudgeInput<A | B> is a discriminated union ({ variant: typeof A; reply: A; task; attempt } | { variant: typeof B; … }), narrowed by isVariant(input, A); a single DTO, listOf(XDto) or PlainTextDto judge has variant as that one class. Migrating: a judge that told variants apart by their fields ("salary" in reply) uses isVariant(input, OfferDto).
  • Breaking — a listOf(oneOf(A, B)) judge reads reply: readonly VariantOf<A | B>[]: each item { variant, value }, as the answer's items are kept, narrowed by isVariant(item, A); it has no variant of its own. Migrating: implements JudgeHandler<readonly (A | B)[]> becomes JudgeHandler<readonly VariantOf<A | B>[]> (the old form does not compile), each item read as item.value.
  • New — VariantOf<T> (agboss/dto, the root), JudgedValueOf<TShape> (what a judge of a reply shape reads) and JudgeInputOf<TJudge>; isVariant(x, XDto) also narrows anything that carries variant (a judge's input, a VariantOf item).
  • Breaking — testJudge(J, …)({ variant, reply, task, attempt }) takes variant too, typed the same way: a variant with another class's value does not compile.

Unreleased — interpreter outcomes, a decision-model interpreter, child pause records (#267, PR 4)

  • 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 one Decision.noul("approved") about the question and the reply — approved when P(yes) > 0.5, the answer { approved }, never a reason or overrideArguments; counted under reply-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>.interpret returns ReplyInterpretation.understood(decision) or ReplyInterpretation.notUnderstood({ reason }) (sync or a promise; ReplyInterpretationKind, UnderstoodReply, NotUnderstoodReply); a plain object does not compile. notUnderstood → UninterpretableReplyError with that reason, the pause pending; a throw is a crash only (unreadable too, its error the cause). Migrating: wrap each returned decision in ReplyInterpretation.understood(…), and replace a throw that declines with return ReplyInterpretation.notUnderstood({ reason }); a test's mockOf(I).interpret.mockResolvedValue(decision) becomes mockReturnValue(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 in result.children and result.record.children lists the resolved pauses its own steps asked; the root's record.pauses keeps the whole tree's. PauseAnswerRecord.executionId names the execution a pause waited in (stored with each answer: a pause_answers row written before this change has none and fails to parse). The state's records are the internal EndedChildRecord (exported as a type; it is ChildExecutionRecord without pauses).
  • 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).

Unreleased — guardrails: @InputGuardrail / @OutputGuardrail, refusals, levels (#120, PR 2)

  • 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 tool answer-guardrail); model else defaults.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) ≥ threshold the execution fails with GuardrailBlockedError (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 that implements GuardrailRefusal (its services and the execution through deps); neither does not compile (the decorator's overloads); at assembly [guardrail.missing-refusal], [guardrail.refusal-twice].
  • New — levels: guardrails on @Application, @Workflow, @Agent, @Router add up, excludeGuardrails on 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 in app.warnings and the start log (an error under agboss check --strict, silent in tests).
  • Breaking — @Workflow({ guards }) is removed with the framework's guard texts (prompt_injection, pii), GuardOptions, GuardsOptions. Migrating: one @InputGuardrail / @OutputGuardrail class per guard (guardrails/*.guardrail.ts), listed in guardrails. 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 @Guardrail hook classes are removed (beforeToolCall, afterToolCall, onChannelDecision, beforeAgentAnswer, GuardrailContext, overrideGuardrails, disableGuardrails, guardrails on @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 / GuardrailHookResult are removed.
  • Changed — observer events: GuardrailStartEvent.input is { side: GuardrailSide, element, text } (the masked text), GuardrailEndEvent.update is GuardrailOutcome { isBlocked, probability }. Spend: guardrail:<name> (category guards; was router:guard:<name>); tests script it with mockLlm(G).thenReturn(decideWith({ blocks: 0.9 })) — an unscripted guardrail passes. A VCR cassette records it as decide guardrail:<name>.
  • agboss describe prints each guardrail (side, threshold, model and where it came from, refusal — the text or Class.refusal() —, the elements it guards); agboss create writes guardrails/prompt-injection.guardrail.ts.

Unreleased — reply interpreters, pause answer records and retention (#267, PR 3)

  • Free-text replies (Pauses → free text): app.resume({ pauseId, reply }, options) — ResumeRequest is now DecisionResumeRequest | ReplyResumeRequest, exactly one of decision / a non-empty reply (else DtoValidationError). The role's replyInterpreter — a @ReplyInterpreter class (-reply-interpreter): DecidedBy.Model (a chat model, one answer tool, static prompt / promptUrls) or DecidedBy.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 by serves, is checked against the tree's budgets first, counted in their ledger and in the execution's spend (once) and traced under reply-interpreter:<name>. Either kind reads in the pause's execution frame (its ExecutionContext: 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 as cause); the pause stays Pending. ReplyInterpreterMissingError (pause.no-reply-interpreter): a reply for 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 a PauseAnswerer.Channel pause —, step, role, answeredBy, responder, resolution, askedAt, resolvedAt, decision PII-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 on ExecutionRecordRepository (a project's own implementation adds them) savePauseAnswer(tenantId, rootExecutionId, answer) and findPauseAnswersByTenantIdAndRootExecutionId(tenantId, rootExecutionId); the built-in and SQLite ones keep them in a pause_answers table (migration 12 of the record store).
  • Retention: @Application({ pauseRecordRetention }), default hours(24) — resolved records (Taken, Cancelled) are removed by the recovery sweep through PausedExecutionRepository.deleteResolvedBefore(cutoff); within the window a late answer is NotPausedError, after it UnknownPauseError; Pending is never removed.
  • Breaking — PausedExecutionRepository (a project's own implementation changes): PauseRecord.resolution (the decision and who answered) is now answer: PauseAnswer (decision, answerer, usage?) — the interface PauseResolution became PauseAnswer, and PauseResolution is the enum above; new record fields copyNumber, askedAt, resolution, resolvedAt. resolveByTenantIdAndPauseId(tenantId, pauseId, answer, at), cancelByTenantIdAndPauseId(tenantId, pauseId, resolution: PauseCancellation, at); new decideByTenantIdAndPauseId (a channel's ChannelOutcome.decided(…) closes its record as Taken / Decided instead of deleting it) and deleteResolvedBefore(cutoff); deleteByTenantIdAndPauseId is removed. SqlitePausedExecutionRepository migrates a PR 2 file.

Unreleased — judges see the answer as its DTO (#262, PR 4)

  • Breaking — @Judge({ name, model, reply, deps }) + JudgeHandler<TReply> (Judges and Quality Gates): a judge declares what it judges — reply is any shape of an agent's output — a DTO class, oneOf(A, B) (typed A | B), listOf(XDto) (readonly XDto[], listOf(oneOf(…)) the items' values) or PlainTextDto for a text agent — and judge(input: JudgeInput<TReply>) gets the agent's validated answer object ({ text } for a text agent), never a serialized string. JudgeHandler and JudgeInput take the reply's type.
  • Breaking — JudgeVerdict.feedback is reason, and verdicts are built only with JudgeVerdict.passed({ metrics? }) / JudgeVerdict.failed({ reason, metrics? }) — a plain { passed, feedback } object no longer compiles. The reason goes back to the agent for its retry. QualityGateError.feedback is rejections: [{ judge, reason }] (JudgeFeedback → JudgeRejection).
  • New rule [judge.wrong-reply]: an agent whose output does not fit a listed judge's reply fails assembly (also agboss check) — a text agent with a DTO judge, a DTO agent with another class, a oneOf variant 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: reply is a plain object of its DTO; another shape does not compile.
  • Migrating: add reply to every @Judge, write implements JudgeHandler<XDto> and JudgeInput<XDto>, read reply.text in a text agent's judge, and return JudgeVerdict.passed() / JudgeVerdict.failed({ reason }).

Unreleased — ThisWorkflow typed per path (#264, PR 3)

  • ThisWorkflow is typed per workflow (Workflow as a step → Recursion): agboss check writes a ThisWorkflowOutputs entry per recursive workflow (input, output, join), keyed by its name; [types.this-workflow-outputs] is removed — recursive workflows with different finishes live in one project. No project code changes.
  • The .returns(…) after a ThisWorkflow launch (.toWorkflow, route, workflow, toWorkflow as a side path, the join of .toParallelWorkflows) is checked by @Workflow({ name, flow }): a wrong class fails on flow, no longer at the .returns(…).
  • Builder types (API reports): every path head (SourceHead, RouteHead, CaseHead, SideHead, GroupHead) takes a claims type parameter; .switch, .chooseOne, .onError, .onJoinError and .joinQuorumGroups are generic over their sub-paths; WorkflowOptions<TName, TFlow> and Workflow(…) are generic; new ThisWorkflowOutputs, ThisWorkflowCheck, ThisWorkflowStepClaim, ThisWorkflowJoinClaim, ClaimsOf, WithClaims, ChildLaunched. The generated ProducerOutputs, Inputs and JoinOutputs no longer hold a this-workflow entry.

Unreleased — the tree's concurrency cap (#264, PR 2)

  • @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 (the Clock). A child's own smaller cap limits its subtree.
  • A quorum's deadline excludes the time its branches wait in the tree's queue; a QuorumNotReachedError past 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 describe prints maxConcurrentExecutions N (set | default) for a workflow that launches children; describe --json limits carry maxConcurrentExecutions and isMaxConcurrentExecutionsSet.

Unreleased — concurrency, instances and unit runners (#262, PR 3)

  • 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.Real runs them as production does (within maxConcurrency); TestConcurrency.inOrder([step(A), step(B, partial)]) makes them complete in that order through completion gates (step(Node, partial?) takes the matcher of when(…)). A step waiting at its gate is cancelled at once by a FailFast, a decided quorum or app.cancel; when no branch can move and the next step is not waiting, the execution fails at once with test.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) sets concurrency: TestConcurrency.Real.
  • Unit runners (Testing → Unit runners): testTool, testAction, testRouter, testArbiter, testRag, testJudge, testGuardrail and testPii — one component on its own container (overrides: [override(Class, Replacement)], environment), every call in its step's frame (executionContext.signal with a timeout on the runner's VirtualClock, run.clock; SpendRecorder and its model calls into spend), 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 }, as testExecutionContext takes them, validated the same way. testPii([Policies]) checks the policies' own rules first (-pii-policy) and gives mask(text) / reveal(text). The agboss create / agboss generate tool / agboss generate openapi tool tests use testTool.
  • 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 by override(RefundApprovalRole, AutoApproveChannel) (the replacement's send must fit the role). AutoApproveChannel / AutoRejectChannel's send now gives DecidedOutcome<AutoDecision> (approved, reason), so they fit any role that approves a tool.
  • GuardrailContext is exported from agboss (the context testGuardrail's hooks take).
  • New page The test split: unit tests with scripts → evaluation and golden sets → make smoke; no test calls a real model.

Unreleased — recursion and the soft stop (#264, PR 1)

  • ThisWorkflow (Workflow as a step → Recursion): a workflow launches itself — .toWorkflow(ThisWorkflow), route(ThisWorkflow), .toParallelWorkflows(ThisWorkflow, …); the node this-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.depth and 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 past maxDepth does not start; the level above gets it as the cause of ChildExecutionFailedError.
  • [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: the recursive: line, per-item child results counted (this-workflow ×0–5); the worst case counts the levels down to maxDepth.

Unreleased — child workflows at run time (#263, PR 2)

  • Children run (Workflow as a step): flow.child-not-runnable is 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's checkpoint_ns; its own executionId, kept in its state across a pause, a restart and a crash (no second onExecutionStart); its own path, steps and maxSteps; 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, a StepFailedError: cause, childExecutionId, itemIndex, workflow): an uncaught error of the child, its own maxSteps / maxVisits included; budgets and cancellation pass through unwrapped (S25). PaidStepError stays the base of StepFailedError (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 carry parentExecutionId / rootExecutionId; executionContext.parent, root, depth, itemIndex, executionOf(W) read the tree; ExecutionTreeRepository keys are the root's.
  • Pauses inside a child (#267): the root's — in result.pending with childExecutionId and, per item, itemIndex; several children may wait at once; resumed by pauseId like any other.
  • Testing: toHaveChildExecutions(W, [...]), toHaveHandledError(E, { at, by }).
  • The worst case of a start counts its child executions (maxItems × the child's, at most maxConcurrency at once) and their depth.
  • Example: examples/flow-patterns/src/vendor/ — a child workflow per vendor.

Unreleased — pauses and resume (#267, PR 2)

  • 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 — the ToolCallApprovalAskDto), 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. A PauseAnswerer.ThreadOwner pause lists its id and takes ownerId; a PauseAnswerer.Channel pause takes a non-empty answeredBy (recorded on the pause and in onExecutionResume, new field answeredBy) and its id is only in the channel's ask.pauseId; the other credential → PauseAnswererError (pause.wrong-answerer). A concrete @Channel named as is answers as the thread's owner. The CLI, the MCP resume_workflow tool ({ pauseId, decision }) and the A2A adapter (resume({ pauseId, decision }), POST /resume { pauseId, decision }, the response's pauseIds) follow.
  • Breaking — ToolCallApprovalDecisionDto is { approved, reason?, overrideArguments? }: by is gone (who answered comes from the resume), feedback → reason; overrideArguments only with approved: true and a valid input of the tool — on resume (the pause keeps waiting) and on ChannelOutcome.decided(…) (the step fails).
  • Breaking for your own PausedExecutionRepository (#267 S35): PausedRun keeps the run and its versions only; each pause is a PauseRecord of its own, keyed by its step's idempotency key — findOrCreateByStepKey, findByTenantIdAndPauseId, findAllByTenantIdAndRootExecutionId, markSentByTenantIdAndPauseId, resolveByTenantIdAndPauseId (atomic Pending → Resolved), cancelByTenantIdAndPauseId, markTakenByTenantIdAndPauseId, deleteByTenantIdAndPauseId; resolveByTenantIdAndThreadId is removed. PauseStatus gains Taken and Cancelled. SqlitePausedExecutionRepository adds a pauses table and drops the old status / decision columns of paused_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 while send is returning is taken at once. FailFast withdraws paused siblings' pauses (Cancelled; a late answer → NotPausedError); so does app.cancel and 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's send that throws fails the agent's step (AgentFailedError, the channel's error as cause).
  • 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.

Unreleased — scripts and matchers (#262, PR 2)

  • when(…) on mockLlm(X) (Testing): turns bound to the node's input values, PartialValues<InputOf<X>> — values only, undefined for an absent field, lists by length and position, variant(XDto, { … }) for a oneOf element (variant takes a partial value there). A call no script matches fails naming its input (test.no-matching-script).
  • Scripts are expectations: a thenReturn turn no call took, a when(…) nothing matched or a scripted tool failure never caused fails a test whose body passed (test.unused-script; thenReturnAlways never does). Breaking for tests that scripted turns their run never reaches: drop them, or use thenReturnAlways.
  • Typed scripts: replyWith(XDto, fields) must be a class of the agent's output and routeTo(...targets) follow Route<R> — one target after .chooseOne(…), the OnDemand targets (or none, with an Always route) before .toParallelAgents(…), which routeTo(…) now scripts too. The generated RouterAnswers has 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 by tenantId + threadId (the same id in another tenant is another thread), bound to ownerId, created only once the budgets pass. Before, it was UnknownThreadError; that stays for cancel of an unknown thread. A bad id (empty, over 200 characters, a control character) is the new InvalidThreadIdError (run.invalid-thread-id). Breaking for your own ExecutionRecordRepository: 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); a Summary and a MemoryTurn carry tenantId next to threadId. The SQLite thread store's migration 11 keys threads by (tenant_id, id) (owners, Terns and summaries by (tenant_id, thread_id)); existing threads are default's and keep their ids.
  • mockAction(A) (the action still runs) and mockTool(T).thenFail(ToolFailure.Timeout); the call matchers toHaveBeenCalledWith, toHaveBeenCalledTimes, toHaveReturned on mockLlm(X) / mockAction(A) (Vitest's own on anything else) and mockLlm(X).inputs.
  • Breaking — toFailWith(ErrorClass, { …fields, cause }) replaces toFailWith({ code, node }) (ExpectedFailure removed): 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 root ErrorClass takes the error's type (ErrorClass<TError>).

Unreleased — optional environment variables (#266 S87, S90)

  • env.optional("X"), env.integer.optional("X"), env.flag.optional("X") — a variable that may be unset: typed T | undefined, unset → undefined (not a missing variable), a set value still checked; agboss check --env marks it (optional) (Environments). New public type EnvironmentValueReader<T>; EnvironmentVariableReader extends it.
  • The base-URL field repeats no default: openRouterBaseUrl: env.optional("OPENROUTER_BASE_URL") — unset → the decorator's baseUrl (Model providers); job-scout and agboss create declare it this way.

Unreleased — PII policies and the model boundary (#120, PR 1)

  • @PiiPolicy is 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: the PiiPolicy interface (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, excludePiiPolicies removes. Breaking: overridePiiPolicies / disablePiiPolicies are 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 piiTokens channel (S38, replaces #121's store).
  • Observability: events, the log, Langfuse spans and result.record are masked by the application's and the workflow's lists (Observability); breaking: onPiiPolicyStart / onPiiPolicyEnd, OnPiiPolicyStart / OnPiiPolicyEnd, PiiPolicyStartEvent / PiiPolicyEndEvent and PiiPolicyPayload are 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 by sensitive: PiiAction.… (masking actions) on DTO and context fields; WorkflowStartTextDto.author is replaceWithToken(); a channel shows such a field through its action instead of ***.
  • @Application({ pseudonymKey: "piiPseudonymKey" }) names a Secret field of the environment (Environments): the HMAC key of sensitive fields, 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].

Unreleased — channel roles and discovery (#267, PR 1)

  • 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>) with approves: XDto or ask / answer, a required answeredBy: PauseAnswerer.ThreadOwner | PauseAnswerer.Channel and an optional replyInterpreter; its one implementation (@Channel, extends the 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 @Channel in a workflow's providers is [di.infrastructure-in-workflow].
  • Breaking — the channel contract: one method, send(ask: ChannelAsk<Role>): Promise<ChannelOutcome<Role>> (ask = { pauseId, value }, typed by the generated ChannelRoles), returning ChannelOutcome.awaitReply() (pause) or ChannelOutcome.decided(decision) (no pause). requestApproval, ChannelRequest and ChannelDecision are removed.
  • Breaking — InboundChannelAdapter and SemanticInboundChannelAdapter are removed (and @Channel({ inboundAdapter })): app.resume(threadId, decision) takes the ToolCallApprovalDecisionDto itself until free-text replies come with #267 PR 3.
  • AutoApproveChannel / AutoRejectChannel now decide at once (decided) — no pause.

Unreleased — rate limits and retries, provider keys in the environment (#266, PR 2)

  • rateLimitPolicy on @ModelProvider (Model providers): RateLimitPolicy.of({ maxConcurrentCalls?, perMinute?: { calls?, tokens? }, balancing? }) — one limit for the whole application, every execution, tenant and instance, its state in RateLimitRepository. A call without room waits (its signal ends the wait — app.cancel with ExecutionCancelledError; the attempt's timeout starts with the room); waiting tenants share free slots by QueueBalancingStrategy (TenantRoundRobin default, TenantLeastInFlight, TenantWeighted by tenant.weight, Fifo); a 429 pauses the provider's whole queue for Retry-After (else the retry delay) on every instance, then halves the instance's concurrency, back by one a minute (AIMD). No policy → no limit.
  • retryPolicy is the only backoff; a wait between attempts holds no slot.
  • RateLimitRepository gets windows and pauses: addToWindow, clearWindow, pauseUntil, pausedUntil — abstract, so breaking for your own implementation (in memory and SqliteRateLimitRepository implement 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's Clock: CircuitBreaker takes (provider, policy, { rateLimits, clock }) and its methods are async; CircuitBreakers and the process-wide breakers are removed.
  • Breaking — provider keys are Secrets of the environment: apiKey names the field (apiKey: "openRouterApiKey"), set with openRouterApiKey: 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_KEY and OPENROUTER_BASE_URL_SETTING are removed — env() / secret() are the one way to read a process variable (Environments).
  • agboss check without --models needs no secret values (it reads the contract's shape); --models connects and fails on a missing secret, naming its variable.
  • Breaking — provider names end with -model-provider ([name.kind-suffix]): the built-ins are openrouter-model-provider and decisions-model-provider (in describe, the startup log and model problems).

Unreleased — quorums at run time (#279, PR 2)

  • 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.Counted with acceptedSequence, Rejected with rejectedBy, 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-runnable is removed.
  • New errors: QuorumNotReachedError (step.quorum) and ArbiterError (step.arbiter), both StepFailedErrors, taken by .onJoinError(E, …); QuorumShortfall.
  • Deadline on the application's Clock: deadlineAt at 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; an ArbiterError or a limit fails the launch.
  • Model arbiters: one decision per answer under arbiter:<name> (answer-arbiter on 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 hook onQuorumDecided (OnQuorumDecided, QuorumDecidedEvent — a node event, step the launching step).
  • Testing: mockLlm(ModelArbiter).thenReturn(decideWith({ counts: 0.9 })).
  • CLI: agboss generate arbiter <name> --workflow … [--agent …] — a code arbiter with one @OnAnswer method per agent of a launch.

Unreleased — logging, observers, describe (#261, PR 6; #269)

  • 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, onExecutionRejected replace onWorkflowStart / End / Pause / Resume, onError and onRunRejected (interfaces OnExecution…; events ExecutionStartEvent, 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 extends ExecutionEvent (executionId, parentExecutionId, rootExecutionId, threadId, tenantId, workflow); node events extend NodeEvent and add step (StepRecord). ValuesOutdatedEvent and LaunchItemsTruncatedEvent gain tenantId.
  • Breaking (#269): ExecutionOperation.Execute | Resume | Cancel in ExecutionRejectedEvent.operation replaces the strings; the event carries tenantId.
  • Breaking: the untyped executionContext / metadata execute options are removed, with ChannelRequest.metadata / .executionContext, GuardrailContext.metadata / .executionContext (no longer generic) and RunContext.metadata: components read the typed contexts through an injected ExecutionContext; McpService.connect(transport, session?) / handleSse(res, endpoint, session?) take McpSessionOptions (tenant, execution, ownerId); A2ACallOptions is signal, ownerId, tenant, execution.
  • New: the Logger contract (infrastructure; ConsoleLogger by default, LogLevel, LogEntry, formatLogEntry) replaces process.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 (code workflow.cancelled → execution.cancelled); agboss migrate imports rewrites the imports.
  • New: nameOf(C) — the name of 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; testWith logs none of it.
  • New: assembly warnings [application.in-memory-cost-ledger], [application.in-memory-pauses] — in app.warnings and the start log, errors under agboss check --strict; agboss check runs a new application check. The CLI's run / chat no longer print app.warnings themselves (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 a limits section (maxSteps set or default, maxVisits, worst cases); --json adds application and a richer limits.
  • flow-patterns lists SqliteCostLedgerRepository (environment sqlitePath) so its strict check passes.

Unreleased — quorums in the grammar and the analyser (#279, PR 1)

  • New: quorums (Parallel): after .toParallelAgents(…), .joinQuorum({ min, max, deadline }) (options always an object; max defaults to min) 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; types Quorums, QuorumJoined, GroupsJoined, GroupStart, GroupValues, GroupPath, GroupHead, AgentsLaunched, QuorumOptions, QuorumGroupsOptions, AnyOfArbiters, ArbiterChoice; AST QuorumKind, CountQuorum, GroupsQuorum, Quorum, QuorumGroup, AcceptedBy, ArbiterClass (LaunchStep.acceptedBy, JoinStep.quorum).
  • New: @Arbiter and @OnAnswer(Agent), attached with .acceptedBy(X) (a chain is an AND, anyOf(X, Y) the first that accepts, S57): by model (criteria per agent) or by code (one method per agent class, typed by the decorator). New agboss/units: hours().
  • What .returns(…) may promise: after .joinQuorum({ min }) with min below the agents launched a single answer is optional(XDto) (compile error and [flow.quorum-return-not-optional] otherwise); a repeated class or a shared output is listOf(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 describe prints 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, QuorumManager and .joinQuorum(n) stay removed (AC11).

Unreleased — budgets and limits at run time (#261, PR 5)

  • 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 its ExecutionContext, 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().limits is removed: WorkflowSettings.builder().limits(…), WorkflowLimits, PerRunLimits, PerDayLimits, PerRunLimitRequiredError ([limits.per-run-required]), WorkflowSettingsError, LimitKey, SpendAccount and RunOptions.account are gone; money is @Budgets only. settings() keeps onIncompatibleResume.
  • Breaking — the limit errors (FinOps): StepsLimitExceededError (limit.steps: workflow, maxSteps, steps; support-workflow took 41 steps; its maxSteps is 40) for maxSteps; BudgetExceededError names its budget — budget ({ name }), scope, key (the application's name, the tenantId, the execution's id), maxSpend, spent, a sentence (tenant acme spent $40.05 today; tenant-daily-budget allows $40) — instead of the keys limits.perRun.cost / limits.perDay.cost. toFailWith({ code }) matches a budget by its name.
  • Breaking — CostLedgerRepository counts per budget, scope key and period: findSpentByCounter(counter), saveAll(counters, records), findMaxSpendByAccount(account), saveMaxSpend(account, maxSpend) (new types BudgetAccount, CostCounter; were findSpentToday(key) / saveAll(key, records)). The ledgers read no time any more — the runtime names each counter's period from the application's Clock — so InMemoryCostLedgerRepository() and SqliteCostLedgerRepository(connection) take no clock. SqliteCostLedgerRepository drops the old per-workflow day table of a file written before (#265 S69 e).
  • maxSteps and launches (#273 S68 f): the default (agents + routers) × 3 must hold the worst case of a per-item launch (maxItems runs) — 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>:eval is gone).
  • agboss describe: limits steps 2–2 (maxSteps 12) (no money there; budgets by scope come with PR 6).

Unreleased — the clock (#262, PR 1)

  • Clock is public (root agboss, an infrastructure contract): now() and sleep(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's timeoutMs, a model call's timeout, retry backoff and Retry-After pauses. The system's clock unless @Application({ providers }) lists a class that extends Clock (created first; it may not depend on the contexts); in a workflow's providers it is [di.infrastructure-in-workflow]. A component that needs the time injects it (deps: [Clock]). Testing → The clock.
  • VirtualClock is public (agboss/testing): the clock fixture and app.clock are one per test, the application's clock in every test (also when it lists its own); new VirtualClock(start?) in unit tests. Breaking: the TestClock type is gone — VirtualClock in its place (agboss migrate imports rewrites the import); its now is a method, so clock.now() reads as before but clock.now is no longer a function to pass around.
  • Lint agboss/no-direct-time fails Date.now(), new Date() / Date() without arguments, performance.now(), setTimeout, setInterval, AbortSignal.timeout and the node:timers modules in the framework's sources (SystemClock excepted).
  • SqliteConnection is 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 clock option of createApp / createAppDeps (AppOptions, AppDepsOptions) is removed — one way to set the clock: list a Clock subclass in @Application({ providers }); tests get the VirtualClock from testWith.

Unreleased — child workflows in the grammar and the analyser (#263, PR 1)

  • 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, maxItems required, joined like any launch; WorkflowLaunchOptions). Checked and typed by agboss 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's Inputs entry. agboss describe prints 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 gives listOf(oneOf(…)) (JoinOutputs key toParallelWorkflows(<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 / .onJoinError side path that can catch a LimitExceededError (its class, a subclass or a parent such as AGBossError / 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 check uses it.

Unreleased — contexts (#261, PR 3)

  • 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 (the WorkflowStarts entry names the start's tenant / execution classes, and a declared execution subclass's tenant is 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 — ENV is removed: read the environment as applicationContext.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 }) (was thread, owner), app.resume(threadId, decision, { tenantId, ownerId }), app.cancel(threadId, { tenantId, ownerId }); ExecutionOutput.threadId (was thread) and the new ExecutionOutput.tenantId.
  • Breaking — a thread belongs to its tenant: its key is tenantId + threadId; app.cancel of a thread the tenant does not have throws UnknownThreadError (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 carries tenantId (Tern store migration 10); PausedExecutionRepository.findByTenantIdAndThreadId, resolveByTenantIdAndThreadId, deleteByTenantIdAndThreadId (were …ByThreadId), keyed by the paused run's tenantId. Leases and tree keys use the execution's tenant (was always "default"); holderInstanceId is ApplicationContext.instanceId.
  • SQLite (#265 S69 e): paused_executions is keyed by (tenant_id, thread_id) and the checkpoints / checkpoint_writes rows carry tenant_id (the run's configurable.tenant_id); a file written before is migrated on first use, its rows in the tenant "default". SqliteConnection reads sqlitePath from applicationContext.environment.
  • Traces carry the tenant: a run's LangGraph metadata (tenantId, executionId, rootExecutionId, threadId, workflowName, workflowVersion) and tags (tenant:<id>), the Langfuse trace's tag tenant:<id> and metadata tenantId; TraceContext.tenantId.
  • testExecutionContext({ tenantContext, tenant, executionContext, execution, application }).

Unreleased — model providers as infrastructure (#266, PR 1)

  • Model providers are the application's (Model providers): @ModelProvider classes are listed only in @Application({ providers }) — none listed → the built-in OpenRouterModelProvider + DecisionsModelProvider; one listed → the list is complete, the built-ins are not added next to it. A @ModelProvider in a workflow's providers → [di.infrastructure-in-workflow].
  • Breaking — no provider setting on a workflow: WorkflowSettings.builder().modelProviders(…), .defaultModelProvider(…) and WorkflowSettings.models are removed; move the list to the application's providers. 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 — ModelProblemCode model.no-provider → model.unserved; ModelProviderDirectory.of(classes) takes no default provider (byName, defaultProvider gone).
  • agboss describe prints model: … → <provider> for each model (--json: models); agboss check --models checks every model of every workflow of the application.

Unreleased — parallel launches at run time (#273, PR 2)

  • Launches run (Workflow → Parallel launches at run time): each branch is one LangGraph Send of one superstep, maxConcurrency a 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-runnable is 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#2 for a copy), visitNumber, itemIndex; StepCoordinates. Breaking (class hierarchy) — AgentFailedError, GuardFailedError, RouterFailedError (with RouterDecisionError) and ActionFailedError extend it instead of PaidStepError directly (still PaidStepErrors); AgentAnswerInvalidError's and ActionFailedError's component / nodeId come from it, and every step error's record details now carries visitNumber (and itemIndex in a per-item run).
  • FailFast and Skip (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.Skip drops only a StepFailedError — a limit in a Skip branch fails the launch; app.cancel during 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 by copyNumber / itemIndex); maxVisits counts launches.
  • Router launches (S23, #280 S47 f): a model router decides its OnDemand routes in one call — a Decision.noul per route on a decision model, one answer tool answer-launch with a boolean per route on a chat model; all no without an Always route → RouterDecisionError. A code router's route() list is launched beside its Always targets. Tests script both with decideWith({ "<target>": P(yes) }) (Testing → Parallel launches).
  • New: result.launches (ExecutionOutput.launches: LaunchRecord[] — launch, items, taken, branches: BranchRecord[]), BranchStatus, and the hook onLaunchItemsTruncated (OnLaunchItemsTruncated, LaunchItemsTruncatedEvent) when a per-item launch dropped items past maxItems (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; toFollowPath takes 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.

Unreleased — the SQLite implementations (#265, PR 3)

  • Every storage contract has a ready SQLite implementation, enabled only by listing it in @Application({ providers }): SqliteExecutionRecordRepository, SqliteCostLedgerRepository, SqlitePausedExecutionRepository, SqliteCheckpointSaver (our own BaseCheckpointSaver on node:sqlite), SqliteExecutionLeaseRepository, SqliteExecutionTreeRepository, SqliteRateLimitRepository (Several instances → SQLite). One connection per application instance on the environment's sqlitePath — required only when one is listed ([environment.missing-field]) — WAL, busy_timeout, atomic operations in BEGIN IMMEDIATE, time from the framework's clock.
  • Breaking for your own ExecutionRecordRepository: a new abstract method completeIfStatus(ternId, expected, outcome): Promise<boolean> — a compare-and-set status write. A resume marks its paused record running with it, so a write that lands after the holder finished the tree no longer leaves a finished record running (#265 S66 h).
  • Cross-process persistence is back for --thread, agboss eval / replay / golden when 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:).

Unreleased — parallel launches in the grammar and the analyser (#273, PR 1)

  • 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 table JoinOutputs (JoinOutputOf<Key>) and by the analyser. New public types: LaunchOptions, Launched, Joined, JoinErrorHandling, Launches, RouterLaunches, ValuesSource / StepValues / JoinValues, the AST's LaunchStep, 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; AfterValue and RouterStep have 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 an OnDemand route without prompt; [router.all-routes-always] now applies to router launches.
  • A model router states spawn on every route before a launch (#273 S50, Routing patterns → 6); a code router's RouterAnswerOf<Self> is a list when a launch follows it.
  • agboss describe prints 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, but createApp stopped with flow.launch-not-runnable until #273 PR 2 (above).

Unreleased — environments and defaults (#261, PR 4)

  • 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 is export const environment: AppEnvironment = { … } in environments/<name>.environment.ts. defineEnvironment, fromEnv, FromEnv, FromEnvOptions, EnvironmentValues, EnvironmentDefinition and the declare module "agboss" { interface Environment … } contract are removed; agboss check writes interface Environment extends AppEnvironment {} into .agboss/types.d.ts, so ENV is typed by the contract (Environments).
  • New: env("X"), env.integer("X"), env.flag("X") (each with { default }), secret("X") and Secret (prints as *** everywhere, .reveal() gives the value; a literal in a Secret field does not compile); EnvironmentErrorCode (environment.not-found, environment.missing-variables, environment.invalid-value, environment.no-contract). A missing variable fails the start as DATABASE_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) loads test.environment.ts and fails without it (no fallback to dev); testWith(App, { environment }) and createApp(App, { environment }) are removed — name an environment with env. Breaking — @Application({ environment }) is required: an application with no settings declares environment: 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 defaults cascade: @Application({ defaults }) → @Workflow({ defaults }) → the component's own field, merged field by field, the most specific level wins (Configuration). Breaking: guards take defaults.guardrail.model (new), never defaults.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 describe shows each router's model and each agent's temperature / maxTokens / history with where it came from ((own), (<application or workflow>), (built-in)).
  • loadWorkflow, studioGraphOf, compareProfiles and workflowOf from agboss/testing assemble a workflow in the application that lists it (its *.application.ts next to it or above): its providers and defaults.

Unreleased — recovery and the graceful stop (#265, PR 2)

  • Recovery (Several instances → Recovery): an execution's record is ExecutionRecordStatus.Running while its tree moves (new public enum, root entry: Running, Answered, Guarded, Failed, Paused); every instance looks for running roots 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 abstract findByStatus(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 — WorkflowResumeEvent has a reason: ResumeReason (new public enum: Answered — app.resume; Recovered — recovery, decision only when it took one).
  • The graceful stop: app.close(options?) stops the sweep, runs onStop, waits for the running executions up to the shutdown timeout — @Application({ shutdownTimeout }), app.close({ timeout }) (CloseOptions), default seconds(25) — then interrupts them: the call rejects with ApplicationStoppingError, no onError, the lease released at once, the record left running for recovery (App → app.close()).
  • SIGTERM / SIGINT: createApp calls app.close() on either (a second one interrupts at once); the listeners are removed when close() finishes, and the signal is raised again when nothing else listens. testWith never listens.

Unreleased — the paths DSL revised: one value per .returns, { about } in the flow, historyOf (#299)

  • Breaking — .returns(value, options?) names one value (#272 S54): a DTO class, optional(…), listOf(…) or oneOf(…). 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). ReturnsStep holds one value.
  • Breaking — a oneOf output is written before its switch: .returns(oneOf(A, B)).switch(…); a .switch without it, or with another oneOf, is [flow.returns-mismatch].
  • Breaking — about(TargetDto, shape) is removed (with AboutWrapper, DeclaredOutput): components declare a plain output; the flow says what a value is about — .returns(CritiqueDto, { about: PlanDto }), { about: [PlanDto, BudgetEstimateDto] }, and for a oneOf on the case, caseOf(ChangesRequestedDto, { about: OfferDraftDto }) (never on .returns(oneOf(…))). A value's about slots are kept on ScopeValueRecord.about. { about } on a start's .returns(…) does not compile ([io.about-on-start] at assembly). [io.ambiguous-cycle] hints name the .returns / caseOf to 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. New historyOf(Dto, { limit }) in input (root entry and agboss/dto; HistoryOf<T>, HistoryWrapper, HistoryOptions): the field is { current, previous } — the current value and the earlier versions oldest first, at most limit — typed HistoryOf<Produced<Dto, P>> in InputOf, shown in the input message (### draft, earlier versions (oldest first)) and by describe. It needs a current Dto on every path ([input.missing-on-path]); the replaced versions are never [input.outdated-on-path]; a limit that is not a positive integer → [input.history-limit].
  • [io.ambiguous-about] now: a producer whose values are { about: T } reading T in two or more fields (with historyOf(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 filtered list:ADto / list:BDto slots are gone; a listOf(ADto) field after such a step is [input.missing-on-path].
  • Path scope, Workflow, Components, Standard DTOs.

Unreleased — the shared contracts and the execution lease (#265, PR 1)

  • 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 provides saveIfAbsentInTree, findByIdInTree, deleteAllInTree) and RateLimitRepository (acquire(key, maxSlots, lease), renew, release; RateLimitLease). There is no ExecutionSlotRepository (Several instances).
  • The execution lease (Q58): app.execute and app.resume move 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 with ExecutionLeaseLostError (execution.lease-lost) — no .onError, no onError, no failed record: the new holder continues.
  • Breaking — PausedExecutionRepository: a paused run has a status (PauseStatus.Pending / Resolved) and, once resolved, its decision; a new abstract resolveByThreadId(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 ExecutionTreeRepository are removed when the tree finishes, fails or is cancelled; a pause keeps them.
  • Testing: testWith(App, { instances: 2 }) with the fixtures apps and clock; clock.advance takes seconds(…) too and returns a promise — await it (the framework's waits sleep on the test clock) (Testing).

Unreleased — @Application, infrastructure, the test entry (#261, PR 2)

  • Breaking — @Application is the one entry: @Application({ name, version, workflows, providers, budgets }) (name ends with -application, the class with Application), and createApp(App, { env }) / testWith(App) take it. createApp(Workflow) and testWith(Workflow) are removed — they do not compile (the generated .agboss/types.d.ts brands 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 / cancel find the workflow of the thread; app.name / app.version are 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's BaseCheckpointSaver — listed only in @Application({ providers }), found by inheritance, each with a built-in in-memory default. Breaking: createApp({ stores, pausedRuns }), AppStores, PausedRunRepository and createSqlitePausedRunRepository are 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, so agboss eval / replay only 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 that implements 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 then settings().limits caps 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.ts in its folder or a folder above it — at that workflow's text start. agboss create / generate workflow write the workflow's *.application.ts and config/budget.config.ts.

Unreleased — one container per application instance (#261, PR 1)

  • Breaking — no scopes: one container per application instance, one instance per class (a class listed by two workflows is one instance). The scope option of every DI decorator, the Scope type, the run-scoped container and [di.scope-mismatch] / [di.run-scope-outside-run] are gone (against #184). Components keep no state: read the execution from ExecutionContext, 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-state replaces agboss/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) → onStart at start, app.close() → onStop → the running executions awaited → onDestroy (dependants first). A failing onInit / onStart stops the start ([di.hook-failed] <Component>.<hook> failed: …); failing stop hooks are collected into one AggregateError. After app.close() execute / resume reject with the new ApplicationStoppingError (app.stopping, an onRunRejected); cancel still works. Breaking: AfterAssemble is removed, OnDestroy now 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] (no new: the container creates every instance) (Components).
  • createAppDeps(…).close(executionsDone?) takes what to wait for between onStop and onDestroy.

Unreleased — code routers (#280)

  • 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 outside routes does not compile); deps in the constructor like an action, the execution through deps: [ExecutionContext]. No model call, cost 0, the same flow as a model router (Routers → Routers decided by code, Routing patterns).
  • @Router options are a union by decidedBy (ModelRouterOptions / CodeRouterOptions): model, prompt, promptUrls, promptVariables and route prompts only on a model router; deps only on a code router.
  • spawn / failure on routes (SpawnPolicy.Always | OnDemand, FailurePolicy.FailFast | Skip), both kinds — the options of a parallel launch (#273). Defaults Always + FailFast; a code router's route OnDemand. With .chooseOne(…) an explicit one → [router.policy-with-choose-one] and Route<R> is every target; before a launch Route<R> leaves Always targets out and every route Always → [router.all-routes-always]. The generated RouterAnswers table types what route() returns.
  • Breaking — router errors (S19): RouterFailedError (step.router, the original as cause: a model call or route() failed) and RouterDecisionError (step.router.invalid-decision, now a RouterFailedError: an answer outside the routes, a list after .chooseOne(…), an unreadable answer — never retried). Both carry component, nodeId, visitNumber. The instance codes router.failed / router.unknown-route, the type RouterFailureCode and the field router are gone (router → nodeId); an unreadable model answer is now RouterDecisionError, not a failure. .onError(RouterFailedError, …) takes both; .onError(DomainError, …) the error inside.
  • agboss describe lists code routers as code; mockLlm(CodeRouter) throws (a code router calls no model — mockOf() its services).

Unreleased — one call per line in flow chains (#289)

  • Style: a flow chain has one call per line — start(A) / .returns(B) / .toFinish(C), and route(X) / .toFinish(F) inside a chooseOne(…) (Workflows → The grammar). In the repo, *.workflow.ts modules and the flow fixtures are left out of Prettier (which joins short chains), formatted by make fmt and checked by ESLint's @stylistic/newline-per-chained-call; the docs' and Wiki's ts snippets that build paths are checked by npm run check:docs (doc:line: … (@stylistic/newline-per-chained-call)). agboss create / agboss generate write workflow modules one call per line.

Unreleased — outdating at run time (#271)

  • scope holds current values only: a write moves the older value of its slot and everything built from it to outdatedValues, each with outdated: { byValueId, reason } — byValueId the root trigger, reason an OutdatedReason (Replaced, AboutReplaced, BuiltFromReplaced) (Path scope → At run time). OutdatedValueRecord.outdated gains reason.
  • onValuesOutdated (OnValuesOutdated, ValuesOutdatedEvent): one event per cascade, never empty, no DTO values; with ExecutionEvent's fields (executionId, parentExecutionId, rootExecutionId, threadId, workflow as a WorkflowRecord) and step (a StepRecord) (Observability); one "values outdated" trace entry per cascade.
  • Records: WorkflowRecord, StepRecord, ScopeValueReference, OutdatedValueReference, OutdatedMark; WorkflowDescriptor, StepDescriptor and ComponentDescriptor extend them.
  • agboss describe prints each about(…) output (CLI).

Unreleased — the execution context (#280)

  • ExecutionContext: the one context of an execution, injected through the constructor — deps: [ExecutionContext], a parameter named executionContext — 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 throws ExecutionContextUnavailableError (context.unavailable).
  • No ctx parameter: ToolHandler.run(input), @McpTool alike; ToolContext and RunContext are 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.metadata and ctx.run.input have no replacement in the context: domain data come through a node's input. 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's ROUTER_FACTORY decisions are accounted as its spend by the framework.
  • A tool call's signal (S45): in a tool call executionContext.signal aborts on the execution's cancel and at the tool's own timeoutMs.
  • Judges: judge(input: JudgeInput) (reply, task, attempt) instead of judge(reply, ctx: JudgeContext); the judge's model through deps: [JudgeModel] (judgeModel.invoke / judgeModel.decide).
  • Testing: testExecutionContext({ … }) replaces testRunContext(); 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.callId is its step.callId.

Unreleased — the flow as paths (#272, #270, #269)

  • 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 without about no 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 paths agent(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 @Workflow nodes, sagas and compensate, the agboss/concurrency entry. Parallel launches return in #273, child workflows in #263, code routers in #280.

  • Generated types: agboss check writes .agboss/types.d.ts (git-ignored) — what types the paths, InputOf<X> / OutputOf<X> and app.execute(Start, input). A generated project runs it on install (npm run types); declare readonly input on a start is no longer needed.

  • Typed data on paths (Path scope): @Agent / @Action / @Router / @WorkflowFinish take input fields and output shapes; wrappers listOf, oneOf, optional, variant, isVariant (agboss/dto). Agents read an input message after static instructions and answer a structured output through answer tools; {{item}} and {{input.…}} in instructions are gone ([prompt.input-in-instructions]).

  • Names: every component name ends 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 with Dto — 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.idempotencyKey and ctx.executionContext are gone from actions — since #280 the execution comes through deps: [ExecutionContext] (above).

  • Limits: @Workflow({ maxSteps }) replaces limits.perRun.steps; maxVisits on routers and agents (VisitsLimitExceededError, catchable with .onError); money stays in settings().

  • Results: ExecutionStatus.Finished | Paused; result.finish is { name }; { record: true } adds the execution record. The thread's history keeps finished executions as start <name>: <input> / finish <name>: <output>.

  • Enums instead of strings (#269): Reasoning.… instead of thinking strings on components, RagMode.Tool | Context, ModelKind, ProviderSort, ImageDetail, ExecutionStatus.

  • defaults.router is { model } (no kind); a @Router takes decidedBy: DecidedBy.Model and a required input.

  • job-scout: one path per turn, names with their kinds, profiles and golden sets under job-scout-workflow/ (Example).

Clone this wiki locally