Changelog - agbossjs/agboss GitHub Wiki

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 β€” 0.1.1: handover telemetry, the fenced effect told (#501 PR 3)

  • Added β€” onFencedEffect (OnFencedEffect, FencedEffectEvent, FencedEffectEventOutcome: Ran, Replayed, Joined, Refused) (Observability): every call of FencedEffectRepository.saveResultIfAbsent from a step, never its result.
  • Added β€” FencedEffectRecord, the lease log's FencedEffect row (Several instances): with the takeover and the hand-over, a refused zombie, one that never wrote and a replay are told apart from app.leaseEventsOf alone.
  • Moved β€” FencedEffectAsyncWorkError to the SQLite store's module; still exported from the root entry.

Unreleased β€” 0.1.1: handover telemetry, a step handed over (#501 PR 2)

  • Added β€” onStepHandedOver (OnStepHandedOver, StepHandedOverEvent, HandOverReason, HandOverSite) (Observability): a step stopped by a lost lease, a fenced write or the app's stop is told once per error, at the innermost site β€” a step, a tool call, a tool interceptor, an undo, a branch, a child.
  • Added β€” StepHandedOverRecord, the lease log's HandedOver row (Several instances), written before the moving call rejects; ExecutionLeaseEventRecord is now LeaseTakeoverRecord | StepHandedOverRecord and checkpointId is the takeover's own field.
  • Added β€” ExecutionHistoryRecord.leaseEvents: a kept execution carries its tree's rows of the lease log (Execution history), and so does agboss records export.

Unreleased β€” 0.1.1: handover telemetry, the lease log (#501 PR 1)

  • Added β€” ExecutionLease.takenOverFrom (PreviousLeaseHolder: holderInstanceId, leaseEpoch): acquire names the holder whose lease it took over after it expired β€” never after a release. Optional: an external ExecutionLeaseRepository still compiles; the published executionLeaseContract has two more cases, so a store that runs it reports it.
  • Added β€” onExecutionLeaseTakeover (OnExecutionLeaseTakeover, ExecutionLeaseTakeoverEvent, LeaseTakeoverReason) (Observability).
  • Added β€” the lease log (Several instances): the infrastructure contract ExecutionLeaseEventRepository (ExecutionLeaseEventRecord = LeaseTakeoverRecord, ExecutionLeaseEventKind), its in-memory default, SqliteExecutionLeaseEventRepository (table execution_lease_events), the contract suite executionLeaseEventContract, app.leaseEventsOf({ tenantId, rootExecutionId }) (LeaseEventsQuery) and @Application({ leaseEventRetention }) (default hours(24), removed by the recovery sweep). Readiness pings it.

Unreleased β€” 0.1.1: the fenced effect (#500)

  • Added β€” FencedEffectRepository<TTransaction> (Several instances, ADR-0026): saveResultIfAbsent(fencedEffectKeyOf(executionContext, name), work) makes a protected write once per step β€” the lease, the scope's mark and the key checked in the write's transaction; FencedEffectStatus.Ran / Replayed / Joined; a zombie gets ExecutionFencedError. The built-in InMemoryFencedEffectRepository and SqliteFencedEffectRepository (with SqliteExecutionLeaseRepository; its work synchronous, FencedEffectAsyncWorkError otherwise).
  • Added β€” @Application({ fencedEffects: { maxResultSize, retention } }) (64 KB, 24 h; [application.fenced-effects-invalid]), FencedEffectSettings, FencedEffectResultTooLargeError, [application.fenced-effect-lease-store] and FencedEffectStoreMismatchError; the recovery sweep removes the rows past the retention.
  • Added β€” fencedEffectContract in @agboss/core/testing/contracts; unit runners hold their lease in the store's lease store.
  • Changed β€” flow-patterns' credit desk charges through SqliteFencedEffectRepository: PaymentService and PaymentKeyReusedError are gone, payment_charges has no idempotency key.
  • agboss describe lists FencedEffectRepository among the infrastructure contracts.

Unreleased β€” 0.1.1: a vitest preset loads AGBoss in the test's module graph (#497)

  • Fixed β€” workflowOf(W) on the package installed from npm (Testing): the application next to W was imported by Node, outside vitest β€” no decorators, or a second module graph. Added β€” @agboss/core/testing/vitest: agbossTest(test?), the test options with server.deps.inline gaining /@agboss\/core/ and setupFiles gaining @agboss/core/testing/setup (merged, never replaced). agboss create writes it.

  • Added β€” TestSetupError from workflowOf(W) without the preset: the application of <workflow> was loaded outside vitest's module graph; use agbossTest() from @agboss/core/testing/vitest in vitest.config.ts, the original error as its cause.

  • Added β€” npm run check:packed and CI's packed-project job: the packed packages in a project of their own, the positive run and the negative one; RELEASE_CHECKLIST.md runs it too.

  • Migration β€” in an existing vitest.config.ts, wrap the test options:

    import { defineConfig } from "vitest/config";
    import { agbossTest } from "@agboss/core/testing/vitest";
    
    export default defineConfig({ test: agbossTest({ include: ["tests/**/*.test.ts"] }) });

    The setupFiles: ["@agboss/core/testing/setup"] entry may go; kept, it is not added twice.

Unreleased β€” job-scout moves to agbossjs/agboss-examples (#489)

  • Removed β€” examples/job-scout (Example, ADR-0025): it is 08-job-scout of agbossjs/agboss-examples, on the published @agboss/core, its history kept. examples/flow-patterns is the one in-repo example: the root's npm run studio and npm run check:models run on it (its new check:models script), and every core and CLI test that used job-scout uses it.
  • Added β€” a check:docs rule: a link to examples/job-scout outside the ADRs and the Changelog fails the check, naming the doc, the line and where the folder went.
  • Added β€” RELEASE_CHECKLIST.md β†’ Examples against the release candidate: every example of agbossjs/agboss-examples runs locally against the packed candidate with real models before a release; their CI stays offline.

Unreleased β€” the project layout in every skill snippet and in flow-patterns (#474)

  • Changed β€” the shipped skills (Components, Naming): every snippet of SKILL.md and references 01–13 is one class per file, named by its file=, at the path agboss generate writes β€” src/[<domain>/]<kind>/…, workflows/<w>/ with the workflow's starts and finishes, agents/<n>/ (and every prompt-bearing kind) with its <n>.prompt.md, DTOs in dto/, the application and environments/ at the src/ root, tests/ mirroring src/. SKILL.md allows one domain level (src/billing/agents/refund/refund.agent.ts); the "condensed" references are gone; @ToolInterceptor lives in tool-interceptors/<n>.tool-interceptor.ts.
  • Changed β€” examples/flow-patterns (Example): dtos/ β†’ dto/, each workflow in <domain>/workflows/<w>/ with its starts and finishes, each agent, router, judge, arbiter, compaction and guardrail in its own folder; its tests mirror src/ (Testing), the cassette in tests/desk/workflows/desk/__snapshots__/. Component names, versions, cassettes, snapshots and eval baselines are unchanged.
  • Added β€” the layout check (packages/agboss/tests/docs/skills-layout.test.ts): one class per block, a class block without file=, a workflow file outside workflows/<w>/, a flat prompt-bearing component, a *.prompt.md not beside its component, a domain with a loose file, a test outside the mirror β€” each named by file and line, over the skills, the example's real files and what agboss generate writes.

Unreleased β€” published store contract suites (#481)

  • Added β€” @agboss/core/testing/contracts (Several instances, Testing, ADR-0024): the core's store contract suites, one function per contract β€” executionLeaseContract, executionRecordContract, executionTreeContract, childExecutionQueueContract, pausedExecutionContract, executionHistoryContract, costLedgerContract, rateLimitContract, meshRequestContract, checkpointSaverContract β€” over a ContractTarget (name, instance(seam), reset?, close?; StoreSeam: the suite's clock, instanceId, application). The in-memory and SQLite stores run the same suites; @agboss/core/testing does not re-export them.

Unreleased β€” fencing external writes (#471)

  • Added β€” executionContext.fence (Several instances, ADR-0023): the lease the step runs under β€” scopeKey, tenantId, leaseExecutionId, token (its leaseEpoch), confirmedUntil, assertHeld(), confirmNow(), fenced(observedToken); ExecutionFence and ExecutionFencedError (execution.lease-lost.fenced, extends ExecutionLeaseLostError) in the root entry.
  • Changed β€” a fenced error is a hand-over everywhere: found in a cause chain or an AggregateError, before the signal's reason β€” no .onError, no undo, no onToolEnd, nothing to the model, no failed record; the moving call rejects with it. A tool's or an interceptor's lost lease ends the agent's loop instead of reaching the model as a tool error.
  • Fixed β€” the remote-children wait yields the root's lease only while no other step of the tree runs (a sibling ParentInstance branch used to run with the lease released, #333 PR 4).
  • Added β€” testing: the leases fixture of testWith (stall / resume, TestLeases) and the fence option of every unit runner and testExecutionContext (TestFenceOptions, clock).

Unreleased β€” pauses, lease yielding and per-child recovery across instances (#333 PR 4)

  • Added β€” pauses and app.resume in ChildPlacement.AnyInstance children (Workflow as a step, Several instances): when a remote child rests at a pause, its worker completes the queue row with ExecutionRecordStatus.Paused (ChildExecutionEnd.status now includes ExecutionRecordStatus.Paused) and the parent's node interrupts with a remote: true waiting marker so the pause appears in result.pending with its childExecutionId and itemIndex and records PauseAnswerRecord in result.children[i].pauses and result.record.pauses. On app.resume from any instance, the parent's node forwards the child's resume map through the queue so any instance continues only that child on <parentThreadId>/child/<id>.
  • Removed β€” GraphRuleErrorCode value graph.remote-child-can-pause: remote children may now use tools that pause.
  • Changed β€” yielding the root lease and per-child crash recovery (#333 S7, S9): while a parent waits on remote children inside its step, it yields its root lease in ExecutionLeaseRepository and re-acquires it before continuing. Each worker holds a 30 s lease in ExecutionLeaseRepository under (tenantId, childExecutionId); on a worker crash or a graceful stop past shutdownTimeout (S7), the queue row stays Running, and once the child's lease is free a live worker reclaims only that child and continues it from its last checkpoint on <parentThreadId>/child/<id> without re-running finished siblings.

Unreleased β€” tree-wide slots, depth and budgets across instances (#333 PR 3)

  • Added β€” shared tree slots in RateLimitRepository (FinOps, Several instances): when a tree can launch a child with ChildPlacement.AnyInstance, its maxConcurrentExecutions cap (and any smaller subtree cap) is held as leases in RateLimitRepository (tree:<tenantId>:<rootExecutionId> and :subtree:<executionId>, renewed while held, polled on the Clock), so the cap holds across instances and a crashed instance's slot expires by itself. Local-only trees stay in-process.
  • Changed β€” immediate ledger recording for remote children (Workflow as a step): a worker running an AnyInstance child writes each superstep's usage to the root's budget counters in CostLedgerRepository as it streams, and the parent marks those records isRecordedElsewhere when folding in the child's state so nothing is counted twice. Depth (maxDepth) and the root's budgets are checked on the worker.

Unreleased β€” flow-patterns drops its memory strategy (#459)

  • Example β€” SameOrderMemoryStrategy removed (Example): the helpline's agent no longer sets memoryStrategy, which is v1 memory. The example shows memory the v2 way: the desk's CompactionStrategy.Custom compaction and historyOf in the review loop. @Agent({ memoryStrategy }) stays in the framework (Memory management).

Unreleased β€” documented: instances: 2 on SQLite :memory: (#458)

  • Docs β€” several instances on SQLite (Several instances): testWith(App, { instances: 2 }) shares the in-memory defaults, but listed SQLite stores on sqlitePath: ":memory:" are one database per instance. A multi-instance test names a file. No code change.

Unreleased β€” A2ACallerRejectedError.wireCode (#457)

  • Breaking β€” A2ACallerRejectedError.code is the class's code (A2A): an instance's code is a2a.caller-rejected, like every AGBossError. The code the server sent (a2a.caller-unauthenticated, a2a.caller-forbidden) moves to the new field wireCode. Migration: read error.wireCode where you read error.code for the server's code, and in tests write toFailWith(A2ACallerRejectedError, { httpStatus: 401, wireCode: "a2a.caller-unauthenticated" }).

Unreleased β€” child workflows on any instance (#333 PR 2)

  • Added β€” ChildPlacement (Workflow as a step): { placement: ChildPlacement.AnyInstance } on .toWorkflow(W, …), .toParallelWorkflows(W, { …, placement }) and route(W, …) lets any instance of the application run the child; default ChildPlacement.ParentInstance (as before). The parent's node waits for it inside its step (#333 S6) β€” FailFast, app.cancel, a lost lease and budgets as for a local child; its failure is ChildExecutionFailedError with the child's error chain as RemoteChildFailures. New rule codes [flow.conflicting-placement] and, until #333 PR 4, [graph.remote-child-can-pause].
  • Added β€” infrastructure ChildExecutionQueueRepository (in memory; SqliteChildExecutionQueueRepository, table child_execution_queue) and ExecutionRecordStatus.Queued (Several instances): queued children claimed atomically by instances with a free step slot (#332); the assembly warning [application.in-memory-child-queue].

Unreleased β€” the built-in model providers yield to an explicit claim (#454)

  • Changed β€” OpenRouterModelProvider and DecisionsModelProvider are catch-alls (Model providers): each serves its models except those another listed provider claims by serves, so a project's provider for its own prefix (a transport on harness/…) is listed next to the built-ins with no narrowed OpenRouterProvider subclass. Before, that combination failed with [model.ambiguous-provider]; two of the project's own providers claiming one model still do. A subclass with its own @ModelProvider does not yield. The key check at start is unchanged. flow-patterns drops FlowPatternsOpenRouterModelProvider.

Unreleased β€” the Mesh Hub: blast radius & federation (#401 PR 7)

  • Added β€” declared edges and the blast radius (Mesh Hub): agboss hub show / hub ui draw every contract a snapshot declares as an edge consumer β†’ producer (dashed when no record shows a call, red when broken), list the problems agboss check --federation finds between the newest snapshots β€” the same checkFederation β€” with their blast radius, and who calls an application whose latest eval run lost pass^k. --json adds edges.declared and federation; hub ui adds /api/blast-radius.

Unreleased β€” the Mesh Hub served: agboss hub ui (#401 PR 6)

  • Added β€” agboss hub ui <path…> [--port N] (Mesh Hub): the Hub on 127.0.0.1 behind a printed token, on agboss eval ui's own server (one local server, its token, Host and CSP rules), read-only: the page, /records β€” every export's executions searched, a page at a time β€” and /api/mesh, /api/applications/<name>, /api/trees/<rootExecutionId>, /api/records, /api/radar.

Unreleased β€” the Mesh Hub: the crystallization radar (#401 PR 5)

  • Added β€” ExecutionStepRecord.decision (Observability): what each visit decided β€” route:<node>, launch:<node>,…, variant:<class> or answer:<hash>; none when it gave nothing. Additive.
  • Added β€” the Crystallization Radar on agboss hub show (Mesh Hub): model steps ranked by spend Γ— stability (the share of one decision for one inputHash); a candidate at β‰₯ 50 calls, stability β‰₯ 0.95 and no failing eval of its workflow. --json: radar.

Unreleased β€” child workflows as runs of their own (#333 PR 1)

  • Changed (internal) β€” ADR-0022, reversing #263 S24: a child execution runs as a root graph on its own checkpoint thread (<parentThreadId>/child/<childExecutionId>), awaited in its parent's step; its launching task keeps a marker of it; a child resting at a pause makes its parent's node wait with a marker the drain follows (Workflow as a step). No public change: paths, records, events, budgets, pauses and undo behave as before. A child that started before this version is continued the old way (one version).

Unreleased β€” flow-patterns as the zero-cost harness: the desk's history (#419)

  • examples/flow-patterns' desk keeps its executions (executionRecordRetention) and its npm run check runs npm run records:check: one offline run, agboss records export, and agboss generate eval-case --from-execution --dry-run. Example.

Unreleased β€” "save as golden case" takes the run's own tenant (#397 PR 5b)

  • Changed β€” the run artifact is schemaVersion 4: each run result keeps its tenantId (the tenant its turns ran for); versions 1 to 3 are still read (Eval suites).
  • Fixed β€” Save as golden case reads the run's own execution: on the run's thread, of the run's tenantId; an execution id kept for several tenants no longer answers with a CLI hint (--tenant) β€” for a run from before version 4 it is 409, worded for the page: run the suite again (Eval suites).

Unreleased β€” the Mesh Hub: execution trees across applications (#401 PR 4)

  • Added β€” Cross-Service Execution Trees on agboss hub show's page (Mesh Hub): each tree that crosses applications, stitched from their exports by each receiver's origin (the calling execution and its call's idempotencyKey, never the tenant); a receiver that exported nothing is a stub; --json lists the trees.

Unreleased β€” the Mesh Hub: agboss hub show (#401 PR 3)

  • Added β€” agboss hub show <path…> [--html <file>] (Mesh Hub): many applications' describe --json snapshots, agboss.eval-run artifacts and agboss.execution-record exports read from files and folders, each by its marker, into one mesh β€” every application, the calls between them seen in the records (remote / origin, each counted once by its step's idempotencyKey), each one's health from its records and eval runs β€” and one self-contained page in the eval report's shell. No project, nothing runs, no network. hub.input-schema, hub.duplicate-application, hub.input-not-found, hub.report-too-large.

Unreleased β€” "save as golden case" in agboss eval ui (#397 PR 5)

  • Added β€” Save as golden case on a run of agboss eval ui with an execution (Eval suites): its kept execution drafted as agboss generate eval-case --from-execution drafts it and written into the suite's file β€” atomically, only while the file's ETag is the page's (else 409), never over a case of the same name (409); without executionRecordRetention the page says why. agboss eval ui takes --application and --env.

Unreleased β€” flow-patterns as the zero-cost harness: the remaining patterns (#419)

  • examples/flow-patterns adds src/intake/ (joinQuorumGroups, tested with TestConcurrency.inOrder and failWith(ModelFailure.RateLimited)), historyOf in the review loop, a pause resumed on a second instance (instances: 2), and in the helpline a stubbed @McpServer / @McpTool (mcpOf) and a custom memory strategy (@agboss/core/memory). Example.

Unreleased β€” flow-patterns as the zero-cost harness: a VCR cassette recorded offline (#419)

  • examples/flow-patterns replays the desk from a cassette recorded through its transport model provider with no key (#439), and its npm run check adds agboss check --cassettes. Example.

Unreleased β€” agboss generate eval-case --from-execution: a case from a kept execution (#396 PR 8)

  • Added β€” agboss generate eval-case <name> --from-execution <executionId> [--suite <name>] [--tenant <id>] (Eval suites): reads one execution from the application's history (Execution history) and adds a case to its suite β€” its input as the turn, its contexts, the thread's earlier finished turns as history, the calls it ran as tools.required, masked as kept, its classes imported; nothing runs. records.not-found, records.ambiguous-execution, records.not-kept, [eval.case-start-mismatch].

Unreleased β€” VCR records through a transport model provider (#439)

  • Fixed β€” a VCR test records through a transport @ModelProvider (Testing): RECORD and RECORD_STALE stopped with its chat models are not loaded (chatExtensionsFor); the recording now builds the providers' gateway as createApp does β€” the chat extensions loaded, the transport created by the test app's container on its first call, with its lifecycle hooks. A replay still creates no provider.

Unreleased β€” execution history: ExecutionHistoryRepository, executionRecordRetention, agboss records export (#401 PR 1)

  • Added β€” @Application({ executionRecordRetention }) (Execution history): unset β†’ nothing is kept (as before); set, every root execution's ExecutionHistoryRecord β€” its ExecutionRecord with executionId, rootExecutionId, threadId, tenantId, application, workflow, status (ExecutionHistoryStatus), startedAt / endedAt, start + input, finish + output, contexts, error β€” masked like its traces, saved when its tree rests or ends and removed by the recovery sweep once it ended longer ago. On the built-in in-memory store it warns [application.in-memory-execution-history].
  • Added β€” infrastructure ExecutionHistoryRepository (in memory, SqliteExecutionHistoryRepository), additive: ExecutionRecordRepository is unchanged; readiness pings it.
  • Added β€” agboss records export (--tenant <id> | --all-tenants) --out <file.jsonl> [--since] [--until]: JSON Lines, one { "kind": "agboss.execution-record", "schema": 1, … } per line, read from the stores without running anything; records.tenant-required, records.not-kept.

Unreleased β€” flow-patterns as the zero-cost harness: an offline eval suite (#419 PR 4)

  • examples/flow-patterns adds desk-eval-suite with a code eval judge, run offline on a new offline environment by npm run eval:check β€” eval run against a checked-in baseline with --fail-on-regression, eval show --html, eval calibrate on checked-in labels β€” and runs its tests with isolate: false (62 s β†’ 24 s). The desk's offline agboss run now takes --env offline. Example.

Unreleased β€” flow-patterns as the zero-cost harness: two applications over A2A (#419 PR 3)

  • examples/flow-patterns serves the desk over A2A from src/desk.application.ts (@A2AExpose, a caller resolver) to a new src/helpline/ consumer (@A2AAgent); its npm run check adds agboss check --federation on both applications and agboss describe --json, and its tests the a2aServer fixture. Example.

Unreleased β€” OpenRouter host routing: pinned hosts and quantizations, servedBy (#395 PR 6)

  • Breaking β€” allow_fallbacks is allowFallbacks (Model providers): the TypeScript API of OpenRouter's routing is camelCase everywhere; only the request body stays snake_case. Migration: in requestFields: { provider: { … } } rename allow_fallbacks to allowFallbacks (a compile error until you do).
  • Added β€” quantizations: Quantization[] (Quantization.Bf16, Fp8, …) beside only, ignore, order, sort and allowFallbacks in OpenRouterRouting.
  • Added β€” UsageRecord.servedBy: the host that served each call, from OpenRouter's answer (plain or streamed).
  • Added β€” agboss check --models checks the pins (CLI): a pinned host or quantization no endpoint of a served model has is [model.no-matching-endpoint].

Unreleased β€” flow-patterns as the zero-cost harness: the operational contracts and every unit runner (#419 PR 2)

  • examples/flow-patterns/src/desk/ adds a code @Judge on toolCalls, a @Rag knowledge base, a PiiPolicyHandler detector, an observer, a @HealthCheck and maxConcurrentSteps; the tests now run all ten unit runners and replyWithInvalidAnswer β†’ AgentAnswerInvalidError. Example.

Unreleased β€” flow-patterns as the zero-cost harness: the extension points (#419 PR 1)

  • examples/flow-patterns gains src/desk/ β€” a custom compaction, input and output code guardrails, a tool interceptor (allow / replyToModel / block, afterToolCall) β€” on a transport @ModelProvider that answers by the request, an offline src/desk.application.ts, its own OpenRouterProvider narrowed to moonshotai/…, and its own eslint.config.js with @agboss/core/eslint; tests for the trajectory matchers, invalid arguments and the unit runners testToolInterceptor, testCompaction, testGuardrail and testTool. Example.

Unreleased β€” the LangGraph migration guide in the project skills (#424)

  • references/13-migrating-from-langgraph.md β€” a skill reference for coding agents that move a LangGraph graph to AGBoss: the read / write matrix (one state β†’ narrow DTOs per producer, routing flags β†’ output variants or routes, reducers β†’ joins, counters β†’ maxVisits), a one-to-one table from StateGraph, addConditionalEdges, Command, Send, interrupt, subgraphs and checkpointers to AGBoss primitives, a compiled "before β†’ after" example and the move to testWith. Routed from SKILL.md and the AGENTS.md that agboss create writes; the project skills are now SKILL.md and 13 references (skills: … (14 files)). CLI.

Unreleased β€” agboss eval run --metamorphic: rewordings call the same tools (#396 PR 7)

  • Added β€” agboss eval run --metamorphic (Eval suites): each case's variants (its last turn reworded) run too, each on a thread of its own, and the check metamorphic β€” after the other checks, under the cascade β€” passes when every variant calls the same set of tool classes as the case's last turn and meets its tools.required again. The artifact's optional metamorphic: true (read by evalRunReportOf); a case's caseHash covers its variants only with the flag.

Unreleased β€” agboss check --federation: declared contracts compared across applications (#398 PR 4)

  • agboss check --federation <snapshot…> β€” the application's consumed and exported contracts against its neighbours' agboss describe --json files (or folders of them), no network: [federation.missing-target], [federation.incompatible-schema] (a structural comparison of the JSON Schemas, field by field) with each problem's blast radius, [federation.snapshot-schema], [federation.duplicate-application], [federation.version-not-bumped] (a warning; --strict makes it an error). A2A Β§ 8.
  • describe --json: each application.contracts.imports MCP entry gets usedBy too (the tools of the application's agents that reach its server).
  • check --json: a problem may carry blastRadius ({ application, workflow, tool }[]).

Unreleased β€” declared contracts: @A2AExpose, a typed @A2AAgent, the contract price, the envelope's audience, describe --json contracts (#398 PR 3)

  • Breaking β€” @A2AAgent({ name, url, target, input, output, price?, deps }) and a typed execute(input) (A2A): target: { application, start }, input and output are required ([a2a.target-required]); the class extends A2AClient<In, Out> and execute(input, call?) replaces execute(input, OutputDto, call?) β€” the input is validated before it is sent, the output when it comes. Names: -a2a-agent on a …A2AAgent class ([name.kind-suffix]). Migration: move the output DTO from each execute call into the decorator with target and input, rename the class …A2AAgent; a client built by hand passes contract: { target, input, output } (A2AContract) β€” without one execute throws [a2a.no-contract].
  • Breaking β€” a2aHttpListener(app, { expose, callerResolver, meshVerifier?, base? }): expose (an @A2AExpose listed in @Application({ exposes }), else [application.expose-unlisted]) replaces start; POST <base>/execute takes start? (400 a2a.unknown-start for a start it does not export, or none with several), and the call chain is checked once the body is read. The same for testWith's a2aServer({ expose, callerResolver }). Migration: @A2AExpose({ name: "<app>-a2a-expose", version, exports: [Start] }), list it in exposes, pass it as expose.
  • Added β€” @A2AExpose({ name, version, exports }) (mirrors @McpExpose) and @Application({ exposes }) (@A2AExpose / @McpExpose): [application.expose-unknown-start], [application.not-an-expose], [application.duplicate-expose], [name.kind-suffix] (-a2a-expose, …A2AExpose).
  • Added β€” the contract price: the calling tool's spend records the larger of price and a trusted peer's verified report (the price alone without one), a2a:<application>.
  • Added β€” the envelope's audience: target.application (MCP: the new @McpServer({ target: { application, expose } })); a receiver of another name refuses it before its resolver β€” 401 mesh.audience-mismatch / an MCP error result; the client's error message starts with the code.
  • Added β€” agboss describe --json application.contracts: exports (@A2AExpose starts with their input and finishes' outputs, @McpExpose tools), imports (@A2AAgents with target, usedBy, price; @McpServer tools with target or null), every DTO with its schemaHash, and schemas with their JSON Schema.

Unreleased β€” agboss eval run --chaos: failures injected into model calls (#396 PR 6)

  • Added β€” agboss eval run --chaos <rate> --chaos-seed <n> [--chaos-failures <list>] (Eval suites): 429s, timeouts and truncated answers injected into the run's model calls at the provider's HTTP client and a transport's complete(), under the retry policy, the rate limit and the breaker, on the real budgets; seeded, the rate and the seed together (usage.missing-option / usage.option-value).
  • Added β€” in @agboss/core/eval: ChaosFailure { RateLimited, Timeout, TruncatedResponse } and EvalRunChaos; the artifact's optional chaos (read by evalRunReportOf). The injection is an option of eval run's internal build path only, never of createApp.

Unreleased β€” labelling runs in agboss eval ui: tags, scores, ΞΊ recomputed (#397 PR 4)

  • Added β€” labels in agboss eval ui (Eval suites): a form per judge verdict on each run's page β€” passed / failed, a 1–4 score, failure tags, a note β€” saved to eval/labels/<suite>.jsonl in #396 PR 4's format (the same run, judge and labeler replaced, the file written atomically), and the run's calibration (calibrateJudges) on every load. New options --labels-dir, --taxonomy, --labeler; the failure taxonomy eval/failure-taxonomy.yaml; new code eval.taxonomy-invalid.

Unreleased β€” agboss eval ui: a folder of runs, read-only, on 127.0.0.1 (#397 PR 3)

  • Added β€” agboss eval ui [--dir <folder>] [--port N] (Eval suites): the run artifacts of a folder (default .agboss/eval) served read-only β€” the runs newest first with each case's attempt dots, each run's report page β€” on 127.0.0.1 only, behind a token it prints, the Host checked; eval.no-runs for a missing folder.
  • Changed β€” the run artifact is schemaVersion 3: profile, agbossVersion, models (EvalModelUse) and piiPolicies, shown in the report's header; versions 1 and 2 are still read (… this agboss reads 1 to 3). EvalRunOptions.profile names the profile for the artifact.

Unreleased β€” compare in both orders, [judge.same-model-family] (#396 PR 5)

  • Changed β€” agboss compare / compareProfiles judge each pair in both orders (Profiles and comparisons): two sure verdicts for one side are a win or a loss, anything else a tie; a verdict for the same position both times is counted in PairwiseResult.positionFlips (new, required) and the table's flips. The A/B order is no longer randomised (the random argument of the internal pairwise is gone); each task costs two pairwise calls.
  • Breaking β€” compareProfiles(Workflow, options) β†’ compareProfiles(Application, { start, … }) (Testing suites and comparisons): the workflow a start of the application opens, assembled as createApp assembles it. Migration: pass the application and start: <its start>.
  • Added β€” the warning [judge.same-model-family] (Judges): a model judge of the judged agent's vendor (the slug's prefix), or a suite's judge of the vendor of an agent of its start's workflow; an error under agboss check --strict.
  • Fixed β€” the eval judge's prompts say "answer" (agboss eval score, compare), not "replyWith".

Unreleased β€” MCP dedupe by _meta.callId, origin on resume legs (#398 PR 2b)

  • Added β€” trusted MCP calls are deduplicated (MCP): a tools/call trusted by the session's peer with a _meta.callId runs once per (tenant, peer, callId) in MeshRequestRepository β€” a repeat gets the kept result, one in flight an error result with _meta["agboss.dev/error"] mesh.request-in-progress.
  • Fixed β€” origin on every leg: a trusted request's execution events carry its origin on a resume and a recovery too (read from the checkpointed contexts), not only on its first leg.

Unreleased β€” the run's timeline: traces in the artifact, the baseline diff, where attempts part ways (#397 PR 2)

  • Changed β€” the run artifact is schemaVersion 2 (Eval suites): each run keeps its last turn's trace (EvalExecutionTrace β€” steps with inputHash, model calls, tool calls, guardrail decisions, judge verdicts; handled errors, pauses, undo actions, children, the output β€” masked like the execution record) and each case its expectation (EvalCaseExpectation). The one reader (evalRunReportOf) reads versions 1 and 2; version 3 or any other is eval.run-invalid (… this agboss reads 1 to 2). New public types in @agboss/core/eval: EvalExecutionTrace, EvalChildTrace, EvalStepTrace, EvalModelCallTrace, EvalToolCallTrace, EvalGuardrailDecisionTrace, EvalJudgeVerdictTrace, EvalHandledErrorTrace, EvalPauseTrace, EvalCompensationTrace, EvalTraceError, EvalCaseExpectation, EvalExpectedToolCallTrace, EvalRunSchemaVersion.
  • Added β€” the HTML report shows each run's timeline, each case's expectation, the comparison with a baseline (a run made with --baseline), and for an unstable case the node where its attempts part ways (attemptDivergenceOf, AttemptDivergenceReason); one value is cut at 2,000 characters on the page. A page above 16 MB leaves out the timelines of cases with pass^k, then all, before eval.report-too-large (now only for a page too large without any timeline).

Unreleased β€” mesh trust: signed envelopes, origin, the caller's budget and deadline, run reports, dedupe, /cancel (#398 PR 2)

  • Added β€” @Application({ mesh: { signingKey?, peers?, idempotencyRetention? } }) (A2A): MeshOptions / MeshPeer β€” the field of the application's Ed25519 private key (a Secret, PKCS #8 PEM) and each peer's public key field (text, SPKI PEM). Envelopes and run reports are signed (JWS EdDSA) with issuedAt, expiresIn, bodyHash, and the caller's budget / deadline claims; origin.step.tool. New rules [mesh.signing-key-missing], [mesh.unknown-peer-key], [mesh.duplicate-peer]; at start [mesh.invalid-key]. maxMeshHops is unchanged (flat).
  • Added β€” trusted peers: A2ACaller.peer? (A2APeer) β€” the peer a resolver authenticated; a request is trusted when its envelope carries that peer's valid signature. Before the resolver the listener refuses a bad signature or an expired envelope (401 mesh.signature-invalid / mesh.envelope-expired), after it a body that is not the signed one (401 mesh.body-mismatch). a2aHttpListener(app, { meshVerifier }) β€” a MeshVerifier (verifyKeyOf(issuer)) for peers' keys. MCP: McpSessionOptions.peer.
  • Added β€” what a trusted request gets: the caller's remaining budget as the execution budget caller-budget (the receiver only lowers its own), its deadline, and its origin β€” executionContext.origin, ExecutionRecord.origin, ExecutionEvent.origin (ExecutionOrigin).
  • Added β€” app.execute(…, { deadline }) and DeadlineExceededError (limit.deadline: deadline, deadlineAt; a LimitExceededError): checked before every step and model call.
  • Added β€” run reports: a trusted answer's report (A2A) / _meta["agboss.dev/report"] (MCP), a signed MeshRunReport (MeshSpendReport); the caller verifies it and records the spend (a2a:<issuer> / mcp:<issuer>) and ToolCallRecord.remote (ToolCallRemote). The A2AExecutionResponse gains executionId and spend.
  • Added β€” MeshRequestRepository (infrastructure, in memory; SqliteMeshRequestRepository, table mesh_requests; MeshRequestKey, MeshRequestRecord): trusted POST /execute requests deduplicated by (tenant, peer, idempotencyKey) β€” a repeat gets the kept answer, one in flight 409 mesh.request-in-progress; removed after idempotencyRetention (default 30 min) by the recovery sweep; pinged by readiness.
  • Added β€” POST <base>/cancel { threadId } (A2ARoute.Cancel) and A2AClient.cancel(threadId).
  • Changed β€” an invalid streaming body: 400 JSON, as for a non-streaming one (it was an SSE failure event).

Unreleased β€” agboss eval calibrate: a judge against people's labels (#396 PR 4)

  • Added β€” agboss eval calibrate --report <run.json> [--labels <labels.jsonl>] (Eval suites): a saved run's judges against people's labels (eval/labels/<suite>.jsonl by default, one EvalLabel per line: runId, executionId, judge, labeler, passed, score?, failureTags, note?) β€” pairwise Cohen's ΞΊ (the judge with each labeler, the labelers with each other), a confusion matrix per labeler, the disagreements, and the rubric by the judge's metrics keys; runs nothing.
  • Added β€” public types in @agboss/core/eval: EvalLabel, EvalCalibration, EvalJudgeCalibration, EvalRaterAgreement, EvalConfusionMatrix, EvalDisagreement, EvalMetricGroup, EvalMetricValue, EvalScoreCounts.
  • Added β€” codes eval.labels-not-found, eval.labels-invalid (exit 2).

Unreleased β€” agboss eval run --baseline: regressions per case, exit 5 (#396 PR 3)

  • Added β€” --baseline <run.json> [--fail-on-regression] (Eval suites): each case against the same case of a run artifact (a run of main) β€” CaseComparison { Regressed, Improved, Unchanged, FlakyOnBaseline, New, Removed, Changed }; a changed caseHash is not compared, a case that flapped on the baseline is quarantined. The table and the counts, the artifact's baseline (EvalRunComparison). A baseline that is no run artifact of the suite is eval.run-invalid (no file: eval.run-not-found), exit 2 β€” read by the same one reader as eval show (#397 S4).
  • Added β€” exit 5 (quality): --fail-on-regression fails with eval.regressed when a case stable on the baseline regressed (CLI).
  • Added β€” the artifact's types are public in @agboss/core/eval: EvalRunReport, EvalCaseReport, EvalRunResult, EvalCheckResult, EvalRunError, EvalRunSummary, EvalRunComponent, EvalRunSpend, EVAL_RUN_KIND, EVAL_RUN_SCHEMA_VERSION, CaseComparison, EvalRunComparison, EvalCaseComparison, EvalCasePasses (no longer in @agboss/core/internal).
  • Added β€” agboss eval run --profile <name>: the suite on a profile of its workflow.
  • Changed β€” a case's history is ExecutionRecordStatus.Written (new member): turns of their thread, read by its memory as before, but never scored by agboss eval score nor saved by agboss golden add (before, they were recorded as answered).

Unreleased β€” join keys for every model component (#395 PR 5b)

  • Added β€” modelComponents in agboss describe --json (CLI): model judges (schemaHash of their reply), guardrails, arbiters, compactions that write summaries, model reply interpreters and model PII detection, each a ComponentRecord with its promptHash / schemaHash; ComponentKind gains Compaction, ReplyInterpreter and PiiPolicy.
  • Changed β€” stale cassettes cover them (Testing): a recording of any of them whose hashes changed is StaleCassetteError / [vcr.stale-cassette], re-recorded by AGBOSS_VCR_MODE=RECORD_STALE npm test.

Unreleased β€” a self-contained HTML report: eval run --html and eval show (#397 PR 1)

  • Added β€” agboss eval run … --html <file> (Eval suites): the run as one self-contained HTML page β€” header, summary (pass@k, pass^k, cases, attempts, spend), unstable cases, every case with an attempt dot per run (passed, failed, error, limit) and each run's checks β€” no external request (a Content-Security-Policy of its own style and script), light and dark, phone width, keyboard; bounded at 16 MB (eval.report-too-large).
  • Added β€” agboss eval show <run.json> [--html <file>]: a saved run artifact read back β€” its table, and the same page β€” without running anything; eval.run-not-found, eval.run-invalid for a file that is not an artifact this version reads.

Unreleased β€” agboss eval run: suites, k runs, pass@k / pass^k, the cascade (#396 PR 2)

  • Breaking β€” agboss eval is made of subcommands (CLI): today's scoring of recorded runs is agboss eval score (the same options); the bare agboss eval prints its subcommands and exits 2 (usage.missing-argument). Migration: add score after eval β€” agboss eval score --start … (job-scout's npm run eval does it).
  • Added β€” agboss eval run [--suite <name>] [--runs <k>] [--report <run.json>] [--env <name>] (Eval suites): the application built as createApp builds it, an @EvalSuite assembled on it, each case run k times β€” its history written into its thread, its turns run with the case's tenant and execution values, the trajectory check over result.toolCalls, judges in the frame of the case's last execution, the cascade β€” pass@k / pass^k per case, unstable for pass@k without pass^k; the run artifact (kind: "agboss.eval-run", schemaVersion: 1) with --report, the envelope's result with --json. A run ending at a finish its judge does not judge fails that check. New code eval.unknown-suite.
  • Added β€” agboss check checks eval suites (Loop 0): every eval-suites/*.eval-suite.ts of the application assembled, its problems on its file, eval-suites in the checks.
  • Fixed β€” the generated types' context blocks are sorted: two applications that declare their own contexts no longer make .agboss/types.d.ts depend on the order of the workflows.

Unreleased β€” testCompaction (#395 PR 5)

  • Added β€” testCompaction(Compaction, options?) (Testing): the tenth unit runner β€” a CompactionRunner that compacts one CompactionInput (previousSummaries, turnsToCompact) and gives a CompactionResult (summaries, requests: CompactionRequest { instructions, input } per model call) for SummaryQueue and SingleSummary on a scripted model, and for CompactionStrategy.Custom (#382) on its own container. It checks the request and the plumbing; survival of facts is an eval case.

Unreleased β€” the mesh envelope: call chains, hop limits and loop detection (#398 PR 1)

  • Added β€” the mesh envelope (A2A, MCP): every A2AClient call made inside an execution sends agboss-mesh (MeshEnvelope: schema, issuer, origin β€” MeshOrigin, MeshOriginStep β€”, callChain of MeshHop / MeshEntry; an unsecured JWS, ids only) and a W3C traceparent; an @McpServer call sends _meta.callId (the step's idempotency key β€” before, the MCP client sent no _meta at all), _meta["agboss.dev/mesh"] and _meta.traceparent. Outside an execution, nothing.
  • Added β€” A2ACallerRequest.envelope?: the envelope as the listener read it, a claim (not verified: never the tenant, a budget or a deadline); a malformed one is ignored.
  • Added β€” loops and hops refused before they run: a request whose chain already lists the receiver's (application, start) β†’ 409 / an MCP error result limit.mesh-loop; one whose chain already lists @Application({ maxMeshHops }) applications (new, default 8, [limits.max-mesh-hops-invalid]) β†’ limit.hops. New errors MeshLoopDetectedError (entry, chain) and HopsLimitExceededError (entry, chain, maxMeshHops, hops), both LimitExceededErrors (MeshLimitPlace for their constructors); thrown by the client, and a tool that meets one fails its step with it β€” never an error result for the model.
  • Added β€” A2AExecutionResponse.code / .details: a failed or limited run's error code (every AGBossError), and for the two mesh limits their facts, so the caller fails the same way.

Unreleased β€” stale cassettes: cassette v2, RECORD_STALE, check --cassettes (#395 PR 4)

  • Added β€” cassette version 2 and StaleCassetteError (Testing): every recorded call keeps its component and that component's promptHash / schemaHash; a replay of an agent or model router that changed since is StaleCassetteError (vcr.stale-cassette, component, changes: CassetteChange[]). Version 1 cassettes keep replaying, without stale detection, with a hint.
  • Added β€” VCRMode.RECORD_STALE and AGBOSS_VCR_MODE: re-records only the stale components' calls; AGBOSS_VCR_MODE=<member> overrides every test's own mode (an unknown value fails the test naming the modes).
  • Added β€” agboss check --cassettes (CLI): the same verdict without a test.

Unreleased β€” the model's invalid turns, scripted (#395 PR 3)

  • Added β€” callToolWithInvalidArguments(Tool, args), callToolWithTruncatedArguments(Tool, partialArgs), replyWithInvalidAnswer(Dto, fields) (Testing): a validation retry is tested without a cast β€” an invalid or a cut tool call (recorded ToolCallStatus.InvalidArguments, counted in step.validationAttempts, the tool never runs) and an answer that does not fit its class (the third fails the step with AgentAnswerInvalidError). Replace replyWith(Dto, { … } as never) with replyWithInvalidAnswer(Dto, { … }).

Unreleased β€” trajectory matchers on a run result (#395 PR 2)

  • Breaking β€” mockLlm(X).toHaveCalledTools([…]) is toHaveScriptedToolCalls([…]) (Testing): the script matcher (the model's calls, exactly, in order) is renamed β€” replace the name in a test; toHaveCalledTools now asserts a run result.
  • Added β€” expect(result).toHaveCalledTools([A, B]) (Testing): each tool called at least once by the root execution's agents, in any order, whatever its status; .not.toHaveCalledTools([…]) β€” none of them was.
  • Added β€” expect(result).toHaveToolCallMatching(Tool, partialOrCheck): some call of the tool has an input matching a deep partial or a check (ToolCallExpectation<TInput>, typed by the tool's input); .not β€” none does. ToolCallMatchers / toolCallMatchers in @agboss/core/testing.

Unreleased β€” trajectories of failed visits, children's steps, unknown tools (#395 PR 1b)

  • Changed β€” a failed visit keeps its records (Observability): the tool calls, judge verdicts and guardrail decisions of an agent whose step fails (QualityGateError, ToolBlockedError, GuardrailBlockedError, a limit, any error) are in result.toolCalls, record.toolCalls and its record.steps entry when a side path takes the failure, in a failed branch's too, and in the ChildExecutionRecord of a child the failure ends.
  • Added β€” ChildExecutionRecord.steps: each child's own ExecutionStepRecords.
  • Added β€” ToolCallStatus.UnknownTool: a call to a tool the agent does not have is recorded.

Unreleased β€” a lazily created transport provider runs its lifecycle hooks (#385 S4)

  • Changed β€” transport providers' lifecycle (Model providers): created on its first call, a transport's onInit β†’ onStart run, awaited, before that call's complete() β€” once under concurrent first calls; a failing hook fails that call with the start's error ([di.hook-failed] X.onStart failed: …) and the next call tries again; app.close() runs onStop / onDestroy for the transports that were created, none for the others. Before, a transport created on a call got no onInit / onStart.

Unreleased β€” a2aHttpListener resolves each request's caller and tenant (#387)

  • Breaking β€” a2aHttpListener(app, { start, callerResolver, base? }) (A2A): built from the application (A2AApp: createApp's or testWith's app), not from an adapter; the positional form a2aHttpListener(adapter, Start, base) is removed. callerResolver is required β€” there is no default tenant: a single-tenant host returns A2ACallerResolution.resolved({}). Migration: a2aHttpListener(new A2AAdapter(app), Start, "/a2a") β†’ a2aHttpListener(app, { start: Start, callerResolver: MyCallerResolver, base: "/a2a" }), with MyCallerResolver an @Injectable listed in providers.
  • Breaking β€” thread β†’ threadId on the wire (the request body, the response) and in the client: A2AExecutionRequest.threadId, A2AExecutionResponse.threadId, A2AClientCall.threadId, A2ARequestSchema / A2AResponseSchema (one field name per meaning: app.execute's threadId).
  • Breaking β€” IA2AAdapter.cancel(threadId, caller) (A2ACallOptions): cancels the caller's tenant's thread only; cancel(thread) is removed. A2AAdapter takes an A2AApp.
  • Added β€” the caller resolver: A2ACallerResolver<typeof Start> (resolveCaller({ route, http }), A2ACallerRequest, A2ARoute), answered by A2ACallerResolution.resolved(caller) / .unauthenticated({ reason, challenge? }) / .forbidden({ reason }) (branded, A2ACallerResolutionKind, ResolvedA2ACallerResolution, RejectedA2ACallerResolution, A2ACallerUnauthenticatedOptions, A2ACallerForbiddenOptions); A2ACaller<typeof Start> is typed by the start's application. The resolver is an @Injectable in @Application({ providers }) or a @Workflow({ providers }), taken from the container β€” not listed β†’ [a2a.caller-resolver-not-listed] when the listener is built. Resolved before the body is read or a header written: 401 (+ WWW-Authenticate), 403, 500 (a2a.caller-unauthenticated, a2a.caller-forbidden, a2a.caller-resolution-failed, body { code, error }); a refusal runs nothing β€” no observer event, no record.
  • Added β€” A2ACallerRejectedError (a2a.caller-rejected; httpStatus, the wire code): A2AClient.send on a 401 / 403 carrying an a2a.caller-* code.
  • Added β€” testWith's a2aServer({ start, callerResolver, base? }) β†’ { url } (A2AServerFixture, A2ATestServer): the listener on 127.0.0.1:0, the loopback open for the test, closed at its end; TestApp.resolve(Class) once the app is built (test.app-not-ready before).
  • Changed β€” @Application({ providers }) documents that ordinary @Injectable services may be listed there too (they always could be: one instance for every workflow).

Unreleased β€” tool-call trajectories on results, records and JudgeInput (#395 PR 1)

  • Added β€” result.toolCalls, result.record.toolCalls, ChildExecutionRecord.toolCalls (Observability): one ToolCallRecord per tool call, in call order β€” step (the tool as its component, with promptHash / schemaHash; the agent's visit), input, output, status: ToolCallStatus (Succeeded, Failed, InvalidArguments, Declined, TimedOut; a tool interceptor's Replied and Blocked, #384), error?, isResultTruncated, startedAt, endedAt. The result's are real values, the record's masked. toolCallsOf(Tool, toolCalls) β†’ TypedToolCall (one tool's calls, typed once narrowed by status: SucceededToolCall, FailedToolCall, UncheckedToolCall).
  • Added β€” result.record.steps: one ExecutionStepRecord per visit β€” step, inputHash, judgeVerdicts (JudgeVerdictRecord), guardrailDecisions (GuardrailDecisionRecord), spend, startedAt, endedAt, error?.
  • Added β€” JudgeInput.toolCalls (Judges): the visit's calls, every attempt's, real values; JudgeInput<T> is now JudgedReply<T> & { toolCalls }. testJudge takes toolCalls optionally ([]). A hand-made JudgeInput (calling judge() directly) needs toolCalls.
  • Fixed β€” a truncated tool call is no silent empty reply: a call whose arguments are no JSON (the provider's invalid_tool_calls) is an InvalidArguments call the model reads about, counted in validationAttempts and in maxToolCalls; a truncated answer is an invalid answer (no more "answer with a tool, not with text").
  • Changed β€” the agent loop's prompt texts gained the invalid-arguments message, so every agent's prompt version changes once.

Unreleased β€” a custom transport for @ModelProvider: ModelProviderHandler.complete(request) (#385)

  • Added β€” transport providers (Model providers): a @ModelProvider class that implements ModelProviderHandler with complete(request) β€” no baseUrl / apiKey, its services through deps, created by the application's container on its first call (one instance, TenantContext read in the calling execution). New public names in @agboss/core/models: ModelProviderRequest, ModelProviderResponse, ModelProviderMessage, ModelProviderMessageRole, ModelProviderToolDefinition, ModelProviderToolCall, ModelProviderUsage, ModelProviderFailureError (model.provider-failure, ModelProviderFailureOptions: retryAfter?, cause?), FinishReason; complete? on ModelProviderHandler; deps on @ModelProvider; the rule [model-provider.both-modes]. Every policy of the gateway runs around it; agboss describe prints transport: X.complete(); part of configHash (only where there is one).
  • Changed β€” ModelProviderHandler.capabilities is optional (required of an HTTP provider by the decorator's overload; a transport without it is not checked against its models).
  • Changed β€” ModelProviderOptions is the union of the HTTP options and the transport options (baseUrl / baseUrlField / apiKey vs deps); ModelProviderType takes constructor arguments. Code that spreads one provider's options into another's decorator narrows them first.
  • Changed β€” a breaker's fallback is at the chat model: while the primary's breaker is open the call goes to the fallback provider's own chat model (its own wire form, not the primary's request re-addressed), so HTTP and transport providers fall back to each other. Accounting is unchanged β€” the fallback's model, its prices.

Unreleased β€” tool interceptors: @ToolInterceptor + BeforeToolCall / AfterToolCall (#384)

  • Added β€” @ToolInterceptor (Tools): a class that implements BeforeToolCall (beforeToolCall(call: ToolCallContext) β†’ ToolCallDecision.allow() / .replyToModel({ message }) / .block({ reason })) and / or AfterToolCall (afterToolCall(call: ToolCallResultContext) β†’ the result the model reads), listed in toolInterceptors of @Application β†’ @Workflow β†’ @Agent β†’ @Tool / @McpTool (outer first), removed with excludeToolInterceptors on an agent or a tool. replyToModel answers the model without calling the tool; block fails the step with the new ToolBlockedError (step.tool-blocked, a StepFailedError); a throwing interceptor fails it with AgentFailedError (its error the cause). beforeToolCall reads the arguments PII-revealed; the result after afterToolCall is validated, cut at maxResultSize, then masked. Rule codes [tool-interceptor.not-an-interceptor], [tool-interceptor.exclude-not-inherited], [tool-interceptor.missing-handler]; agboss describe prints tool interceptors, describe --json each agent's chains, configHash covers them (a workflow without one keeps its hash); testToolInterceptor in @agboss/core/testing. Replaces the tool hooks removed in #120 S123.
  • Changed (internal) β€” the framework's own per-call tool context is ToolInvocationContext now; ToolCallContext is the interceptor's (root entry).

Unreleased β€” @agboss/core/eval: @EvalSuite and code judges (#396 PR 1)

  • Added β€” the entry @agboss/core/eval (Eval suites): @EvalSuite({ name, description, start, checks, cascade, cases }) in eval-suites/<name>.eval-suite.ts (-eval-suite), the case builders evalCase, turn, pastTurn, toolCall, toolInvariant, the checks checkTrajectory() and judgedBy(Judge), the enums EvalCascade { StopAtFirstFailure, RunAll } and CheckStatus { Passed, Failed, Skipped }; types EvalSuiteOptions, EvalSuiteMeta, EvalCase, EvalCaseOf, EvalTurn, EvalPastTurn, EvalValueOf, EvalCheck, TrajectoryEvalCheck, JudgeEvalCheck, EvalToolExpectations, ExpectedToolCall, ToolArgumentsOf, ToolInvariant. Declarations only β€” nothing runs at import; the runner comes with PR 2. Assembly rules [eval.unknown-start], [eval.duplicate-case], [eval.judge-wrong-reply], [name.kind-suffix]; case values β†’ EvalCaseInvalidError (eval.case-invalid, root entry, with EvalCaseIssue).
  • Added β€” code judges (Judges): @Judge({ decidedBy: DecidedBy.Code }) decides by judge(input) alone β€” no model, no cost β€” in an agent's quality gate and in an eval suite; model does not compile on it, [judge.model-on-code-judge] for a model or an injected JudgeModel; testJudge runs it without a model script. decidedBy is optional (DecidedBy.Model by default); new types ModelJudgeMeta, CodeJudgeMeta.
  • Changed β€” JudgeMeta and JudgeOptions are unions discriminated by decidedBy (JudgeOptions is now a type alias, no longer an interface): code that reads meta.model narrows first.

Unreleased β€” agboss/no-component-state knows every component decorator (#402)

  • Fixed β€” agboss/no-component-state missed components (Components): this.x = … outside the constructor is now reported for @InputGuardrail / @OutputGuardrail, @Budget, @Arbiter, @ReplyInterpreter, @Compaction and @ModelProvider too; the stale Guardrail name is gone. The decorators come from one list of the framework (src/components/component-decorators.ts), and a test fails when a class decorator a public entry exports is neither listed nor named as not a component β€” a new decorator cannot be forgotten. A project whose components kept state there now gets lint errors for it.

Unreleased β€” agboss create ships a working ESLint config (#388)

  • Added β€” lint in a new project (CLI): agboss create writes eslint.config.js and the lint script (eslint ., also in npm run check) with its dev dependencies β€” typescript-eslint (strictTypeChecked), agboss/no-component-state and agboss/no-direct-time on src/ outside the tests, @stylistic/newline-per-chained-call on every *.workflow.ts; the project passes it as written.
  • Added β€” the entry @agboss/core/eslint (#388 S2): agbossPlugin (agboss/no-component-state, agboss/no-direct-time) and FLOW_CHAIN_RULE for a project's ESLint config; the repository's own config uses it too (lint runs on the build). @typescript-eslint/utils is a new optional peer of agboss, needed only by this entry.
  • Changed β€” agboss/no-direct-time's message no longer says "framework code": `Date.now` reads the time directly: inject the application's Clock ….

Unreleased β€” RuleCode holds only the codes a rule reports (#386)

  • Breaking β€” ten RuleCode members are removed: graph.two-next-steps, graph.router-not-last-in-chain, graph.cycle-without-router, graph.duplicate-node, router.self-without-agent-before, router.empty-route-text, router.unbounded-cycle (the transition DSL before #272) and batch.invalid-batch-size, batch.invalid-concurrency-limit, batch.strategy-not-decorated (batchParallel, removed). No rule reported them; a project that names one (a switch over RuleCode, a test expecting it) no longer compiles β€” the codes that replaced them are [graph.duplicate-node-name], [router.no-prompt] and [graph.cycle-without-max-visits]. The skill's error catalog (reference 11) drops its "reserved" entries; a test checks every RuleCode member is reported by some rule.

Unreleased β€” a custom compaction strategy: CompactionStrategy.Custom + CompactionHandler (#382)

  • Added β€” CompactionStrategy.Custom (Memory management): a @Compaction class that implements CompactionHandler β€” compact({ previousSummaries, turnsToCompact }) (CompactionInput, each turn a CompactionTurn: startName, input, finishName, output) returns the summaries readers see from now on; deps injected by the container, run in the frame of the execution's start. Counted, run lazily, PII-masked and stored like the built-in strategies; [compaction.missing-handler]; agboss describe prints strategy: custom (X.compact()), the class is part of configHash.
  • Changed β€” CompactionError.summaryMaxTokens is SummaryMaxTokens | undefined: undefined for a custom compaction.

Unreleased β€” code guardrails: decidedBy: DecidedBy.Code + GuardrailHandler (#383)

  • Added β€” guardrails decided by code (Guards): @InputGuardrail / @OutputGuardrail({ decidedBy: DecidedBy.Code }) β€” the class's check(text) (implements GuardrailHandler) returns GuardrailVerdict.passed() / .blocked() on the masked text; no model call, cost 0; the same refusal (text or refusal()), GuardrailBlockedError (probability 1, threshold 1), levels, excludeGuardrails, events and testGuardrail; a throwing check fails closed (GuardFailedError, its error as cause); [guardrail.missing-handler]; agboss describe prints decidedBy: code. decidedBy is optional, DecidedBy.Model by default: every existing guardrail compiles and hashes as before. New public types CodeGuardrailTextOptions, CodeGuardrailComputedOptions; GuardFailedError takes an optional cause.

Unreleased β€” the agboss skill: references 02, 06, 08, 09, 11, 12 and compensation (#305 PR 3)

  • Added β€” the rest of the coding-agent skill in packages/agboss/skills/agboss/references/: 02 (every flow form, with the rule codes each can hit, compensation on side paths and the automatic undo), 06 (budgets, model providers, retry, rate limits, tenant-fair queues, circuit breakers with a fallback), 08 (results, records, observers, the log, Langfuse), 09 (stores, several instances, health probes, the graceful stop, Kubernetes, deploys by config hash), 11 (every CLI command, its exit codes and JSON envelope, Studio as it is, and the full catalog β€” every RuleCode and the application warnings with a before β†’ after fix; check:docs now checks the catalog is complete) and 12 (four multi-file recipes that compile and whose tests pass); compensation added to 03 (compensateWith, committedBy, compensationTimeout, the hooks) and 10 (toHaveCompensated, result.compensations).

Unreleased β€” an interrupted undo is finished by recovery; the record after the undo (#340 PR 4)

  • Added β€” durable automatic undo (Several instances): the reserved tree keys @agboss/unwinding ({ reason, cause }), @agboss/unwinding:<childExecutionId> and @agboss/compensated/<compensationKey>; an undo interrupted by a stop, a crash or a lost lease is finished by the instance that takes the tree over β€” recovery before its compatibility check, a resume at its lease take β€” the undos marked done skipped, then the tree ends failed with the recorded reason, without a pass (compensation.recovered in the log); a child re-entered mid-undo finishes its own before any step.
  • Changed β€” the root's record is written after its undo (Rollback): running while the undo runs, then failed with the final error β€” CompensationFailedError when an undo failed (before, the record kept the original error).
  • Changed β€” app.cancel rejects with CompensationFailedError when it ran the undo itself (a paused tree, or a paused tree of another instance's request) and an undo failed β€” the others ran, the tree is cancelled (Cancellation); before, it returned { cancelled: true }. Breaking for a caller that expected the cancel to resolve.

Unreleased β€” project skills for coding agents, shipped with agboss (#305 PR 1)

  • Added β€” skills/agboss/ in the agboss package (SKILL.md and 12 references; 02, 06, 08, 09, 11 and 12 stubs until #305 PR 3) β€” CLI β†’ Project skills, ADR-0021.
  • Added β€” agboss create writes AGENTS.md, a one-line CLAUDE.md and .claude/skills/agboss/ (with .agboss-skills.json), and .claude/skills/agboss/ in the generated .gitignore.
  • Added β€” agboss check and agboss generate sync the skills folder with the installed agboss (skills: … installed | updated | repaired), warnings [skills.local-edit], [skills.unmanaged-file], [skills.sync-failed], [skills.no-framework] β€” never a changed exit code; --json has result.skills; "agboss": { "skills": false } turns it off.
  • Added β€” check:docs checks the skills: their snippets compile, their version header, codes and matrix coverage (Home).

Unreleased β€” the agboss skill: SKILL.md and six references (#305 PR 2)

  • Added β€” the content of the coding-agent skill shipped in packages/agboss/skills/agboss/: SKILL.md (the 10 invariants, the npx agboss generate β†’ check β†’ describe / npm test loop, the gate's rules, the routing table) and references 01 (composition and DTOs), 03 (components, DI, lifecycle), 04 (MCP and A2A), 05 (channels and pauses), 07 (guardrails and PII), 10 (testing, VCR, evals) β€” every ts block compiled by check:docs, every matrix name checked against the API reports (tests/skills/coverage.ts). CLAUDE.md and QUALITY.md say reasoning, not thinking.

Unreleased β€” the automatic undo when an execution ends without a finish (#340 PR 3)

  • Added β€” the undo of an execution that ends without a finish (Rollback): an uncaught failure or limit (CompensationReason.ExecutionFailed), a cancel or a launch's FailFast abort (ExecutionCancelled) undoes the execution's open undoable actions, newest first, before it ends β€” a child in its parent's step before its error is raised (Workflow as a step), a root by its lease's holder before its keys are removed and onExecutionError is observed, after awaiting the tree's running step bodies up to compensationTimeout (one that returns later is undone at once, in process).
  • Changed β€” app.cancel of a paused tree takes its lease, withdraws its pauses, undoes, then returns { cancelled: true } (Cancellation); a cancel request (#360) found at lease take undoes first, then ends the tree as before.
  • Changed β€” CompensationFailedError passes a child's boundary as it is, never wrapped in ChildExecutionFailedError: no .onError / .onJoinError of the parent takes it, and the parent undoes its own open actions.
  • Added β€” ExecutionErrorEvent.compensations (Observability): the undo actions that ran for the failed execution. Breaking for a project that builds ExecutionErrorEvent objects itself (a test double of an observer): the field is required.
  • Changed β€” the undo's service spend is written to the ledger at once when no state will hold it: a failed join that fails the execution, an execution ending without a finish (FinOps).
  • README: "declared action compensation (compensateWith / committedBy)" is back in the feature line.

Unreleased β€” agboss check never prints ok without written types (#376)

  • Fixed β€” agboss check printed ok when type generation found no workflow (CLI): an application module outside src/ that does not export its workflows wrote no .agboss/types.d.ts. Now it is [types.no-flow] (a CLI problem, not a RuleCode), naming the application file and the fix β€” export { MyWorkflow } from it, or keep it in src/ beside its *.workflow.ts files β€” exit 3, in the printed problems and in --json. How workflows are found is unchanged.
  • Fixed β€” a DTO name clash inside one workflow names both files: [dto.duplicate-name] said "(unknown file)"; the diagnostic of type generation, which names the files, is kept instead.

Unreleased β€” a DTO name clash fails agboss check (#371)

  • Fixed β€” agboss check printed ok without writing .agboss/types.d.ts (Workflow, CLI): two DTO classes of one name in two workflows (seen by no single workflow's rules) failed type generation silently. Now [dto.duplicate-name] names both files, in the printed problems and in --json, exit 3; any error that kept the file from being written is reported, and a failed write is [types.not-written] (a CLI problem, not a RuleCode). createApp / testWith are unchanged: they check DTO names per workflow.

Unreleased β€” the shutdown grace (#367 PR 2)

  • Changed β€” app.close() / SIGTERM interrupts the running executions a grace before the end of the shutdown timeout β€” min(seconds(5), 20 %) of it (seconds(25) β†’ 5 s, seconds(10) β†’ 2 s) β€” and spends the grace on the step bodies they left running, then releases the leases and runs onDestroy: one close() now waits for those bodies, and the process always ends within shutdownTimeout (App β†’ app.close(), Several instances). Several calls: the stop ends at the earliest end any call set (before: the latest); close({ timeout: 0 }) stops waiting for the bodies at once. The grace is not a setting; no public API change.

Unreleased β€” committedBy needs compensateWith (#339)

  • Added β€” [action.committer-without-compensator] (Rollback), an assembly error: an @Action with committedBy but no compensateWith β€” the option has no effect, since nothing undoes the action. The message names the action and its committer: add compensateWith, or remove committedBy. RuleCode gains the code.

Unreleased β€” compensation records and the failed-join undo (#340 PR 2)

  • Added β€” compensation records (Rollback): every @Action({ compensateWith }) that succeeds opens a record in the execution's state β€” committed by its committedBy action or a finish (a child's by its own finish), closed by its undo on a side path (CompensationReason.SidePath). ExecutionOutput.compensations, ExecutionRecord.compensations: CompensationRecord { compensated, compensator, reason, status, error?, startedAt, endedAt }; CompensationReason, CompensationStatus.
  • Added β€” a failed join undoes its finished branches (Rollback, Parallel): before .onJoinError or the failure, newest first, after awaiting the launch's running branches β€” each undo in its own frame (key <step key>:compensation, StepRecord.compensates, a clean-up signal), no slot, no budget check (CompensationReason.JoinFailed). An undo that fails β†’ the others still run, then CompensationFailedError (compensation.failed, failures, cause), taken by no side path.
  • Added β€” @Application({ compensationTimeout }) (App), default seconds(15).
  • Added β€” observer hooks onCompensationStart / onCompensationEnd (Observability); matcher toHaveCompensated(Action, { by, reason?, status? }) (Testing); agboss describe marks a launched undoable action (on a failed join: automatic).
  • The undo of an execution that ends without a finish (a cancel, an uncaught failure or limit, an aborted child) came with #340 PR 3 (above).

Unreleased β€” app.close() waits for interrupted step bodies (#367)

  • Fixed β€” app.close() / SIGTERM no longer runs onDestroy or releases a tree's lease while a step body its interrupted pass left running still runs (App β†’ app.close(), Several instances): LangGraph rejects the pass before a body that ignores its signal returns, so a recovering instance could rerun the step with the same idempotency key at the same time. The instance now keeps the lease until the tree's bodies returned and starts onDestroy once every body did, at the latest at the end of the shutdown timeout; a body still running then is logged as app.step-still-running (LogLevel.Warn). The interrupted call still rejects with ApplicationStoppingError, once its lease is released. No public API change.

Unreleased β€” declared compensation: compensateWith / committedBy and their checks (#339 PR 1)

  • Added β€” @Action({ compensateWith, committedBy }) (Rollback, ADR-0020, replacing ADR-0019's "no automatic compensation"): an action names the @Action that undoes it and, optionally, the one after which nothing undoes it. Checked at assembly: [action.compensator-not-an-action], [action.committer-not-an-action], [action.self-compensation], [action.compensator-compensates], [action.invalid-compensator-input]; an .onError / .onJoinError side path after an open undoable action reaches a finish only through its undo, newest first ([flow.uncompensated-action], [flow.compensation-out-of-order]); with committedBy every path runs the committer or the undo ([flow.uncommitted-action]); a committer no path reaches is a warning ([flow.committer-unreachable]).
  • Added β€” agboss describe prints each undoable action (compensateWith … Β· committedBy …), --json its node's action (CLI).
  • Added β€” examples/flow-patterns/src/hiring/: the send-offer workflow, its eighth workflow.
  • Nothing runs automatically yet: the undo where no path can say it (a cancel, an uncaught failure, a failed join's finished branches) is #340.

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/core/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/core/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/core/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/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/core/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/core/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/core/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/core/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/core/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/core/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/core" { 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/core/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/core/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/core/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).

⚠️ **GitHub.com Fallback** ⚠️